Closer/functions/dist/couples/onCoupleKeyRotated.test.js

24 lines
1.5 KiB
JavaScript
Raw Normal View History

feat(crypto): couple-key rotation, phase 1 — rotate forward (Future.md security #3) 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 <noreply@anthropic.com>
2026-07-15 02:49:50 -05:00
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const onCoupleKeyRotated_1 = require("./onCoupleKeyRotated");
describe('isKeyGenerationIncrease', () => {
it('fires on a genuine rotation (0 → 1, and every later bump)', () => {
expect((0, onCoupleKeyRotated_1.isKeyGenerationIncrease)(undefined, 1)).toBe(true);
expect((0, onCoupleKeyRotated_1.isKeyGenerationIncrease)(0, 1)).toBe(true);
expect((0, onCoupleKeyRotated_1.isKeyGenerationIncrease)(3, 4)).toBe(true);
});
it('ignores every non-rotation update to the couple doc', () => {
// The trigger watches the whole couple doc — streaks, rhythm, recovery re-wraps all pass through here.
expect((0, onCoupleKeyRotated_1.isKeyGenerationIncrease)(undefined, undefined)).toBe(false);
expect((0, onCoupleKeyRotated_1.isKeyGenerationIncrease)(2, 2)).toBe(false);
expect((0, onCoupleKeyRotated_1.isKeyGenerationIncrease)(0, undefined)).toBe(false);
});
it('never fires on a downgrade — rules forbid it, but a redelivered stale event must not alert either', () => {
expect((0, onCoupleKeyRotated_1.isKeyGenerationIncrease)(5, 4)).toBe(false);
});
it('treats junk as zero rather than alerting on garbage', () => {
expect((0, onCoupleKeyRotated_1.isKeyGenerationIncrease)('x', 'y')).toBe(false);
expect((0, onCoupleKeyRotated_1.isKeyGenerationIncrease)(null, 'high')).toBe(false);
});
});
//# sourceMappingURL=onCoupleKeyRotated.test.js.map