8.3 KiB
Key-storage migration — design (DESIGN ONLY, not yet approved)
Status: proposal for owner review. No code ships from this doc until the owner signs off — it touches the E2EE root of trust, where a mistake means permanent, unrecoverable content loss. Batch 5.1 of the roadmap (
make-a-plan-for-recursive-spindle.md).
Context / problem
All on-device secrets go through data/local/SecurePreferencesFactory, which uses
androidx.security:security-crypto:1.0.0 (EncryptedSharedPreferences + MasterKeys). That
library is deprecated and unmaintained by Google (no fixes, no target-SDK updates). Six stores
depend on it:
| Store | Holds | If lost… |
|---|---|---|
crypto/CoupleKeyStore |
the couple AES-256 keyset (Tink, JSON) + recovery phrase | all E2EE content unreadable — the crown jewel |
crypto/UserKeyManager |
per-user ECIES P-256 private key | sealed-answer/keybox flows break until re-published |
crypto/PendingAnswerKeyStore |
one-time per-answer keys, pre-reveal | a pending answer can't be revealed |
data/local/RecoveryPhraseStore |
the recovery phrase | falls back to partner copy |
data/local/PendingInviteStore |
inviter's code + phrase during pairing | pairing restart |
data/repository/SharedPreferencesLocalAnswerRepository |
local answer drafts | drafts lost |
The load-bearing hazard to fix along the way
SecurePreferencesFactory.encryptedSharedPreferences() catches any failure opening the store and
calls reset() — which deletes the file — then recreates it empty. For draft/pending stores
that's an acceptable "start over." For CoupleKeyStore it is silent, permanent couple-key
destruction: a transient Keystore hiccup, an OS upgrade that invalidates the master key, or a
botched migration would wipe the couple key with no prompt. The couple is partner-recoverable today
(the partner holds the same key + phrase), so it's not total loss — but it turns a recoverable blip
into a full re-pair/restore. Any migration must remove this auto-wipe for the couple-key store and
replace it with a fail-closed path that preserves ciphertext and asks the user to recover, never
deletes.
Goals
- Move off
androidx.security:security-cryptoto a maintained primitive. - Never lose the couple key during or after migration — the overriding constraint.
- Zero user-visible disruption for the happy path (transparent, lazy migration).
- Keep the wire/at-rest format of server-stored data unchanged (this is purely on-device storage;
Firestore
enc:v1:ciphertext and the recovery-phrase-wrappedwrappedCoupleKeyare untouched).
Non-goals
- No change to the E2EE scheme itself (Tink AEAD, Argon2id recovery wrap, ECIES keyboxes) — see
SECURITY.md/docs/Engineering_Reference_Manual.md. - No change to server storage or Firestore rules.
- Not couple-key rotation / forward secrecy (separate roadmap item).
Target design (recommended)
Tink's Android-Keystore-backed keyset storage (AndroidKeysetManager +
AndroidKeystoreKmsClient), replacing EncryptedSharedPreferences:
- A single app master key in the AndroidKeyStore (
AndroidKeystoreKmsClient, alias e.g.closer_master_key) wraps each stored Tink keyset; keysets persist in a plainSharedPreferencesas Tink-encrypted blobs. This is Tink-native (we already depend ontink-android), maintained, and removes theandroidx.securitydependency. - Secrets that are raw strings (recovery phrase, invite phrase, local drafts) are wrapped with a dedicated Tink AEAD whose keyset is itself Keystore-master-wrapped (same mechanism), so nothing is stored in cleartext.
- Fail-closed, not fail-wiped: if a keyset can't be decrypted, surface a recover-this-device flow (existing partner-assisted restore / recovery-phrase paths) — never delete.
Alternative considered: raw AndroidKeyStore AES-GCM + manual IV/blob management. Rejected — more bespoke crypto code to get wrong; Tink already gives us the KMS-client wrapper.
Migration strategy
Transparent, lazy, per-store, one direction. For each key on read:
- Dual-read window. New
SecureStoreV2tries the new (Tink-Keystore) location first; on miss, falls back to reading the oldEncryptedSharedPreferencesvalue. - Re-wrap on read. When a value is found only in the old store, decrypt it via the old lib and write it to the new store, then return it. (Lazy migration — no big-bang pass, no startup stall.) The old value is left in place until the migration is confirmed durable (see below).
- Confirm, then clean. Only after the new value has been successfully read back from the new store (a verify-read) is the old entry deleted. A crash between steps 2 and 3 is safe: next read re-does the re-wrap idempotently.
- Never-lose-the-couple-key rule. For
CoupleKeyStorespecifically: the old-store delete in step 3 is gated on a successful new-store round-trip AND is a no-op if the new value is absent. Thereset()/auto-wipe path is removed for this store; a decrypt failure raises a typedKeyUnavailablethat routes to recovery UI, not deletion.
Failure matrix
| Situation | Behavior |
|---|---|
| New store has the value | Use it (fast path). |
| Only old store has it | Re-wrap → new store → verify → (later) delete old. |
| Neither has it, key expected | KeyUnavailable → recovery flow (partner/phrase). No wipe. |
| New-store write fails mid-migration | Keep old value; return decrypted value from old; retry next read. |
| Keystore master invalidated (OS upgrade / biometric change) | Detect, treat as KeyUnavailable → recovery; do NOT recreate empty. |
Consumer ordering (lowest → highest risk)
PendingInviteStore,SharedPreferencesLocalAnswerRepository,PendingAnswerKeyStore— ephemeral/rebuildable; a wipe here is tolerable, so migrate first to shake out the mechanism.UserKeyManager(ECIES) — recoverable by re-publishing a fresh public key; medium risk.RecoveryPhraseStore— partner-recoverable; medium risk.CoupleKeyStorelast — only after the mechanism is proven on the others, and only with the fail-closed (no-wipe) behavior in place.
Staged rollout + telemetry
- Behind a Remote Config flag
key_storage_v2_enabled(build the RC wrapper first — seeFuture.md, currently no wrapper exists). Default off; enable to a small % first. - Content-free telemetry only (respecting the analytics consent toggle): migration attempted /
succeeded / fell-back /
KeyUnavailablecounts per store — never key material. Watch theKeyUnavailable-on-couple-key rate like a hawk; any nonzero blip is a rollback trigger. - Kill switch: flag off →
SecureStoreV2reads new-then-old but stops writing new (freezes migration) without breaking either store.
Test plan (before any staged rollout)
- Instrumented (real Keystore, can't be JVM-unit-tested): write via old lib → read via V2 → re-wrap → new-store round-trip → old entry cleaned only after verify.
- Fresh-device / process-death: kill between re-wrap and cleanup; assert idempotent recovery, no loss.
- Keystore-invalidation simulation: force a decrypt failure; assert
KeyUnavailable+ recovery route, assert the file is NOT deleted. - Full E2EE round-trip after migration (encrypt/decrypt a message, reveal a daily answer) on a migrated device — extends the existing QA "content is ciphertext at rest" pass.
- Backward-compat: a device that never migrates (flag off) keeps working on the old store.
Open questions for the owner
- Acceptable rollout %/duration for the couple-key store, given partner-recoverability as the safety net?
- Keep the old
androidx.securitydependency for a full release cycle as the dual-read source, then remove — or remove sooner? - Do we take the opportunity to also remove the
reset()auto-wipe from the other stores now (independent, small, strictly-safer change), or bundle it all?
Recommendation
Ship the reset()-auto-wipe removal for CoupleKeyStore as a small, standalone, pre-migration
fix (it's strictly safer and independent of the library swap), then do the Tink-Keystore migration
lazily behind the RC flag in the consumer order above, couple key last. Nothing here is implemented
until this design is approved.