feat(rules): add read-gated secure subdoc for couple-key encrypted answers (schemaVersion 2)

This commit is contained in:
null 2026-06-26 12:41:06 -05:00
parent f7418df700
commit e5c9c43317
1 changed files with 41 additions and 4 deletions

View File

@ -117,6 +117,25 @@ service cloud.firestore {
.hasOnly(['isRevealed', 'answerKeyReleased', 'updatedAt']);
}
// 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']);
}
// 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) {
@ -670,6 +689,9 @@ service cloud.firestore {
// Daily question answers: each user writes their own; both members read.
match /daily_question/{date}/answers/{userId} {
// 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.
allow read: if isCouplesMember(coupleId);
allow create: if isCouplesMember(coupleId)
@ -681,16 +703,17 @@ service cloud.firestore {
// whose metadata disagrees with the path it lands in.
&& request.resource.data.answerDate is string
&& request.resource.data.answerDate == date
// schemaVersion 3: partner-proof sealed answer (the only accepted shape).
&& isSealedAnswerCreate(request.resource.data);
// schemaVersion 2 = couple-key (current); 3 = legacy sealed partner-proof.
&& (isCoupleKeyAnswerCreate(request.resource.data)
|| isSealedAnswerCreate(request.resource.data));
allow update: if isCouplesMember(coupleId)
&& request.auth.uid == userId
&& request.resource.data.userId == resource.data.userId
&& request.resource.data.questionId == resource.data.questionId
&& request.resource.data.answerType == resource.data.answerType
// Sealed answers: only reveal metadata may change; payload is immutable.
&& isSealedAnswerUpdate();
// Only reveal metadata may change; the encrypted payload is immutable.
&& (isCoupleKeyAnswerUpdate() || isSealedAnswerUpdate());
allow delete: if false;
@ -718,6 +741,20 @@ service cloud.firestore {
allow update: if false;
allow delete: if false;
}
// 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;
}
}
// Games use enc:v1: (schemaVersion 2 / shared couple key).