Simplify PasskeyAuthenticationActivity.kt (#520)

This commit is contained in:
Leendert de Borst
2025-10-20 10:43:56 +02:00
parent 4cff77b927
commit 11ea12499b
2 changed files with 70 additions and 396 deletions

View File

@@ -7,7 +7,7 @@ import android.util.Log
import androidx.credentials.provider.PendingIntentHandler
import androidx.credentials.provider.ProviderGetCredentialRequest
import net.aliasvault.app.vaultstore.VaultStore
import net.aliasvault.app.vaultstore.models.Passkey
import net.aliasvault.app.vaultstore.getPasskeyById
import net.aliasvault.app.vaultstore.passkey.PasskeyAuthenticator
import net.aliasvault.app.vaultstore.passkey.PasskeyHelper
import org.json.JSONObject
@@ -98,7 +98,7 @@ class PasskeyAuthenticationActivity : Activity() {
return
}
val passkey = getPasskeyById(passkeyId, db, vaultStore)
val passkey = vaultStore.getPasskeyById(passkeyId, db)
if (passkey == null) {
Log.e(TAG, "Passkey not found: $passkeyId")
setResult(RESULT_CANCELED)
@@ -111,11 +111,7 @@ class PasskeyAuthenticationActivity : Activity() {
) ?: ""
val requestObj = JSONObject(requestJson)
val rpId = passkey.rpId
val origin = requestObj.optString("origin", "https://$rpId")
// Extract clientDataHash from Chrome's request
// Chrome provides a pre-computed clientDataHash that we must use for signing
var clientDataHashFromChrome: ByteArray? = null
providerRequest.credentialOptions.forEach { option ->
if (option is androidx.credentials.GetPublicKeyCredentialOption) {
@@ -123,18 +119,39 @@ class PasskeyAuthenticationActivity : Activity() {
}
}
// If Chrome didn't provide clientDataHash, build clientDataJSON and hash it
val clientDataHash: ByteArray
val clientDataJson: String?
if (clientDataHashFromChrome != null) {
clientDataHash = clientDataHashFromChrome
// Don't build clientDataJSON - Chrome has its own
clientDataJson = null
} else {
val challenge = requestObj.optString("challenge", "")
val origin = requestObj.optString("origin", "https://${passkey.rpId}")
val json = buildClientDataJson(challenge, origin)
clientDataHash = sha256(json.toByteArray(Charsets.UTF_8))
clientDataJson = json
}
// Use PasskeyAuthenticator.getAssertion for signing
val credentialId = PasskeyHelper.guidToBytes(passkey.id.toString())
val prfInputs = extractPrfInputs(requestObj)
val assertion = PasskeyAuthenticator.getAssertion(
credentialId = credentialId,
clientDataHash = clientDataHash,
rpId = passkey.rpId,
privateKeyJWK = passkey.privateKey,
userId = passkey.userHandle,
uvPerformed = true,
prfInputs = prfInputs,
prfSecret = passkey.prfKey,
)
val response = buildAuthenticationResponseWithSignature(
requestJson,
credentialId,
passkey.privateKey,
passkey.userHandle,
origin,
prfInputs,
passkey.prfKey,
clientDataHashFromChrome,
// Build response JSON
val response = buildPublicKeyCredentialResponse(
assertion = assertion,
clientDataJson = clientDataJson,
)
val resultIntent = Intent()
@@ -163,89 +180,7 @@ class PasskeyAuthenticationActivity : Activity() {
}
/**
* Get passkey by its UUID (not credential ID)
*/
private fun getPasskeyById(
passkeyId: UUID,
db: android.database.sqlite.SQLiteDatabase,
vaultStore: VaultStore,
): net.aliasvault.app.vaultstore.models.Passkey? {
val query = """
SELECT Id, CredentialId, RpId, UserHandle, PublicKey, PrivateKey, PrfKey,
DisplayName, CreatedAt, UpdatedAt, IsDeleted
FROM Passkeys
WHERE Id = ? AND IsDeleted = 0
LIMIT 1
""".trimIndent()
Log.d(TAG, "Querying for passkey with ID: $passkeyId")
val cursor = db.rawQuery(query, arrayOf(passkeyId.toString().uppercase()))
cursor.use {
if (it.moveToFirst()) {
Log.d(TAG, "Found passkey in database")
return parsePasskeyRow(it)
} else {
Log.w(TAG, "Passkey not found in database. Checking all passkeys...")
// Debug: List all passkeys to see what's in the database
val debugQuery = "SELECT Id, RpId, DisplayName FROM Passkeys WHERE IsDeleted = 0"
val debugCursor = db.rawQuery(debugQuery, null)
debugCursor.use { debugIt ->
var count = 0
while (debugIt.moveToNext()) {
count++
val id = debugIt.getString(0)
val rpId = debugIt.getString(1)
val displayName = debugIt.getString(2)
Log.d(TAG, "Passkey $count: ID=$id, RpId=$rpId, DisplayName=$displayName")
}
Log.d(TAG, "Total passkeys in database: $count")
}
}
}
return null
}
/**
* Parse passkey from cursor (simplified version from VaultStorePasskey)
*/
private fun parsePasskeyRow(cursor: android.database.Cursor): net.aliasvault.app.vaultstore.models.Passkey? {
try {
val id = UUID.fromString(cursor.getString(0))
val parentCredentialId = UUID.fromString(cursor.getString(1))
val rpId = cursor.getString(2)
val userHandle = if (!cursor.isNull(3)) cursor.getBlob(3) else null
val publicKey = cursor.getString(4).toByteArray(Charsets.UTF_8)
val privateKey = cursor.getString(5).toByteArray(Charsets.UTF_8)
val prfKey = if (!cursor.isNull(6)) cursor.getBlob(6) else null
val displayName = cursor.getString(7)
// Use current date for createdAt/updatedAt as we don't need them here
val now = java.util.Date()
return net.aliasvault.app.vaultstore.models.Passkey(
id = id,
parentCredentialId = parentCredentialId,
rpId = rpId,
userHandle = userHandle,
userName = null,
publicKey = publicKey,
privateKey = privateKey,
prfKey = prfKey,
displayName = displayName,
createdAt = now,
updatedAt = now,
isDeleted = false,
)
} catch (e: Exception) {
Log.e(TAG, "Error parsing passkey row", e)
return null
}
}
/**
* Build clientDataJSON for WebAuthn (from challenge ByteArray)
* Build clientDataJSON for WebAuthn
*/
private fun buildClientDataJson(challenge: ByteArray, origin: String): String {
val challengeB64 = base64urlEncode(challenge)
@@ -254,26 +189,14 @@ class PasskeyAuthenticationActivity : Activity() {
/**
* Build clientDataJSON for WebAuthn
*
* IMPORTANT: Per WebAuthn spec (https://www.w3.org/TR/webauthn-2/#clientdatajson-serialization),
* the server/RP MUST parse clientDataJSON as JSON and validate only the required fields.
* Browsers (Chrome) may add extra fields like "other_keys_can_be_added_here" to test that
* RPs don't do naive template matching.
*
* We build a minimal, spec-compliant clientDataJSON here. The server should:
* 1. Parse as JSON (not string compare!)
* 2. Validate type === "webauthn.get"
* 3. Validate challenge matches expected value
* 4. Validate origin is allowed for this RP
* 5. Ignore any extra fields
*
* CRITICAL: Must NOT escape forward slashes in origin!
* JavaScript JSON.stringify() doesn't escape slashes by default, but Android's JSONObject does.
*/
private fun buildClientDataJson(challenge: String, origin: String): String {
// Build JSON manually WITHOUT escaping forward slashes
// This matches browser behavior where JSON.stringify() doesn't escape slashes
// NOTE: We only include required fields. Extra fields from browsers are allowed by spec.
// TODO: check if this point is ever hit?
Log.d(TAG, "--------------------------------------")
Log.d(TAG, "Building clientDataJSON for WebAuthn manually")
Log.d(TAG, "--------------------------------------")
return """{"type":"webauthn.get","challenge":"$challenge","origin":"$origin","crossOrigin":false}"""
}
@@ -339,79 +262,20 @@ class PasskeyAuthenticationActivity : Activity() {
}
/**
* Build WebAuthn assertion response with proper signature
* Build PublicKeyCredential response from PasskeyAuthenticator assertion result
*
* CRITICAL: If Chrome provides clientDataHash, we MUST use that for signing.
* Otherwise, we build our own clientDataJSON.
*
* Chrome on Android will replace our clientDataJSON with its own (including extra fields),
* so we must sign using Chrome's pre-computed hash to ensure the signature matches.
* @param assertion The assertion result from PasskeyAuthenticator.getAssertion
* @param clientDataJson Optional clientDataJSON string. If null, clientDataJSON will be
* omitted from the response (used when Chrome provides clientDataHash)
*/
@Suppress("LongParameterList")
private fun buildAuthenticationResponseWithSignature(
requestJson: String,
credentialId: ByteArray,
privateKeyJWK: ByteArray,
userHandle: ByteArray?,
origin: String,
prfInputs: PasskeyAuthenticator.PrfInputs?,
prfSecret: ByteArray?,
clientDataHashFromChrome: ByteArray?,
private fun buildPublicKeyCredentialResponse(
assertion: PasskeyAuthenticator.PasskeyAssertionResult,
clientDataJson: String?,
): androidx.credentials.GetCredentialResponse {
val requestObj = JSONObject(requestJson)
val challengeB64 = requestObj.optString("challenge", "")
// Determine clientDataHash - use Chrome's if provided, otherwise build our own
val clientDataHash: ByteArray
val clientDataB64: String
if (clientDataHashFromChrome != null) {
clientDataHash = clientDataHashFromChrome
val clientDataJson = buildClientDataJson(challengeB64, origin)
clientDataB64 = base64urlEncode(clientDataJson.toByteArray(Charsets.UTF_8))
} else {
val clientDataJson = buildClientDataJson(challengeB64, origin)
val clientDataBytes = clientDataJson.toByteArray(Charsets.UTF_8)
clientDataHash = sha256(clientDataBytes)
clientDataB64 = base64urlEncode(clientDataBytes)
}
val rpId = requestObj.optString("rpId", "")
if (rpId.isEmpty()) {
throw IllegalArgumentException("rpId required in request")
}
val userVerified = true
val authData = buildAuthenticatorData(
rpId = rpId,
userVerified = userVerified,
includeExtensions = false,
)
val authDataB64 = base64urlEncode(authData)
val dataToSign = authData + clientDataHash
val privateKey = importPrivateKeyFromJWK(privateKeyJWK)
val signer = java.security.Signature.getInstance("SHA256withECDSA")
signer.initSign(privateKey)
signer.update(dataToSign)
val rawSignature = signer.sign()
val signature = canonicalizeECDSASignature(rawSignature)
// Step 6: Evaluate PRF extension if requested
val prfResults = if (prfInputs?.first != null && prfSecret != null) {
val first = evaluatePrf(prfSecret, prfInputs.first!!)
val second = prfInputs.second?.let { evaluatePrf(prfSecret, it) }
PasskeyAuthenticator.PrfResults(first, second)
} else {
null
}
// Build WebAuthn assertion response JSON
val credentialIdB64 = base64urlEncode(credentialId)
val signatureB64 = base64urlEncode(signature)
val userHandleB64 = userHandle?.let { base64urlEncode(it) }
val credentialIdB64 = base64urlEncode(assertion.credentialId)
val signatureB64 = base64urlEncode(assertion.signature)
val authDataB64 = base64urlEncode(assertion.authenticatorData)
val userHandleB64 = assertion.userHandle?.let { base64urlEncode(it) }
val responseObj = JSONObject().apply {
put("id", credentialIdB64)
@@ -422,7 +286,11 @@ class PasskeyAuthenticationActivity : Activity() {
put(
"response",
JSONObject().apply {
put("clientDataJSON", clientDataB64)
// Only include clientDataJSON if we built it ourselves
// When Chrome provides clientDataHash, omit this field
clientDataJson?.let {
put("clientDataJSON", base64urlEncode(it.toByteArray(Charsets.UTF_8)))
}
put("authenticatorData", authDataB64)
put("signature", signatureB64)
userHandleB64?.let { put("userHandle", it) }
@@ -431,7 +299,7 @@ class PasskeyAuthenticationActivity : Activity() {
put(
"clientExtensionResults",
prfResults?.let {
assertion.prfResults?.let {
JSONObject().apply {
put(
"prf",
@@ -454,201 +322,4 @@ class PasskeyAuthenticationActivity : Activity() {
androidx.credentials.PublicKeyCredential(responseObj.toString()),
)
}
/**
* Build authenticatorData deterministically per WebAuthn spec
*
* @param rpId The relying party ID
* @param userVerified Whether user verification was performed
* @param includeExtensions Whether to include extension data
* @return The authenticatorData bytes
*/
private fun buildAuthenticatorData(
rpId: String,
userVerified: Boolean,
includeExtensions: Boolean = false,
): ByteArray {
// rpIdHash: SHA-256(rpId)
val rpIdHash = sha256(rpId.toByteArray(Charsets.UTF_8))
// Flags byte (bit 0 = UP, bit 2 = UV, bit 7 = ED)
var flags: Byte = 0x01 // UP (User Present) = 1
if (userVerified) {
flags = (flags.toInt() or 0x04).toByte() // UV (User Verified) = 1
}
if (includeExtensions) {
flags = (flags.toInt() or 0x80).toByte() // ED (Extension Data) = 1
}
// BE (Backup Eligible) and BS (Backup State) = 0 (bits 3 and 4)
// We're not a synced/backup authenticator for credential manager purposes
// signCount: 4 bytes, big-endian, set to 0
// (we don't maintain per-credential counters)
val signCount = byteArrayOf(0x00, 0x00, 0x00, 0x00)
// Extensions: empty CBOR map if ED flag is set
// PRF results go in clientExtensionResults, not authenticator extensions
val extensions = if (includeExtensions) {
// Empty CBOR map: 0xA0
byteArrayOf(0xA0.toByte())
} else {
byteArrayOf()
}
return rpIdHash + flags + signCount + extensions
}
/**
* Canonicalize ECDSA signature to low-S form per WebAuthn spec
*/
private fun canonicalizeECDSASignature(derSignature: ByteArray): ByteArray {
// Parse DER-encoded signature
val (r, s) = parseDERSignature(derSignature)
// secp256r1 order (n)
val n = java.math.BigInteger(
"FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551",
16,
)
val halfN = n.shiftRight(1)
val canonicalS = if (s > halfN) n.subtract(s) else s
// Re-encode to DER
return encodeDERSignature(r, canonicalS)
}
/**
* Parse DER-encoded ECDSA signature into (r, s) components
*/
private fun parseDERSignature(der: ByteArray): Pair<java.math.BigInteger, java.math.BigInteger> {
var offset = 0
// SEQUENCE tag
require(der[offset++].toInt() == 0x30) { "Invalid DER: expected SEQUENCE" }
// SEQUENCE length
val seqLength = der[offset++].toInt() and 0xFF
require(seqLength == der.size - 2) { "Invalid DER: incorrect SEQUENCE length" }
// INTEGER tag for r
require(der[offset++].toInt() == 0x02) { "Invalid DER: expected INTEGER for r" }
val rLength = der[offset++].toInt() and 0xFF
val rBytes = der.copyOfRange(offset, offset + rLength)
offset += rLength
val r = java.math.BigInteger(1, rBytes)
// INTEGER tag for s
require(der[offset++].toInt() == 0x02) { "Invalid DER: expected INTEGER for s" }
val sLength = der[offset++].toInt() and 0xFF
val sBytes = der.copyOfRange(offset, offset + sLength)
val s = java.math.BigInteger(1, sBytes)
return Pair(r, s)
}
/**
* Encode (r, s) components into DER-encoded ECDSA signature
*/
private fun encodeDERSignature(r: java.math.BigInteger, s: java.math.BigInteger): ByteArray {
fun encodeInteger(value: java.math.BigInteger): ByteArray {
val bytes = value.toByteArray()
// BigInteger.toByteArray() includes sign byte if MSB is set
// DER INTEGER should have minimal encoding
return byteArrayOf(0x02.toByte(), bytes.size.toByte()) + bytes
}
val rEncoded = encodeInteger(r)
val sEncoded = encodeInteger(s)
val totalLength = rEncoded.size + sEncoded.size
return byteArrayOf(
0x30.toByte(), // SEQUENCE tag
totalLength.toByte(), // SEQUENCE length
) + rEncoded + sEncoded
}
/**
* Import public key from private key JWK (uses x, y coordinates)
*/
private fun importPublicKeyFromPrivateJWK(jwkData: ByteArray): java.security.interfaces.ECPublicKey {
val jwkString = String(jwkData, Charsets.UTF_8)
val jwk = JSONObject(jwkString)
// Extract x and y coordinates
val xBase64url = jwk.optString("x") ?: throw IllegalArgumentException("Missing 'x' in JWK")
val yBase64url = jwk.optString("y") ?: throw IllegalArgumentException("Missing 'y' in JWK")
val xBytes = base64urlDecode(xBase64url)
val yBytes = base64urlDecode(yBase64url)
// Create ECPoint
val x = java.math.BigInteger(1, xBytes)
val y = java.math.BigInteger(1, yBytes)
val point = java.security.spec.ECPoint(x, y)
// Get P-256 curve parameters
val ecSpec = java.security.spec.ECGenParameterSpec("secp256r1")
val params = java.security.AlgorithmParameters.getInstance("EC")
params.init(ecSpec)
val ecParameterSpec = params.getParameterSpec(java.security.spec.ECParameterSpec::class.java)
// Create ECPublicKeySpec
val pubKeySpec = java.security.spec.ECPublicKeySpec(point, ecParameterSpec)
// Generate the public key
val keyFactory = java.security.KeyFactory.getInstance("EC")
return keyFactory.generatePublic(pubKeySpec) as java.security.interfaces.ECPublicKey
}
/**
* Import private key from JWK format (duplicate from PasskeyAuthenticator for local use)
*/
private fun importPrivateKeyFromJWK(jwkData: ByteArray): java.security.interfaces.ECPrivateKey {
val jwkString = String(jwkData, Charsets.UTF_8)
val jwk = JSONObject(jwkString)
// Extract the d parameter (private key component)
val dBase64url = jwk.optString("d")
?: throw IllegalArgumentException("Missing 'd' parameter in JWK")
// Decode base64url to bytes
val dBytes = base64urlDecode(dBase64url)
// Convert to BigInteger (d parameter is the private key value)
val d = java.math.BigInteger(1, dBytes)
// Get P-256 curve parameters
val ecSpec = java.security.spec.ECGenParameterSpec("secp256r1")
val params = java.security.AlgorithmParameters.getInstance("EC")
params.init(ecSpec)
val ecParameterSpec = params.getParameterSpec(java.security.spec.ECParameterSpec::class.java)
// Create ECPrivateKeySpec with the d value and curve parameters
val privKeySpec = java.security.spec.ECPrivateKeySpec(d, ecParameterSpec)
// Generate the private key
val keyFactory = java.security.KeyFactory.getInstance("EC")
return keyFactory.generatePrivate(privKeySpec) as java.security.interfaces.ECPrivateKey
}
/**
* Evaluate PRF (hmac-secret extension) - duplicate from PasskeyAuthenticator
*/
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 = javax.crypto.Mac.getInstance("HmacSHA256")
val secretKey = javax.crypto.spec.SecretKeySpec(secret, "HmacSHA256")
mac.init(secretKey)
return mac.doFinal(hashedSalt)
}
}

View File

@@ -61,7 +61,7 @@ fun VaultStore.getPasskeyByCredentialId(credentialId: ByteArray, db: SQLiteDatab
LIMIT 1
""".trimIndent()
val cursor = db.rawQuery(query, arrayOf(credentialIdString))
val cursor = db.rawQuery(query, arrayOf(credentialIdString.uppercase()))
cursor.use {
if (it.moveToFirst()) {
return parsePasskeyRow(it)
@@ -84,7 +84,7 @@ fun VaultStore.getPasskeysForCredential(credentialId: UUID, db: SQLiteDatabase):
""".trimIndent()
val passkeys = mutableListOf<Passkey>()
val cursor = db.rawQuery(query, arrayOf(credentialId.toString()))
val cursor = db.rawQuery(query, arrayOf(credentialId.toString().uppercase()))
cursor.use {
while (it.moveToNext()) {
@@ -202,8 +202,8 @@ fun VaultStore.insertPasskey(passkey: Passkey, db: SQLiteDatabase) {
db.execSQL(
insert,
arrayOf(
passkey.id.toString(),
passkey.parentCredentialId.toString(),
passkey.id.toString().uppercase(),
passkey.parentCredentialId.toString().uppercase(),
passkey.rpId,
passkey.userHandle,
publicKeyString,
@@ -243,7 +243,7 @@ fun VaultStore.createCredentialWithPasskey(
db.execSQL(
serviceInsert,
arrayOf(
serviceId.toString(),
serviceId.toString().uppercase(),
displayName,
"https://$rpId",
logo,
@@ -264,7 +264,7 @@ fun VaultStore.createCredentialWithPasskey(
db.execSQL(
aliasInsert,
arrayOf(
aliasId.toString(),
aliasId.toString().uppercase(),
"",
"",
"",
@@ -286,9 +286,9 @@ fun VaultStore.createCredentialWithPasskey(
db.execSQL(
credentialInsert,
arrayOf(
credentialId.toString(),
serviceId.toString(),
aliasId.toString(),
credentialId.toString().uppercase(),
serviceId.toString().uppercase(),
aliasId.toString().uppercase(),
userName,
null,
timestamp,
@@ -364,7 +364,7 @@ fun VaultStore.replacePasskey(
SELECT ServiceId FROM Credentials WHERE Id = ? LIMIT 1
""".trimIndent()
val cursor = db.rawQuery(credQuery, arrayOf(credentialId.toString()))
val cursor = db.rawQuery(credQuery, arrayOf(credentialId.toString().uppercase()))
cursor.use {
if (it.moveToFirst()) {
val serviceId = it.getString(0)
@@ -388,7 +388,7 @@ fun VaultStore.replacePasskey(
WHERE Id = ?
""".trimIndent()
db.execSQL(deleteQuery, arrayOf(timestamp, oldPasskeyId.toString()))
db.execSQL(deleteQuery, arrayOf(timestamp, oldPasskeyId.toString().uppercase()))
// Create the new passkey with the same credential ID
val updatedPasskey = newPasskey.copy(
@@ -405,7 +405,7 @@ fun VaultStore.replacePasskey(
/**
* Get a passkey by its ID
*/
private fun VaultStore.getPasskeyById(passkeyId: UUID, db: SQLiteDatabase): Passkey? {
fun VaultStore.getPasskeyById(passkeyId: UUID, db: SQLiteDatabase): Passkey? {
val query = """
SELECT Id, CredentialId, RpId, UserHandle, PublicKey, PrivateKey, PrfKey,
DisplayName, CreatedAt, UpdatedAt, IsDeleted
@@ -414,7 +414,10 @@ private fun VaultStore.getPasskeyById(passkeyId: UUID, db: SQLiteDatabase): Pass
LIMIT 1
""".trimIndent()
val cursor = db.rawQuery(query, arrayOf(passkeyId.toString()))
// Always convert the UUID to uppercase when querying the DB
val upperPasskeyId = passkeyId.toString().uppercase()
val cursor = db.rawQuery(query, arrayOf(upperPasskeyId))
cursor.use {
if (it.moveToFirst()) {
return parsePasskeyRow(it)