mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-13 01:45:12 -04:00
Refactor passkey logic implementation (#520)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user