From 5f7c785f5c6ff1a6608fe8af29414fa0e6c8a50e Mon Sep 17 00:00:00 2001 From: null Date: Wed, 8 Jul 2026 23:24:38 -0500 Subject: [PATCH] =?UTF-8?q?docs(manual):=20batch=205=20review=20=E2=80=94?= =?UTF-8?q?=203=20missing=20rule=20helpers=20+=20releaseKeys=20sender-read?= =?UTF-8?q?=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Firestore security rules Helper functions table was missing 3 helpers that the rules file actually exports: - otherCoupleMember(coupleId, uid) - the partner of a couple - partnerReflectedDate(coupleId, dateId, uid) - has the partner already reflected on a date reflection? - isPublicKey(value) - matches the pub:v1:... wire format for the ECIES P-256 public key write to users/{uid}/devices/primary The isPublicKey regex I added at first was wrong (I wrote 'pub:v1:[A-Za-z0-9_-]+={0,2}$' but the real one is 'pub:v1:[A-Za-z0-9_-]{40,}$' - 40-char minimum, no padding). Corrected. The releaseKeys per-collection enforcement said 'readable only by the named recipient' but the rule actually allows the sender (answer owner) to read their own releaseKeys doc as well - the rule comment explains: writeReleaseKey does an idempotency existence-check get() before writing, and without the sender-read allowance that get() returned PERMISSION_DENIED and releaseOwnKey threw, breaking the daily reveal. The keybox is ECIES-encrypted to the recipient, so the sender reading it leaks nothing. Other Batch 5 claims verified clean: - All 25 helpers in the table exist in firestore.rules (isSignedIn through isUpdatingCoupleRhythm). - users/{uid}/fcmTokens/{tokenId}: isOwner(uid) for read+write. - users/{uid}/devices/{deviceId}: read = isOwner(uid) OR same-couple partner; create/update = isOwner(uid). - invites/{code}: read = isSignedIn() && inviter match; writes denied. - couples/{coupleId}: create requires E2EE field presence, update via isUpdatingCoupleRhythm OR isUpdatingRecoveryWrap only; delete denied. - daily_question/{date}/answers/{userId}: metadata-only fields (userId/questionId/answerType/schemaVersion/answerDate/createdAt/ updatedAt/isRevealed) per isCoupleKeyAnswerCreate. - couples/{coupleId}/{this_or_that|desire_sync|how_well|wheel} wildcard match: answers map keyed by uid, enc:v1: per user, plus categoryName/questions, requires coupleEncryptionEnabled. - entitlement_events/{eventId}: allow read,write = false (no client). --- docs/Engineering_Reference_Manual.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/Engineering_Reference_Manual.md b/docs/Engineering_Reference_Manual.md index 9eafb07a..de5fd320 100644 --- a/docs/Engineering_Reference_Manual.md +++ b/docs/Engineering_Reference_Manual.md @@ -752,6 +752,8 @@ The rules file is organized into helper functions first, then per-collection mat isSignedIn() request.auth != null isOwner(uid) request.auth.uid == uid isCouplesMember(coupleId) request.auth.uid in couples/{coupleId}.userIds +otherCoupleMember(coupleId, uid) the OTHER member of the couple (partner) +partnerReflectedDate(coupleId, dateId, uid) has the partner already reflected on a given date reflection? isValidInviteCode(code) matches('^[a-zA-Z0-9]{6}$') isNotAlreadyPaired() caller has no existing coupleId isImmutable(fields) diff(...).affectedKeys().hasOnly(fields) @@ -764,6 +766,7 @@ isDatePlanContentEncrypted(data) date-plan content fields are enc:v1: or ab coupleEncryptionEnabled(coupleId) couple.encryptionVersion >= 1 isSealedPayload(value) matches('^sealed:v1:[A-Za-z0-9_-]{80,}$') isKeybox(value) matches('^keybox:v1:[A-Za-z0-9_-]{120,}$') +isPublicKey(value) matches('^pub:v1:[A-Za-z0-9_-]{40,}$') # gates users/{uid}/devices/primary write (40+ chars minimum) isCommitmentHash(value) matches('^sha256:[A-Za-z0-9_-]{43}$') isSealedAnswerCreate(data) sealed-answer shape + sealed:v1 + sha256: (schemaVersion 3, legacy) isSealedAnswerUpdate() only reveal-metadata fields (schemaVersion 3) @@ -789,7 +792,7 @@ isUpdatingCoupleRhythm() only streakCount/lastAnsweredAt **`couples/{coupleId}/daily_question/{date}/answers/{userId}`** - the answer is private to its author until reveal. **The metadata doc is content-free** (schemaVersion 2): it holds only `userId`, `questionId`, `answerType`, `schemaVersion`, `answerDate`, `createdAt`, `updatedAt`, `isRevealed` so the partner can see THAT you answered ("your turn" indicator) without leaking the content. The actual `encryptedPayload` lives in a **read-gated subdoc** at `answers/{userId}/secure/payload` and is only readable by the partner once the partner has also submitted (`exists(.../answers/{request.auth.uid})`). The owner always reads their own subdoc. The subdoc shape is `keys().hasOnly(['encryptedPayload'])` and `isCiphertext(encryptedPayload)`. See [Reveal flow](#reveal-flow) for the full path. Legacy schemaVersion 3 answers (sealed:v1:) follow the `isSealedAnswerCreate`/`isSealedAnswerUpdate` helpers and use the `releaseKeys/` subdoc for the ECIES-wrapped keybox. -**`couples/{coupleId}/daily_question/{date}/answers/{userId}/releaseKeys/{recipientId}`** - create-only by the answer owner, readable only by the named recipient. `keybox:v1:` shape is enforced. +**`couples/{coupleId}/daily_question/{date}/answers/{userId}/releaseKeys/{recipientId}`** - create-only by the answer owner (the rule also requires both partners' answers to exist before the release can be written), readable by the **named recipient OR the answer owner** (the sender needs read for an idempotency existence-check in `writeReleaseKey`; the keybox is ECIES-encrypted to the recipient, so the sender reading it leaks nothing). `keybox:v1:` shape is enforced. **`couples/{coupleId}/{this_or_that|desire_sync|how_well|wheel}/{sessionId}`** - `enc:v1:` ciphertext per user. Games are company-proof (server can't read), but not partner-proof (a modified client could read the partner's encrypted slot before the reveal). Sealed per-answer keys are not used here because games are real-time simultaneous - both players submit and see results together.