feat(backup): add Firestore rules (backup manifest/chunks, restore_requests with isPublicKey helper) and Storage rules (users/{uid}/backups/)

This commit is contained in:
null 2026-06-30 20:43:26 -05:00
parent 4b4f79361f
commit 4640649593
2 changed files with 67 additions and 0 deletions

View File

@ -89,6 +89,11 @@ service cloud.firestore {
return value is string && value.matches('^keybox:v1:[A-Za-z0-9_-]{120,}$');
}
function isPublicKey(value) {
// pub:v1: + URL-safe base64 no-padding of a Tink ECIES public keyset JSON.
return value is string && value.matches('^pub:v1:[A-Za-z0-9_-]{40,}$');
}
function isCommitmentHash(value) {
// 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}$');
@ -684,6 +689,56 @@ service cloud.firestore {
}
}
// ── E2EE conversation backup ────────────────────────────────────────────
// Members read/write their own couple's backup. The manifest holds pointers + a `generation`
// counter (optimistic concurrency); chunk/snapshot BODIES are enc:v1: ciphertext (server-blind).
// The snapshot blob itself lives in Storage. recursiveDelete(coupleRef) cascades this subtree.
match /backup/{doc} {
allow read, write: if isCouplesMember(coupleId);
match /chunks/{seq} {
allow read: if isCouplesMember(coupleId);
allow create, update: if isCouplesMember(coupleId)
&& isCiphertext(request.resource.data.payload);
allow delete: if isCouplesMember(coupleId); // compaction folds chunks into a snapshot
}
}
// ── Partner-assisted restore requests ───────────────────────────────────
// The recovering member (recipientUid) creates their OWN request carrying a FRESH ECIES public
// key. The PARTNER (the other member) writes the keybox — the couple key wrapped to that pubkey —
// only after confirming the out-of-band 6-digit code. Server never sees plaintext key material.
match /restore_requests/{recipientUid} {
allow read: if isCouplesMember(coupleId);
// Recipient creates their own request (no keybox yet).
allow create: if isCouplesMember(coupleId)
&& request.auth.uid == recipientUid
&& request.resource.data.recipientUid == recipientUid
&& isPublicKey(request.resource.data.recipientPublicKey)
&& request.resource.data.status == 'REQUESTED'
&& !('keybox' in request.resource.data)
&& request.resource.data.keys().hasOnly(
['recipientUid', 'recipientPublicKey', 'requestNonce', 'status', 'createdAt', 'expiresAt']);
// The PARTNER (not the recipient) writes the keybox + flips status to READY; pubkey/nonce immutable.
allow update: if isCouplesMember(coupleId)
&& request.auth.uid != recipientUid
&& recipientUid in get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds
&& request.resource.data.recipientPublicKey == resource.data.recipientPublicKey
&& request.resource.data.requestNonce == resource.data.requestNonce
&& isKeybox(request.resource.data.keybox)
&& request.resource.data.status == 'READY'
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['keybox', 'status', 'fulfilledAt']);
// Either member may flip status only (decline / expire / mark restored) — never touching keys.
allow update: if isCouplesMember(coupleId)
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status'])
&& request.resource.data.status in ['DECLINED', 'EXPIRED', 'RESTORED'];
// Only the recipient consumes (deletes) their own request after unwrapping.
allow delete: if isCouplesMember(coupleId) && request.auth.uid == recipientUid;
}
// Couple Lore stores revealed answer summaries. Summary text must remain
// encrypted with the couple key; prompts/metadata can stay plaintext.
match /lore/{loreId} {

View File

@ -30,6 +30,18 @@ service firebase.storage {
allow read: if request.auth != null && request.auth.uid == uid;
}
// Encrypted conversation-backup snapshots. Written by the uploading member under their OWN path
// (couple-key ciphertext, so Storage holds nothing readable). The PARTNER reads via the tokenized
// download URL recorded in the couple-gated backup manifest — same model as chat media. Living
// under users/{uid}/ means the existing onUserDelete `users/{uid}/` cleanup covers backups too.
// 50 MB cap (a full-history snapshot for a long-lived couple).
match /users/{uid}/backups/{file} {
allow write: if request.auth != null
&& request.auth.uid == uid
&& request.resource.size < 50 * 1024 * 1024;
allow read: if request.auth != null && request.auth.uid == uid;
}
// Deny all other paths by default.
match /{allPaths=**} {
allow read, write: if false;