# 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 1. Move off `androidx.security:security-crypto` to a **maintained** primitive. 2. **Never lose the couple key** during or after migration — the overriding constraint. 3. Zero user-visible disruption for the happy path (transparent, lazy migration). 4. 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-wrapped `wrappedCoupleKey` are 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 plain `SharedPreferences` as Tink-encrypted blobs. This is Tink-native (we already depend on `tink-android`), maintained, and removes the `androidx.security` dependency. - 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: 1. **Dual-read window.** New `SecureStoreV2` tries the new (Tink-Keystore) location first; on miss, falls back to reading the **old** `EncryptedSharedPreferences` value. 2. **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). 3. **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. 4. **Never-lose-the-couple-key rule.** For `CoupleKeyStore` specifically: 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. The `reset()`/auto-wipe path is removed for this store; a decrypt failure raises a typed `KeyUnavailable` that 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) 1. `PendingInviteStore`, `SharedPreferencesLocalAnswerRepository`, `PendingAnswerKeyStore` — ephemeral/rebuildable; a wipe here is tolerable, so migrate first to shake out the mechanism. 2. `UserKeyManager` (ECIES) — recoverable by re-publishing a fresh public key; medium risk. 3. `RecoveryPhraseStore` — partner-recoverable; medium risk. 4. **`CoupleKeyStore` last** — 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 — see `Future.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 / `KeyUnavailable` counts per store — never key material. Watch the `KeyUnavailable`-on-couple-key rate like a hawk; any nonzero blip is a rollback trigger. - Kill switch: flag off → `SecureStoreV2` reads 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 1. **Acceptable rollout %/duration** for the couple-key store, given partner-recoverability as the safety net? 2. Keep the old `androidx.security` dependency for a full release cycle as the dual-read source, then remove — or remove sooner? 3. 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.