Problem
RootKeyStore (Android + iOS) holds the device's identity. A single
breaking change — an alias rename, a SharedPreferences key rename, an
algorithm tweak, an envelope-field rename — would silently make every
upgrading user's persisted rootkey unreadable, which is identity loss.
The cost is asymmetric: the change is one line, the consequence is
unrecoverable.
We currently rely on (a) careful review of RootKeyStore.kt /
RootKeyStore.swift PRs, and (b) the docstrings in the file. Neither
catches a developer who's confidently wrong.
This issue scopes the protections we should add.
What kinds of changes break previously-persisted rootkeys?
A non-exhaustive enumeration. Each of these is a one-line change that
silently breaks reads on existing installs:
- Rename
WRAPPER_KEY_ALIAS (Android) → loadWrapperKey returns
null → throws.
- Rename
PREFS_NAME or PREFS_KEY (Android) →
prefs.getString(PREFS_KEY, null) returns null → goes to
first-install path → identity loss.
- Change
KeyGenParameterSpec flags (Android, e.g. set
setUserAuthenticationRequired(true) or change accessibility) →
existing wrapper key may invalidate or the new key isn't compatible
with old envelopes.
- Change
kSecAttrService / kSecAttrAccount (iOS) →
SecItemCopyMatching returns errSecItemNotFound → throws.
- Change
kSecAttrAccessible (iOS) → existing item still
readable on most paths but inconsistencies in restore semantics.
- Add/remove/rename envelope JSON fields (Android) →
parseEnvelope throws or reads stale data.
- Bump
ENVELOPE_VERSION constant without a migration path →
parseEnvelope throws "Unsupported rootkey envelope version".
- Change
GCM_TAG_LENGTH_BITS / GCM_IV_LENGTH_BYTES →
existing ciphertext fails to decrypt.
- Change
ROOTKEY_BYTE_LENGTH → length check throws.
- Refactor that introduces silent regeneration on read failure
(e.g. catching RootKeyException and falling through to
generate). This is the worst category — silently turns existing
users into "first launch".
- Migration logic that deletes the legacy entry before verifying
the new one → window of identity loss if the verify fails.
Protections, grouped by class
A. Test-based protections
A1. Fixture-based round-trip tests (highest value, medium cost)
Plant a known-good envelope (encrypted under a test-fixed wrapper
key) in test resources. Test that the current RootKeyStore decrypts
it back to the expected 16 bytes. Any change that affects the
read path — algorithm, IV, schema, parsing, length checks, defaults —
breaks this test loudly before the bad code ships.
Cost: requires injecting the wrapper-key source so tests can use a
fixed key (production keeps AndroidKeyStore / Keychain). Small
refactor (~30 LOC each platform) to introduce a WrapperKeyProvider
interface or constructor seam.
A2. Envelope schema snapshot test (medium value, low cost)
Unit test that asserts the JSON envelope produced by
generateAndPersist has exactly the expected fields with expected
types. Adding a field, renaming iv → nonce, etc. fails the test.
Smaller scope than A1 but catches the schema-change subset cheaply.
A3. Cross-version migration tests (highest value, high cost)
Maintain historical envelope fixtures (V1, V2, ...) in test
resources. Every release runs "v1 envelope must still be readable by
current code" tests. When we eventually do introduce a v2 wrapper
key, the v2 release adds a "v1 envelope must migrate to v2 cleanly"
test that survives forever.
Cost: ongoing maintenance — each format change needs a fixture
preserved and a test added.
A4. Maestro end-to-end test (covered by #39 already)
Cold-launch app, read MapeoManager.deviceId, kill app, cold-launch,
assert deviceId matches. Catches the very worst case ("rootkey is
gone after restart") but doesn't isolate which layer broke.
B. Build-time / process protections
B1. CODEOWNERS pinning RootKeyStore + envelope format docs (low
cost, real value)
/.github/CODEOWNERS:
android/src/main/java/com/comapeo/core/RootKeyStore.kt @<reviewer>
ios/RootKeyStore.swift @<reviewer>
docs/root-key-storage-and-migration-plan.md @<reviewer>
Forces a designated reviewer on every change, regardless of who
opened the PR. Doesn't prevent breaking changes, but makes accidental
ones much less likely to land.
B2. PR template question (low cost, marginal value)
"Does this PR touch RootKeyStore? If yes, link the migration test
covering the change."
B3. File header DO-NOT-CHANGE banner (low cost, marginal value)
A loud comment block at the top of each RootKeyStore listing the
constants that affect persistence and what each one's renaming /
rotation means. Doesn't stop anyone, but makes "I didn't realise"
defenses harder.
B4. Lint rule (medium cost, low value — overkill)
A custom Detekt / SwiftLint rule that flags any change to the
constants. Probably not worth the maintenance.
C. Runtime protections
C1. Existing: parseEnvelope throws on missing fields, version
mismatch. Already in place. No-op for this issue.
C2. Existing: read-back verification in generateAndPersist.
Already in place. No-op.
C3. Existing: no silent regeneration. Already in place via
RootKeyException from every read failure. The risk is future
refactors silently catching this; the linchpin is test coverage.
D. Production telemetry
D1. Crashlytics / Sentry alert on RootKeyException (high value,
ongoing operational cost)
Catch identity loss in the wild on the first user who hits it.
Threshold-based alerting on a baseline of zero.
D2. "First install" rate per release (high value, ongoing cost)
A sudden spike in RootKeyStore: generated for first install log
events after a new release means that release inadvertently broke
the read path for existing installs.
Both D1 and D2 require log/event infrastructure we don't have today;
this is a longer-horizon project.
Prioritised recommendation
Land in this order:
- B1 (CODEOWNERS) — half-day, catches ~80% of careless mistakes.
- A2 (envelope schema snapshot test) — half-day, low maintenance,
catches schema regressions immediately.
- A1 (fixture round-trip tests) — 1–2 days including the
WrapperKeyProvider refactor. Highest test-coverage value.
- B3 (file header banner) — 30 minutes, free signal.
- A3 (cross-version migration tests) — defer until we actually
have a v2 format to test against. Add the test scaffolding when
v2 is introduced; don't pre-build it for hypothetical futures.
- D1 + D2 (production telemetry) — track via separate
observability epic (out of scope here).
Acceptance for the first-pass implementation
A2 + B1 + B3 are the minimum-viable gate; A1 is the load-bearing
test. A3 and D* can come later or via separate issues.
Problem
RootKeyStore(Android + iOS) holds the device's identity. A singlebreaking change — an alias rename, a SharedPreferences key rename, an
algorithm tweak, an envelope-field rename — would silently make every
upgrading user's persisted rootkey unreadable, which is identity loss.
The cost is asymmetric: the change is one line, the consequence is
unrecoverable.
We currently rely on (a) careful review of
RootKeyStore.kt/RootKeyStore.swiftPRs, and (b) the docstrings in the file. Neithercatches a developer who's confidently wrong.
This issue scopes the protections we should add.
What kinds of changes break previously-persisted rootkeys?
A non-exhaustive enumeration. Each of these is a one-line change that
silently breaks reads on existing installs:
WRAPPER_KEY_ALIAS(Android) →loadWrapperKeyreturnsnull → throws.
PREFS_NAMEorPREFS_KEY(Android) →prefs.getString(PREFS_KEY, null)returns null → goes tofirst-install path → identity loss.
KeyGenParameterSpecflags (Android, e.g. setsetUserAuthenticationRequired(true)or change accessibility) →existing wrapper key may invalidate or the new key isn't compatible
with old envelopes.
kSecAttrService/kSecAttrAccount(iOS) →SecItemCopyMatchingreturnserrSecItemNotFound→ throws.kSecAttrAccessible(iOS) → existing item stillreadable on most paths but inconsistencies in restore semantics.
parseEnvelopethrows or reads stale data.ENVELOPE_VERSIONconstant without a migration path →parseEnvelopethrows "Unsupported rootkey envelope version".GCM_TAG_LENGTH_BITS/GCM_IV_LENGTH_BYTES→existing ciphertext fails to decrypt.
ROOTKEY_BYTE_LENGTH→ length check throws.(e.g. catching
RootKeyExceptionand falling through togenerate). This is the worst category — silently turns existingusers into "first launch".
the new one → window of identity loss if the verify fails.
Protections, grouped by class
A. Test-based protections
A1. Fixture-based round-trip tests (highest value, medium cost)
Plant a known-good envelope (encrypted under a test-fixed wrapper
key) in test resources. Test that the current
RootKeyStoredecryptsit back to the expected 16 bytes. Any change that affects the
read path — algorithm, IV, schema, parsing, length checks, defaults —
breaks this test loudly before the bad code ships.
Cost: requires injecting the wrapper-key source so tests can use a
fixed key (production keeps AndroidKeyStore / Keychain). Small
refactor (~30 LOC each platform) to introduce a
WrapperKeyProviderinterface or constructor seam.
A2. Envelope schema snapshot test (medium value, low cost)
Unit test that asserts the JSON envelope produced by
generateAndPersisthas exactly the expected fields with expectedtypes. Adding a field, renaming
iv→nonce, etc. fails the test.Smaller scope than A1 but catches the schema-change subset cheaply.
A3. Cross-version migration tests (highest value, high cost)
Maintain historical envelope fixtures (V1, V2, ...) in test
resources. Every release runs "v1 envelope must still be readable by
current code" tests. When we eventually do introduce a v2 wrapper
key, the v2 release adds a "v1 envelope must migrate to v2 cleanly"
test that survives forever.
Cost: ongoing maintenance — each format change needs a fixture
preserved and a test added.
A4. Maestro end-to-end test (covered by #39 already)
Cold-launch app, read
MapeoManager.deviceId, kill app, cold-launch,assert deviceId matches. Catches the very worst case ("rootkey is
gone after restart") but doesn't isolate which layer broke.
B. Build-time / process protections
B1. CODEOWNERS pinning RootKeyStore + envelope format docs (low
cost, real value)
/.github/CODEOWNERS:Forces a designated reviewer on every change, regardless of who
opened the PR. Doesn't prevent breaking changes, but makes accidental
ones much less likely to land.
B2. PR template question (low cost, marginal value)
"Does this PR touch RootKeyStore? If yes, link the migration test
covering the change."
B3. File header DO-NOT-CHANGE banner (low cost, marginal value)
A loud comment block at the top of each
RootKeyStorelisting theconstants that affect persistence and what each one's renaming /
rotation means. Doesn't stop anyone, but makes "I didn't realise"
defenses harder.
B4. Lint rule (medium cost, low value — overkill)
A custom Detekt / SwiftLint rule that flags any change to the
constants. Probably not worth the maintenance.
C. Runtime protections
C1. Existing:
parseEnvelopethrows on missing fields, versionmismatch. Already in place. No-op for this issue.
C2. Existing: read-back verification in
generateAndPersist.Already in place. No-op.
C3. Existing: no silent regeneration. Already in place via
RootKeyExceptionfrom every read failure. The risk is futurerefactors silently catching this; the linchpin is test coverage.
D. Production telemetry
D1. Crashlytics / Sentry alert on
RootKeyException(high value,ongoing operational cost)
Catch identity loss in the wild on the first user who hits it.
Threshold-based alerting on a baseline of zero.
D2. "First install" rate per release (high value, ongoing cost)
A sudden spike in
RootKeyStore: generated for first installlogevents after a new release means that release inadvertently broke
the read path for existing installs.
Both D1 and D2 require log/event infrastructure we don't have today;
this is a longer-horizon project.
Prioritised recommendation
Land in this order:
catches schema regressions immediately.
WrapperKeyProviderrefactor. Highest test-coverage value.have a v2 format to test against. Add the test scaffolding when
v2 is introduced; don't pre-build it for hypothetical futures.
observability epic (out of scope here).
Acceptance for the first-pass implementation
RootKeyStoreSchemaTest.kt(instrumented) verifyingthe
generateAndPersistenvelope contains exactly the v1fields with the expected types.
RootKeyStoreSchemaTests.swiftdoing the equivalentkeychain-attribute check.
RootKeyStoreaccepts an injectable wrapper-keysource for tests; production wiring unchanged.
RootKeyStoreaccepts an injectable keychain accessorfor tests; production wiring unchanged.
under a fixed wrapper key, asserts current code decrypts it
back to the expected 16 bytes.
RootKeyStorelists theidentity-affecting constants and the consequences of changing
each.
A2 + B1 + B3 are the minimum-viable gate; A1 is the load-bearing
test. A3 and D* can come later or via separate issues.