docs: update Future.md, ClaudeQAPlan.md, ClaudeReport.md, ClaudeiOSPlan.md, Engineering_Reference_Manual.md for R24 backup/restore
This commit is contained in:
parent
db948511fb
commit
1e9f8b97bc
|
|
@ -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
|
(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
|
phone" silently loses the relationship history. (Also exercised from the account-lifecycle angle in Pass F and the
|
||||||
Settings → Security flow in Pass M.)
|
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;
|
- **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
|
admin-only writes rejected from clients; service-account JSONs never committed; no plaintext/secrets in logcat; temp
|
||||||
files deleted.
|
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) ·
|
in-app/Together record**; tapping the push → Home, not a dead-end) ·
|
||||||
`date_reflection_partner`/`date_reflection_ready`(**onDateReflectionWritten** → partner → the date reflection;
|
`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) ·
|
"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).
|
`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
|
- **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
|
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
|
|
@ -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
|
`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_*` /
|
reflection write/decrypt/reveal (mirror `FirestoreDateReflectionDataSource`) and the `date_reflection_*` /
|
||||||
`date_logged` notification types; until then iOS can't participate in date reflections.
|
`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)
|
### 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
|
All routes from the refreshed audit's screen map, **including the NEW Messages experience** (inbox + conversation
|
||||||
|
|
|
||||||
32
Future.md
32
Future.md
|
|
@ -3,6 +3,38 @@
|
||||||
Non-blocking ideas: things that work today but could be better, plus feature ideas. Actual bugs
|
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.
|
(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 Room∪Firestore 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
|
## UI
|
||||||
|
|
||||||
_(No open UI defects. The P0 onboarding/auth crash filed here 2026-06-28 was fixed + verified live and moved to
|
_(No open UI defects. The P0 onboarding/auth crash filed here 2026-06-28 was fixed + verified live and moved to
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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`
|
### 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".
|
**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.
|
**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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue