2026-06-16 01:13:20 -05:00
|
|
|
|
rules_version = '2';
|
|
|
|
|
|
service cloud.firestore {
|
|
|
|
|
|
match /databases/{database}/documents {
|
|
|
|
|
|
|
|
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function isSignedIn() {
|
|
|
|
|
|
return request.auth != null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isOwner(uid) {
|
|
|
|
|
|
return isSignedIn() && request.auth.uid == uid;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isCouplesMember(coupleId) {
|
|
|
|
|
|
return isSignedIn()
|
|
|
|
|
|
&& request.auth.uid in
|
|
|
|
|
|
get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 04:12:58 -05:00
|
|
|
|
// The other member of a 2-person couple (relative to uid).
|
|
|
|
|
|
function otherCoupleMember(coupleId, uid) {
|
|
|
|
|
|
return get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds[0] == uid
|
|
|
|
|
|
? get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds[1]
|
|
|
|
|
|
: get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds[0];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Has the partner already written their reflection for this date? Once true, the author's
|
|
|
|
|
|
// reflection is sealed (edits are no longer allowed).
|
|
|
|
|
|
function partnerReflectedDate(coupleId, dateId, uid) {
|
|
|
|
|
|
return exists(/databases/$(database)/documents/couples/$(coupleId)/date_reflections/$(dateId)/answers/$(otherCoupleMember(coupleId, uid)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 21:45:04 -05:00
|
|
|
|
function isValidInviteCode(code) {
|
|
|
|
|
|
// Code must be exactly 6 alphanumeric characters
|
|
|
|
|
|
return code.matches('^[a-zA-Z0-9]{6}$');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isNotAlreadyPaired() {
|
2026-06-16 22:42:53 -05:00
|
|
|
|
// Check that the requesting user does not already have a coupleId.
|
|
|
|
|
|
// A missing user doc is treated as unpaired.
|
|
|
|
|
|
let userPath = /databases/$(database)/documents/users/$(request.auth.uid);
|
|
|
|
|
|
return !exists(userPath) || get(userPath).data.coupleId == null;
|
2026-06-16 21:45:04 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 22:42:53 -05:00
|
|
|
|
// Admin SDK / Cloud Functions bypass Firestore rules, so any operation that
|
|
|
|
|
|
// must only be performed server-side is denied for all direct client writes.
|
2026-06-16 21:46:56 -05:00
|
|
|
|
|
|
|
|
|
|
function isImmutable(fields) {
|
2026-06-20 23:14:47 -05:00
|
|
|
|
return fields is list
|
|
|
|
|
|
&& !request.resource.data.diff(resource.data).affectedKeys().hasAny(fields);
|
2026-06-16 21:46:56 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 23:30:58 -05:00
|
|
|
|
function isValidSwipeAction(action) {
|
|
|
|
|
|
return action == 'love' || action == 'maybe' || action == 'skip';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 19:41:27 -05:00
|
|
|
|
function isValidDatePlanStatus(status) {
|
|
|
|
|
|
return status == 'draft' || status == 'planned' || status == 'completed';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isValidBucketListCategory(category) {
|
|
|
|
|
|
return category == 'adventure' || category == 'travel' || category == 'food'
|
|
|
|
|
|
|| category == 'learning' || category == 'romance' || category == 'intimacy'
|
|
|
|
|
|
|| category == 'seasonal';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
|
function isCiphertext(value) {
|
|
|
|
|
|
return value is string && value.matches('^enc:v1:[A-Za-z0-9+/]+={0,2}$');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-23 17:47:07 -05:00
|
|
|
|
// A field is acceptable if it's absent, null, or enc:v1 ciphertext — never plaintext.
|
|
|
|
|
|
function cipherOrAbsent(data, key) {
|
|
|
|
|
|
return !(key in data) || data[key] == null || isCiphertext(data[key]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Date plan user content must be ciphertext; dateIdeaId/scheduledDate/status/timestamps
|
|
|
|
|
|
// stay plaintext because Firestore queries and ordering depend on them.
|
|
|
|
|
|
function isDatePlanContentEncrypted(data) {
|
|
|
|
|
|
return cipherOrAbsent(data, 'scheduledTime')
|
|
|
|
|
|
&& cipherOrAbsent(data, 'budget')
|
|
|
|
|
|
&& cipherOrAbsent(data, 'duration')
|
|
|
|
|
|
&& cipherOrAbsent(data, 'activity')
|
|
|
|
|
|
&& cipherOrAbsent(data, 'food')
|
|
|
|
|
|
&& cipherOrAbsent(data, 'conversationPrompts')
|
|
|
|
|
|
&& cipherOrAbsent(data, 'optionalChallenge');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
|
function coupleEncryptionEnabled(coupleId) {
|
|
|
|
|
|
return get(/databases/$(database)/documents/couples/$(coupleId)).data.encryptionVersion >= 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 00:23:58 -05:00
|
|
|
|
// Sealed-answer helpers (schemaVersion 3, partner-proof reveal).
|
|
|
|
|
|
|
|
|
|
|
|
function isSealedPayload(value) {
|
2026-06-20 01:51:02 -05:00
|
|
|
|
// sealed:v1: + URL-safe base64 no-padding body; 80 chars minimum rules out trivially short values
|
|
|
|
|
|
return value is string && value.matches('^sealed:v1:[A-Za-z0-9_-]{80,}$');
|
2026-06-20 00:23:58 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isKeybox(value) {
|
2026-06-20 01:51:02 -05:00
|
|
|
|
// keybox:v1: + URL-safe base64 no-padding; ECIES-P256 wrapping a 32-byte key is ~174 chars
|
|
|
|
|
|
return value is string && value.matches('^keybox:v1:[A-Za-z0-9_-]{120,}$');
|
2026-06-20 00:23:58 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-30 20:43:26 -05:00
|
|
|
|
function isPublicKey(value) {
|
|
|
|
|
|
// pub:v1: + URL-safe base64 no-padding of a Tink ECIES public keyset JSON.
|
|
|
|
|
|
return value is string && value.matches('^pub:v1:[A-Za-z0-9_-]{40,}$');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 00:23:58 -05:00
|
|
|
|
function isCommitmentHash(value) {
|
2026-06-20 01:51:02 -05:00
|
|
|
|
// sha256: + URL-safe base64 no-padding of a 32-byte digest = exactly 43 chars
|
|
|
|
|
|
return value is string && value.matches('^sha256:[A-Za-z0-9_-]{43}$');
|
2026-06-20 00:23:58 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Returns true when the incoming data satisfies the sealed-answer create shape.
|
|
|
|
|
|
// Plaintext content fields (writtenText, selectedOptionIds, scaleValue) are
|
|
|
|
|
|
// rejected by the hasOnly check below — they must never appear in a sealed doc.
|
|
|
|
|
|
function isSealedAnswerCreate(data) {
|
|
|
|
|
|
return data.keys().hasOnly([
|
|
|
|
|
|
'userId', 'questionId', 'answerType', 'encryptedPayload',
|
|
|
|
|
|
'commitmentHash', 'schemaVersion', 'answerKeyReleased',
|
2026-06-20 00:26:52 -05:00
|
|
|
|
'answerDate', 'createdAt', 'updatedAt', 'isRevealed'
|
2026-06-20 00:23:58 -05:00
|
|
|
|
])
|
|
|
|
|
|
&& isSealedPayload(data.encryptedPayload)
|
|
|
|
|
|
&& isCommitmentHash(data.commitmentHash)
|
|
|
|
|
|
&& data.schemaVersion == 3
|
|
|
|
|
|
&& data.answerKeyReleased == false
|
|
|
|
|
|
&& data.isRevealed == false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Only the reveal metadata fields may change after a sealed answer is created.
|
|
|
|
|
|
function isSealedAnswerUpdate() {
|
|
|
|
|
|
return resource.data.schemaVersion == 3
|
|
|
|
|
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
|
|
|
|
|
.hasOnly(['isRevealed', 'answerKeyReleased', 'updatedAt']);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 12:41:06 -05:00
|
|
|
|
// Couple-key daily answer (schemaVersion 2): the answer doc is metadata only (no content).
|
|
|
|
|
|
// The encrypted content lives in the read-gated `secure` subdoc, so this doc can stay
|
|
|
|
|
|
// readable (drives the partner's "your turn" indicator) without leaking the answer.
|
|
|
|
|
|
function isCoupleKeyAnswerCreate(data) {
|
|
|
|
|
|
return data.keys().hasOnly([
|
|
|
|
|
|
'userId', 'questionId', 'answerType',
|
|
|
|
|
|
'schemaVersion', 'answerDate', 'createdAt', 'updatedAt', 'isRevealed'
|
|
|
|
|
|
])
|
|
|
|
|
|
&& data.schemaVersion == 2
|
|
|
|
|
|
&& data.isRevealed == false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// After a couple-key answer is created, only the reveal flag may flip (drives the opened push).
|
|
|
|
|
|
function isCoupleKeyAnswerUpdate() {
|
|
|
|
|
|
return resource.data.schemaVersion == 2
|
|
|
|
|
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
|
|
|
|
|
.hasOnly(['isRevealed', 'updatedAt']);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 00:41:48 -05:00
|
|
|
|
// Thread sealed answers differ from daily answers: no answerDate (threads use threadId
|
|
|
|
|
|
// as context), no isRevealed field (reveal state is tracked by the thread VM).
|
|
|
|
|
|
function isSealedThreadAnswerCreate(data) {
|
|
|
|
|
|
return data.keys().hasOnly([
|
|
|
|
|
|
'userId', 'questionId', 'answerType', 'encryptedPayload',
|
|
|
|
|
|
'commitmentHash', 'schemaVersion', 'answerKeyReleased',
|
|
|
|
|
|
'createdAt', 'updatedAt'
|
|
|
|
|
|
])
|
|
|
|
|
|
&& isSealedPayload(data.encryptedPayload)
|
|
|
|
|
|
&& isCommitmentHash(data.commitmentHash)
|
|
|
|
|
|
&& data.schemaVersion == 3
|
|
|
|
|
|
&& data.answerKeyReleased == false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isSealedThreadAnswerUpdate() {
|
|
|
|
|
|
return resource.data.schemaVersion == 3
|
|
|
|
|
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
|
|
|
|
|
.hasOnly(['answerKeyReleased', 'updatedAt']);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
// ── Recovery-phrase change handshake (3 narrow write shapes) ──────────────────────
|
|
|
|
|
|
// The phrase is a per-couple secret both partners hold locally; the server never sees it in
|
|
|
|
|
|
// plaintext. A change therefore travels as ciphertext under the couple's own key, and the WRAP is
|
|
|
|
|
|
// re-made only after every member confirms they can read it — until then the old phrase still
|
|
|
|
|
|
// works, so an abandoned change is a no-op instead of an unrecoverable couple.
|
|
|
|
|
|
|
|
|
|
|
|
/** Phase 1: publish the encrypted phrase + bump the generation. The wrap is untouched here. */
|
|
|
|
|
|
function isPublishingPhraseSync() {
|
|
|
|
|
|
return request.resource.data.diff(resource.data).affectedKeys().hasOnly(
|
|
|
|
|
|
['encryptedPhraseSync', 'phraseGeneration']
|
|
|
|
|
|
)
|
|
|
|
|
|
&& isCiphertext(request.resource.data.encryptedPhraseSync)
|
|
|
|
|
|
// A CHANGED blob must advance the generation — the same lesson as C-ROTATE-001: an unchanged
|
|
|
|
|
|
// value is absent from affectedKeys(), so without this a re-publish could go unnoticed.
|
|
|
|
|
|
&& request.resource.data.phraseGeneration is int
|
|
|
|
|
|
&& request.resource.data.phraseGeneration > resource.data.get('phraseGeneration', 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Phase 2: a member acks ONLY their own uid, and only the generation currently published. */
|
|
|
|
|
|
function isAckingPhraseSync() {
|
|
|
|
|
|
return request.resource.data.diff(resource.data).affectedKeys().hasOnly(['phraseAckedBy'])
|
|
|
|
|
|
&& request.resource.data.phraseAckedBy[request.auth.uid] == resource.data.phraseGeneration
|
|
|
|
|
|
&& request.resource.data.phraseAckedBy.diff(
|
|
|
|
|
|
resource.data.get('phraseAckedBy', {})
|
|
|
|
|
|
).affectedKeys().hasOnly([request.auth.uid]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Phase 3: the wrap catches up to a published, fully-acked phrase. Key generation must NOT move. */
|
|
|
|
|
|
function isCompletingPhraseChange() {
|
|
|
|
|
|
// The re-wrap IS the completion. Without this, advancing phraseWrapGeneration on its own passes
|
|
|
|
|
|
// (once acks exist) and tells both devices to store a phrase the wrap was never made from —
|
|
|
|
|
|
// leaving the couple with a phrase that unwraps nothing. Caught by the rules tests.
|
|
|
|
|
|
return request.resource.data.diff(resource.data).affectedKeys().hasAny(['wrappedCoupleKey'])
|
|
|
|
|
|
&& request.resource.data.phraseWrapGeneration is int
|
|
|
|
|
|
&& request.resource.data.phraseWrapGeneration == resource.data.get('phraseGeneration', 0)
|
|
|
|
|
|
&& request.resource.data.phraseWrapGeneration > resource.data.get('phraseWrapGeneration', 0)
|
|
|
|
|
|
// The key must NOT move here: this write re-wraps the SAME keyset under a new phrase. Both
|
|
|
|
|
|
// sides need .get() defaults — couples are created without keyGeneration, and reading a
|
|
|
|
|
|
// never-written field is an error (which would deny every first phrase change).
|
|
|
|
|
|
&& request.resource.data.get('keyGeneration', 0) == resource.data.get('keyGeneration', 0)
|
|
|
|
|
|
// The wrap may only move once BOTH devices have confirmed they can read the new phrase.
|
|
|
|
|
|
// Without this the rules would take a client's word for it, and a client that completes
|
|
|
|
|
|
// early leaves the partner holding a phrase that unwraps nothing — the exact disaster this
|
|
|
|
|
|
// handshake exists to prevent. Acks live in resource.data (this write can't touch them).
|
|
|
|
|
|
&& resource.data.get('phraseAckedBy', {}).get(resource.data.userIds[0], 0)
|
|
|
|
|
|
== resource.data.get('phraseGeneration', 0)
|
|
|
|
|
|
&& resource.data.get('phraseAckedBy', {}).get(resource.data.userIds[1], 0)
|
|
|
|
|
|
== resource.data.get('phraseGeneration', 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
|
function isUpdatingRecoveryWrap() {
|
|
|
|
|
|
return request.resource.data.encryptionVersion >= 1
|
|
|
|
|
|
&& request.resource.data.wrappedCoupleKey is string
|
|
|
|
|
|
&& request.resource.data.kdfSalt is string
|
|
|
|
|
|
&& request.resource.data.kdfParams is string
|
|
|
|
|
|
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
|
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
|
|
|
|
'wrappedCoupleKey', 'kdfSalt', 'kdfParams', 'keyGeneration', 'phraseWrapGeneration'
|
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
|
|
|
|
])
|
fix(crypto): rotation must advance keyGeneration — a cached read silently stranded the partner (C-ROTATE-001)
Caught live on the fixture couple, second rotation. The first rotation worked;
the second looked identical from the rotating device — dialog confirmed, no
error, keyset swapped locally — and permanently locked the partner out of every
message written afterwards: 🔒 "Couldn't unlock on this device", with no way to
recover on his own, because the one signal that tells a partner to adopt a new
key never fired.
The chain: rotateCoupleKey read the couple through the cache-first
getCoupleById, got the stale keyGeneration=0 (the doc was already at 1), and
computed next = 1 — the value already on the document. Firestore doesn't count
an unchanged value as a change: affectedKeys() omitted keyGeneration, so the
monotonic rule never engaged, onCoupleKeyRotated never fired, and the partner's
device kept seeing "up to date". Meanwhile the write DID replace
wrappedCoupleKey with a wrap of the newer keyset and the rotating device DID
commit it locally — new content under a key only one phone has.
Same disease as C-AUTH-001, new organ: a cache-first read driving a one-shot
decision. Fixed at both layers:
- client: getCoupleByIdFromServer (Source.SERVER) backs the rotation decision;
offline throws, nothing is written, the user is told nothing changed —
rotation must fail loudly rather than half-happen.
- rules: a write that CHANGES wrappedCoupleKey must now carry a strictly
increased keyGeneration. The stranding write is unrepresentable server-side,
whatever any future client computes. (The old rule only checked monotonicity
when keyGeneration was itself in affectedKeys — which is exactly the case
that never arises when the bug happens.)
CoupleKeyRotationRepositoryTest pins it: generation comes from the server never
the cache, the published generation strictly advances, server write precedes the
local commit, offline fails without guessing. Mutation-checked by reinstating
the cached read — the two key tests fail, restored, suite green.
The fixture partner is un-stranded by rotating once more with this build: the
server read yields the true generation, the write is a real change, the trigger
fires, and adoption delivers the latest keyset (which retains every older key,
so the stranded-era messages decrypt too).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:33:12 -05:00
|
|
|
|
// A CHANGED wrap is a key rotation, and a rotation MUST advance keyGeneration: that bump is
|
|
|
|
|
|
// the only signal the partner's device has to adopt the new key. Publishing a new wrap while
|
|
|
|
|
|
// leaving the generation alone silently strands the partner on 🔒 forever — so the rules make
|
|
|
|
|
|
// it impossible rather than trusting every client to compute the next generation correctly
|
|
|
|
|
|
// (C-ROTATE-001: a cached read did exactly that). Note an unchanged value is absent from
|
|
|
|
|
|
// affectedKeys(), which is what let the stale write through before.
|
|
|
|
|
|
&& (
|
|
|
|
|
|
!request.resource.data.diff(resource.data).affectedKeys().hasAny(['wrappedCoupleKey'])
|
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
|
|
|
|
// Lawful reason #1: a key rotation — keyGeneration advances (C-ROTATE-001). A rotation
|
|
|
|
|
|
// must not smuggle phraseWrapGeneration along; that combination is caught by the
|
|
|
|
|
|
// phraseWrapGeneration guard below, which only accepts a genuine phase 3.
|
fix(crypto): rotation must advance keyGeneration — a cached read silently stranded the partner (C-ROTATE-001)
Caught live on the fixture couple, second rotation. The first rotation worked;
the second looked identical from the rotating device — dialog confirmed, no
error, keyset swapped locally — and permanently locked the partner out of every
message written afterwards: 🔒 "Couldn't unlock on this device", with no way to
recover on his own, because the one signal that tells a partner to adopt a new
key never fired.
The chain: rotateCoupleKey read the couple through the cache-first
getCoupleById, got the stale keyGeneration=0 (the doc was already at 1), and
computed next = 1 — the value already on the document. Firestore doesn't count
an unchanged value as a change: affectedKeys() omitted keyGeneration, so the
monotonic rule never engaged, onCoupleKeyRotated never fired, and the partner's
device kept seeing "up to date". Meanwhile the write DID replace
wrappedCoupleKey with a wrap of the newer keyset and the rotating device DID
commit it locally — new content under a key only one phone has.
Same disease as C-AUTH-001, new organ: a cache-first read driving a one-shot
decision. Fixed at both layers:
- client: getCoupleByIdFromServer (Source.SERVER) backs the rotation decision;
offline throws, nothing is written, the user is told nothing changed —
rotation must fail loudly rather than half-happen.
- rules: a write that CHANGES wrappedCoupleKey must now carry a strictly
increased keyGeneration. The stranding write is unrepresentable server-side,
whatever any future client computes. (The old rule only checked monotonicity
when keyGeneration was itself in affectedKeys — which is exactly the case
that never arises when the bug happens.)
CoupleKeyRotationRepositoryTest pins it: generation comes from the server never
the cache, the published generation strictly advances, server write precedes the
local commit, offline fails without guessing. Mutation-checked by reinstating
the cached read — the two key tests fail, restored, suite green.
The fixture partner is un-stranded by rotating once more with this build: the
server read yields the true generation, the write is a real change, the trigger
fires, and adoption delivers the latest keyset (which retains every older key,
so the stranded-era messages decrypt too).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:33:12 -05:00
|
|
|
|
|| (request.resource.data.keyGeneration is int
|
|
|
|
|
|
&& request.resource.data.keyGeneration > resource.data.get('keyGeneration', 0))
|
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
|
|
|
|
// Lawful reason #2: completing a recovery-phrase change — phraseWrapGeneration catches up
|
|
|
|
|
|
// to the published, fully-acked phraseGeneration. The wrap changes but the KEY doesn't, so
|
|
|
|
|
|
// there is no rotation to signal; the phrase handshake is the signal. Anything else that
|
|
|
|
|
|
// changes the wrap is still rejected, which is what keeps a silent re-wrap impossible.
|
|
|
|
|
|
|| isCompletingPhraseChange()
|
fix(crypto): rotation must advance keyGeneration — a cached read silently stranded the partner (C-ROTATE-001)
Caught live on the fixture couple, second rotation. The first rotation worked;
the second looked identical from the rotating device — dialog confirmed, no
error, keyset swapped locally — and permanently locked the partner out of every
message written afterwards: 🔒 "Couldn't unlock on this device", with no way to
recover on his own, because the one signal that tells a partner to adopt a new
key never fired.
The chain: rotateCoupleKey read the couple through the cache-first
getCoupleById, got the stale keyGeneration=0 (the doc was already at 1), and
computed next = 1 — the value already on the document. Firestore doesn't count
an unchanged value as a change: affectedKeys() omitted keyGeneration, so the
monotonic rule never engaged, onCoupleKeyRotated never fired, and the partner's
device kept seeing "up to date". Meanwhile the write DID replace
wrappedCoupleKey with a wrap of the newer keyset and the rotating device DID
commit it locally — new content under a key only one phone has.
Same disease as C-AUTH-001, new organ: a cache-first read driving a one-shot
decision. Fixed at both layers:
- client: getCoupleByIdFromServer (Source.SERVER) backs the rotation decision;
offline throws, nothing is written, the user is told nothing changed —
rotation must fail loudly rather than half-happen.
- rules: a write that CHANGES wrappedCoupleKey must now carry a strictly
increased keyGeneration. The stranding write is unrepresentable server-side,
whatever any future client computes. (The old rule only checked monotonicity
when keyGeneration was itself in affectedKeys — which is exactly the case
that never arises when the bug happens.)
CoupleKeyRotationRepositoryTest pins it: generation comes from the server never
the cache, the published generation strictly advances, server write precedes the
local commit, offline fails without guessing. Mutation-checked by reinstating
the cached read — the two key tests fail, restored, suite green.
The fixture partner is un-stranded by rotating once more with this build: the
server read yields the true generation, the write is a real change, the trigger
fires, and adoption delivers the latest keyset (which retains every older key,
so the stranded-era messages decrypt too).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:33:12 -05:00
|
|
|
|
)
|
|
|
|
|
|
// Monotonic like encryptionVersion: never downgradeable, so a replayed or stale rotation
|
|
|
|
|
|
// write can't roll the couple back to an older wrap.
|
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
|
|
|
|
&& (
|
|
|
|
|
|
!request.resource.data.diff(resource.data).affectedKeys().hasAny(['keyGeneration'])
|
|
|
|
|
|
|| (request.resource.data.keyGeneration is int
|
|
|
|
|
|
&& request.resource.data.keyGeneration > resource.data.get('keyGeneration', 0))
|
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
|
|
|
|
)
|
|
|
|
|
|
// phraseWrapGeneration is the signal partners' devices trust to mean "the wrap now uses the
|
|
|
|
|
|
// new phrase — store it". It must therefore ONLY move as part of a real phase 3: on its own
|
|
|
|
|
|
// it is a one-write weapon that makes the partner overwrite their working phrase with one
|
|
|
|
|
|
// that unwraps nothing. Being in the allowlist above is not permission to move it.
|
|
|
|
|
|
&& (
|
|
|
|
|
|
!request.resource.data.diff(resource.data).affectedKeys().hasAny(['phraseWrapGeneration'])
|
|
|
|
|
|
|| isCompletingPhraseChange()
|
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
|
|
|
|
);
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isUpdatingCoupleRhythm() {
|
|
|
|
|
|
return request.resource.data.diff(resource.data).affectedKeys().hasOnly([
|
|
|
|
|
|
'streakCount', 'lastAnsweredAt'
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 01:13:20 -05:00
|
|
|
|
// ── Users ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
// Each user owns exactly their own document.
|
2026-06-16 20:16:47 -05:00
|
|
|
|
// hasPremium is server-only: clients may not write it directly.
|
2026-06-16 01:13:20 -05:00
|
|
|
|
|
|
|
|
|
|
match /users/{uid} {
|
2026-06-24 10:02:54 -05:00
|
|
|
|
// Owner reads their own doc; a paired partner may read the other's doc (name + photo)
|
|
|
|
|
|
// — they share a coupleId. Without this, partner name/photo never load (shows
|
|
|
|
|
|
// "Your partner" / blank avatar everywhere: pairing screen, Home, games).
|
|
|
|
|
|
allow read: if isOwner(uid)
|
|
|
|
|
|
|| (
|
|
|
|
|
|
request.auth != null
|
|
|
|
|
|
&& resource.data.coupleId != null
|
|
|
|
|
|
&& exists(/databases/$(database)/documents/users/$(request.auth.uid))
|
|
|
|
|
|
&& get(/databases/$(database)/documents/users/$(request.auth.uid)).data.coupleId == resource.data.coupleId
|
|
|
|
|
|
);
|
2026-06-16 20:16:47 -05:00
|
|
|
|
allow create: if isOwner(uid)
|
|
|
|
|
|
&& !request.resource.data.keys().hasAny(['hasPremium']);
|
2026-06-27 16:35:41 -05:00
|
|
|
|
// Field allowlist (hardening): the owner may update ONLY known profile/aux fields. This blocks
|
|
|
|
|
|
// the server-owned `hasPremium`/`premium` flags AND any arbitrary junk keys a client could set on
|
|
|
|
|
|
// its own doc. Entitlements live in the server-only `entitlements/premium` subdoc; no gate reads
|
|
|
|
|
|
// these root fields, so this is defense-in-depth, not a fix for a live hole. Keep this list in sync
|
|
|
|
|
|
// with `User.kt` + `FirestoreUserDataSource` if a new client-written field is added.
|
2026-06-16 20:16:47 -05:00
|
|
|
|
allow update: if isOwner(uid)
|
2026-06-27 16:35:41 -05:00
|
|
|
|
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
|
|
|
|
|
|
'email', 'displayName', 'photoUrl', 'sex', 'partnerId', 'coupleId',
|
2026-07-02 02:42:55 -05:00
|
|
|
|
'plan', 'birthDate', 'createdAt', 'lastActiveAt', 'fcmToken',
|
2026-06-28 10:00:25 -05:00
|
|
|
|
'notifPartnerAnswered', 'notifChatMessage',
|
2026-06-30 00:38:06 -05:00
|
|
|
|
// Daily/streak/promotional prefs mirrored so the scheduled senders can honor them.
|
|
|
|
|
|
'notifDailyReminder', 'notifStreakReminder', 'notifPromotional',
|
2026-06-28 10:00:25 -05:00
|
|
|
|
// M-001: quiet-hours window mirrored for server-side push suppression.
|
|
|
|
|
|
'quietHoursEnabled', 'quietHoursStartMinutes', 'quietHoursEndMinutes', 'timezone'
|
2026-06-27 16:35:41 -05:00
|
|
|
|
]);
|
2026-06-17 19:10:45 -05:00
|
|
|
|
|
|
|
|
|
|
// Entitlements written server-side only (RevenueCat webhook via Admin SDK).
|
|
|
|
|
|
// Client needs read access so FirestoreEntitlementChecker can observe premium state.
|
|
|
|
|
|
match /entitlements/{entitlementDoc} {
|
2026-06-24 18:52:50 -05:00
|
|
|
|
// Owner reads their own; a paired partner may also read it so premium can be shared
|
|
|
|
|
|
// across the couple (chat media unlocks if EITHER partner is premium).
|
|
|
|
|
|
allow read: if isOwner(uid)
|
|
|
|
|
|
|| (
|
|
|
|
|
|
request.auth != null
|
|
|
|
|
|
&& exists(/databases/$(database)/documents/users/$(uid))
|
|
|
|
|
|
&& get(/databases/$(database)/documents/users/$(uid)).data.coupleId != null
|
|
|
|
|
|
&& exists(/databases/$(database)/documents/users/$(request.auth.uid))
|
|
|
|
|
|
&& get(/databases/$(database)/documents/users/$(request.auth.uid)).data.coupleId
|
|
|
|
|
|
== get(/databases/$(database)/documents/users/$(uid)).data.coupleId
|
|
|
|
|
|
);
|
2026-06-17 19:10:45 -05:00
|
|
|
|
allow write: if false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Notification queue written server-side only (Cloud Functions).
|
|
|
|
|
|
// No client read needed; the app reacts to FCM push, not this collection.
|
|
|
|
|
|
match /notification_queue/{notificationId} {
|
2026-06-23 18:23:49 -05:00
|
|
|
|
// Written server-side (Admin SDK bypasses rules). The owner may read their own
|
|
|
|
|
|
// activity feed and flip a notification's `read` flag — nothing else.
|
|
|
|
|
|
allow read: if isOwner(uid);
|
|
|
|
|
|
allow update: if isOwner(uid)
|
|
|
|
|
|
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['read']);
|
|
|
|
|
|
allow create, delete: if false;
|
2026-06-17 19:10:45 -05:00
|
|
|
|
}
|
feat: E2EE — Tink AEAD, Argon2id KDF, recovery phrase, encrypted Firestore fields (batch v0.2.6)
- Add crypto module: CoupleKeyStore (EncryptedSharedPreferences), RecoveryKeyManager (Argon2id + AES-256-GCM key wrap), FieldEncryptor (AEAD per-field), CoupleEncryptionManager (orchestration)
- Add Tink + Bouncy Castle dependencies to build.gradle.kts, register AeadConfig in CloserApp
- Encrypt answer fields (writtenText, selectedOptionIds, scaleValue) on write, decrypt on read
- Encrypt DesireSync, HowWell, WheelAnswer, QuestionThread fields via CoupleEncryptionManager
- Generate recovery phrase during invite creation, display in CreateInviteScreen
- Add recovery phrase input to InviteConfirmScreen for encrypted invites
- Add RecoveryScreen + RecoveryViewModel for post-pairing key recovery
- Update Couple model with encryptionVersion, wrappedCoupleKey, kdfSalt, kdfParams
- Update Firestore rules: allow couple doc creation by members, fcmTokens path, encryptionVersion monotonic check, invite doc extended fields
2026-06-19 19:52:35 -05:00
|
|
|
|
|
2026-06-20 23:59:24 -05:00
|
|
|
|
// Per-user outcome mirrors for cross-relationship progress stats.
|
|
|
|
|
|
// Writes are server-side only (submitOutcomeCallable); direct client writes denied.
|
|
|
|
|
|
match /outcomes/{dayKey} {
|
|
|
|
|
|
allow read: if isOwner(uid)
|
|
|
|
|
|
&& dayKey in ['day_0', 'day_30', 'day_60', 'day_90'];
|
|
|
|
|
|
allow create, update, delete: if false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: E2EE — Tink AEAD, Argon2id KDF, recovery phrase, encrypted Firestore fields (batch v0.2.6)
- Add crypto module: CoupleKeyStore (EncryptedSharedPreferences), RecoveryKeyManager (Argon2id + AES-256-GCM key wrap), FieldEncryptor (AEAD per-field), CoupleEncryptionManager (orchestration)
- Add Tink + Bouncy Castle dependencies to build.gradle.kts, register AeadConfig in CloserApp
- Encrypt answer fields (writtenText, selectedOptionIds, scaleValue) on write, decrypt on read
- Encrypt DesireSync, HowWell, WheelAnswer, QuestionThread fields via CoupleEncryptionManager
- Generate recovery phrase during invite creation, display in CreateInviteScreen
- Add recovery phrase input to InviteConfirmScreen for encrypted invites
- Add RecoveryScreen + RecoveryViewModel for post-pairing key recovery
- Update Couple model with encryptionVersion, wrappedCoupleKey, kdfSalt, kdfParams
- Update Firestore rules: allow couple doc creation by members, fcmTokens path, encryptionVersion monotonic check, invite doc extended fields
2026-06-19 19:52:35 -05:00
|
|
|
|
// FCM registration tokens: owner can read/write their own tokens.
|
|
|
|
|
|
match /fcmTokens/{tokenId} {
|
|
|
|
|
|
allow read, write: if isOwner(uid);
|
|
|
|
|
|
}
|
2026-06-20 00:23:58 -05:00
|
|
|
|
|
|
|
|
|
|
// Per-user ECIES public keys for sealed-answer key release.
|
2026-06-20 00:41:48 -05:00
|
|
|
|
// The owner writes their own public key; only the user's current partner may read
|
|
|
|
|
|
// it (to wrap release keys). Restricting to couple members prevents a malicious
|
|
|
|
|
|
// user from reading arbitrary public keys and pre-encrypting speculative release keys.
|
2026-06-20 00:23:58 -05:00
|
|
|
|
match /devices/{deviceId} {
|
2026-06-20 00:41:48 -05:00
|
|
|
|
allow read: if isOwner(uid)
|
|
|
|
|
|
|| (isSignedIn()
|
|
|
|
|
|
&& get(/databases/$(database)/documents/users/$(uid)).data.coupleId != null
|
|
|
|
|
|
&& get(/databases/$(database)/documents/users/$(uid)).data.coupleId
|
|
|
|
|
|
== get(/databases/$(database)/documents/users/$(request.auth.uid)).data.coupleId);
|
2026-06-20 00:23:58 -05:00
|
|
|
|
allow create, update: if isOwner(uid)
|
|
|
|
|
|
&& request.resource.data.publicKey is string
|
2026-06-22 10:53:05 -05:00
|
|
|
|
&& request.resource.data.publicKey.matches('^pub:v1:.*')
|
2026-06-20 00:23:58 -05:00
|
|
|
|
&& request.resource.data.keys().hasOnly(['deviceId', 'publicKey', 'platform', 'updatedAt']);
|
|
|
|
|
|
allow delete: if false;
|
|
|
|
|
|
}
|
2026-06-16 01:13:20 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 23:30:58 -05:00
|
|
|
|
// ── Date ideas (read-only catalog) ─────────────────────────────────────────
|
|
|
|
|
|
// Curated date ideas are readable by any authenticated user.
|
|
|
|
|
|
// Writes are server-only (admin SDK / Cloud Functions seeding).
|
|
|
|
|
|
|
|
|
|
|
|
match /date_ideas/{dateIdeaId} {
|
|
|
|
|
|
allow read: if isSignedIn();
|
|
|
|
|
|
allow create, update, delete: if false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 01:13:20 -05:00
|
|
|
|
// ── Invite codes ──────────────────────────────────────────────────────────
|
2026-06-20 23:28:20 -05:00
|
|
|
|
// Invite system is server-side only for writes. Clients may only read their
|
|
|
|
|
|
// own pending invites. The invite document ID is a 6-character code; it is
|
|
|
|
|
|
// enumerable, so direct client create/update/delete is denied.
|
2026-06-16 01:13:20 -05:00
|
|
|
|
|
|
|
|
|
|
match /invites/{code} {
|
2026-06-19 21:46:12 -05:00
|
|
|
|
// Read: only the inviter may read their own invite (e.g. to check status).
|
|
|
|
|
|
// Non-inviters are denied to prevent invite-code enumeration.
|
2026-06-20 23:28:20 -05:00
|
|
|
|
// Expired invites remain readable by the inviter for diagnostics.
|
2026-06-16 21:45:04 -05:00
|
|
|
|
allow read: if isSignedIn()
|
2026-06-20 23:28:20 -05:00
|
|
|
|
&& request.auth.uid == resource.data.inviterUserId;
|
2026-06-16 21:45:04 -05:00
|
|
|
|
|
2026-06-20 23:28:20 -05:00
|
|
|
|
// Create / Update / Delete: server-side / Cloud Functions only.
|
|
|
|
|
|
// The Admin SDK bypasses these rules. Direct client writes are denied
|
|
|
|
|
|
// because 6-character codes are enumerable and invite creation involves
|
|
|
|
|
|
// rate limiting, uniqueness checks, and key material the client cannot
|
|
|
|
|
|
// be trusted to produce safely.
|
|
|
|
|
|
allow create, update, delete: if false;
|
2026-06-16 01:13:20 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Couples ───────────────────────────────────────────────────────────────
|
2026-06-16 21:46:56 -05:00
|
|
|
|
// Only the two members of a couple may read couple data.
|
|
|
|
|
|
// Writes are restricted by field ownership and immutability.
|
2026-06-16 01:13:20 -05:00
|
|
|
|
|
|
|
|
|
|
match /couples/{coupleId} {
|
2026-06-16 21:46:56 -05:00
|
|
|
|
// Read: both members can read
|
|
|
|
|
|
allow read: if isCouplesMember(coupleId);
|
|
|
|
|
|
|
2026-06-19 21:52:19 -05:00
|
|
|
|
// Create: server-side only via the acceptInviteCallable Cloud Function.
|
|
|
|
|
|
// The Admin SDK bypasses these rules. The shape check remains as defense
|
|
|
|
|
|
// in depth in case any other trusted server process creates a couple doc.
|
feat: E2EE — Tink AEAD, Argon2id KDF, recovery phrase, encrypted Firestore fields (batch v0.2.6)
- Add crypto module: CoupleKeyStore (EncryptedSharedPreferences), RecoveryKeyManager (Argon2id + AES-256-GCM key wrap), FieldEncryptor (AEAD per-field), CoupleEncryptionManager (orchestration)
- Add Tink + Bouncy Castle dependencies to build.gradle.kts, register AeadConfig in CloserApp
- Encrypt answer fields (writtenText, selectedOptionIds, scaleValue) on write, decrypt on read
- Encrypt DesireSync, HowWell, WheelAnswer, QuestionThread fields via CoupleEncryptionManager
- Generate recovery phrase during invite creation, display in CreateInviteScreen
- Add recovery phrase input to InviteConfirmScreen for encrypted invites
- Add RecoveryScreen + RecoveryViewModel for post-pairing key recovery
- Update Couple model with encryptionVersion, wrappedCoupleKey, kdfSalt, kdfParams
- Update Firestore rules: allow couple doc creation by members, fcmTokens path, encryptionVersion monotonic check, invite doc extended fields
2026-06-19 19:52:35 -05:00
|
|
|
|
allow create: if isSignedIn()
|
|
|
|
|
|
&& request.auth.uid in request.resource.data.userIds
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
|
&& request.resource.data.keys().hasAll([
|
|
|
|
|
|
'id', 'userIds', 'inviteCode', 'createdAt', 'streakCount',
|
|
|
|
|
|
'wrappedCoupleKey', 'kdfSalt', 'kdfParams', 'encryptionVersion'
|
|
|
|
|
|
])
|
|
|
|
|
|
&& request.resource.data.encryptionVersion == 2
|
|
|
|
|
|
&& request.resource.data.wrappedCoupleKey is string
|
|
|
|
|
|
&& request.resource.data.kdfSalt is string
|
|
|
|
|
|
&& request.resource.data.kdfParams is string
|
feat: E2EE — Tink AEAD, Argon2id KDF, recovery phrase, encrypted Firestore fields (batch v0.2.6)
- Add crypto module: CoupleKeyStore (EncryptedSharedPreferences), RecoveryKeyManager (Argon2id + AES-256-GCM key wrap), FieldEncryptor (AEAD per-field), CoupleEncryptionManager (orchestration)
- Add Tink + Bouncy Castle dependencies to build.gradle.kts, register AeadConfig in CloserApp
- Encrypt answer fields (writtenText, selectedOptionIds, scaleValue) on write, decrypt on read
- Encrypt DesireSync, HowWell, WheelAnswer, QuestionThread fields via CoupleEncryptionManager
- Generate recovery phrase during invite creation, display in CreateInviteScreen
- Add recovery phrase input to InviteConfirmScreen for encrypted invites
- Add RecoveryScreen + RecoveryViewModel for post-pairing key recovery
- Update Couple model with encryptionVersion, wrappedCoupleKey, kdfSalt, kdfParams
- Update Firestore rules: allow couple doc creation by members, fcmTokens path, encryptionVersion monotonic check, invite doc extended fields
2026-06-19 19:52:35 -05:00
|
|
|
|
&& request.resource.data.keys().hasOnly([
|
|
|
|
|
|
'id', 'userIds', 'inviteCode', 'createdAt', 'streakCount',
|
|
|
|
|
|
'wrappedCoupleKey', 'kdfSalt', 'kdfParams', 'encryptionVersion']);
|
2026-06-16 21:46:56 -05:00
|
|
|
|
|
|
|
|
|
|
// Update: field-level restrictions
|
2026-06-19 20:33:08 -05:00
|
|
|
|
// - user IDs, invite code, and createdAt are immutable
|
feat: E2EE — Tink AEAD, Argon2id KDF, recovery phrase, encrypted Firestore fields (batch v0.2.6)
- Add crypto module: CoupleKeyStore (EncryptedSharedPreferences), RecoveryKeyManager (Argon2id + AES-256-GCM key wrap), FieldEncryptor (AEAD per-field), CoupleEncryptionManager (orchestration)
- Add Tink + Bouncy Castle dependencies to build.gradle.kts, register AeadConfig in CloserApp
- Encrypt answer fields (writtenText, selectedOptionIds, scaleValue) on write, decrypt on read
- Encrypt DesireSync, HowWell, WheelAnswer, QuestionThread fields via CoupleEncryptionManager
- Generate recovery phrase during invite creation, display in CreateInviteScreen
- Add recovery phrase input to InviteConfirmScreen for encrypted invites
- Add RecoveryScreen + RecoveryViewModel for post-pairing key recovery
- Update Couple model with encryptionVersion, wrappedCoupleKey, kdfSalt, kdfParams
- Update Firestore rules: allow couple doc creation by members, fcmTokens path, encryptionVersion monotonic check, invite doc extended fields
2026-06-19 19:52:35 -05:00
|
|
|
|
// - encryptionVersion is monotonically non-decreasing (cannot downgrade)
|
2026-06-19 20:33:08 -05:00
|
|
|
|
// - only the explicitly listed mutable fields may change; everything else
|
|
|
|
|
|
// (including currentQuestionId, activePackId, id) is server-only
|
2026-06-16 21:46:56 -05:00
|
|
|
|
allow update: if isCouplesMember(coupleId)
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
|
&& isImmutable(['id', 'userIds', 'inviteCode', 'createdAt'])
|
|
|
|
|
|
&& (
|
|
|
|
|
|
isUpdatingCoupleRhythm()
|
|
|
|
|
|
|| isUpdatingRecoveryWrap()
|
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
|
|
|
|
|| isPublishingPhraseSync()
|
|
|
|
|
|
|| isAckingPhraseSync()
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
|
);
|
2026-06-16 21:46:56 -05:00
|
|
|
|
|
2026-06-16 22:42:53 -05:00
|
|
|
|
// Delete: server-only (admin SDK only). Admin SDK bypasses rules.
|
|
|
|
|
|
allow delete: if false;
|
2026-06-16 01:13:20 -05:00
|
|
|
|
|
2026-06-16 19:44:28 -05:00
|
|
|
|
match /sessions/{sessionId} {
|
2026-06-16 21:46:56 -05:00
|
|
|
|
// Read: both members can read
|
|
|
|
|
|
allow read: if isCouplesMember(coupleId);
|
|
|
|
|
|
|
2026-06-25 21:43:06 -05:00
|
|
|
|
// Per-couple active-session pointer used as an atomic lock so two partners starting a game
|
|
|
|
|
|
// at the same instant converge to ONE session instead of two divergent ones (F-RACE-001).
|
|
|
|
|
|
// It holds no game content (only activeSessionId + updatedAt) and carries no status/
|
|
|
|
|
|
// completedAt, so it never appears in the active-session or history queries.
|
|
|
|
|
|
allow create, update: if sessionId == '_active' && isCouplesMember(coupleId);
|
|
|
|
|
|
|
2026-06-16 21:46:56 -05:00
|
|
|
|
// Create: either member can start a session
|
|
|
|
|
|
allow create: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& request.resource.data.startedByUserId == request.auth.uid;
|
|
|
|
|
|
|
2026-06-25 09:37:37 -05:00
|
|
|
|
// Update: any couple member may record session progress/completion.
|
|
|
|
|
|
// (Async two-device games mark each player done via `completedByUsers`; the
|
|
|
|
|
|
// session flips active→completed once both are in. The previous rule only
|
|
|
|
|
|
// allowed `status`/`completedAt`, so every `completedByUsers` write was denied
|
|
|
|
|
|
// and finished games never closed — locking the couple out of new games. B-001.)
|
2026-06-16 21:46:56 -05:00
|
|
|
|
allow update: if isCouplesMember(coupleId)
|
2026-06-25 09:37:37 -05:00
|
|
|
|
// startedByUserId is immutable for direct client writes.
|
2026-06-16 22:42:53 -05:00
|
|
|
|
&& request.resource.data.startedByUserId == resource.data.startedByUserId
|
2026-06-28 22:24:46 -05:00
|
|
|
|
// Only session progress/completion/join fields may change. (`joinedByUsers` records the
|
|
|
|
|
|
// non-starter opening the session → drives the partner_joined_game push. The server-only
|
|
|
|
|
|
// `joinNotifiedAt`/`startNotifiedAt`/`finishNotifiedAt` flags are intentionally NOT here,
|
|
|
|
|
|
// so clients can't spoof a "notified" claim — the Cloud Function writes those via Admin.)
|
2026-06-25 09:37:37 -05:00
|
|
|
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
2026-06-28 22:24:46 -05:00
|
|
|
|
.hasOnly(['status', 'completedAt', 'completedByUsers', 'joinedByUsers'])
|
2026-06-29 11:02:31 -05:00
|
|
|
|
// Defense-in-depth: a member may only ADD THEIR OWN uid to the progress arrays — never
|
|
|
|
|
|
// spoof the partner's join/completion, and never remove entries. (old ⊆ new ⊆ old ∪ {self};
|
|
|
|
|
|
// `get(...,[])` tolerates docs created before these fields existed.) Compatible with the
|
|
|
|
|
|
// client writes: markUserComplete / markUserJoined / abandonSession all leave the arrays as
|
|
|
|
|
|
// old or old+self.
|
|
|
|
|
|
&& request.resource.data.get('completedByUsers', [])
|
|
|
|
|
|
.hasAll(resource.data.get('completedByUsers', []))
|
|
|
|
|
|
&& request.resource.data.get('completedByUsers', [])
|
|
|
|
|
|
.hasOnly(resource.data.get('completedByUsers', []).concat([request.auth.uid]))
|
|
|
|
|
|
&& request.resource.data.get('joinedByUsers', [])
|
|
|
|
|
|
.hasAll(resource.data.get('joinedByUsers', []))
|
|
|
|
|
|
&& request.resource.data.get('joinedByUsers', [])
|
|
|
|
|
|
.hasOnly(resource.data.get('joinedByUsers', []).concat([request.auth.uid]))
|
2026-06-25 09:37:37 -05:00
|
|
|
|
// status is monotonic: stay the same, or transition active → completed (never revert).
|
|
|
|
|
|
&& (request.resource.data.status == resource.data.status
|
|
|
|
|
|
|| (resource.data.status == 'active' && request.resource.data.status == 'completed'));
|
2026-06-16 21:46:56 -05:00
|
|
|
|
|
2026-06-16 22:42:53 -05:00
|
|
|
|
// Delete: server-only (admin SDK). Admin SDK bypasses rules.
|
|
|
|
|
|
allow delete: if false;
|
2026-06-16 19:44:28 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 01:13:20 -05:00
|
|
|
|
// Question threads live under the couple document.
|
|
|
|
|
|
match /question_threads/{threadId} {
|
2026-06-16 21:46:56 -05:00
|
|
|
|
// Read: both members can read
|
|
|
|
|
|
allow read: if isCouplesMember(coupleId);
|
|
|
|
|
|
|
|
|
|
|
|
// Create: either member can create a thread
|
|
|
|
|
|
allow create: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& request.resource.data.createdByUserId == request.auth.uid;
|
|
|
|
|
|
|
|
|
|
|
|
// Update: valid state transitions only, currentIndex only incrementable
|
|
|
|
|
|
allow update: if isCouplesMember(coupleId)
|
|
|
|
|
|
// Status transitions must be valid: NOT_STARTED → ANSWERED_BY_ONE → REVEALED → COMPLETED
|
|
|
|
|
|
&& (resource.data.status == 'NOT_STARTED' && request.resource.data.status == 'ANSWERED_BY_ONE'
|
|
|
|
|
|
|| resource.data.status == 'ANSWERED_BY_ONE' && request.resource.data.status == 'REVEALED'
|
|
|
|
|
|
|| resource.data.status == 'REVEALED' && request.resource.data.status == 'COMPLETED'
|
2026-06-19 21:08:55 -05:00
|
|
|
|
|| resource.data.status == 'ANSWERED_BY_ONE' && request.resource.data.status == 'COMPLETED'
|
|
|
|
|
|
|| resource.data.status == request.resource.data.status
|
|
|
|
|
|
&& request.resource.data.currentIndex > resource.data.currentIndex)
|
2026-06-16 21:46:56 -05:00
|
|
|
|
// currentIndex can only be incremented, never decremented or reset
|
|
|
|
|
|
&& request.resource.data.currentIndex != null
|
|
|
|
|
|
&& (resource.data.currentIndex == null || request.resource.data.currentIndex >= resource.data.currentIndex)
|
|
|
|
|
|
// No other fields should change
|
|
|
|
|
|
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status', 'currentIndex']);
|
|
|
|
|
|
|
2026-06-16 22:42:53 -05:00
|
|
|
|
// Delete: server-only (admin SDK). Admin SDK bypasses rules.
|
|
|
|
|
|
allow delete: if false;
|
2026-06-16 01:13:20 -05:00
|
|
|
|
|
|
|
|
|
|
// Answers: each user writes their own; both members can read all answers.
|
2026-06-23 17:06:23 -05:00
|
|
|
|
// Strict couples must use schemaVersion 3 (sealed:v1: partner-proof).
|
|
|
|
|
|
// schemaVersion 2 is accepted only for v1 migration couples.
|
2026-06-16 01:13:20 -05:00
|
|
|
|
match /answers/{userId} {
|
2026-06-20 00:41:48 -05:00
|
|
|
|
allow read: if isCouplesMember(coupleId);
|
2026-06-20 01:19:02 -05:00
|
|
|
|
allow delete: if false;
|
2026-06-20 00:41:48 -05:00
|
|
|
|
allow create: if isCouplesMember(coupleId)
|
2026-06-19 21:08:55 -05:00
|
|
|
|
&& isOwner(userId)
|
2026-06-20 01:19:02 -05:00
|
|
|
|
&& request.resource.data.userId == request.auth.uid
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
|
&& coupleEncryptionEnabled(coupleId)
|
2026-06-23 17:06:23 -05:00
|
|
|
|
&& isSealedThreadAnswerCreate(request.resource.data);
|
2026-06-20 00:41:48 -05:00
|
|
|
|
allow update: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& isOwner(userId)
|
2026-06-23 17:06:23 -05:00
|
|
|
|
&& isSealedThreadAnswerUpdate();
|
2026-06-20 00:41:48 -05:00
|
|
|
|
|
2026-06-20 01:10:20 -05:00
|
|
|
|
// One-time key release for sealed thread answers (same guards as daily answer release keys).
|
2026-06-20 00:41:48 -05:00
|
|
|
|
match /releaseKeys/{recipientId} {
|
|
|
|
|
|
allow read: if isCouplesMember(coupleId) && request.auth.uid == recipientId;
|
|
|
|
|
|
allow create: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& request.auth.uid == userId
|
2026-06-20 01:10:20 -05:00
|
|
|
|
&& recipientId != userId
|
|
|
|
|
|
&& recipientId in get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds
|
|
|
|
|
|
// Both answers must exist before either key can be released — prevents early single-sided release.
|
|
|
|
|
|
&& exists(/databases/$(database)/documents/couples/$(coupleId)/question_threads/$(threadId)/answers/$(recipientId))
|
2026-06-20 00:41:48 -05:00
|
|
|
|
&& isKeybox(request.resource.data.encryptedAnswerKey)
|
2026-06-20 01:10:20 -05:00
|
|
|
|
&& request.resource.data.recipientUserId == recipientId
|
2026-06-20 00:41:48 -05:00
|
|
|
|
&& request.resource.data.keys().hasOnly(['recipientUserId', 'encryptedAnswerKey', 'releasedAt']);
|
|
|
|
|
|
allow update, delete: if false;
|
|
|
|
|
|
}
|
2026-06-16 01:13:20 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 21:46:56 -05:00
|
|
|
|
// Discussion messages: any couple member can read, but only the author can write/update/delete
|
2026-06-16 01:13:20 -05:00
|
|
|
|
match /messages/{messageId} {
|
2026-06-16 21:46:56 -05:00
|
|
|
|
allow read: if isCouplesMember(coupleId);
|
2026-06-24 15:20:44 -05:00
|
|
|
|
// Text messages carry ciphertext in `text`; image messages carry only a `mediaUrl`
|
|
|
|
|
|
// pointing at the encrypted bytes in Storage (the photo itself is E2E-encrypted).
|
2026-06-16 21:46:56 -05:00
|
|
|
|
allow create: if isCouplesMember(coupleId)
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
|
&& coupleEncryptionEnabled(coupleId)
|
|
|
|
|
|
&& request.resource.data.authorUserId == request.auth.uid
|
2026-06-24 15:20:44 -05:00
|
|
|
|
&& request.resource.data.keys().hasOnly(['authorUserId', 'text', 'createdAt', 'type', 'mediaUrl'])
|
|
|
|
|
|
&& (
|
|
|
|
|
|
(request.resource.data.get('type', 'text') == 'image'
|
|
|
|
|
|
&& request.resource.data.mediaUrl is string
|
|
|
|
|
|
&& request.resource.data.mediaUrl.size() > 0)
|
|
|
|
|
|
||
|
|
|
|
|
|
(request.resource.data.get('type', 'text') == 'text'
|
|
|
|
|
|
&& isCiphertext(request.resource.data.text))
|
|
|
|
|
|
);
|
2026-06-16 21:46:56 -05:00
|
|
|
|
allow update: if isCouplesMember(coupleId)
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
|
&& coupleEncryptionEnabled(coupleId)
|
|
|
|
|
|
&& resource.data.authorUserId == request.auth.uid
|
2026-06-19 21:22:27 -05:00
|
|
|
|
&& request.resource.data.keys().hasOnly(['text'])
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
|
&& isCiphertext(request.resource.data.text);
|
2026-06-16 21:46:56 -05:00
|
|
|
|
allow delete: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& resource.data.authorUserId == request.auth.uid;
|
2026-06-16 01:13:20 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 21:46:56 -05:00
|
|
|
|
// Reactions: any couple member can read, but only the creator can write/update/delete
|
2026-06-16 01:13:20 -05:00
|
|
|
|
match /reactions/{reactionId} {
|
2026-06-16 21:46:56 -05:00
|
|
|
|
allow read: if isCouplesMember(coupleId);
|
|
|
|
|
|
allow create: if isCouplesMember(coupleId)
|
2026-06-19 21:22:27 -05:00
|
|
|
|
&& request.resource.data.userId == request.auth.uid
|
|
|
|
|
|
&& request.resource.data.keys().hasOnly(['userId', 'emoji', 'createdAt']);
|
2026-06-16 21:46:56 -05:00
|
|
|
|
allow update: if isCouplesMember(coupleId)
|
2026-06-19 21:22:27 -05:00
|
|
|
|
&& resource.data.userId == request.auth.uid
|
|
|
|
|
|
&& request.resource.data.keys().hasOnly(['userId', 'emoji', 'createdAt']);
|
2026-06-16 21:46:56 -05:00
|
|
|
|
allow delete: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& resource.data.userId == request.auth.uid;
|
2026-06-16 01:13:20 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-16 23:30:58 -05:00
|
|
|
|
|
2026-06-24 16:14:18 -05:00
|
|
|
|
// Conversations: the Messages inbox. Each conversation (the couple chat or a per-question
|
|
|
|
|
|
// discussion) holds E2E-encrypted messages; only metadata + the encrypted last-message
|
|
|
|
|
|
// preview live on the conversation doc.
|
|
|
|
|
|
match /conversations/{conversationId} {
|
|
|
|
|
|
allow read: if isCouplesMember(coupleId);
|
|
|
|
|
|
// Members may create/merge-update the conversation doc within the allowed shape; any
|
|
|
|
|
|
// last-message preview must be ciphertext (encrypted on-device before write).
|
|
|
|
|
|
allow write: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& request.resource.data.keys().hasOnly(
|
2026-06-24 18:47:39 -05:00
|
|
|
|
['type', 'questionId', 'createdAt', 'lastMessageAt', 'lastMessagePreview', 'lastMessageSenderId', 'reads', 'typing'])
|
2026-06-24 16:14:18 -05:00
|
|
|
|
&& (!('lastMessagePreview' in request.resource.data)
|
|
|
|
|
|
|| isCiphertext(request.resource.data.lastMessagePreview));
|
|
|
|
|
|
|
|
|
|
|
|
// Messages: author-only create; text is ciphertext OR it's an image message pointing at
|
|
|
|
|
|
// encrypted Storage bytes; immutable after creation.
|
|
|
|
|
|
match /messages/{messageId} {
|
|
|
|
|
|
allow read: if isCouplesMember(coupleId);
|
|
|
|
|
|
allow create: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& coupleEncryptionEnabled(coupleId)
|
|
|
|
|
|
&& request.resource.data.authorUserId == request.auth.uid
|
2026-06-24 18:44:13 -05:00
|
|
|
|
&& request.resource.data.keys().hasOnly(['authorUserId', 'text', 'createdAt', 'type', 'mediaUrl', 'durationMs', 'reactions', 'deleted'])
|
2026-06-24 16:14:18 -05:00
|
|
|
|
&& (
|
2026-06-24 16:34:53 -05:00
|
|
|
|
(request.resource.data.get('type', 'text') in ['image', 'voice']
|
2026-06-24 16:14:18 -05:00
|
|
|
|
&& request.resource.data.mediaUrl is string
|
|
|
|
|
|
&& request.resource.data.mediaUrl.size() > 0)
|
|
|
|
|
|
||
|
|
|
|
|
|
(request.resource.data.get('type', 'text') == 'text'
|
|
|
|
|
|
&& isCiphertext(request.resource.data.text))
|
|
|
|
|
|
);
|
2026-06-24 18:44:13 -05:00
|
|
|
|
// Reactions: any couple member may change ONLY the reactions map.
|
|
|
|
|
|
// Unsend: only the author may set the `deleted` tombstone.
|
|
|
|
|
|
allow update: if isCouplesMember(coupleId) && (
|
|
|
|
|
|
request.resource.data.diff(resource.data).affectedKeys().hasOnly(['reactions'])
|
|
|
|
|
|
||
|
|
|
|
|
|
(resource.data.authorUserId == request.auth.uid
|
|
|
|
|
|
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['deleted']))
|
|
|
|
|
|
);
|
|
|
|
|
|
allow delete: if false;
|
2026-06-24 16:14:18 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-23 22:14:36 -05:00
|
|
|
|
// Date swipes: per-couple, per-date partner swipe state. The action is E2E
|
|
|
|
|
|
// ciphertext so the server can't read date preferences; only swipedAt is plaintext.
|
2026-06-16 23:30:58 -05:00
|
|
|
|
match /date_swipes/{dateIdeaId} {
|
2026-06-23 22:14:36 -05:00
|
|
|
|
// Read: both couple members can read the shared swipe document (ciphertext).
|
2026-06-16 23:30:58 -05:00
|
|
|
|
allow read: if isCouplesMember(coupleId);
|
|
|
|
|
|
|
2026-06-23 22:14:36 -05:00
|
|
|
|
// Create (doc doesn't exist yet): only the caller's own entry may be present,
|
|
|
|
|
|
// and the action must be ciphertext.
|
|
|
|
|
|
allow create: if isCouplesMember(coupleId)
|
2026-06-16 23:30:58 -05:00
|
|
|
|
&& request.resource.data.keys().hasOnly(['actions'])
|
|
|
|
|
|
&& request.resource.data.actions.keys().hasOnly([request.auth.uid])
|
|
|
|
|
|
&& request.resource.data.actions[request.auth.uid].keys().hasOnly(['action', 'swipedAt'])
|
2026-06-23 22:14:36 -05:00
|
|
|
|
&& isCiphertext(request.resource.data.actions[request.auth.uid].action)
|
|
|
|
|
|
&& request.resource.data.actions[request.auth.uid].swipedAt is number;
|
|
|
|
|
|
|
|
|
|
|
|
// Update (partner may already have an entry): a merge write exposes the whole
|
|
|
|
|
|
// post-write doc, so diff to ensure ONLY the caller's own entry changed.
|
|
|
|
|
|
allow update: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& request.resource.data.keys().hasOnly(['actions'])
|
|
|
|
|
|
&& request.resource.data.actions[request.auth.uid].keys().hasOnly(['action', 'swipedAt'])
|
|
|
|
|
|
&& isCiphertext(request.resource.data.actions[request.auth.uid].action)
|
|
|
|
|
|
&& request.resource.data.actions[request.auth.uid].swipedAt is number
|
|
|
|
|
|
&& resource.data.actions.diff(request.resource.data.actions).affectedKeys().hasOnly([request.auth.uid]);
|
2026-06-16 23:30:58 -05:00
|
|
|
|
|
|
|
|
|
|
// Delete: server-only (admin SDK). Admin SDK bypasses rules.
|
|
|
|
|
|
allow delete: if false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-23 22:14:36 -05:00
|
|
|
|
// Date matches: revealed mutual-love matches (matchId == dateIdeaId).
|
|
|
|
|
|
// Server is blind to encrypted swipes, so the client writes the marker when it
|
|
|
|
|
|
// detects mutual love; a Cloud Function fires the notification on create. The
|
|
|
|
|
|
// creator must be one of the two matched members. fcmNotified flips server-side.
|
2026-06-16 23:30:58 -05:00
|
|
|
|
match /date_matches/{matchId} {
|
|
|
|
|
|
allow read: if isCouplesMember(coupleId);
|
2026-06-23 22:14:36 -05:00
|
|
|
|
allow create: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& request.resource.data.keys().hasOnly(['dateIdeaId', 'revealedAt', 'matchedBy', 'fcmNotified'])
|
|
|
|
|
|
&& request.resource.data.dateIdeaId is string
|
|
|
|
|
|
&& request.resource.data.matchedBy is list
|
|
|
|
|
|
&& request.resource.data.matchedBy.size() == 2
|
|
|
|
|
|
&& request.auth.uid in request.resource.data.matchedBy
|
|
|
|
|
|
&& request.resource.data.fcmNotified == false;
|
|
|
|
|
|
allow update, delete: if false;
|
2026-06-16 23:30:58 -05:00
|
|
|
|
}
|
2026-06-17 00:05:46 -05:00
|
|
|
|
|
chore(dates): remove the dead date_plan_preferences plumbing (Batch F)
Dead code isn't inert — this carried a live-looking E2EE encryptor and a rules
block, inviting the next engineer to extend a surface nothing reads (which is
exactly how N-002 happened: writes into a collection with no reader). Removed,
all verified caller-dead first: the Firestore preference methods + mapper on
FirestoreDatePlanDataSource, the repository preference surface (interface +
impl + the assemblePlanSuggestion placeholder, whose body was literally
`val x = /* comment */` swallowing a return — dead AND weird), the
DatePlanPreference/DatePlanSuggestion domain models, DatePlanPreferenceDao +
its DI provider + the AppDatabase accessor, and the firestore.rules block
(collection is now default-deny, strictly safer; stray pre-R15 docs are
orphaned — launch-checklist note in Future.md). DateBuilderViewModel's
vestigial savePreference() renamed createPlan() — it has created real PLANNED
plans since the N-002 fix.
The one deliberate KEEP: DatePlanPreferenceEntity stays registered in
AppDatabase. Room's identity hash is computed from the SCHEMA, and the DB ships
via createFromAsset with no migrations — dropping the entity changes the hash
and crashes every install at first DB open. The entity now carries a loud KDoc
saying exactly that (and the AppDatabase comment points at it), so nobody
"cleans it up" without regenerating the asset DB. Removing DAOs/methods is
hash-neutral; proven live: fresh install on-device, DB opens clean, DB-served
content renders.
Also repaired collateral from my own removal script: it over-cut plansRef
(caught by compile, restored verbatim) — and the repo impl no longer injects
the same datasource twice under two names.
Live post-removal: Create Plan saves without error or PERMISSION_DENIED (the
surviving savePlan path). Caveat, stated honestly: the Home "Date coming up"
tile wasn't re-observed because the Compose date picker resists uiautomator
(cells expose content-desc only) and a today-dated plan is excluded by design
(scheduledDate > now) — the tile logic is untouched by this batch and was
live-verified in R15. Full unit suite green, assembleDebug clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:34:54 -05:00
|
|
|
|
// (date_plan_preferences removed 2026-07-16 — the per-partner preference surface was dead code;
|
|
|
|
|
|
// the collection is deliberately default-deny. Stray pre-R15 docs are orphaned: launch checklist.)
|
2026-06-17 00:05:46 -05:00
|
|
|
|
|
|
|
|
|
|
// Date plans: complete plans assembled from partner preferences.
|
2026-06-17 19:41:27 -05:00
|
|
|
|
// Both members can read and delete; writes are field-validated.
|
|
|
|
|
|
// createdAt is immutable after creation (excluded from the update allowed-keys set).
|
2026-06-17 00:05:46 -05:00
|
|
|
|
match /date_plans/{planId} {
|
|
|
|
|
|
allow read: if isCouplesMember(coupleId);
|
2026-06-17 19:41:27 -05:00
|
|
|
|
allow create: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& request.resource.data.keys().hasAll(['dateIdeaId', 'scheduledDate', 'status', 'createdAt', 'updatedAt'])
|
|
|
|
|
|
&& request.resource.data.keys().hasOnly([
|
|
|
|
|
|
'dateIdeaId', 'scheduledDate', 'scheduledTime', 'budget', 'duration',
|
|
|
|
|
|
'status', 'activity', 'food', 'conversationPrompts', 'optionalChallenge',
|
|
|
|
|
|
'createdAt', 'updatedAt'
|
|
|
|
|
|
])
|
2026-06-23 17:47:07 -05:00
|
|
|
|
&& isValidDatePlanStatus(request.resource.data.status)
|
|
|
|
|
|
&& isDatePlanContentEncrypted(request.resource.data);
|
2026-06-17 19:41:27 -05:00
|
|
|
|
allow update: if isCouplesMember(coupleId)
|
|
|
|
|
|
// Only the explicitly-listed fields may change on update.
|
|
|
|
|
|
// createdAt is intentionally absent — it cannot be modified after creation.
|
|
|
|
|
|
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
|
|
|
|
|
|
'dateIdeaId', 'scheduledDate', 'scheduledTime', 'budget', 'duration',
|
|
|
|
|
|
'status', 'activity', 'food', 'conversationPrompts', 'optionalChallenge',
|
|
|
|
|
|
'updatedAt'
|
|
|
|
|
|
])
|
2026-06-23 17:47:07 -05:00
|
|
|
|
&& isValidDatePlanStatus(request.resource.data.status)
|
|
|
|
|
|
&& isDatePlanContentEncrypted(request.resource.data);
|
2026-06-17 19:41:27 -05:00
|
|
|
|
allow delete: if isCouplesMember(coupleId);
|
2026-06-17 00:05:46 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Bucket list items: shared list for both partners.
|
2026-06-17 19:41:27 -05:00
|
|
|
|
// addedBy must match the caller on creation; addedBy and addedAt are immutable.
|
|
|
|
|
|
// Marking an item complete requires the caller to own the completedBy field.
|
2026-06-17 00:05:46 -05:00
|
|
|
|
match /bucket_list/{itemId} {
|
|
|
|
|
|
allow read: if isCouplesMember(coupleId);
|
2026-06-17 19:41:27 -05:00
|
|
|
|
allow create: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& request.resource.data.keys().hasAll(['title', 'addedBy', 'addedAt', 'isCompleted'])
|
|
|
|
|
|
&& request.resource.data.keys().hasOnly([
|
|
|
|
|
|
'title', 'description', 'category', 'addedBy', 'addedAt',
|
|
|
|
|
|
'completedBy', 'completedAt', 'isCompleted'
|
|
|
|
|
|
])
|
|
|
|
|
|
&& request.resource.data.addedBy == request.auth.uid
|
2026-06-23 17:06:23 -05:00
|
|
|
|
&& isValidBucketListCategory(request.resource.data.category)
|
|
|
|
|
|
// Strict E2EE: user content must be ciphertext.
|
|
|
|
|
|
&& isCiphertext(request.resource.data.title)
|
|
|
|
|
|
&& (!('description' in request.resource.data)
|
|
|
|
|
|
|| request.resource.data.description == null
|
|
|
|
|
|
|| isCiphertext(request.resource.data.description));
|
2026-06-17 19:41:27 -05:00
|
|
|
|
allow update: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly([
|
|
|
|
|
|
'title', 'description', 'category', 'isCompleted', 'completedBy', 'completedAt'
|
|
|
|
|
|
])
|
|
|
|
|
|
&& isImmutable(['addedBy', 'addedAt'])
|
|
|
|
|
|
// completedBy must be the caller when marking an item complete
|
2026-06-23 17:06:23 -05:00
|
|
|
|
&& (!request.resource.data.isCompleted || request.resource.data.completedBy == request.auth.uid)
|
|
|
|
|
|
// Strict E2EE: title/description remain ciphertext (merged result is always encrypted).
|
|
|
|
|
|
&& isCiphertext(request.resource.data.title)
|
|
|
|
|
|
&& (!('description' in request.resource.data)
|
|
|
|
|
|
|| request.resource.data.description == null
|
|
|
|
|
|
|| isCiphertext(request.resource.data.description));
|
2026-06-17 19:41:27 -05:00
|
|
|
|
allow delete: if isCouplesMember(coupleId);
|
2026-06-17 00:05:46 -05:00
|
|
|
|
}
|
2026-06-18 00:18:05 -05:00
|
|
|
|
|
2026-06-30 18:14:56 -05:00
|
|
|
|
// Date Replay — completed-date log. PLAINTEXT app metadata (date-idea title/category + timestamp,
|
|
|
|
|
|
// not private words; the reflection content is E2EE below). Idempotent merge on doc id = matchId.
|
|
|
|
|
|
match /date_history/{dateId} {
|
|
|
|
|
|
allow read: if isCouplesMember(coupleId);
|
|
|
|
|
|
allow create, update: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& request.resource.data.keys().hasOnly(['dateIdeaId', 'title', 'category', 'completedAt', 'addedBy'])
|
|
|
|
|
|
&& request.resource.data.addedBy is string
|
|
|
|
|
|
&& request.resource.data.completedAt is number;
|
|
|
|
|
|
allow delete: if isCouplesMember(coupleId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Date reflection metadata: each user writes their own; both members read (drives "your turn").
|
|
|
|
|
|
// Mirrors daily_question/answers — encrypted content is in the read-gated `secure` subdoc below.
|
|
|
|
|
|
match /date_reflections/{dateId}/answers/{userId} {
|
|
|
|
|
|
allow read: if isCouplesMember(coupleId);
|
|
|
|
|
|
allow create: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& request.auth.uid == userId
|
|
|
|
|
|
&& request.resource.data.userId == request.auth.uid
|
|
|
|
|
|
&& request.resource.data.isRevealed == false
|
|
|
|
|
|
&& request.resource.data.keys().hasOnly(['userId', 'schemaVersion', 'createdAt', 'updatedAt', 'isRevealed']);
|
|
|
|
|
|
allow update: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& request.auth.uid == userId
|
|
|
|
|
|
&& request.resource.data.userId == resource.data.userId
|
|
|
|
|
|
// Only the reveal flag may flip; the encrypted payload is immutable.
|
|
|
|
|
|
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['isRevealed', 'updatedAt']);
|
|
|
|
|
|
allow delete: if false;
|
|
|
|
|
|
|
|
|
|
|
|
// Couple-key encrypted reflection content. Read-gated: you can read your PARTNER's content only
|
|
|
|
|
|
// once YOU have also reflected (the "private until both" gate). Your own content is always readable.
|
|
|
|
|
|
match /secure/{doc} {
|
|
|
|
|
|
allow read: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& (request.auth.uid == userId
|
|
|
|
|
|
|| exists(/databases/$(database)/documents/couples/$(coupleId)/date_reflections/$(dateId)/answers/$(request.auth.uid)));
|
|
|
|
|
|
allow create: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& request.auth.uid == userId
|
|
|
|
|
|
&& isCiphertext(request.resource.data.encryptedPayload)
|
|
|
|
|
|
&& request.resource.data.keys().hasOnly(['encryptedPayload']);
|
2026-07-01 04:12:58 -05:00
|
|
|
|
// Author may edit their OWN still-sealed reflection ONLY until the partner reflects. Once the
|
|
|
|
|
|
// partner has reflected the content is immutable (the "sealed until both reveal" guarantee).
|
|
|
|
|
|
allow update: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& request.auth.uid == userId
|
|
|
|
|
|
&& !partnerReflectedDate(coupleId, dateId, userId)
|
|
|
|
|
|
&& isCiphertext(request.resource.data.encryptedPayload)
|
|
|
|
|
|
&& request.resource.data.keys().hasOnly(['encryptedPayload']);
|
|
|
|
|
|
allow delete: if false;
|
2026-06-30 18:14:56 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-30 20:43:26 -05:00
|
|
|
|
// ── E2EE conversation backup ────────────────────────────────────────────
|
|
|
|
|
|
// Members read/write their own couple's backup. The manifest holds pointers + a `generation`
|
|
|
|
|
|
// counter (optimistic concurrency); chunk/snapshot BODIES are enc:v1: ciphertext (server-blind).
|
|
|
|
|
|
// The snapshot blob itself lives in Storage. recursiveDelete(coupleRef) cascades this subtree.
|
|
|
|
|
|
match /backup/{doc} {
|
|
|
|
|
|
allow read, write: if isCouplesMember(coupleId);
|
|
|
|
|
|
match /chunks/{seq} {
|
|
|
|
|
|
allow read: if isCouplesMember(coupleId);
|
|
|
|
|
|
allow create, update: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& isCiphertext(request.resource.data.payload);
|
|
|
|
|
|
allow delete: if isCouplesMember(coupleId); // compaction folds chunks into a snapshot
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Partner-assisted restore requests ───────────────────────────────────
|
|
|
|
|
|
// The recovering member (recipientUid) creates their OWN request carrying a FRESH ECIES public
|
|
|
|
|
|
// key. The PARTNER (the other member) writes the keybox — the couple key wrapped to that pubkey —
|
|
|
|
|
|
// only after confirming the out-of-band 6-digit code. Server never sees plaintext key material.
|
|
|
|
|
|
match /restore_requests/{recipientUid} {
|
|
|
|
|
|
allow read: if isCouplesMember(coupleId);
|
|
|
|
|
|
|
|
|
|
|
|
// Recipient creates their own request (no keybox yet).
|
|
|
|
|
|
allow create: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& request.auth.uid == recipientUid
|
|
|
|
|
|
&& request.resource.data.recipientUid == recipientUid
|
|
|
|
|
|
&& isPublicKey(request.resource.data.recipientPublicKey)
|
|
|
|
|
|
&& request.resource.data.status == 'REQUESTED'
|
|
|
|
|
|
&& !('keybox' in request.resource.data)
|
feat(restore): spare the requesting device its own "was this you?" alert (Future.md security #2)
The restore self-alerts fan out to every device the recipient owns — including
the new device that is doing the requesting. "Was this you?" sent to the asker
is noise; the copies that matter go to their partner and to any OTHER device the
real owner still holds (the phished-password-without-device-loss case), and
those are untouched. For a legit single-device owner the self-alert becomes a
clean no-op, which was the point.
The requesting device identifies itself: RestoreManager fetches its own FCM
token at request time and the doc carries it as a create-only, optional
requesterFcmToken. Doc-embedded on purpose — MainActivity's token registration
races the restore flow on a fresh device, so cross-referencing fcmTokens
server-side can't reliably name the requester. Strictly best-effort on the
client (runCatching → null → field omitted, never written hollow): a restore
must never block or fail over a notification nicety, pinned by test.
sendPushToUser gains optional excludeTokens (filtered after merge/dedupe;
excluding everything is a clean zero no-op via the existing empty-list guard),
threaded through the shared queueAndPush — the notification_queue record is
still written, so the in-app alert history stays complete — and applied to the
two self-alerts only; the partner "help them restore" push is deliberately
unfiltered. Rules: requesterFcmToken joins the create allowlist as an optional
plain string (opaque device identifier, no format to pin); partner-update and
status-flip rules are unaffected since the field is create-only. Old clients
never send it; the server reads it only if present — no deploy-order coupling.
Tests: 3 new push.test.ts cases (exclusion, exclude-all no-op, absent-list
unchanged) — mutation check on the filter kills exactly those; 2 new
RestoreManagerTest cases (token embedded; FCM failure never blocks the request).
Functions 101/101, tsc clean; Android suite + assembleDebug green.
Deploy (scoped): firebase deploy --only firestore:rules, then
--only functions:onRestoreRequested,functions:onRestoreFulfilled.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 02:39:20 -05:00
|
|
|
|
// Optional: the requesting device's own FCM token, so the self-alert can skip it. An
|
|
|
|
|
|
// opaque device string — deliberately NOT validated beyond type (no format to pin).
|
|
|
|
|
|
&& (!('requesterFcmToken' in request.resource.data)
|
|
|
|
|
|
|| request.resource.data.requesterFcmToken is string)
|
2026-06-30 20:43:26 -05:00
|
|
|
|
&& request.resource.data.keys().hasOnly(
|
feat(restore): spare the requesting device its own "was this you?" alert (Future.md security #2)
The restore self-alerts fan out to every device the recipient owns — including
the new device that is doing the requesting. "Was this you?" sent to the asker
is noise; the copies that matter go to their partner and to any OTHER device the
real owner still holds (the phished-password-without-device-loss case), and
those are untouched. For a legit single-device owner the self-alert becomes a
clean no-op, which was the point.
The requesting device identifies itself: RestoreManager fetches its own FCM
token at request time and the doc carries it as a create-only, optional
requesterFcmToken. Doc-embedded on purpose — MainActivity's token registration
races the restore flow on a fresh device, so cross-referencing fcmTokens
server-side can't reliably name the requester. Strictly best-effort on the
client (runCatching → null → field omitted, never written hollow): a restore
must never block or fail over a notification nicety, pinned by test.
sendPushToUser gains optional excludeTokens (filtered after merge/dedupe;
excluding everything is a clean zero no-op via the existing empty-list guard),
threaded through the shared queueAndPush — the notification_queue record is
still written, so the in-app alert history stays complete — and applied to the
two self-alerts only; the partner "help them restore" push is deliberately
unfiltered. Rules: requesterFcmToken joins the create allowlist as an optional
plain string (opaque device identifier, no format to pin); partner-update and
status-flip rules are unaffected since the field is create-only. Old clients
never send it; the server reads it only if present — no deploy-order coupling.
Tests: 3 new push.test.ts cases (exclusion, exclude-all no-op, absent-list
unchanged) — mutation check on the filter kills exactly those; 2 new
RestoreManagerTest cases (token embedded; FCM failure never blocks the request).
Functions 101/101, tsc clean; Android suite + assembleDebug green.
Deploy (scoped): firebase deploy --only firestore:rules, then
--only functions:onRestoreRequested,functions:onRestoreFulfilled.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 02:39:20 -05:00
|
|
|
|
['recipientUid', 'recipientPublicKey', 'requestNonce', 'status', 'createdAt', 'expiresAt',
|
|
|
|
|
|
'requesterFcmToken']);
|
2026-06-30 20:43:26 -05:00
|
|
|
|
|
|
|
|
|
|
// The PARTNER (not the recipient) writes the keybox + flips status to READY; pubkey/nonce immutable.
|
|
|
|
|
|
allow update: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& request.auth.uid != recipientUid
|
|
|
|
|
|
&& recipientUid in get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds
|
|
|
|
|
|
&& request.resource.data.recipientPublicKey == resource.data.recipientPublicKey
|
|
|
|
|
|
&& request.resource.data.requestNonce == resource.data.requestNonce
|
|
|
|
|
|
&& isKeybox(request.resource.data.keybox)
|
|
|
|
|
|
&& request.resource.data.status == 'READY'
|
|
|
|
|
|
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['keybox', 'status', 'fulfilledAt']);
|
|
|
|
|
|
|
|
|
|
|
|
// Either member may flip status only (decline / expire / mark restored) — never touching keys.
|
|
|
|
|
|
allow update: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status'])
|
|
|
|
|
|
&& request.resource.data.status in ['DECLINED', 'EXPIRED', 'RESTORED'];
|
|
|
|
|
|
|
|
|
|
|
|
// Only the recipient consumes (deletes) their own request after unwrapping.
|
|
|
|
|
|
allow delete: if isCouplesMember(coupleId) && request.auth.uid == recipientUid;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-23 17:06:23 -05:00
|
|
|
|
// Couple Lore stores revealed answer summaries. Summary text must remain
|
|
|
|
|
|
// encrypted with the couple key; prompts/metadata can stay plaintext.
|
|
|
|
|
|
match /lore/{loreId} {
|
|
|
|
|
|
allow read: if isCouplesMember(coupleId);
|
|
|
|
|
|
allow create, update: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& coupleEncryptionEnabled(coupleId)
|
|
|
|
|
|
&& request.resource.data.keys().hasOnly([
|
|
|
|
|
|
'questionId', 'questionText', 'ownAnswer', 'partnerAnswer',
|
|
|
|
|
|
'modeTag', 'date', 'schemaVersion', 'savedAt'
|
|
|
|
|
|
])
|
|
|
|
|
|
&& request.resource.data.questionId is string
|
|
|
|
|
|
&& request.resource.data.questionText is string
|
|
|
|
|
|
&& request.resource.data.date is string
|
|
|
|
|
|
&& request.resource.data.schemaVersion == 2
|
|
|
|
|
|
&& isCiphertext(request.resource.data.ownAnswer)
|
|
|
|
|
|
&& (!('partnerAnswer' in request.resource.data)
|
|
|
|
|
|
|| request.resource.data.partnerAnswer == null
|
|
|
|
|
|
|| isCiphertext(request.resource.data.partnerAnswer));
|
|
|
|
|
|
allow delete: if false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 22:02:40 -05:00
|
|
|
|
// Memory Lane capsules: member-readable; author creates with ENCRYPTED content (title/content/
|
|
|
|
|
|
// promptUsed are enc:v1:). status flips sealed→unlocked (client or the scheduled unlock fn).
|
|
|
|
|
|
match /capsules/{capsuleId} {
|
|
|
|
|
|
allow read: if isCouplesMember(coupleId);
|
|
|
|
|
|
allow create: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& coupleEncryptionEnabled(coupleId)
|
|
|
|
|
|
&& request.resource.data.authorId == request.auth.uid
|
|
|
|
|
|
&& request.resource.data.keys().hasOnly(
|
|
|
|
|
|
['authorId', 'title', 'content', 'promptUsed', 'unlockAt', 'createdAt', 'status'])
|
|
|
|
|
|
&& isCiphertext(request.resource.data.title)
|
|
|
|
|
|
&& isCiphertext(request.resource.data.content)
|
|
|
|
|
|
&& (!('promptUsed' in request.resource.data)
|
|
|
|
|
|
|| request.resource.data.promptUsed == null
|
|
|
|
|
|
|| isCiphertext(request.resource.data.promptUsed));
|
|
|
|
|
|
allow update: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& (
|
|
|
|
|
|
// Author re-saves encrypted content before unlock
|
|
|
|
|
|
(request.resource.data.diff(resource.data).affectedKeys().hasOnly(['title', 'content', 'promptUsed', 'unlockAt'])
|
|
|
|
|
|
&& isCiphertext(request.resource.data.title)
|
|
|
|
|
|
&& isCiphertext(request.resource.data.content))
|
|
|
|
|
|
||
|
|
|
|
|
|
// Status transition (e.g. sealed → unlocked)
|
|
|
|
|
|
request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status'])
|
|
|
|
|
|
);
|
|
|
|
|
|
allow delete: if isCouplesMember(coupleId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Connection Challenges: catalog-referenced (no free-text user content), members track progress.
|
|
|
|
|
|
match /challenges/{challengeId} {
|
|
|
|
|
|
allow read: if isCouplesMember(coupleId);
|
|
|
|
|
|
allow create: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& request.resource.data.keys().hasOnly(['challengeId', 'startedAt', 'status', 'completions']);
|
|
|
|
|
|
allow update: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['completions', 'status']);
|
|
|
|
|
|
allow delete: if isCouplesMember(coupleId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 23:59:24 -05:00
|
|
|
|
// Outcomes: couple-level 30/60/90 day check-ins. Both members can read.
|
|
|
|
|
|
// Writes are server-side only via submitOutcomeCallable; direct client writes denied.
|
|
|
|
|
|
match /outcomes/{dayKey} {
|
|
|
|
|
|
allow read: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& dayKey in ['day_0', 'day_30', 'day_60', 'day_90'];
|
|
|
|
|
|
allow create, update, delete: if false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-18 00:18:05 -05:00
|
|
|
|
// Daily question: server-assigned once per day per couple.
|
|
|
|
|
|
// Writes are server-only (Cloud Functions / Admin SDK).
|
|
|
|
|
|
match /daily_question/{date} {
|
|
|
|
|
|
allow read: if isCouplesMember(coupleId);
|
|
|
|
|
|
allow write: if false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Daily question answers: each user writes their own; both members read.
|
|
|
|
|
|
match /daily_question/{date}/answers/{userId} {
|
2026-06-26 12:41:06 -05:00
|
|
|
|
// The answer doc holds only metadata (no content) so the partner can see THAT you
|
|
|
|
|
|
// answered ("your turn / waiting for you"); the encrypted content lives in the
|
|
|
|
|
|
// read-gated `secure` subdoc below. Both members may read metadata.
|
2026-06-18 00:18:05 -05:00
|
|
|
|
allow read: if isCouplesMember(coupleId);
|
2026-06-20 00:23:58 -05:00
|
|
|
|
|
2026-06-18 00:18:05 -05:00
|
|
|
|
allow create: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& request.auth.uid == userId
|
|
|
|
|
|
&& request.resource.data.userId == request.auth.uid
|
|
|
|
|
|
&& request.resource.data.questionId is string
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
|
&& request.resource.data.answerType is string
|
2026-06-20 01:10:20 -05:00
|
|
|
|
// answerDate must match the path segment — prevents a client writing a doc
|
|
|
|
|
|
// whose metadata disagrees with the path it lands in.
|
|
|
|
|
|
&& request.resource.data.answerDate is string
|
|
|
|
|
|
&& request.resource.data.answerDate == date
|
2026-06-26 12:41:06 -05:00
|
|
|
|
// schemaVersion 2 = couple-key (current); 3 = legacy sealed partner-proof.
|
|
|
|
|
|
&& (isCoupleKeyAnswerCreate(request.resource.data)
|
|
|
|
|
|
|| isSealedAnswerCreate(request.resource.data));
|
2026-06-20 00:23:58 -05:00
|
|
|
|
|
2026-06-18 00:18:05 -05:00
|
|
|
|
allow update: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& request.auth.uid == userId
|
|
|
|
|
|
&& request.resource.data.userId == resource.data.userId
|
|
|
|
|
|
&& request.resource.data.questionId == resource.data.questionId
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
|
&& request.resource.data.answerType == resource.data.answerType
|
2026-06-26 12:41:06 -05:00
|
|
|
|
// Only reveal metadata may change; the encrypted payload is immutable.
|
|
|
|
|
|
&& (isCoupleKeyAnswerUpdate() || isSealedAnswerUpdate());
|
2026-06-20 00:23:58 -05:00
|
|
|
|
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
|
allow delete: if false;
|
2026-06-20 00:23:58 -05:00
|
|
|
|
|
|
|
|
|
|
// Release keys: the sender releases their one-time answer key to the recipient
|
|
|
|
|
|
// after both partners have submitted.
|
|
|
|
|
|
match /releaseKeys/{recipientId} {
|
2026-06-24 10:02:54 -05:00
|
|
|
|
// The recipient reads the key released to them. The sender (answer owner = {userId})
|
|
|
|
|
|
// must also be able to read their own released doc, because writeReleaseKey does an
|
|
|
|
|
|
// idempotency existence-check get() before writing — without this, that get() was
|
|
|
|
|
|
// PERMISSION_DENIED, releaseOwnKey threw, and the daily reveal failed. The keybox is
|
|
|
|
|
|
// ECIES-encrypted to the recipient, so the sender reading it leaks nothing.
|
|
|
|
|
|
allow read: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& (request.auth.uid == recipientId || request.auth.uid == userId);
|
2026-06-20 00:23:58 -05:00
|
|
|
|
|
|
|
|
|
|
// Create-only: written by the answer owner (sender) after both answers exist.
|
|
|
|
|
|
allow create: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& request.auth.uid == userId
|
|
|
|
|
|
&& recipientId != userId
|
|
|
|
|
|
&& recipientId in get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds
|
|
|
|
|
|
&& exists(/databases/$(database)/documents/couples/$(coupleId)/daily_question/$(date)/answers/$(recipientId))
|
|
|
|
|
|
&& isKeybox(request.resource.data.encryptedAnswerKey)
|
|
|
|
|
|
&& request.resource.data.recipientUserId == recipientId
|
|
|
|
|
|
&& request.resource.data.keys().hasOnly(['recipientUserId', 'encryptedAnswerKey', 'releasedAt']);
|
|
|
|
|
|
|
|
|
|
|
|
allow update: if false;
|
|
|
|
|
|
allow delete: if false;
|
|
|
|
|
|
}
|
2026-06-26 12:41:06 -05:00
|
|
|
|
|
|
|
|
|
|
// Couple-key encrypted answer content (schemaVersion 2). Read-gated: you can read your
|
|
|
|
|
|
// PARTNER's content only once YOU have also answered — the cryptographic "private until
|
|
|
|
|
|
// both answered" gate. Your own content is always readable.
|
|
|
|
|
|
match /secure/{doc} {
|
|
|
|
|
|
allow read: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& (request.auth.uid == userId
|
|
|
|
|
|
|| exists(/databases/$(database)/documents/couples/$(coupleId)/daily_question/$(date)/answers/$(request.auth.uid)));
|
|
|
|
|
|
allow create: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& request.auth.uid == userId
|
|
|
|
|
|
&& isCiphertext(request.resource.data.encryptedPayload)
|
|
|
|
|
|
&& request.resource.data.keys().hasOnly(['encryptedPayload']);
|
|
|
|
|
|
allow update, delete: if false;
|
|
|
|
|
|
}
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 00:41:48 -05:00
|
|
|
|
// Games use enc:v1: (schemaVersion 2 / shared couple key).
|
|
|
|
|
|
// They are company-proof but not partner-proof: a modified client could read the
|
|
|
|
|
|
// partner's encrypted slot before the reveal screen. Sealed per-answer keys are not
|
|
|
|
|
|
// used here because games are real-time simultaneous — both players submit and see
|
|
|
|
|
|
// results together; there is no single async "reveal" event to gate on.
|
feat: strict E2EE — encryption migration, Firestore rules enforcement, version 2 protocol (batch v0.2.11)
- Add CoupleAnswerMigrationDataSource: one-time per-user rewrite of all historical answer-bearing fields (daily answers, thread answers/messages, ThisOrThat, DesireSync, HowWell, Wheel) to ciphertext
- Add EncryptionUpgradeScreen + ViewModel: handles version-0→1→2 migration, recovery phrase display, partner coordination
- Add FieldEncryptorTest: round-trip, cross-couple binding, null-key, plaintext-not-leaked
- CoupleEncryptionManager: STRICT_ENCRYPTION_VERSION=2, requireAead() throws on missing key, setupLegacyCouple, pendingRecoveryPhrase/acknowledge
- CoupleKeyStore: pending recovery phrase storage/clear
- FieldEncryptor: switch from android.util.Base64 to java.util.Base64
- All data sources: use requireAead() (throws instead of silent plaintext fallback), encrypt all answer-bearing writes
- FirestoreCoupleDataSource: beginEncryptionMigration (atomic version-0→1 claim), markEncryptionMigrationComplete (per-user + version-2 promotion)
- CoupleRepositoryImpl: require wrappedKey on invite acceptance (no more optional)
- HomeScreen/ViewModel: route to EncryptionUpgradeScreen for version-0 or unmigrated version-1 couples
- Firestore rules: isCiphertext validator, isEncryptedAnswerPayload, isStartingEncryptionMigration, isCompletingOwnEncryptionMigration, isUpdatingRecoveryWrap, isUpdatingCoupleRhythm; enforce ciphertext on all answer/message writes; game collection rules (this_or_that, desire_sync, how_well, wheel) with per-user answer ownership; couple doc update split into 4 mutually exclusive paths; invite doc requires createdAt + wrappedKey fields; isImmutable uses diff().hasAny() instead of field equality
- Firestore rules tests: encryption migration scenarios, plaintext rejection, per-user answer ownership, game collection ciphertext enforcement
- firebase.json: emulator port 8180
- .gitignore: firestore-tests/node_modules
2026-06-19 20:53:52 -05:00
|
|
|
|
match /{gameCollection}/{sessionId} {
|
|
|
|
|
|
allow read: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& gameCollection in ['this_or_that', 'desire_sync', 'how_well', 'wheel'];
|
|
|
|
|
|
allow create: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& coupleEncryptionEnabled(coupleId)
|
|
|
|
|
|
&& gameCollection in ['this_or_that', 'desire_sync', 'how_well', 'wheel']
|
|
|
|
|
|
&& request.resource.data.answers is map
|
|
|
|
|
|
&& request.resource.data.answers.keys().hasOnly([request.auth.uid])
|
|
|
|
|
|
&& isCiphertext(request.resource.data.answers[request.auth.uid])
|
|
|
|
|
|
&& request.resource.data.keys().hasOnly(['answers', 'categoryName', 'questions']);
|
|
|
|
|
|
allow update: if isCouplesMember(coupleId)
|
|
|
|
|
|
&& coupleEncryptionEnabled(coupleId)
|
|
|
|
|
|
&& gameCollection in ['this_or_that', 'desire_sync', 'how_well', 'wheel']
|
|
|
|
|
|
&& request.resource.data.answers is map
|
|
|
|
|
|
&& request.resource.data.answers.diff(resource.data.answers).affectedKeys()
|
|
|
|
|
|
.hasOnly([request.auth.uid])
|
|
|
|
|
|
&& isCiphertext(request.resource.data.answers[request.auth.uid])
|
|
|
|
|
|
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['answers']);
|
2026-06-18 00:18:05 -05:00
|
|
|
|
allow delete: if false;
|
|
|
|
|
|
}
|
2026-06-16 01:13:20 -05:00
|
|
|
|
}
|
2026-06-17 19:42:41 -05:00
|
|
|
|
|
|
|
|
|
|
// ── entitlement_events ────────────────────────────────────────────────────
|
|
|
|
|
|
// Cloud Functions write idempotency markers here via the Admin SDK.
|
|
|
|
|
|
// No client access needed — explicit deny prevents accidental future grants.
|
|
|
|
|
|
match /entitlement_events/{eventId} {
|
|
|
|
|
|
allow read, write: if false;
|
|
|
|
|
|
}
|
2026-07-06 21:06:57 -05:00
|
|
|
|
|
|
|
|
|
|
// ── aggregate_stats ───────────────────────────────────────────────────────
|
|
|
|
|
|
// Privacy-safe cross-couple rollups written by aggregateOutcomeStats (Admin SDK).
|
|
|
|
|
|
// EXPORT-ONLY: no client may read cross-couple aggregates — the owner reads via console.
|
|
|
|
|
|
match /aggregate_stats/{doc} {
|
|
|
|
|
|
allow read, write: if false;
|
|
|
|
|
|
}
|
2026-06-16 01:13:20 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|