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
↓ peach-android-a0af1c52.tar.gzatlas at this save
files changed5 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 −0app/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) {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)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(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",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 }