diff --git a/docs/Engineering_Reference_Manual.md b/docs/Engineering_Reference_Manual.md index 3ccd1bc3..c50ea966 100644 --- a/docs/Engineering_Reference_Manual.md +++ b/docs/Engineering_Reference_Manual.md @@ -353,7 +353,7 @@ The recovery phrase is the only human-readable secret in the system. It is never 2. The inviter encrypts the phrase with the invite code using `encryptPhraseWithCode` and stores the blob on the invite document. 3. The acceptor receives the encrypted blob, decrypts it with the same code, and stores the phrase locally. 4. The phrase is used to unwrap the couple keyset from `wrappedCoupleKey`. -5. The recovery phrase can be shown in settings and used to recover the couple key on a new device. **Both partners hold the same phrase** (the acceptor stores it in step 3), so either partner can re-reveal it in Settings → Security and supply it to the other - the partner is a built-in backup, and the new-device Recovery screen guides the user to "ask your partner." Changing the recovery phrase re-wraps the locally-held keyset and uploads a new `wrappedCoupleKey` to Firestore - **but it desyncs the partner's stored copy**; see the landmine [Recovery-phrase change desync](#recovery-phrase-change-desync---changing-the-couple-phrase-silently-breaks-the-partners-ask-your-partner-recovery). The change-phrase path is currently UNWIRED. +5. The recovery phrase can be shown in settings and used to recover the couple key on a new device. **Both partners hold the same phrase** (the acceptor stores it in step 3), so either partner can re-reveal it in Settings → Security and supply it to the other - the partner is a built-in backup, and the new-device Recovery screen guides the user to "ask your partner." Changing the recovery phrase runs a three-phase handshake (publish sealed → both devices ack → re-wrap) so the partner's stored copy never goes stale; see [Recovery-phrase change desync](#recovery-phrase-change-desync---changing-the-couple-phrase-silently-breaks-the-partners-ask-your-partner-recovery) for the design and the rules that enforce it. Wired in Settings → Security (2026-07-16). iOS does generate and store a recovery phrase in the current build (R24 batch 2+); iOS couples now have the same partner-assisted recovery path as Android. See [iOS E2EE gap](#ios-e2ee-gap-status-as-of-current-batch) for the still-pending schemaVersion 3 interop. @@ -454,9 +454,20 @@ The commitment hash lets the reveal step verify that the decrypted payload match - `ReleaseKeyEncryptor.kt` wraps a one-time answer key to the recipient's public key. - Context info for ECIES: `coupleId|questionId|senderUserId|recipientUserId`. This binds the wrapped key to a specific origin and destination. -### Known limitation: single-device keys +### Second-device sealed reveals: healed by escrow (2026-07-16, `3c74aec8`) -`UserKeyManager.kt` documents a known limitation: there is **one keypair per user, stored only on the device that created it**. If a user signs in on a second device and generates a new keypair, sealed answers whose keys were wrapped for the old public key become undecryptable. The fix path is multi-device key distribution, but it is not implemented. **Do not market multi-device support** until this is resolved. +This section used to say "one keypair per user, stored only on the device that created it… a second device generates a new keypair and sealed answers become undecryptable." **Two parts of that were wrong**, and the correction matters because it changes what the fix has to be: + +- A second device does **not** overwrite the published public key. Every publish site is existence-gated (`SealedRevealManager.ensurePublicKeyPublished` returns early when a key exists), so device 1 keeps working and the partner keeps sealing to a key that is still valid. +- The real failure was narrower and quieter: device 2 simply had **no private key**, so `decryptPartnerAnswer` returned null and the UI said **"waiting for your partner"** — a lie, and one the user could never report accurately (fixed separately, `4952b129`: the result is now typed, and an unopenable keybox reports LOST_LOCAL_KEY). + +**Fix — escrow, not fan-out.** The private keyset is stored encrypted under the **couple key** (AAD = uid) at `users/{uid}/devices/primary/secure/escrow`. Any device holding the couple key — a precondition for reading anything at all — imports it and becomes crypto-identical to the original. Published heal-forward on Home load (idempotent, best-effort, never blocks Home), so existing users escrow on next launch. + +**Why this leaks nothing**: the only party besides the owner who can hold the couple key is the partner, and everything this keypair protects (release keys for the partner's *own* answers; restore keyboxes of the *shared* couple keyset) is material the partner already has. Escrow adds zero capability to anyone. It is owner-only regardless — and note **why it's a subdoc**: `devices/primary` must stay partner-readable (they seal to the public key) and rules cannot gate a single field, so the secret needs its own document. That distinction is the mutation-checked test: making the escrow inherit the parent's read rule fails exactly "the PARTNER cannot read the escrow". + +**Rejected**: multi-device pubkey fan-out (seal every release key to N keys + device lifecycle/pruning — an order of magnitude more protocol for the same outcome, with no multi-device product story to justify it); "re-seal on demand" (the sealer deliberately deletes the plaintext one-time key at release, `SealedRevealManager:68`). + +**Residual, documented not hidden**: a user whose only device dies *before* ever publishing an escrow keeps the old behaviour — their pre-escrow sealed history stays locked. The key never left that phone; nothing fixes that retroactively. **Multi-device is still not a marketed feature** — escrow heals the second device, it does not make two devices co-equal. ### iOS → Android sealed-answer bridge (`wrapReleaseKeyCallable`) {#ios-android-sealed-answer-bridge-wrapreleasekeycallable} @@ -1363,8 +1374,9 @@ OOB code = `truncate6(SHA-256(pubkey ‖ nonce))`. ### Recovery-phrase change desync - changing the couple phrase silently breaks the partner's "ask your partner" recovery **What**: the recovery phrase is a single **per-couple** secret that **both partners** receive at pairing and store locally (Keystore-backed `RecoveryPhraseStore` / `CoupleKeyStore`); it is never on the server in plaintext. This is by design and is a *feature* - it makes the partner a built-in backup: a user on a new device can recover by asking their partner to read them the phrase (Settings → Security), and the Recovery screen now tells them to. **The trap:** `CoupleRepository.changeRecoveryPhrase` → `CoupleEncryptionManager.rewrapWithNewPhrase` re-wraps the key under a new phrase and uploads a new `wrappedCoupleKey`, but only re-saves the new phrase on the **changer's** device. There is no channel to push the new phrase to the partner's device, so the partner's stored phrase (and their Settings → Security reveal) goes **stale** - it no longer matches `wrappedCoupleKey`, silently breaking the primary recovery path. -**Status (2026-06-29)**: the change-phrase API is **UNWIRED** - no UI caller - so the desync is **not user-reachable today**. It now carries a loud warning at all three layers (`CoupleRepository.changeRecoveryPhrase` KDoc, the impl comment, `rewrapWithNewPhrase` KDoc). -**Re-introduction risk**: do **not** wire a "change recovery phrase" UI without first solving partner re-sharing (force the partner to re-save the new phrase - e.g. re-share + re-confirm on their device - or treat the phrase as a fixed pairing secret that can't be changed). Otherwise a user who changed the phrase and then lost it would be unrecoverable, and "ask your partner" would hand over a wrong phrase. See [SECURITY.md](../SECURITY.md) (recovery section). +**FIXED (2026-07-16, `5716aead`)** - the feature is wired, and the desync is designed out rather than warned about. The phrase can't reach the partner via the server in plaintext, but it CAN travel sealed to the key they already hold, and the WRAP moves last: **phase 1** publish the new phrase (`encryptedPhraseSync`, `enc:v1:` under the couple key) + bump `phraseGeneration`; **phase 2** each device confirms it can read it (`phraseAckedBy[uid]`); **phase 3** once BOTH acked, re-wrap under it + `phraseWrapGeneration`. Until phase 3 the OLD phrase unwraps everything, so an abandoned change is a no-op, not an unrecoverable couple. A device stores the new phrase only when the wrap is actually made from it - the stored phrase and the wrap never disagree, which IS the invariant. Driven by the couple-doc watcher (`CoupleKeyRotationAdopter.advancePhraseChange`), phrase adoption ordered BEFORE rotation adoption (rotation unwraps with the stored phrase). Either device may complete phase 3, so an offline changer can't strand the couple. Phase 3 is a **transaction** re-reading `keyGeneration`: a phase-3 write racing a rotation would republish a wrap of the pre-rotation keyset and roll the rotation back. An old client ignores the fields, never acks, and the change simply never completes - it degrades to "nothing happened", the correct failure direction. +**Rules (`firestore.rules`, and the reason to run `firestore-tests/`)**: three narrow write shapes - `isPublishingPhraseSync` / `isAckingPhraseSync` / `isCompletingPhraseChange`. The emulator harness caught THREE bugs in the first cut, two critical: (1) `request.resource.data.keyGeneration` errors when the field never existed (couples are created without it) so **phase 3 was denied for every couple** - after the UI had shown the user their new phrase; both sides now `.get()`-default. (2) `phraseWrapGeneration` was allowlisted but guarded by NOTHING - a bare one-field write passed (an unchanged wrap short-circuits the wrap clause) and the partner's client trusts that field as proof the wrap moved, so **one write made them overwrite their working phrase with a dud**. (3) phase 3 never checked acks server-side. Plus a fourth from the tests: the predicate didn't require the wrap to actually change. +**Re-introduction risk**: the danger isn't the feature now, it's the shortcut. Any future change to key material must keep the rule the handshake encodes: **the wrap and every device's stored phrase must never disagree**, and the only way to keep that true across two phones is to move the wrap LAST, after every device has confirmed. Never re-wrap and hope the partner catches up. And never trust a client-written "everything is consistent now" field (`phraseWrapGeneration`) without a server-side rule making it unforgeable - the partner's device acts on it. `rewrapWithNewPhrase` is still the low-level primitive: it only makes the wrap, tells nobody, and publishing its output on its own re-creates this landmine exactly. See [SECURITY.md](../SECURITY.md) (recovery section). ### N-001 / N-002 - VMs that wait for the screen to push an id silently no-op if nothing pushes it **Symptom (R15)**: the **Bucket List was entirely non-functional** - add/load/complete/delete all did nothing, no error, no logcat. **Root cause**: `BucketListViewModel` gated every operation on `if (coupleId.isEmpty()) return`, expecting the screen to call `setCoupleId(...)` - but `BucketListScreen` never did (the nav route passes no coupleId and there's no `LaunchedEffect`). So `coupleId` stayed `""` and every op returned early **silently**. Same class hit **Date Builder (N-002)**: `savePreference()` bailed on `dateIdeaId.isEmpty()` while **nothing ever calls `setDateIdeaId`**, the preference had an empty `coupleId`, and it wrote to `date_plan_preferences` - a collection **no screen reads**. So "Create Plan" silently saved nothing.