diff --git a/passkeys/CREDENTIAL_FORMAT.md b/passkeys/CREDENTIAL_FORMAT.md new file mode 100644 index 000000000..c5d1cb355 --- /dev/null +++ b/passkeys/CREDENTIAL_FORMAT.md @@ -0,0 +1,34 @@ +# Passkey credential format + +APS and soft-fido2 share a CBOR map for portable Git/OpenPGP passkey credentials. This document records fields whose representation is part of the compatibility contract rather than an implementation detail. + +## Credential backup state + +The canonical representation is one CBOR text field: + +```cbor +backup_state: "notEligible" | "eligible" | "backedUp" +``` + +| Value | WebAuthn BE | WebAuthn BS | +|---|---:|---:| +| `notEligible` | 0 | 0 | +| `eligible` | 1 | 0 | +| `backedUp` | 1 | 1 | + +`BE=0, BS=1` is invalid. + +APS releases that predate this contract wrote two booleans: + +```cbor +backup_eligible: true +backup_state: false +``` + +Readers accept that legacy representation for migration. Writers must emit only the canonical text field and must not emit `backup_eligible`. A credential is therefore migrated lazily the next time it is saved or updated. + +Repositories used by multiple APS installations should upgrade all active writers before relying on the canonical representation. An older APS release does not understand the text value and may rewrite the credential using the historical boolean representation during a later update; current readers remain compatible with either form. + +When neither legacy nor canonical backup fields are present, APS treats the existing Git/OpenPGP credential as `eligible`, matching the established migration policy for syncable credentials. + +Canonical and legacy fields must not be mixed. Unknown text values, incorrect CBOR types, conflicting representations, and the invalid legacy combination `backup_eligible=false, backup_state=true` are rejected. diff --git a/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/model/CredentialBackupState.kt b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/model/CredentialBackupState.kt new file mode 100644 index 000000000..26121171b --- /dev/null +++ b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/model/CredentialBackupState.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.passkeys.model + +/** + * The three valid WebAuthn Backup Eligibility (BE) and Backup State (BS) combinations. + * + * [serializedName] is the stable credential-file representation shared with soft-fido2. The invalid + * `BE=0, BS=1` combination is intentionally unrepresentable. + */ +public enum class CredentialBackupState(public val serializedName: String) { + NOT_ELIGIBLE("notEligible"), + ELIGIBLE("eligible"), + BACKED_UP("backedUp"); + + public val isEligible: Boolean + get() = this != NOT_ELIGIBLE + + public val isBackedUp: Boolean + get() = this == BACKED_UP + + public companion object { + public fun fromSerializedName(value: String): CredentialBackupState = + entries.firstOrNull { it.serializedName == value } + ?: throw IllegalArgumentException("Unknown credential backup state: '$value'") + + public fun fromFlags( + backupEligible: Boolean, + backupState: Boolean, + ): CredentialBackupState = + when { + !backupEligible && !backupState -> NOT_ELIGIBLE + backupEligible && !backupState -> ELIGIBLE + backupEligible && backupState -> BACKED_UP + else -> + throw IllegalArgumentException("Invalid credential backup state: BS=1 requires BE=1") + } + } +} diff --git a/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/model/StoredCredential.kt b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/model/StoredCredential.kt index 1e897c7d7..bd3d85415 100644 --- a/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/model/StoredCredential.kt +++ b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/model/StoredCredential.kt @@ -63,8 +63,12 @@ public data class StoredCredential( map["created"] = CborValue.UnsignedInteger(BigInteger.valueOf(created)) map["discoverable"] = if (discoverable) CborValue.True else CborValue.False map["extensions"] = CborValue.Map(extensions.toCborMap()) - map["backup_eligible"] = if (backupEligible) CborValue.True else CborValue.False - map["backup_state"] = if (backupState) CborValue.True else CborValue.False + val credentialBackupState = + CredentialBackupState.fromFlags( + backupEligible = backupEligible, + backupState = backupState, + ) + map["backup_state"] = CborValue.TextString(credentialBackupState.serializedName) return Cbor.fromMap(CborMap.from(map)).toBytes() } @@ -129,6 +133,51 @@ public data class StoredCredential( private val p256Curve by lazy { CustomNamedCurves.getByName("secp256r1") } + /** + * Decodes the canonical soft-fido2 representation and the legacy APS boolean representation. + * Writers always emit the canonical text value, so legacy credentials migrate on their next + * save without requiring an eager repository rewrite. + */ + private fun parseBackupState(map: CborMap): CredentialBackupState { + val hasBackupState = map.contains("backup_state") + val hasBackupEligible = map.contains("backup_eligible") + val canonicalState = map.getString("backup_state") + + if (canonicalState != null) { + require(!hasBackupEligible) { + "Credential mixes canonical 'backup_state' with legacy 'backup_eligible'" + } + return CredentialBackupState.fromSerializedName(canonicalState) + } + + if (!hasBackupState && !hasBackupEligible) { + // Existing Git/OpenPGP credentials are syncable under APS's established migration policy. + return CredentialBackupState.ELIGIBLE + } + + val legacyBackupEligible = + if (hasBackupEligible) { + map.getBoolean("backup_eligible") + ?: throw IllegalArgumentException("Legacy 'backup_eligible' must be a CBOR boolean") + } else { + true + } + val legacyBackupState = + if (hasBackupState) { + map.getBoolean("backup_state") + ?: throw IllegalArgumentException( + "'backup_state' must be a canonical CBOR text value or legacy boolean" + ) + } else { + false + } + + return CredentialBackupState.fromFlags( + backupEligible = legacyBackupEligible, + backupState = legacyBackupState, + ) + } + public fun deriveP256PublicKey(privateKeyScalar: ByteArray): ByteArray { val n = p256Curve.n val d = BigInteger(1, privateKeyScalar) @@ -180,8 +229,7 @@ public data class StoredCredential( map.getLong("created") ?: throw IllegalArgumentException("Missing 'created' field") val discoverable = map.getBoolean("discoverable") ?: true val extensionsMap = map.getMap("extensions") - val backupEligible = map.getBoolean("backup_eligible") ?: true - val backupState = map.getBoolean("backup_state") ?: false + val credentialBackupState = parseBackupState(map) return StoredCredential( id = id, @@ -194,8 +242,8 @@ public data class StoredCredential( created = created, discoverable = discoverable, extensions = extensionsMap?.let { Extensions.fromCborMap(it) } ?: Extensions(), - backupEligible = backupEligible, - backupState = backupState, + backupEligible = credentialBackupState.isEligible, + backupState = credentialBackupState.isBackedUp, ) } @@ -207,8 +255,7 @@ public data class StoredCredential( val userMap = map.getMap("user") val signCount = map.getLong("sign_count")?.toULong() ?: 0uL val created = map.getLong("created") ?: 0L - val backupEligible = map.getBoolean("backup_eligible") ?: true - val backupState = map.getBoolean("backup_state") ?: false + val credentialBackupState = parseBackupState(map) val rpId = rpMap.getString("id") ?: throw IllegalArgumentException("Missing 'rp.id' field") val userName = @@ -225,8 +272,8 @@ public data class StoredCredential( userDisplayName = userDisplayName, createdAt = kotlin.time.Instant.fromEpochSeconds(created), signCount = signCount, - backupEligible = backupEligible, - backupState = backupState, + backupEligible = credentialBackupState.isEligible, + backupState = credentialBackupState.isBackedUp, ) } diff --git a/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/model/CredentialBackupStateCodecTest.kt b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/model/CredentialBackupStateCodecTest.kt new file mode 100644 index 000000000..fa75f5941 --- /dev/null +++ b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/model/CredentialBackupStateCodecTest.kt @@ -0,0 +1,184 @@ +/* + * Copyright (C) 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +@file:OptIn(kotlin.time.ExperimentalTime::class) + +package app.passwordstore.passkeys.model + +import app.passwordstore.passkeys.cbor.Cbor +import app.passwordstore.passkeys.cbor.CborMap +import app.passwordstore.passkeys.cbor.CborValue +import java.math.BigInteger +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse + +class CredentialBackupStateCodecTest { + + @Test + fun `canonical names match soft-fido2 serde`() { + assertEquals("notEligible", CredentialBackupState.NOT_ELIGIBLE.serializedName) + assertEquals("eligible", CredentialBackupState.ELIGIBLE.serializedName) + assertEquals("backedUp", CredentialBackupState.BACKED_UP.serializedName) + } + + @Test + fun `serializer emits only canonical backup_state text`() { + val cases = + listOf( + Triple(false, false, "notEligible"), + Triple(true, false, "eligible"), + Triple(true, true, "backedUp"), + ) + + for ((eligible, backedUp, expected) in cases) { + val map = Cbor.parse(credential(eligible, backedUp).toCbor()).asMap() + + assertEquals(expected, map.getString("backup_state")) + assertFalse(map.contains("backup_eligible")) + assertEquals(null, map.getBoolean("backup_state")) + } + } + + @Test + fun `all canonical states deserialize through full and metadata parsers`() { + for (state in CredentialBackupState.entries) { + val map = baseMap() + map["backup_state"] = CborValue.TextString(state.serializedName) + + assertDecodedState(encode(map), state) + } + } + + @Test + fun `valid legacy boolean combinations migrate`() { + val cases = + listOf( + Triple(false, false, CredentialBackupState.NOT_ELIGIBLE), + Triple(true, false, CredentialBackupState.ELIGIBLE), + Triple(true, true, CredentialBackupState.BACKED_UP), + ) + + for ((eligible, backedUp, expected) in cases) { + assertDecodedState(legacyEncoding(eligible, backedUp), expected) + } + } + + @Test + fun `invalid legacy BS without BE is rejected`() { + val bytes = legacyEncoding(backupEligible = false, backupState = true) + + assertFailsWith { StoredCredential.fromCbor(bytes) } + assertFailsWith { StoredCredential.metadataFromCbor(bytes) } + } + + @Test + fun `missing backup fields default to eligible`() { + val map = baseMap() + + assertDecodedState(encode(map), CredentialBackupState.ELIGIBLE) + } + + @Test + fun `canonical and legacy representations cannot be mixed`() { + val map = baseMap() + map["backup_state"] = CborValue.TextString("eligible") + map["backup_eligible"] = CborValue.True + val bytes = encode(map) + + assertFailsWith { StoredCredential.fromCbor(bytes) } + assertFailsWith { StoredCredential.metadataFromCbor(bytes) } + } + + @Test + fun `unknown canonical state is rejected`() { + val map = baseMap() + map["backup_state"] = CborValue.TextString("syncedSomewhere") + val bytes = encode(map) + + assertFailsWith { StoredCredential.fromCbor(bytes) } + assertFailsWith { StoredCredential.metadataFromCbor(bytes) } + } + + @Test + fun `malformed backup fields are rejected instead of defaulted`() { + val malformedState = baseMap() + malformedState["backup_state"] = CborValue.UnsignedInteger(BigInteger.ZERO) + + val malformedEligible = baseMap() + malformedEligible["backup_eligible"] = CborValue.TextString("true") + + for (bytes in listOf(encode(malformedState), encode(malformedEligible))) { + assertFailsWith { StoredCredential.fromCbor(bytes) } + assertFailsWith { StoredCredential.metadataFromCbor(bytes) } + } + } + + @Test + fun `reencoding legacy credentials performs a canonical lazy migration`() { + val legacy = StoredCredential.fromCbor(legacyEncoding(true, false)) + val migratedMap = Cbor.parse(legacy.toCbor()).asMap() + + assertEquals("eligible", migratedMap.getString("backup_state")) + assertFalse(migratedMap.contains("backup_eligible")) + } + + @Test + fun `flag conversion makes invalid state unrepresentable`() { + assertEquals( + CredentialBackupState.NOT_ELIGIBLE, + CredentialBackupState.fromFlags(false, false), + ) + assertEquals(CredentialBackupState.ELIGIBLE, CredentialBackupState.fromFlags(true, false)) + assertEquals(CredentialBackupState.BACKED_UP, CredentialBackupState.fromFlags(true, true)) + assertFailsWith { + CredentialBackupState.fromFlags(backupEligible = false, backupState = true) + } + } + + private fun assertDecodedState(bytes: ByteArray, expected: CredentialBackupState) { + val full = StoredCredential.fromCbor(bytes) + val metadata = StoredCredential.metadataFromCbor(bytes) + + assertEquals(expected.isEligible, full.backupEligible) + assertEquals(expected.isBackedUp, full.backupState) + assertEquals(expected.isEligible, metadata.backupEligible) + assertEquals(expected.isBackedUp, metadata.backupState) + } + + private fun legacyEncoding(backupEligible: Boolean, backupState: Boolean): ByteArray { + val map = baseMap() + map["backup_eligible"] = if (backupEligible) CborValue.True else CborValue.False + map["backup_state"] = if (backupState) CborValue.True else CborValue.False + return encode(map) + } + + private fun baseMap(): MutableMap { + val map = Cbor.parse(credential().toCbor()).asMap().toMutableMap() + map.remove("backup_state") + map.remove("backup_eligible") + return map + } + + private fun encode(map: Map): ByteArray = + Cbor.fromMap(CborMap.from(map)).toBytes() + + private fun credential( + backupEligible: Boolean = true, + backupState: Boolean = false, + ): StoredCredential = + StoredCredential( + id = byteArrayOf(0x01, 0x02), + rp = RelyingParty(id = "example.com"), + user = User(id = byteArrayOf(0x03), name = "alice", displayName = "Alice"), + signCount = 0u, + alg = StoredCredential.ALG_ES256, + privateKey = ByteArray(32).also { it[31] = 1 }, + created = 1_700_000_000L, + backupEligible = backupEligible, + backupState = backupState, + ) +}