From 8ac434464dc9e7fcc75c401863b7c2f197fc17f3 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sun, 2 Aug 2026 02:18:56 +0200 Subject: [PATCH 1/8] feat(passkeys): define canonical credential backup state codec --- .../passkeys/model/CredentialBackupState.kt | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 passkeys/core/src/main/kotlin/app/passwordstore/passkeys/model/CredentialBackupState.kt 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..a349e5b43 --- /dev/null +++ b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/model/CredentialBackupState.kt @@ -0,0 +1,44 @@ +/* + * 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" + ) + } + } +} From d75cfd21226a3fc25bcff5882c84fc5512a2d92d Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sun, 2 Aug 2026 02:19:09 +0200 Subject: [PATCH 2/8] docs(passkeys): define canonical backup state encoding --- passkeys/CREDENTIAL_FORMAT.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 passkeys/CREDENTIAL_FORMAT.md diff --git a/passkeys/CREDENTIAL_FORMAT.md b/passkeys/CREDENTIAL_FORMAT.md new file mode 100644 index 000000000..46b6453e9 --- /dev/null +++ b/passkeys/CREDENTIAL_FORMAT.md @@ -0,0 +1,32 @@ +# 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. + +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. From 5a706252582d9f7b58a3078ad67457b06e59d367 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sun, 2 Aug 2026 02:19:36 +0200 Subject: [PATCH 3/8] test(passkeys): cover canonical and legacy backup state encoding --- .../model/CredentialBackupStateCodecTest.kt | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 passkeys/core/src/test/kotlin/app/passwordstore/passkeys/model/CredentialBackupStateCodecTest.kt 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..58f6df9d2 --- /dev/null +++ b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/model/CredentialBackupStateCodecTest.kt @@ -0,0 +1,185 @@ +/* + * 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 +import kotlin.test.assertTrue + +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, + ) +} From 2674ff6909a2a6b461f66cd99f9efe5d694281f0 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sun, 2 Aug 2026 02:19:52 +0200 Subject: [PATCH 4/8] chore: add issue 111 source transformation --- scripts/apply_issue_111_backup_state_codec.py | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 scripts/apply_issue_111_backup_state_codec.py diff --git a/scripts/apply_issue_111_backup_state_codec.py b/scripts/apply_issue_111_backup_state_codec.py new file mode 100644 index 000000000..cdef8f9ab --- /dev/null +++ b/scripts/apply_issue_111_backup_state_codec.py @@ -0,0 +1,94 @@ +from pathlib import Path + +path = Path( + "passkeys/core/src/main/kotlin/app/passwordstore/passkeys/model/StoredCredential.kt" +) +text = path.read_text() + +old_serializer = ''' map["backup_eligible"] = if (backupEligible) CborValue.True else CborValue.False + map["backup_state"] = if (backupState) CborValue.True else CborValue.False +''' +new_serializer = ''' val credentialBackupState = + CredentialBackupState.fromFlags( + backupEligible = backupEligible, + backupState = backupState, + ) + map["backup_state"] = CborValue.TextString(credentialBackupState.serializedName) +''' +assert text.count(old_serializer) == 1 +text = text.replace(old_serializer, new_serializer, 1) + +old_curve = ''' private val p256Curve by lazy { CustomNamedCurves.getByName("secp256r1") } + + public fun deriveP256PublicKey(privateKeyScalar: ByteArray): ByteArray { +''' +new_curve = ''' 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 { +''' +assert text.count(old_curve) == 1 +text = text.replace(old_curve, new_curve, 1) + +old_parser = ''' val backupEligible = map.getBoolean("backup_eligible") ?: true + val backupState = map.getBoolean("backup_state") ?: false +''' +new_parser = ''' val credentialBackupState = parseBackupState(map) +''' +assert text.count(old_parser) == 2 +text = text.replace(old_parser, new_parser) + +old_constructor = ''' backupEligible = backupEligible, + backupState = backupState, +''' +new_constructor = ''' backupEligible = credentialBackupState.isEligible, + backupState = credentialBackupState.isBackedUp, +''' +assert text.count(old_constructor) == 2 +text = text.replace(old_constructor, new_constructor) + +path.write_text(text) From 02fb71030b552096be0e05f66f07a23923d687a4 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sun, 2 Aug 2026 02:20:58 +0200 Subject: [PATCH 5/8] ci: apply and validate issue 111 backup state codec --- .../apply-issue-111-backup-state-codec.yml | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/apply-issue-111-backup-state-codec.yml diff --git a/.github/workflows/apply-issue-111-backup-state-codec.yml b/.github/workflows/apply-issue-111-backup-state-codec.yml new file mode 100644 index 000000000..045e6352e --- /dev/null +++ b/.github/workflows/apply-issue-111-backup-state-codec.yml @@ -0,0 +1,48 @@ +name: Apply issue 111 backup state codec + +on: + pull_request: + types: [opened, synchronize] + branches: [main] + +permissions: + contents: write + +jobs: + apply-and-validate: + if: github.event.pull_request.head.ref == 'agent/canonicalize-passkey-backup-state' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - uses: gradle/actions/setup-gradle@v4 + + - name: Apply canonical backup state codec + run: python3 scripts/apply_issue_111_backup_state_codec.py + + - name: Format Kotlin sources + run: ./gradlew spotlessApply + + - name: Run passkeys core tests + run: ./gradlew :passkeys:core:test + + - name: Commit clean implementation + env: + BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + rm -f scripts/apply_issue_111_backup_state_codec.py + rm -f .github/workflows/apply-issue-111-backup-state-codec.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "fix(passkeys): canonicalize backup state credential encoding" + git push origin "HEAD:${BRANCH}" From b4c11dc3dfa10be17e815786b38ea1b2ce25c0a7 Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sun, 2 Aug 2026 02:22:38 +0200 Subject: [PATCH 6/8] fix: target issue 111 constructor replacements precisely --- scripts/apply_issue_111_backup_state_codec.py | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/scripts/apply_issue_111_backup_state_codec.py b/scripts/apply_issue_111_backup_state_codec.py index cdef8f9ab..f9b5b6830 100644 --- a/scripts/apply_issue_111_backup_state_codec.py +++ b/scripts/apply_issue_111_backup_state_codec.py @@ -82,13 +82,26 @@ assert text.count(old_parser) == 2 text = text.replace(old_parser, new_parser) -old_constructor = ''' backupEligible = backupEligible, +old_full_constructor = ''' extensions = extensionsMap?.let { Extensions.fromCborMap(it) } ?: Extensions(), + backupEligible = backupEligible, + backupState = backupState, +''' +new_full_constructor = ''' extensions = extensionsMap?.let { Extensions.fromCborMap(it) } ?: Extensions(), + backupEligible = credentialBackupState.isEligible, + backupState = credentialBackupState.isBackedUp, +''' +assert text.count(old_full_constructor) == 1 +text = text.replace(old_full_constructor, new_full_constructor, 1) + +old_metadata_constructor = ''' signCount = signCount, + backupEligible = backupEligible, backupState = backupState, ''' -new_constructor = ''' backupEligible = credentialBackupState.isEligible, +new_metadata_constructor = ''' signCount = signCount, + backupEligible = credentialBackupState.isEligible, backupState = credentialBackupState.isBackedUp, ''' -assert text.count(old_constructor) == 2 -text = text.replace(old_constructor, new_constructor) +assert text.count(old_metadata_constructor) == 1 +text = text.replace(old_metadata_constructor, new_metadata_constructor, 1) path.write_text(text) From 3080d22610a9ccd8c5abeef23757a30ba906b643 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:24:13 +0000 Subject: [PATCH 7/8] fix(passkeys): canonicalize backup state credential encoding --- .../apply-issue-111-backup-state-codec.yml | 48 -------- .../passkeys/model/CredentialBackupState.kt | 8 +- .../passkeys/model/StoredCredential.kt | 67 +++++++++-- .../model/CredentialBackupStateCodecTest.kt | 1 - scripts/apply_issue_111_backup_state_codec.py | 107 ------------------ 5 files changed, 60 insertions(+), 171 deletions(-) delete mode 100644 .github/workflows/apply-issue-111-backup-state-codec.yml delete mode 100644 scripts/apply_issue_111_backup_state_codec.py diff --git a/.github/workflows/apply-issue-111-backup-state-codec.yml b/.github/workflows/apply-issue-111-backup-state-codec.yml deleted file mode 100644 index 045e6352e..000000000 --- a/.github/workflows/apply-issue-111-backup-state-codec.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Apply issue 111 backup state codec - -on: - pull_request: - types: [opened, synchronize] - branches: [main] - -permissions: - contents: write - -jobs: - apply-and-validate: - if: github.event.pull_request.head.ref == 'agent/canonicalize-passkey-backup-state' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.ref }} - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: '21' - - - uses: gradle/actions/setup-gradle@v4 - - - name: Apply canonical backup state codec - run: python3 scripts/apply_issue_111_backup_state_codec.py - - - name: Format Kotlin sources - run: ./gradlew spotlessApply - - - name: Run passkeys core tests - run: ./gradlew :passkeys:core:test - - - name: Commit clean implementation - env: - BRANCH: ${{ github.event.pull_request.head.ref }} - run: | - rm -f scripts/apply_issue_111_backup_state_codec.py - rm -f .github/workflows/apply-issue-111-backup-state-codec.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "fix(passkeys): canonicalize backup state credential encoding" - git push origin "HEAD:${BRANCH}" 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 index a349e5b43..26121171b 100644 --- a/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/model/CredentialBackupState.kt +++ b/passkeys/core/src/main/kotlin/app/passwordstore/passkeys/model/CredentialBackupState.kt @@ -8,8 +8,8 @@ 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. + * [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"), @@ -36,9 +36,7 @@ public enum class CredentialBackupState(public val serializedName: String) { backupEligible && !backupState -> ELIGIBLE backupEligible && backupState -> BACKED_UP else -> - throw IllegalArgumentException( - "Invalid credential backup state: BS=1 requires BE=1" - ) + 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 index 58f6df9d2..fa75f5941 100644 --- a/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/model/CredentialBackupStateCodecTest.kt +++ b/passkeys/core/src/test/kotlin/app/passwordstore/passkeys/model/CredentialBackupStateCodecTest.kt @@ -15,7 +15,6 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse -import kotlin.test.assertTrue class CredentialBackupStateCodecTest { diff --git a/scripts/apply_issue_111_backup_state_codec.py b/scripts/apply_issue_111_backup_state_codec.py deleted file mode 100644 index f9b5b6830..000000000 --- a/scripts/apply_issue_111_backup_state_codec.py +++ /dev/null @@ -1,107 +0,0 @@ -from pathlib import Path - -path = Path( - "passkeys/core/src/main/kotlin/app/passwordstore/passkeys/model/StoredCredential.kt" -) -text = path.read_text() - -old_serializer = ''' map["backup_eligible"] = if (backupEligible) CborValue.True else CborValue.False - map["backup_state"] = if (backupState) CborValue.True else CborValue.False -''' -new_serializer = ''' val credentialBackupState = - CredentialBackupState.fromFlags( - backupEligible = backupEligible, - backupState = backupState, - ) - map["backup_state"] = CborValue.TextString(credentialBackupState.serializedName) -''' -assert text.count(old_serializer) == 1 -text = text.replace(old_serializer, new_serializer, 1) - -old_curve = ''' private val p256Curve by lazy { CustomNamedCurves.getByName("secp256r1") } - - public fun deriveP256PublicKey(privateKeyScalar: ByteArray): ByteArray { -''' -new_curve = ''' 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 { -''' -assert text.count(old_curve) == 1 -text = text.replace(old_curve, new_curve, 1) - -old_parser = ''' val backupEligible = map.getBoolean("backup_eligible") ?: true - val backupState = map.getBoolean("backup_state") ?: false -''' -new_parser = ''' val credentialBackupState = parseBackupState(map) -''' -assert text.count(old_parser) == 2 -text = text.replace(old_parser, new_parser) - -old_full_constructor = ''' extensions = extensionsMap?.let { Extensions.fromCborMap(it) } ?: Extensions(), - backupEligible = backupEligible, - backupState = backupState, -''' -new_full_constructor = ''' extensions = extensionsMap?.let { Extensions.fromCborMap(it) } ?: Extensions(), - backupEligible = credentialBackupState.isEligible, - backupState = credentialBackupState.isBackedUp, -''' -assert text.count(old_full_constructor) == 1 -text = text.replace(old_full_constructor, new_full_constructor, 1) - -old_metadata_constructor = ''' signCount = signCount, - backupEligible = backupEligible, - backupState = backupState, -''' -new_metadata_constructor = ''' signCount = signCount, - backupEligible = credentialBackupState.isEligible, - backupState = credentialBackupState.isBackedUp, -''' -assert text.count(old_metadata_constructor) == 1 -text = text.replace(old_metadata_constructor, new_metadata_constructor, 1) - -path.write_text(text) From 1327d920135450da5df6637254514b8fe858d7df Mon Sep 17 00:00:00 2001 From: Alexander Gil Casas Date: Sun, 2 Aug 2026 02:26:22 +0200 Subject: [PATCH 8/8] docs(passkeys): clarify mixed-version migration behavior --- passkeys/CREDENTIAL_FORMAT.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/passkeys/CREDENTIAL_FORMAT.md b/passkeys/CREDENTIAL_FORMAT.md index 46b6453e9..c5d1cb355 100644 --- a/passkeys/CREDENTIAL_FORMAT.md +++ b/passkeys/CREDENTIAL_FORMAT.md @@ -27,6 +27,8 @@ 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.