docs: update Future.md, ClaudeQAPlan.md, ClaudeReport.md, ClaudeiOSPlan.md, Engineering_Reference_Manual.md for R24 backup/restore

This commit is contained in:
null 2026-06-30 20:43:34 -05:00
parent db948511fb
commit 1e9f8b97bc
5 changed files with 119 additions and 1 deletions

View File

@ -745,6 +745,19 @@ Account); Paywall; Your Progress/Activity; Recovery.
(only `wrappedCoupleKey`/`encryptedRecoveryPhrase` ever transit — verify via admin read). Without this, "I got a new
phone" silently loses the relationship history. (Also exercised from the account-lifecycle angle in Pass F and the
Settings → Security flow in Pass M.)
- **CONVERSATION BACKUP + FULL PARTNER-ASSISTED RESTORE (R24) — server-blind + the OOB-code gate.** Send messages →
a backup accrues (`couples/{id}/backup/manifest` + `.../chunks/{seq}` — admin-read shows ONLY `enc:v1:` payloads;
snapshot blob at Storage `users/{uid}/backups/{id}` is ciphertext). **Self-restore:** on a device with the couple
key, "restore" repopulates the local cache; admin confirms the server held only ciphertext. **Full partner-assist
(no phrase) — the headline:** simulate device loss WITHOUT `pm clear` (clear only `couple_crypto_secure` +
`user_key_secure` + `conversation_cache.db` via `run-as`) → recipient A "Ask your partner to restore" → shows a
6-digit code → partner B gets `restore_requested` push → B **types the code** → A's key + content restore, **never
entering the phrase**. Admin confirms only `keybox:v1:`/ciphertext on the server. **Negative (rules):** non-member
read of backup/restore docs **403**; partner writing a keybox to a non-partner request **403**; creating a
restore_request for another uid **403**; post-unpair fulfil **403**. **OOB-code binding:** a mismatched code is
**rejected** (B's device refuses to wrap); a swapped pubkey yields a different code. Files: `data/backup/*`,
`crypto/CoupleKeyTransfer.kt`, `data/remote/FirestoreBackupDataSource.kt`, `functions/src/backup/onRestoreRequested.ts`.
Unit coverage: `CoupleKeyTransferTest` + `BackupCodecTest`. See the Eng Ref Manual **R24-BACKUP** landmine.
- **D5 App Check / Functions / secrets:** App Check enforced; callables validate auth+membership; webhook authenticity;
admin-only writes rejected from clients; service-account JSONs never committed; no plaintext/secrets in logcat; temp
files deleted.
@ -897,7 +910,9 @@ open**. No duplicates; rate limiter (20/day, 100/week) doesn't drop legit ones.
in-app/Together record**; tapping the push → Home, not a dead-end) ·
`date_reflection_partner`/`date_reflection_ready`(**onDateReflectionWritten** → partner → the date reflection;
"your turn" when one reflects, "ready to reveal" when both; gated `notifPartnerAnswered`+quiet hours) ·
`date_logged`(**onDateHistoryCreated** → partner → reflect on the just-logged date) · `spki`(key identity/confirm → security/key screen) ·
`date_logged`(**onDateHistoryCreated** → partner → reflect on the just-logged date) ·
`restore_requested`(**onRestoreRequested** → partner → the restore-consent screen; high-signal help request, NOT
suppressed by the routine partner-activity toggle, only quiet hours) · `spki`(key identity/confirm → security/key screen) ·
`subscription_entitlement_changed` & `security_recovery` (if present).
- **Game-notification suite (per game):** A starts from Play hub → B gets the start/join push (if supported) → B taps
and lands on the correct join/waiting/active screen → B can join from there → A sees B joined/answered → both finish

File diff suppressed because one or more lines are too long

View File

@ -72,6 +72,21 @@ Implement byte-compatible Swift crypto for every Android wire format:
`secure/payload` reveal as the daily question (`date_history` is plaintext). iOS must implement the matching
reflection write/decrypt/reveal (mirror `FirestoreDateReflectionDataSource`) and the `date_reflection_*` /
`date_logged` notification types; until then iOS can't participate in date reflections.
- **⛔ Conversation backup + full partner-assisted restore (REQUIRED before iOS launch — R24).** Android now keeps a
couple-key-encrypted conversation backup (`couples/{id}/backup/manifest` + `.../chunks/{seq}` `enc:v1:`; snapshot
blob at Storage `users/{uid}/backups/{id}`) and a partner-assist flow (`couples/{id}/restore_requests/{uid}` with
a fresh `pub:v1:` + partner-written `keybox:v1:`; ECIES context `"{coupleId}|restore|{sender}|{recipient}|{nonce}"`;
6-digit OOB code = `truncate6(SHA-256(pubkey‖nonce))`). For **cross-platform** restore, iOS must byte-match the
`BackupCodec` JSON envelope, the couple-key wrap, and the keybox context/code so an iOS device can restore (self or
partner-assisted) from an Android partner and vice-versa — same class as the existing E2EE interop gate. Also wire
the `restore_requested` notification type. Until then iOS can't back up/restore or help a partner restore.
- **⛔ Partner-assist consent hardening (R24-b).** iOS's consent screen must mirror Android: resolve the recipient
via the user service and show their **email (plaintext anchor) + locally-decrypted display name**, and gate
Approve on **both the 6-digit code AND an explicit "I reached them" confirmation**. Mirror the two lifecycle
fixes — **delete any existing `restore_requests/{uid}` before re-creating** (a `set()` over an existing doc is a
rule-denied key-changing update) and **reject an expired request at fulfil**. Handle the new `restore_self_alert`
notification type (route to account security). The server (`onRestoreRequested`/`onRestoreFulfilled`) is shared,
so this is purely the iOS client half.
### 2.4 Screens & features to parity (~48 + new messaging)
All routes from the refreshed audit's screen map, **including the NEW Messages experience** (inbox + conversation

View File

@ -3,6 +3,38 @@
Non-blocking ideas: things that work today but could be better, plus feature ideas. Actual bugs
(broken/incorrect behavior) live in `ClaudeReport.md`, not here.
## Backup, restore & E2EE (follow-ons to R24)
- **Option B — relay-and-delete for messages.** Now that E2EE backup + restore exist (R24), make the Room
`conversation_cache` the **source of truth** and flip Firestore `messages` to a transient relay
(TTL / delete-after-delivery), plus **back up media into the snapshot** (since `chat_media` would then be
relay-deleted). This is the payoff the R24 backup unblocks. Rewire the chat read to local-first + live-merge
(additive, flag-guarded — dedupe RoomFirestore by message id).
- **Backup trigger hardening.** Backups currently run opportunistically from `HomeViewModel.loadHome` (throttled).
Move to **WorkManager** (deferred, retried, network/battery-constrained) + an app-background trigger for
reliability; add a **resumable, paginated initial backfill** for very large existing histories.
- **Settings visibility + control.** "Last backed up: {time} · {N} messages" indicator + manual "Back up now" /
"Restore history", and an **opt-out** toggle for users who want zero server retention. Include a dedicated
**"Recent restore activity"** list — the R24-b `restore_self_alert` entries already land in `notification_queue`
as the raw audit; this surfaces them so the owner can review restores on their account in one place.
- **Email-verification challenge for partner-assisted restore (strongest anti-account-takeover control).** Before
a restore request is honored, send a code to the account's **registered email** and require the recipient to
enter it. A phished-password attacker who lacks inbox access can't complete it (the couple-email match that
defeats the on-screen identity check does NOT defeat this). Needs mail infra (SendGrid / a Firebase Auth action)
— deferred for that reason. R24-b shipped the on-screen identity + confirm + owner self-alert as the pragmatic
interim.
- **Restore-request lifecycle cleanup.** A `restore_requests` doc left at `READY` (partner wrapped the key but the
recipient never completed) leaves an ECIES `keybox` — ciphertext sealed only to the recipient, useless to
anyone else, but untidy. Add a **scheduled cleanup** of expired requests (and their keyboxes). R24-b already
enforces expiry at fulfil time and deletes any stale request before a re-request.
- **Owner-alert precision.** The R24-b "was this you?" self-alert also reaches the *requesting* device (harmless).
Optionally exclude it via a client-written token hint on the request (would add a rules-allowed field).
- **Couple-key rotation / forward secrecy.** A couple-key compromise exposes all history incl. backups (no FS
today). Add rotation (both devices re-key) — hard but the right long-term hardening.
- **Server-independent anti-rollback freshness.** A malicious server could serve a stale manifest to hide recent
messages; today mitigated by the `generation` counter + a Phase-1 Firestore cross-check. Add a signed/monotonic
freshness signal for the Option-B world.
## UI
_(No open UI defects. The P0 onboarding/auth crash filed here 2026-06-28 was fixed + verified live and moved to

View File

@ -1163,6 +1163,60 @@ SCRIPTS.md
These are bugs that cost real debugging time and are easy to re-introduce if you don't know they existed. Before changing the relevant area, re-read the linked fix commit and the QA report entry. Format: **ID** — what it was — where it lives now.
### R24-BACKUP — E2EE conversation backup + full partner-assisted restore (design + re-introduction hazards)
**What it is**: devices keep a couple-key-encrypted backup of all conversations so a new/wiped device can restore
history, and a partner can fully restore for the other (key + content, no phrase). Files:
`data/backup/BackupManager.kt` (incremental append + compaction), `data/backup/BackupRestoreManager.kt` (restore
into the `conversation_cache` Room DB), `data/backup/RestoreManager.kt` (partner-assist for both roles),
`data/remote/FirestoreBackupDataSource.kt` (manifest/chunks + restore_requests), `crypto/CoupleKeyTransfer.kt`
(couple-key ECIES wrap + the OOB verification code), `functions/src/backup/onRestoreRequested.ts`.
**Wire formats / layout**: `couples/{id}/backup/manifest` (pointers + `generation`), `.../backup/manifest/chunks/{seq}`
(each `payload` is `enc:v1:` of a `BackupCodec` JSON batch), snapshot blob at Storage `users/{uid}/backups/{id}`
(couple-key ciphertext, tokenized URL in the manifest), `couples/{id}/restore_requests/{recipientUid}` (fresh
`pub:v1:` + partner-written `keybox:v1:`). Keybox ECIES context = `"{coupleId}|restore|{sender}|{recipient}|{nonce}"`;
OOB code = `truncate6(SHA-256(pubkey ‖ nonce))`.
**Re-introduction hazards**:
- **Both partners write the same couple backup** → converge only via **message-id dedupe** (restore upsert) +
**manifest `generation` CAS** in a transaction. Never blind-overwrite the manifest. **Delete folded chunks +
the previous snapshot ONLY after the manifest commits** (crash-safe); a lost CAS must orphan-clean its just-
uploaded blob and retry.
- **Append misses mutations.** `appendChunk` only catches messages with a NEW `createdAt`; deletes/reactions on old
messages are updates → captured only by **compaction's full-state re-read**. Don't "optimize" compaction into an
append-only fold.
- **Only back up resolved server timestamps** (`getTimestamp` non-null) — a pending write has a null timestamp and
would corrupt the cursor.
- **Backup blobs live under `users/{uid}/backups/`** (uploader-scoped write, so Storage rules can authorize it +
the existing `onUserDelete` `users/{uid}/` cleanup covers them). Do NOT move them under `couples/` without adding
membership-checked write auth (Storage rules can't read Firestore) + a `couples/` delete cleanup.
- **Partner-assist security is the OOB code.** B must **type** the 6-digit code A reads aloud before wrapping the
couple key; the code is a fingerprint of the exact pubkey in the request doc (defeats a server/MITM pubkey swap +
account-takeover). Never reduce it to a tap-to-approve. Consume (delete) the request after unwrap so no wrapped
key lingers.
- **Restore requires the couple key first** (phrase or partner keybox) — content decrypt is couple-key. Fail soft
everywhere (missing key → skip backup / "restore unavailable", never crash; never log keys/plaintext/phrase).
- **Re-request must delete before create (R24-b, Bug A).** `createRestoreRequest` does `.set()`; over an existing
`restore_requests/{uid}` doc that's an **update** changing `recipientPublicKey`/`requestNonce`, which matches **no**
rule (the keybox rule needs `auth.uid != recipientUid`; the status-only rule can't touch keys) → silent
`PERMISSION_DENIED`. So a retry after an expired/abandoned request fails. `RestoreManager.requestRestore` now
**deletes any existing request first**, then creates fresh. Don't drop the delete.
- **Enforce request expiry at fulfil (R24-b, Bug B).** `expiresAt` was advisory — nothing checked it, so a partner
could approve a stale request and wrap the key to a since-replaced pubkey. `fulfillRestore` now **rejects when
`now > expiresAt`** (`expiresAt <= 0` = no-expiry legacy is allowed); the consent screen treats expired/absent as
"no active request" (its own empty state, distinct from a live one).
- **Partner-assist consent shows identity + requires a confirm (R24-b).** The consent screen resolves the recipient
via `userRepository.getUser(partnerUid)` — **email is plaintext (the anti-impersonation anchor), displayName is
decrypted locally** (the approving partner holds the couple key; the server can't decrypt it, so this is
necessarily client-side). Approve is gated on `code.length==6 && confirmed`. The email/name/confirm are **UX +
accidental-approval + social-engineering-friction** controls, NOT a takeover defense (the takeover email matches);
the OOB code + the owner self-alert are the takeover controls. Keep the identity strictly client-side — never send
displayName to a function (breaks E2EE).
- **Owner self-alerts (R24-b).** `onRestoreRequested` sends a SECOND notification — to the **recipient's own
devices** (`type:'restore_self_alert'`, quiet-hours **bypassed**, deduped via `couples/{id}.lastRestoreSelfAlertAt`
within 60s) — plus `onRestoreFulfilled` (onUpdate, guarded to the single REQUESTED→READY edge) on key transfer.
Each notification branch is independently try/caught so one failing token-fetch never aborts the other. Server
bodies stay generic (can't name the recipient — E2EE). Client type wired in `PartnerNotificationManager`
(`RESTORE_SELF_ALERT` → `isEnabled` true, `routeFor` `AppRoute.SECURITY`, `fromRemoteType`).
### R23-DQ-001 — sourcing "already answered?" from local Room only → silent re-answer data loss against the immutable `secure/payload`
**Symptom (R23)**: on a device whose local answer store was empty while Firestore still held the user's daily answer (fresh device / reinstall with cleared data / wiped prefs), Home showed a stale **"your turn"** and the daily-question screen offered an **editable re-answer form**. Submitting logged `Write failed at couples/{id}/daily_question/{date}/answers/{uid}/secure/payload: PERMISSION_DENIED` (swallowed). If the user picked a *different* answer it was **silently lost** — the `secure/payload` doc is immutable (`allow update: if false`), so the overwrite is denied and the reveal keeps the *old* content while the UI claimed "saved".
**Root cause**: `DailyQuestionViewModel.loadDailyQuestion` and `HomeViewModel` derived answered-state from **local Room/prefs only** (`localAnswerRepository.getAnswer` / `observeAnswers``answeredQuestionIds`), with no fallback to Firestore. Room and Firestore can legitimately diverge (Auth + Firestore persist across a reinstall; the local answer store does not), so the app offered an action the rules forbid.