Skip to content
34 changes: 34 additions & 0 deletions passkeys/CREDENTIAL_FORMAT.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)
}

Expand All @@ -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 =
Expand All @@ -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,
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<IllegalArgumentException> { StoredCredential.fromCbor(bytes) }
assertFailsWith<IllegalArgumentException> { 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<IllegalArgumentException> { StoredCredential.fromCbor(bytes) }
assertFailsWith<IllegalArgumentException> { StoredCredential.metadataFromCbor(bytes) }
}

@Test
fun `unknown canonical state is rejected`() {
val map = baseMap()
map["backup_state"] = CborValue.TextString("syncedSomewhere")
val bytes = encode(map)

assertFailsWith<IllegalArgumentException> { StoredCredential.fromCbor(bytes) }
assertFailsWith<IllegalArgumentException> { 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<IllegalArgumentException> { StoredCredential.fromCbor(bytes) }
assertFailsWith<IllegalArgumentException> { 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<IllegalArgumentException> {
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<String, CborValue> {
val map = Cbor.parse(credential().toCbor()).asMap().toMutableMap()
map.remove("backup_state")
map.remove("backup_eligible")
return map
}

private fun encode(map: Map<String, CborValue>): 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,
)
}