A couple-key compromise currently exposes everything, forever, because the key
never changes. This adds the rotation ceremony: a fresh AES-256-GCM key becomes
the keyset's primary while the old keys stay for reads. The keyset is a Tink
keyring and every enc:v1: blob carries its key-id internally, so all history
keeps decrypting with zero wire-format changes — none of the 25 isCiphertext
rule sites move. Phase 1 protects FUTURE content only (a stolen keyset still
contains the old key); forward secrecy for history is phase 2, which builds on
the keyGeneration plumbing laid here. The Security-screen copy says so plainly.
The ceremony (CoupleRepositoryImpl.rotateCoupleKey): read the couple fresh so
concurrent rotations collide at the rules instead of overwriting each other →
prepareRotation builds the rotated keyset and re-wraps it under the SAME phrase
(fail-closed with a typed error when this device lacks keyset or phrase; nothing
persisted anywhere) → ONE merge write lands the new wrap + a strictly-increasing
keyGeneration atomically, so the partner can never observe a bumped generation
pointing at the old wrap → only then commitRotation stores locally. A failed
server write leaves the device coherent on the old key; a crash after it
self-heals through the same adoption path as the partner.
Adoption (CoupleEncryptionManager.adoptRotationIfNeeded, hooked into Home's
healing block, synchronously before the screen settles — until the rotated
keyset is stored, new content renders locked): couple.keyGeneration ahead of the
local generation → unwrap the published wrap with the locally-stored phrase →
replace the keyset. Replaced only on success, never deleted on failure, so old
content survives anything. No phrase on this device → needsRecovery, and both
recovery flows already deliver the rotated keyset for free (phrase entry unwraps
the current wrap; partner-assist exports the current keyset). Same phrase both
sides is the entire distribution trick — no new ceremony, no partner action.
Server: onCoupleKeyRotated (couples/{id} update, pure isKeyGenerationIncrease
edge guard so streak/rhythm/re-wrap updates never fire it, and a rules-forbidden
downgrade or redelivered stale event never alerts) sends both members the 🔑
security alert through the house pipeline, bypassing quiet hours like the
restore self-alerts. The push is also functional: the partner's closed app can't
read new-key content until it next loads Home — the tap takes them there.
Rules: isUpdatingRecoveryWrap admits keyGeneration, strictly increasing
(monotonic like encryptionVersion), untouched for plain phrase re-wraps.
Tests (real Tink, mocks stop at storage): history readable after rotation + NEW
writes unreadable by the old keyset — mutation-checked by dropping setPrimary,
which kills exactly that test (a rotation that forgets setPrimary passes
everything else while protecting nothing) — same-phrase unwrap reads both eras
(the partner's whole adoption, proven), prepare persists nothing until commit,
fail-closed without phrase/keyset, adoption state machine incl. corrupt-wrap.
Android suite green, assembleDebug clean, functions 105/105, tsc clean.
Deploy (scoped): firebase deploy --only firestore:rules and
--only functions:onCoupleKeyRotated. Live verify follows deploys.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
dist/ is what firebase deploy ships (no predeploy hook), so the compiled output
travels with the source it came from. Contains cleanupRestoreRequests,
queueAndPush, and the excludeTokens seam.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The webhook verified an Ed25519 signature in an X-Signature header, but RevenueCat
offers no public-key signing — it sends HMAC-SHA256 in X-RevenueCat-Webhook-Signature
(t=<ts>,v1=<hex>) computed over "<ts>.<rawBody>". As written, every real event would
have 401'd and premium would never sync for the partner.
- Rewrite verification to HMAC-SHA256 with a +/-5-min timestamp replay guard and a
constant-time compare; extract a pure verifyWebhookSignature() for unit testing.
- Rename secret REVENUECAT_SIGNING_KEY -> REVENUECAT_WEBHOOK_SECRET (it is an HMAC
secret, not an Ed25519 key). Never deployed, so no migration.
- Uncomment the export in index.ts (deploy still gated on seeding the secret).
- Add revenueCatWebhook.test.ts: valid / tampered / wrong-secret / missing / stale.
- Reconcile Future.md + Engineering_Reference_Manual.md to the real scheme.
Verified against the live account: the entitlement identifier is now closer_premium,
so events match entitlementLogic. Build + 80 tests green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
RevenueCat isn't set up, so exporting revenueCatWebhook forced a Secret Manager entry:
defineSecret('REVENUECAT_SIGNING_KEY') runs at module load, and Firebase validates every
declared secret across the whole codebase at deploy time (even functions excluded via --only),
failing with "no latest version of the secret". Comment out the export so the file isn't loaded
during discovery — no secret, no validation. revenueCatWebhook.ts (already migrated to v2) is
untouched; re-enable by uncommenting the export, seeding the real key, and deploying it.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Split the single broad onGamePartFinished (couples/{coupleId}/{gameType}/{sessionId}
wildcard, which fired a no-op invocation on every write to ANY couple subcollection) into
four narrow, explicitly-pathed triggers sharing one handler:
onThisOrThatPartFinished, onWheelPartFinished, onHowWellPartFinished, onDesireSyncPartFinished.
Behavior is identical for the four game collections; the spurious invocations for
messages/reactions/etc. are eliminated. (Background triggers have no client name dependency;
the old export is dropped and the four deploy fresh — the deploy runbook already accounts for
this.)
onGameSessionUpdate: move the `!change.after.exists` deletion guard ABOVE its four reads
(session/couple/userA/userB) so a delete/no-op event returns before doing any reads. The
delicate exactly-once claim-flag logic is otherwise untouched.
Build clean; 70 tests green. Discovery loads all four split triggers as v2 in us-central1
(36 functions total); old onGamePartFinished gone. dist rebuilt.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Migrate all callables off functions.https.onCall to firebase-functions/v2/https onCall:
createInvite, acceptInvite, leaveCouple, submitOutcome, sendGentleReminder,
sendThinkingOfYou, checkDeviceIntegrity, syncEntitlement, assignDailyQuestionCallable,
wrapReleaseKey. context.auth/app → request.auth/app, data arg → request.data. The 8
client-hardcoded callable names are preserved verbatim (verified via emulator discovery).
The manual `if (!request.app)` App Check check is a 1:1 port (no enforceAppCheck switch).
Hardening folded in:
- acceptInviteCallable: await the previously fire-and-forget partner_joined push (gen 2
freezes the instance after the response) — still swallows push errors so a failed push
never fails the accept.
- checkDeviceIntegrity: 10s timeout on the Play Integrity client.request so a hung upstream
can't pin the instance (fail-closed catch already handles the throw); memory 512MiB.
- wrapReleaseKey: memory 512MiB (tink); HttpsError swapped to v2; lazy tink require + graceful
failure preserved.
- Error mapping with `if (e instanceof HttpsError) throw e` re-throw guard around the risky
DB sections in acceptInvite, leaveCouple, submitOutcome, sendGentleReminder,
sendThinkingOfYou — raw errors map to a clean 'internal' without masking intentional codes
(resource-exhausted rate limits, permission-denied, etc.). leaveCouple's best-effort
recursiveDelete sweep now swallows errors (the transactional leave already succeeded).
- Adopt shared sendPushToUser()/logger; remove copied token readers + plaintext token logging.
Delete dead placeholder callables notifications/reminders.ts (sendDailyQuestionReminder,
sendPartnerAnsweredNotification) — no client caller; wrote sent:false rows nothing consumed.
Build clean; 70 tests green; discovery loads all callables as v2 in us-central1. dist rebuilt.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Groundwork for the v1→v2 Cloud Functions migration; no function is migrated yet
(all 35 still load as v1, verified via emulator discovery).
- options.ts: setGlobalOptions({ region: 'us-central1', maxInstances: 20 }), imported
first in index.ts so it applies before any v2 function is defined. Region pin is
load-bearing — the Android client uses the default region.
- notifications/push.ts: single canonical getUserTokens() + sendPushToUser() that
batches via messaging.sendEachForMulticast() and prunes dead tokens, to replace the
~10 copied token readers and ~19 copied send/prune blocks in later batches.
- log.ts: firebase-functions/logger re-export + redactToken() (FCM tokens are secrets).
- push.test.ts: 9 unit tests (token merge/dedupe, BatchResponse→dead-token mapping,
send/prune/no-op/whole-batch-failure paths). 67 tests green.
firebase-functions stays at v5.1.1 for the migration (supports both the root v1 API and
the /v2 subpaths); bump to v6 is deferred to the final batch once nothing references the
root namespace, so the build stays green at every step.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Tracked dist output rebuilt to match src (aggregateOutcomeStats export +
aggregateOutcomes module), keeping the deployed bundle in sync with source.
Co-Authored-By: Claude Fable 5 <[email protected]>
- New Cloud Function: onEntitlementChanged (Firestore onWrite on entitlements/premium) — edge-triggered inactive→active, notifies the OTHER partner so couple-shared unlock isn't silent
- New notification type SUBSCRIPTION_CHANGED → routes to SUBSCRIPTION
- AnswerRevealViewModel: re-issue markRevealed if best-effort failed (offline/transient) so partner_opened_answer push eventually fires
- firestore.rules: harden users/{uid} update allowlist (defense-in-depth; no live hole)
- 18 new brand glyph vector drawables (drawable-nodpi/)
- SettingsScreen / PlayHubScreen / WaitingForPartnerScreen: swap Material icons for new brand glyphs
- ClaudeQA docs + Future.md updated