diff --git a/firestore.rules b/firestore.rules index 278f1a8a..6e47fbf1 100644 --- a/firestore.rules +++ b/firestore.rules @@ -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} { diff --git a/storage.rules b/storage.rules index 0815bd36..c8247c92 100644 --- a/storage.rules +++ b/storage.rules @@ -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;