Closer/functions
null 5716aead8f feat(crypto): wire "change recovery phrase" with a partner handshake (closes the desync landmine)
The API existed and was deliberately unwired, carrying warnings at three layers:
re-wrapping the couple key under a new phrase leaves the PARTNER's stored copy
stale, so their Settings → Security reveals a phrase that unwraps nothing and the
"lost your phrase? ask your partner" path — the whole reason both partners hold it
— hands over a dud. This makes it shippable rather than deleting it.

The phrase can't travel via the server in plaintext, but it can travel sealed to
the key both partners already hold. And the WRAP moves last:

  phase 1  publish the new phrase (enc:v1: under the couple key) + phraseGeneration
  phase 2  each device confirms it can read it (ack)
  phase 3  once BOTH acked, re-wrap under it + phraseWrapGeneration

Until phase 3 the old phrase unwraps everything, so an interrupted change is a
no-op instead of 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 the landmine is about. An old client that ignores the fields
never acks, so the change simply never completes: it degrades to "nothing
happened", the right failure direction for a crypto rollout.

Two design notes against the plan. (1) The plan's "disable Rotate mid-handshake"
guard is gone: nothing stores the new phrase until the wrap moves, so a rotation
landing mid-handshake wraps under the phrase everyone still has, and phase 3 runs
in a TRANSACTION that re-reads keyGeneration — without it, a phase-3 write racing a
rotation republishes a wrap of the pre-rotation keyset and rolls the rotation back,
stranding the partner. The transaction subsumes the guard; a UI gate would have been
theatre. (2) Either device completes phase 3, so an offline changer can't leave the
couple showing a phrase the wrap doesn't honour.

I ALSO FOUND THE HARNESS I SHOULD HAVE BEEN USING: firestore-tests/ runs the rules
against the emulator. It immediately proved three bugs in my own rules, two of them
critical, all now fixed and pinned by 20 new tests (141 total, mutation-checked):

  - phase 3 was DEAD for every couple. `request.resource.data.keyGeneration` errors
    when the field never existed — couples are created without it — so the first
    phrase change of any couple was denied AFTER the UI had already shown the user
    their new phrase. The dud-phrase outcome, relocated. Both sides now default.
  - `phraseWrapGeneration` was in the allowlist 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 one that unwraps nothing.
    Permanently, silently, no crypto needed. Now only a genuine phase 3 may move it.
  - phase 3 never checked the acks server-side (client-only), so a client could
    complete before the partner had the phrase — exactly what the handshake exists
    to prevent. The rules now require both members' acks at the current generation.
    A fourth, caught by the tests themselves: the predicate didn't require the wrap
    to actually change, so advancing the generation alone still passed.

Also: the harness revealed the C-ROTATE-001 rules I shipped earlier broke a
standing test ("a member can re-wrap the couple key — allowed"). No live flow used
it (updateWrappedKey was dead code, now deleted), and the test encoded the very
behaviour the hardening removes — rewritten to assert the new invariant plus the
lawful rotation path.

Rules deploy is user-gated and NOT yet done; the client tolerates the old rules
(phase 1 is rejected, nothing breaks). Android suite + 108 functions tests + 141
rules tests green. Live 2-device verification still pending on the throwaway couple.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 21:57:58 -05:00
..
dist feat(crypto): couple-key rotation, phase 1 — rotate forward (Future.md security #3) 2026-07-15 02:49:50 -05:00
scripts feat(dates): Firestore-backed date catalog + sponsored ideas 2026-07-06 21:02:22 -05:00
src feat(crypto): wire "change recovery phrase" with a partner handshake (closes the desync landmine) 2026-07-15 21:57:58 -05:00
jest.config.js fix: test infrastructure and entitlement logic updates 2026-06-17 21:08:13 -05:00
package-lock.json build(functions): B6a bump firebase-functions to v7 (latest) 2026-07-08 00:03:17 -05:00
package.json build(functions): bump runtime Node 20 → 22 (Node 20 decommissioned 2026-10-30) 2026-07-08 03:42:18 -05:00
tsconfig.json feat(billing): server-side entitlement sync — RevenueCat webhook handler, entitlement logic, Firestore EntitlementChecker, Hilt DI, callable sync function (batch 10) 2026-06-17 01:25:51 -05:00