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-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
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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: 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([
|
|
|
|
|
|
'wrappedCoupleKey', 'kdfSalt', 'kdfParams'
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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',
|
|
|
|
|
|
'plan', '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()
|
|
|
|
|
|
);
|
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
|
|
|
|
|
|
|
|
|
|
// Date plan preferences: per-partner preferences for building date plans.
|
2026-06-17 19:12:14 -05:00
|
|
|
|
// Both members can read; either member can write a preference document.
|
|
|
|
|
|
// Document IDs are Firestore auto-IDs (not user IDs).
|
|
|
|
|
|
match /date_plan_preferences/{prefId} {
|
2026-06-17 00:05:46 -05:00
|
|
|
|
allow read: if isCouplesMember(coupleId);
|
|
|
|
|
|
allow create, update: if isCouplesMember(coupleId)
|
2026-06-17 19:12:14 -05:00
|
|
|
|
&& request.resource.data.keys().hasAll(['dateIdeaId', 'createdAt', 'updatedAt'])
|
|
|
|
|
|
&& request.resource.data.keys().hasOnly([
|
|
|
|
|
|
'dateIdeaId', 'preferredDate', 'preferredTime',
|
|
|
|
|
|
'budget', 'duration', 'createdAt', 'updatedAt'
|
2026-06-23 17:47:07 -05:00
|
|
|
|
])
|
|
|
|
|
|
// Strict E2EE: user-entered details are ciphertext (dateIdeaId/dates stay plaintext for queries).
|
|
|
|
|
|
&& cipherOrAbsent(request.resource.data, 'preferredTime')
|
|
|
|
|
|
&& cipherOrAbsent(request.resource.data, 'budget')
|
|
|
|
|
|
&& cipherOrAbsent(request.resource.data, 'duration');
|
2026-06-17 00:05:46 -05:00
|
|
|
|
allow delete: if false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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-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-06-16 01:13:20 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|