From dad709fc20c47af796fc2be6d9a85f00dd12a8bb Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Tue, 14 Oct 2025 15:40:51 +0200 Subject: [PATCH] Refactor passkey logic implementation (#520) --- .../src/utils/passkey/PasskeyAuthenticator.ts | 7 + .../src/utils/passkey/PasskeyHelper.ts | 11 +- .../passkey/PasskeyAuthenticator.kt | 504 ++++++++++++++++++ .../app/vaultstore/passkey/PasskeyHelper.kt | 63 +++ .../Passkeys/PasskeyAuthenticator.swift | 8 +- .../Passkeys/PasskeyHelper.swift | 64 +-- 6 files changed, 597 insertions(+), 60 deletions(-) create mode 100644 apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/passkey/PasskeyAuthenticator.kt create mode 100644 apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/passkey/PasskeyHelper.kt diff --git a/apps/browser-extension/src/utils/passkey/PasskeyAuthenticator.ts b/apps/browser-extension/src/utils/passkey/PasskeyAuthenticator.ts index 7cf69832b..d30e083a9 100644 --- a/apps/browser-extension/src/utils/passkey/PasskeyAuthenticator.ts +++ b/apps/browser-extension/src/utils/passkey/PasskeyAuthenticator.ts @@ -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 diff --git a/apps/browser-extension/src/utils/passkey/PasskeyHelper.ts b/apps/browser-extension/src/utils/passkey/PasskeyHelper.ts index 9e27afe4b..75dd4ae3e 100644 --- a/apps/browser-extension/src/utils/passkey/PasskeyHelper.ts +++ b/apps/browser-extension/src/utils/passkey/PasskeyHelper.ts @@ -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 { /** diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/passkey/PasskeyAuthenticator.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/passkey/PasskeyAuthenticator.kt new file mode 100644 index 000000000..b13f614f2 --- /dev/null +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/passkey/PasskeyAuthenticator.kt @@ -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: } + */ + 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) + } +} diff --git a/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/passkey/PasskeyHelper.kt b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/passkey/PasskeyHelper.kt new file mode 100644 index 000000000..0a5d3d75f --- /dev/null +++ b/apps/mobile-app/android/app/src/main/java/net/aliasvault/app/vaultstore/passkey/PasskeyHelper.kt @@ -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() + } + +} diff --git a/apps/mobile-app/ios/VaultStoreKit/Passkeys/PasskeyAuthenticator.swift b/apps/mobile-app/ios/VaultStoreKit/Passkeys/PasskeyAuthenticator.swift index 436aabdf4..735dd569e 100644 --- a/apps/mobile-app/ios/VaultStoreKit/Passkeys/PasskeyAuthenticator.swift +++ b/apps/mobile-app/ios/VaultStoreKit/Passkeys/PasskeyAuthenticator.swift @@ -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 diff --git a/apps/mobile-app/ios/VaultStoreKit/Passkeys/PasskeyHelper.swift b/apps/mobile-app/ios/VaultStoreKit/Passkeys/PasskeyHelper.swift index df2a8da92..eb7df11d5 100644 --- a/apps/mobile-app/ios/VaultStoreKit/Passkeys/PasskeyHelper.swift +++ b/apps/mobile-app/ios/VaultStoreKit/Passkeys/PasskeyHelper.swift @@ -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 }