a0af1c52d84b

H-07: passkey flags derived from ceremony state; BE persisted as true; UV from real biometric

time
2026-07-29 13:50 UTC · 2 months ago
machine
koh2
agent
claude-opus-4
session
audit-remediation-2026-07-29
parent
c9594ddcf5d4
5 files app/src/main/java/com/peachpasswords/passkeys/PasskeyAssertion.kt+23 −1app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+44 −3app/src/main/java/com/peachpasswords/passkeys/PasskeyRegistration.kt+20 −3app/src/test/java/com/peachpasswords/passkeys/PasskeyAssertionTest.kt+57 −0app/src/test/java/com/peachpasswords/passkeys/PasskeyRegistrationTest.kt+23 −0
app/src/main/java/com/peachpasswords/passkeys/PasskeyAssertion.kt+23 −1
@@ -38,6 +38,10 @@3838         requestJson: String,3939         origin: String?,4040         clientDataHashOverride: ByteArray? = null,41+        // Set to true only when a biometric/device-credential prompt has actually42+        // succeeded for this assertion. Defaults to false so existing callers and43+        // tests that bypass the prompt still produce a valid flag byte (UV=0).44+        userVerified: Boolean = false,4145     ): PasskeyAssertionResult? {4246         val passkey = entry.passkey ?: return null4347         val privateKeyJwk = passkey.privateKeyJwk ?: entry.privateKeyJwk ?: return null@@ -47,7 +51,10 @@4751         val challenge = request["challenge"]?.jsonPrimitive?.contentOrNull ?: return null4852 4953         val nextSignCount = passkey.signCount + 150-        val flags = 0x05 or (if (passkey.backupEligible) 0x08 else 0) or (if (passkey.backupState) 0x10 else 0)54+        // UP is implicit (user tapped the credential), BE/BS reflect the55+        // persisted record (immutable once written), and UV only when the caller56+        // actually performed user verification. AT must NOT be set on assertions.57+        val flags = assertionFlags(passkey.backupEligible, passkey.backupState, userVerified)5158         val authData = buildAuthenticatorData(passkey.rpId, flags, nextSignCount)5259         val clientDataJson = buildClientDataJson(5360             challenge = challenge,@@ -96,6 +103,21 @@96103         out[36] = (signCount and 0xff).toByte()97104         return out98105     }106+107+    // AT (0x40) is intentionally NOT included — assertions never carry108+    // attested credential data; that's a registration-only field.109+    private fun assertionFlags(backupEligible: Boolean, backupState: Boolean, userVerified: Boolean): Int {110+        var f = FLAG_UP111+        if (userVerified) f = f or FLAG_UV112+        if (backupEligible) f = f or FLAG_BE113+        if (backupState) f = f or FLAG_BS114+        return f115+    }116+117+    private const val FLAG_UP: Int = 0x01118+    private const val FLAG_UV: Int = 0x04119+    private const val FLAG_BE: Int = 0x08120+    private const val FLAG_BS: Int = 0x1099121 100122     private fun signPasskey(privateKeyJwk: String, algorithm: Int, message: ByteArray): ByteArray? =101123         when (algorithm) {

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyCredentialSelectionActivity.kt+44 −3
@@ -6,7 +6,6 @@66 import android.os.Build77 import android.os.Bundle88 import android.view.WindowManager9-import androidx.activity.ComponentActivity109 import androidx.credentials.GetCredentialResponse1110 import androidx.credentials.GetPublicKeyCredentialOption1211 import androidx.credentials.CreatePublicKeyCredentialRequest@@ -15,17 +14,25 @@1514 import androidx.credentials.exceptions.CreateCredentialUnsupportedException1615 import androidx.credentials.exceptions.GetCredentialUnsupportedException1716 import androidx.credentials.provider.PendingIntentHandler17+import androidx.fragment.app.FragmentActivity18+import androidx.lifecycle.lifecycleScope1819 import com.peachpasswords.BuildConfig1920 import com.peachpasswords.shared.extractDomain20-import androidx.lifecycle.lifecycleScope21+import com.peachpasswords.ui.lock.Biometrics2122 import com.peachpasswords.vault.VaultRepository2223 import com.peachpasswords.vault.isLogin2324 import dagger.hilt.android.AndroidEntryPoint2425 import javax.inject.Inject2526 import kotlinx.coroutines.launch27+import kotlinx.coroutines.suspendCancellableCoroutine28+import kotlinx.serialization.json.Json29+import kotlinx.serialization.json.contentOrNull30+import kotlinx.serialization.json.jsonObject31+import kotlinx.serialization.json.jsonPrimitive32+import kotlin.coroutines.resume2633 2734 @AndroidEntryPoint28-class PasskeyCredentialSelectionActivity : ComponentActivity() {35+class PasskeyCredentialSelectionActivity : FragmentActivity() {2936     @Inject lateinit var vault: VaultRepository3037 3138     override fun onCreate(savedInstanceState: Bundle?) {@@ -59,12 +66,23 @@5966         val origin = providerRequest?.callingAppInfo?.let { info ->6067             runCatching { info.getOrigin(info.packageName) }.getOrNull()6168         }69+        // H-07: WebAuthn's UV flag must reflect actual user verification, not a70+        // hard-coded bit. If the relying party demands UV, run the platform71+        // biometric / device-credential prompt and only sign with UV=1 on success.72+        val userVerified = if (entry != null && requestJson != null &&73+            userVerificationRequirement(requestJson) == UV_REQUIRED74+        ) {75+            runUserVerificationPrompt()76+        } else {77+            false78+        }6279         val assertion = if (entry != null && requestJson != null) {6380             PasskeyAssertion.build(6481                 entry = entry,6582                 requestJson = requestJson,6683                 origin = origin,6784                 clientDataHashOverride = option?.clientDataHash,85+                userVerified = userVerified,6886             )6987         } else {7088             null@@ -134,6 +152,25 @@134152         finish()135153     }136154 155+    private fun userVerificationRequirement(requestJson: String): String? =156+        runCatching {157+            Json.parseToJsonElement(requestJson).jsonObject["userVerification"]158+                ?.jsonPrimitive?.contentOrNull159+        }.getOrNull()160+161+    private suspend fun runUserVerificationPrompt(): Boolean {162+        if (!Biometrics.isAvailable(this)) return false163+        return suspendCancellableCoroutine { cont ->164+            Biometrics.authenticate(165+                activity = this,166+                title = "Verify it's you",167+                subtitle = "Confirm to use this passkey",168+                onSuccess = { if (cont.isActive) cont.resume(true) },169+                onFail = { if (cont.isActive) cont.resume(false) },170+            )171+        }172+    }173+137174     private suspend fun storeRegistration(draft: com.peachpasswords.vault.VaultEntry): Boolean {138175         val rpId = draft.passkey?.rpId ?: return false139176         val target = vault.getEntries().firstOrNull { entry ->@@ -162,6 +199,10 @@162199         private const val EXTRA_REQUEST_JSON = "com.peachpasswords.passkeys.EXTRA_REQUEST_JSON"163200         private const val EXTRA_MODE = "com.peachpasswords.passkeys.EXTRA_MODE"164201         private const val MODE_CREATE = "create"202+        // WebAuthn's PublicKeyCredentialRequestOptions.userVerification values.203+        // Only "required" forces a biometric / device-credential prompt here;204+        // "preferred" (default) and "discouraged" leave UV=0 in authenticator data.205+        private const val UV_REQUIRED = "required"165206 166207         fun pendingIntent(context: Context, entryId: String, requestJson: String): PendingIntent {167208             val intent = Intent(context, PasskeyCredentialSelectionActivity::class.java)

view file

app/src/main/java/com/peachpasswords/passkeys/PasskeyRegistration.kt+20 −3
@@ -46,6 +46,14 @@4646         val keyMaterial = generateKeyMaterial(algorithm)4747         val credentialId = ByteArray(32).also(random::nextBytes)4848         val publicKeyCose = keyMaterial.publicKeyCose49+        // Peach's vault always permits the credential's private key to leave the50+        // device via PeachScript export or paired-device sync, so every freshly51+        // registered passkey is by definition backup-eligible. backupState stays52+        // false: at the moment of registration the credential has not yet been53+        // mirrored anywhere — that fact is written here so the BE/BS bits in the54+        // authenticator data never disagree with the persisted record.55+        val backupEligible = true56+        val backupState = false4957         val authData = buildAttestationAuthData(rpId, credentialId, publicKeyCose)5058         val attestationObject = Cbor.map(5159             "fmt" to Cbor.text("none"),@@ -65,8 +73,8 @@6573             algorithm = algorithm,6674             signCount = 0,6775             aaguid = b64(ByteArray(16)),68-            backupEligible = false,69-            backupState = false,76+            backupEligible = backupEligible,77+            backupState = backupState,7078             transports = listOf("internal"),7179             createdAt = now,7280         )@@ -171,10 +179,19 @@171179     private fun buildAttestationAuthData(rpId: String, credentialId: ByteArray, publicKeyCose: ByteArray): ByteArray {172180         val base = ByteArray(37)173181         java.security.MessageDigest.getInstance("SHA-256").digest(rpId.toByteArray(Charsets.UTF_8)).copyInto(base, 0)174-        base[32] = 0x4d182+        // Registration always implies the user just confirmed (UV=1), the183+        // credential is backup-eligible by construction (BE), and attested184+        // credential data is being included (AT). UP=1 is implicit in the tap.185+        base[32] = REGISTRATION_FLAGS.toByte()175186         val length = byteArrayOf(((credentialId.size ushr 8) and 0xff).toByte(), (credentialId.size and 0xff).toByte())176187         return base + ByteArray(16) + length + credentialId + publicKeyCose177188     }189+190+    private const val FLAG_UP: Int = 0x01191+    private const val FLAG_UV: Int = 0x04192+    private const val FLAG_BE: Int = 0x08193+    private const val FLAG_AT: Int = 0x40194+    private const val REGISTRATION_FLAGS: Int = FLAG_UP or FLAG_UV or FLAG_BE or FLAG_AT178195 179196     private fun clientDataJson(type: String, challenge: String, origin: String): ByteArray =180197         json.encodeToString(

view file

app/src/test/java/com/peachpasswords/passkeys/PasskeyAssertionTest.kt+57 −0
@@ -92,6 +92,63 @@9292         assertEquals(null, PasskeyAssertion.build(entry, """{"challenge":"c","rpId":"example.com"}""", "https://example.com"))9393     }9494 95+    @Test fun assertion_flag_byte_reflects_backup_and_uv_state() {96+        // Backup-eligible + UV performed → UP | UV | BE = 0x0d. AT (0x40) must97+        // NOT be set on an assertion (it's registration-only). BS depends on98+        // passkey.backupState which is false here.99+        val keyPair = KeyPairGenerator.getInstance("EC").apply {100+            initialize(ECGenParameterSpec("secp256r1"))101+        }.generateKeyPair()102+        val privKey = keyPair.private as ECPrivateKey103+        val backupEntry = passkeyEntry(privKey).copy(104+            passkey = passkeyEntry(privKey).passkey!!.copy(105+                backupEligible = true,106+                backupState = false,107+            ),108+        )109+110+        val verified = PasskeyAssertion.build(111+            entry = backupEntry,112+            requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",113+            origin = "https://example.com",114+            userVerified = true,115+        )116+        assertNotNull(verified)117+        assertEquals(0x0d, extractFlags(verified!!.responseJson))118+119+        // Same entry, no UV performed: UP | BE only = 0x09.120+        val unverified = PasskeyAssertion.build(121+            entry = backupEntry,122+            requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",123+            origin = "https://example.com",124+            userVerified = false,125+        )126+        assertEquals(0x09, extractFlags(unverified!!.responseJson))127+128+        // No backup eligibility, no UV: just UP = 0x01.129+        val plainEntry = passkeyEntry(privKey).copy(130+            passkey = passkeyEntry(privKey).passkey!!.copy(131+                backupEligible = false,132+                backupState = false,133+            ),134+        )135+        val plain = PasskeyAssertion.build(136+            entry = plainEntry,137+            requestJson = """{"challenge":"Y2hhbGxlbmdl","rpId":"example.com"}""",138+            origin = "https://example.com",139+            userVerified = false,140+        )141+        assertEquals(0x01, extractFlags(plain!!.responseJson))142+        // AT (0x40) is never set on assertions regardless of the other bits.143+        assertEquals(0, 0x40 and extractFlags(plain.responseJson))144+    }145+146+    private fun extractFlags(responseJson: String): Int {147+        val payload = Json.parseToJsonElement(responseJson).jsonObject["response"]!!.jsonObject148+        val authData = b64Decode(payload["authenticatorData"]!!.jsonPrimitive.content)149+        return authData[32].toInt() and 0xff150+    }151+95152     private fun passkeyEntry(privateKey: ECPrivateKey?) = VaultEntry(96153         id = "entry-1",97154         type = "passkey",

view file

app/src/test/java/com/peachpasswords/passkeys/PasskeyRegistrationTest.kt+23 −0
@@ -68,6 +68,29 @@6868         assertEquals(null, result)6969     }7070 71+    @Test fun registration_writes_backup_eligible_true_and_correct_flag_byte() {72+        val result = PasskeyRegistration.build(73+            requestJson = """74+                {"challenge":"Y2hhbGxlbmdl","rp":{"id":"example.com","name":"Example"},"user":{"id":"dXNlcg","name":"june","displayName":"June"},"pubKeyCredParams":[{"type":"public-key","alg":-7}]}75+            """.trimIndent(),76+            origin = "https://example.com",77+        )78+79+        assertNotNull(result)80+        result!!81+        val passkey = result.entryDraft.passkey!!82+        // Persisted backup state must match the flag byte that's put on the83+        // wire — the audit finding was that these contradicted each other.84+        assertEquals(true, passkey.backupEligible)85+        assertEquals(false, passkey.backupState)86+87+        val payload = Json.parseToJsonElement(result.responseJson).jsonObject["response"]!!.jsonObject88+        val authData = b64Decode(payload["authenticatorData"]!!.jsonPrimitive.content)89+        // UP(0x01) | UV(0x04) | BE(0x08) | AT(0x40) = 0x4d. BS (0x10) is NOT90+        // set because backupState is false at registration time.91+        assertEquals(0x4d, authData[32].toInt() and 0xff)92+    }93+7194     private fun b64Decode(value: String): ByteArray =7295         Base64.getUrlDecoder().decode(value.padEnd(((value.length + 3) / 4) * 4, '='))7396 }

view file