feat(backup): add Firestore rules (backup manifest/chunks, restore_requests with isPublicKey helper) and Storage rules (users/{uid}/backups/)
This commit is contained in:
parent
4b4f79361f
commit
4640649593
|
|
@ -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} {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue