docs(manual): batch 5 review — 3 missing rule helpers + releaseKeys sender-read fix

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).
This commit is contained in:
null 2026-07-08 23:24:38 -05:00
parent 5e292e66ea
commit 5f7c785f5c
1 changed files with 4 additions and 1 deletions

View File

@ -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.