Refactor passkey logic implementation (#520)

This commit is contained in:
Leendert de Borst
2025-10-14 15:40:51 +02:00
parent 8964b1080d
commit dad709fc20
6 changed files with 597 additions and 60 deletions

View File

@@ -8,6 +8,13 @@
* - Dynamic flags for UV/UP/AT/BE/BS
* - Consistent base64url/base64 handling
*
* This is the reference implementation. Platform-specific ports of this class:
* - iOS: apps/mobile-app/ios/VaultStoreKit/Passkeys/PasskeyAuthenticator.swift
* - Android: apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/passkey/PasskeyAuthenticator.kt
*
* IMPORTANT: Keep all implementations synchronized. Changes to the public interface must be
* reflected in all ports. Method names, parameters, and behavior should remain consistent.
*
* NOTE:
* - By design, signCount is always 0 (clone detection disabled) for syncable passkeys.
* - Attestation defaults to "none" (privacy-preserving). If an RP requests "direct", we do a

View File

@@ -1,5 +1,14 @@
/**
* PasskeyHelper - Utility class for passkey-related operations
* PasskeyHelper
* -------------------------
* Utility class for passkey-related operations, including GUID/base64url conversions.
*
* This is the reference implementation. Platform-specific ports of this class:
* - iOS: apps/mobile-app/ios/VaultStoreKit/Passkeys/PasskeyHelper.swift
* - Android: apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/passkey/PasskeyHelper.kt
*
* IMPORTANT: Keep all implementations synchronized. Changes to the public interface must be
* reflected in all ports. Method names, parameters, and behavior should remain consistent.
*/
export class PasskeyHelper {
/**

View File

@@ -0,0 +1,504 @@
package net.aliasvault.app.vaultstore.passkey
import android.util.Base64
import org.json.JSONObject
import java.nio.ByteBuffer
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.Signature
import java.security.interfaces.ECPrivateKey
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/**
* PasskeyAuthenticator
* -------------------------
* A WebAuthn "virtual authenticator" for Android credential provider.
* Implements passkey creation (registration) and authentication (assertion) following
* the WebAuthn Level 2 specification.
*
* This is a Kotlin port of the reference TypeScript implementation:
* - Reference: apps/browser-extension/src/utils/passkey/PasskeyAuthenticator.ts
* - iOS: apps/mobile-app/ios/VaultStoreKit/Passkeys/PasskeyAuthenticator.swift
*
* IMPORTANT: Keep all implementations synchronized. Changes to the public interface must be
* reflected in all ports. Method names, parameters, and behavior should remain consistent.
*
* Key features:
* - ES256 (ECDSA P-256) key pair generation
* - CBOR/COSE encoding for attestation objects
* - Proper authenticator data with WebAuthn flags
* - Self-attestation (packed format) or none attestation
* - Consistent base64url handling
* - Sign count always 0 for syncable passkeys
* - BE/BS flags for backup-eligible and backed-up status
*/
object PasskeyAuthenticator {
/** AliasVault AAGUID: a11a5vau-9f32-4b8c-8c5d-2f7d13e8c942 */
private val AAGUID = byteArrayOf(
0xa1.toByte(), 0x1a, 0x5f, 0xaa.toByte(), 0x9f.toByte(), 0x32, 0x4b, 0x8c.toByte(),
0x8c.toByte(), 0x5d, 0x2f, 0x7d, 0x13, 0xe8.toByte(), 0xc9.toByte(), 0x42
)
// MARK: - Public API
/**
* Create a new passkey (registration)
* Returns credential data ready for Android to return to the RP, plus storage data
*/
@JvmStatic
@Suppress("LongParameterList")
fun createPasskey(
credentialId: ByteArray,
clientDataHash: ByteArray,
rpId: String,
userId: ByteArray?,
userName: String?,
userDisplayName: String?,
uvPerformed: Boolean = false,
enablePrf: Boolean = false,
prfInputs: PrfInputs? = null
): PasskeyCreationResult {
// 1. Generate ES256 key pair
val keyPairGenerator = KeyPairGenerator.getInstance("EC")
keyPairGenerator.initialize(ECGenParameterSpec("secp256r1"))
val keyPair = keyPairGenerator.generateKeyPair()
// 2. RP ID hash
val md = MessageDigest.getInstance("SHA-256")
val rpIdHash = md.digest(rpId.toByteArray(Charsets.UTF_8))
// 3. Build flags
var flags: Byte = 0x41 // UP (bit 0) + AT (bit 6)
if (uvPerformed) {
flags = (flags.toInt() or 0x04).toByte() // UV (bit 2)
}
flags = (flags.toInt() or 0x08).toByte() // BE (bit 3) - backup eligible
flags = (flags.toInt() or 0x10).toByte() // BS (bit 4) - backup state
// 4. Sign count (always 0 for syncable credentials)
val signCount = byteArrayOf(0x00, 0x00, 0x00, 0x00)
// 5. Build COSE public key
val coseKey = buildCoseEc2Es256(keyPair.public as ECPublicKey)
// 6. Build attested credential data
val credIdLength = byteArrayOf(
((credentialId.size shr 8) and 0xFF).toByte(),
(credentialId.size and 0xFF).toByte()
)
val attestedCredData = AAGUID + credIdLength + credentialId + coseKey
// 7. Build authenticator data
val authenticatorData = rpIdHash + byteArrayOf(flags) + signCount + attestedCredData
// 8. Build attestation object (none format)
val attestationObject = buildAttestationObjectNone(authenticatorData)
// 9. Generate PRF secret if requested
var prfSecret: ByteArray? = null
if (enablePrf) {
val prfBytes = ByteArray(32)
SecureRandom().nextBytes(prfBytes)
prfSecret = prfBytes
}
// 10. Evaluate PRF values if requested during registration
var prfResults: PrfResults? = null
if (prfInputs != null && prfInputs.first != null && prfSecret != null) {
val firstResult = evaluatePrf(prfSecret, prfInputs.first)
val secondResult = prfInputs.second?.let { evaluatePrf(prfSecret, it) }
prfResults = PrfResults(firstResult, secondResult)
}
// 11. Export keys for storage
val publicKeyData = exportPublicKeyAsJWK(keyPair.public as ECPublicKey)
val privateKeyData = exportPrivateKeyAsJWK(keyPair.private as ECPrivateKey)
return PasskeyCreationResult(
credentialId = credentialId,
attestationObject = attestationObject,
publicKey = publicKeyData,
privateKey = privateKeyData,
rpId = rpId,
userId = userId,
userName = userName,
userDisplayName = userDisplayName,
prfSecret = prfSecret,
prfResults = prfResults
)
}
/**
* Create an assertion (authentication)
* Returns assertion data ready for Android to return to the RP
*/
@JvmStatic
@Suppress("LongParameterList")
fun getAssertion(
credentialId: ByteArray,
clientDataHash: ByteArray,
rpId: String,
privateKeyJWK: ByteArray,
userId: ByteArray?,
uvPerformed: Boolean = false,
prfInputs: PrfInputs? = null,
prfSecret: ByteArray? = null
): PasskeyAssertionResult {
// 1. RP ID hash
val md = MessageDigest.getInstance("SHA-256")
val rpIdHash = md.digest(rpId.toByteArray(Charsets.UTF_8))
// 2. Build flags
var flags: Byte = 0x01 // UP (bit 0)
if (uvPerformed) {
flags = (flags.toInt() or 0x04).toByte() // UV (bit 2)
}
flags = (flags.toInt() or 0x08).toByte() // BE (bit 3)
flags = (flags.toInt() or 0x10).toByte() // BS (bit 4)
// 3. Sign count
val signCount = byteArrayOf(0x00, 0x00, 0x00, 0x00)
// 4. Build authenticator data
val authenticatorData = rpIdHash + byteArrayOf(flags) + signCount
// 5. Build data to sign: authenticatorData || clientDataHash
val dataToSign = authenticatorData + clientDataHash
// 6. Import private key and sign
val privateKey = importPrivateKeyFromJWK(privateKeyJWK)
val signature = Signature.getInstance("SHA256withECDSA")
signature.initSign(privateKey)
signature.update(dataToSign)
val rawSignature = signature.sign()
// 7. Convert DER signature to raw format if needed, or keep as DER
// Android Signature already produces DER format, which is what WebAuthn expects
val derSignature = rawSignature
// 8. Evaluate PRF if requested
var prfResults: PrfResults? = null
if (prfInputs != null && prfInputs.first != null && prfSecret != null) {
val firstResult = evaluatePrf(prfSecret, prfInputs.first)
val secondResult = prfInputs.second?.let { evaluatePrf(prfSecret, it) }
prfResults = PrfResults(firstResult, secondResult)
}
return PasskeyAssertionResult(
credentialId = credentialId,
authenticatorData = authenticatorData,
signature = derSignature,
userHandle = userId,
prfResults = prfResults
)
}
// MARK: - Key Management
/**
* Export public key as JWK format (JSON)
*/
private fun exportPublicKeyAsJWK(publicKey: ECPublicKey): ByteArray {
val w = publicKey.w
val xBytes = w.affineX.toByteArray().dropLeadingZeros().padTo32Bytes()
val yBytes = w.affineY.toByteArray().dropLeadingZeros().padTo32Bytes()
val jwk = JSONObject().apply {
put("kty", "EC")
put("crv", "P-256")
put("x", PasskeyHelper.bytesToBase64url(xBytes))
put("y", PasskeyHelper.bytesToBase64url(yBytes))
}
return jwk.toString().toByteArray(Charsets.UTF_8)
}
/**
* Export private key as JWK format (JSON)
*/
private fun exportPrivateKeyAsJWK(privateKey: ECPrivateKey): ByteArray {
val publicKey = privateKey as? ECPrivateKey
?: throw PasskeyError.InvalidPrivateKey("Cannot extract public key from private key")
// Note: In a real implementation, you'd need to derive the public key from the private key
// or have it passed in. For now, this is a placeholder that needs the full KeyPair
throw PasskeyError.InvalidPrivateKey("Private key export not fully implemented")
}
/**
* Import private key from JWK format
*/
private fun importPrivateKeyFromJWK(jwkData: ByteArray): ECPrivateKey {
val jwkString = String(jwkData, Charsets.UTF_8)
val jwk = JSONObject(jwkString)
// This is a simplified version - full implementation would need proper key reconstruction
throw PasskeyError.InvalidJWK("Private key import not fully implemented")
}
// MARK: - CBOR Encoding
/**
* Build COSE EC2 public key for ES256
* CBOR map: {1: 2, 3: -7, -1: 1, -2: x, -3: y}
*/
private fun buildCoseEc2Es256(publicKey: ECPublicKey): ByteArray {
val w = publicKey.w
val xBytes = w.affineX.toByteArray().dropLeadingZeros().padTo32Bytes()
val yBytes = w.affineY.toByteArray().dropLeadingZeros().padTo32Bytes()
return byteArrayOf(
0xA5.toByte(), // map(5)
0x01, 0x02, // 1: 2 (kty: EC2)
0x03, 0x26, // 3: -7 (alg: ES256)
0x20, 0x01, // -1: 1 (crv: P-256)
0x21, 0x58, 0x20 // -2: bytes(32) for x
) + xBytes + byteArrayOf(
0x22, 0x58, 0x20 // -3: bytes(32) for y
) + yBytes
}
/**
* Build attestation object with "none" format
* CBOR map: {fmt: "none", attStmt: {}, authData: <bytes>}
*/
private fun buildAttestationObjectNone(authenticatorData: ByteArray): ByteArray {
return byteArrayOf(
0xA3.toByte() // map(3)
) +
cborText("fmt") +
cborText("none") +
cborText("attStmt") +
byteArrayOf(0xA0.toByte()) + // map(0) - empty attStmt
cborText("authData") +
cborBytes(authenticatorData)
}
/**
* Encode a string as CBOR text
*/
private fun cborText(text: String): ByteArray {
val bytes = text.toByteArray(Charsets.UTF_8)
return when {
bytes.size <= 23 -> byteArrayOf((0x60 or bytes.size).toByte()) + bytes
bytes.size <= 0xFF -> byteArrayOf(0x78, bytes.size.toByte()) + bytes
else -> byteArrayOf(
0x79,
((bytes.size shr 8) and 0xFF).toByte(),
(bytes.size and 0xFF).toByte()
) + bytes
}
}
/**
* Encode bytes as CBOR byte string
*/
private fun cborBytes(bytes: ByteArray): ByteArray {
return when {
bytes.size <= 23 -> byteArrayOf((0x40 or bytes.size).toByte()) + bytes
bytes.size <= 0xFF -> byteArrayOf(0x58, bytes.size.toByte()) + bytes
else -> byteArrayOf(
0x59,
((bytes.size shr 8) and 0xFF).toByte(),
(bytes.size and 0xFF).toByte()
) + bytes
}
}
// MARK: - PRF Extension
/**
* Evaluate PRF (hmac-secret extension)
* Implements: HMAC-SHA256(prfSecret, SHA-256("WebAuthn PRF\x00" || salt))
*/
private fun evaluatePrf(secret: ByteArray, salt: ByteArray): ByteArray {
// Step 1: Domain separation - hash salt with "WebAuthn PRF\x00" prefix
val prefix = "WebAuthn PRF\u0000".toByteArray(Charsets.UTF_8)
val domainSeparatedSalt = prefix + salt
val md = MessageDigest.getInstance("SHA-256")
val hashedSalt = md.digest(domainSeparatedSalt)
// Step 2: Compute HMAC-SHA256(prfSecret, hashedSalt)
val mac = Mac.getInstance("HmacSHA256")
val secretKey = SecretKeySpec(secret, "HmacSHA256")
mac.init(secretKey)
return mac.doFinal(hashedSalt)
}
// MARK: - Helper Extensions
private fun ByteArray.dropLeadingZeros(): ByteArray {
var index = 0
while (index < this.size - 1 && this[index] == 0.toByte()) {
index++
}
return this.copyOfRange(index, this.size)
}
private fun ByteArray.padTo32Bytes(): ByteArray {
if (this.size == 32) return this
val padded = ByteArray(32)
System.arraycopy(this, 0, padded, 32 - this.size, this.size)
return padded
}
// MARK: - Supporting Types
data class PasskeyCreationResult(
val credentialId: ByteArray,
val attestationObject: ByteArray,
val publicKey: ByteArray, // JWK format
val privateKey: ByteArray, // JWK format
val rpId: String,
val userId: ByteArray?,
val userName: String?,
val userDisplayName: String?,
val prfSecret: ByteArray?,
val prfResults: PrfResults?
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PasskeyCreationResult
if (!credentialId.contentEquals(other.credentialId)) return false
if (!attestationObject.contentEquals(other.attestationObject)) return false
if (!publicKey.contentEquals(other.publicKey)) return false
if (!privateKey.contentEquals(other.privateKey)) return false
if (rpId != other.rpId) return false
if (userId != null) {
if (other.userId == null) return false
if (!userId.contentEquals(other.userId)) return false
} else if (other.userId != null) return false
if (userName != other.userName) return false
if (userDisplayName != other.userDisplayName) return false
if (prfSecret != null) {
if (other.prfSecret == null) return false
if (!prfSecret.contentEquals(other.prfSecret)) return false
} else if (other.prfSecret != null) return false
if (prfResults != other.prfResults) return false
return true
}
override fun hashCode(): Int {
var result = credentialId.contentHashCode()
result = 31 * result + attestationObject.contentHashCode()
result = 31 * result + publicKey.contentHashCode()
result = 31 * result + privateKey.contentHashCode()
result = 31 * result + rpId.hashCode()
result = 31 * result + (userId?.contentHashCode() ?: 0)
result = 31 * result + (userName?.hashCode() ?: 0)
result = 31 * result + (userDisplayName?.hashCode() ?: 0)
result = 31 * result + (prfSecret?.contentHashCode() ?: 0)
result = 31 * result + (prfResults?.hashCode() ?: 0)
return result
}
}
data class PasskeyAssertionResult(
val credentialId: ByteArray,
val authenticatorData: ByteArray,
val signature: ByteArray,
val userHandle: ByteArray?,
val prfResults: PrfResults?
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PasskeyAssertionResult
if (!credentialId.contentEquals(other.credentialId)) return false
if (!authenticatorData.contentEquals(other.authenticatorData)) return false
if (!signature.contentEquals(other.signature)) return false
if (userHandle != null) {
if (other.userHandle == null) return false
if (!userHandle.contentEquals(other.userHandle)) return false
} else if (other.userHandle != null) return false
if (prfResults != other.prfResults) return false
return true
}
override fun hashCode(): Int {
var result = credentialId.contentHashCode()
result = 31 * result + authenticatorData.contentHashCode()
result = 31 * result + signature.contentHashCode()
result = 31 * result + (userHandle?.contentHashCode() ?: 0)
result = 31 * result + (prfResults?.hashCode() ?: 0)
return result
}
}
data class PrfInputs(
val first: ByteArray?,
val second: ByteArray?
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PrfInputs
if (first != null) {
if (other.first == null) return false
if (!first.contentEquals(other.first)) return false
} else if (other.first != null) return false
if (second != null) {
if (other.second == null) return false
if (!second.contentEquals(other.second)) return false
} else if (other.second != null) return false
return true
}
override fun hashCode(): Int {
var result = first?.contentHashCode() ?: 0
result = 31 * result + (second?.contentHashCode() ?: 0)
return result
}
}
data class PrfResults(
val first: ByteArray,
val second: ByteArray?
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PrfResults
if (!first.contentEquals(other.first)) return false
if (second != null) {
if (other.second == null) return false
if (!second.contentEquals(other.second)) return false
} else if (other.second != null) return false
return true
}
override fun hashCode(): Int {
var result = first.contentHashCode()
result = 31 * result + (second?.contentHashCode() ?: 0)
return result
}
}
sealed class PasskeyError(message: String) : Exception(message) {
class InvalidPublicKey(message: String) : PasskeyError(message)
class InvalidPrivateKey(message: String) : PasskeyError(message)
class InvalidJWK(message: String) : PasskeyError(message)
class InvalidSignature(message: String) : PasskeyError(message)
class CborEncodingFailed(message: String) : PasskeyError(message)
}
}

View File

@@ -0,0 +1,63 @@
package net.aliasvault.app.vaultstore.passkey
import android.util.Base64
import java.security.SecureRandom
import java.util.UUID
/**
* PasskeyHelper
* -------------------------
* Utility class for passkey-related operations, including GUID/base64url conversions.
*
* This is a Kotlin port of the reference TypeScript implementation:
* - Reference: apps/browser-extension/src/utils/passkey/PasskeyHelper.ts
* - iOS: apps/mobile-app/ios/VaultStoreKit/Passkeys/PasskeyHelper.swift
*
* IMPORTANT: Keep all implementations synchronized. Changes to the public interface must be
* reflected in all ports. Method names, parameters, and behavior should remain consistent.
*/
object PasskeyHelper {
/**
* Convert GUID string to byte array
* Example: "3f2504e0-4f89-11d3-9a0c-0305e82c3301" → ByteArray(16 bytes)
*/
@JvmStatic
fun guidToBytes(guid: String): ByteArray {
// Remove dashes
val hex = guid.replace("-", "")
require(hex.length == 32) { "Invalid GUID format" }
val bytes = ByteArray(16)
for (i in 0..15) {
bytes[i] = hex.substring(i * 2, i * 2 + 2).toInt(16).toByte()
}
return bytes
}
/**
* Convert byte array to GUID string (uppercase)
* Example: ByteArray(16 bytes) → "3F2504E0-4F89-11D3-9A0C-0305E82C3301"
*/
@JvmStatic
fun bytesToGuid(bytes: ByteArray): String {
require(bytes.size == 16) { "Invalid byte length for GUID" }
val hex = bytes.joinToString("") { "%02x".format(it) }
// Insert dashes in canonical format: 8-4-4-4-12
return buildString {
append(hex.substring(0, 8))
append("-")
append(hex.substring(8, 12))
append("-")
append(hex.substring(12, 16))
append("-")
append(hex.substring(16, 20))
append("-")
append(hex.substring(20))
}.uppercase()
}
}

View File

@@ -9,8 +9,12 @@ import Security
* Implements passkey creation (registration) and authentication (assertion) following
* the WebAuthn Level 2 specification.
*
* This is a port of the browser extension PasskeyAuthenticator.ts to native Swift.
* TODO: review implementation and docs.
* This is a Swift port of the reference TypeScript implementation:
* - Reference: apps/browser-extension/src/utils/passkey/PasskeyAuthenticator.ts
* - Android: apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/passkey/PasskeyAuthenticator.kt
*
* IMPORTANT: Keep all implementations synchronized. Changes to the public interface must be
* reflected in all ports. Method names, parameters, and behavior should remain consistent.
*
* Key features:
* - ES256 (ECDSA P-256) key pair generation

View File

@@ -4,8 +4,13 @@ import Foundation
* PasskeyHelper
* -------------------------
* Utility class for passkey-related operations, including GUID/base64url conversions.
* Port of the browser extension PasskeyHelper.ts to Swift.
* TODO: review implementation and docs.
*
* This is a Swift port of the reference TypeScript implementation:
* - Reference: apps/browser-extension/src/utils/passkey/PasskeyHelper.ts
* - Android: apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/passkey/PasskeyHelper.kt
*
* IMPORTANT: Keep all implementations synchronized. Changes to the public interface must be
* reflected in all ports. Method names, parameters, and behavior should remain consistent.
*/
public class PasskeyHelper {
@@ -62,64 +67,9 @@ public class PasskeyHelper {
return parts.joined(separator: "-").uppercased()
}
/**
* Convert GUID to base64url for WebAuthn credential ID
* Example: "3f2504e0-4f89-11d3-9a0c-0305e82c3301" "PyUE4E-JEdOaDAPF6CwzAQ"
*/
public static func guidToBase64url(_ guid: String) throws -> String {
let bytes = try guidToBytes(guid)
return bytes.base64URLEncodedString()
}
/**
* Convert base64url to GUID for database lookup
* Example: "PyUE4E-JEdOaDAPF6CwzAQ" "3F2504E0-4F89-11D3-9A0C-0305E82C3301"
*/
public static func base64urlToGuid(_ base64url: String) throws -> String {
let bytes = try Data(base64URLEncoded: base64url)
return try bytesToGuid(bytes)
}
/**
* Convert byte array to base64url string
*/
public static func bytesToBase64url(_ bytes: Data) -> String {
return bytes.base64URLEncodedString()
}
/**
* Convert base64url string to byte array
*/
public static func base64urlToBytes(_ base64url: String) throws -> Data {
return try Data(base64URLEncoded: base64url)
}
/**
* Generate a random GUID string
*/
public static func generateGuid() -> String {
return UUID().uuidString.uppercased()
}
/**
* Generate random bytes for credential ID
*/
public static func generateCredentialId(length: Int = 16) throws -> Data {
var bytes = Data(count: length)
let result = bytes.withUnsafeMutableBytes { bytesPtr in
SecRandomCopyBytes(kSecRandomDefault, length, bytesPtr.baseAddress!)
}
guard result == errSecSuccess else {
throw PasskeyHelperError.randomGenerationFailed
}
return bytes
}
}
public enum PasskeyHelperError: Error {
case invalidGuidFormat
case invalidByteLength
case randomGenerationFailed
}