-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfirestore.rules
More file actions
159 lines (145 loc) · 6.34 KB
/
firestore.rules
File metadata and controls
159 lines (145 loc) · 6.34 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Sites: opaque hidden-volume blobs keyed by SHA-256 of the slug.
//
// The public demo vault at /s/demo is read-only for anyone going
// through the client SDK. It's a shared walkthrough with known
// credentials (see src/lib/vault/demo.ts), so allowing writes
// would invite vandalism. The canonical ciphertext was seeded
// once manually before this guard was enabled; any future content
// change requires temporarily relaxing this guard, re-seeding via
// the normal client flow, then restoring it.
match /sites/{siteId} {
allow read: if true;
allow create: if isValidCreate(request.resource.data)
&& !isDemoSite(siteId);
allow update: if isValidUpdate(resource.data, request.resource.data)
&& !isDemoSite(siteId);
allow delete: if false;
}
// Timelock capsules: ciphertext encrypted to a future drand round.
// Anyone can create; reads are public (client-side decryption once the
// beacon round releases). Updates are forbidden.
match /timelocks/{capsuleId} {
allow read: if true;
allow create: if isValidTimelock(request.resource.data);
allow update, delete: if false;
}
// Encrypted Send: short-lived, view-capped one-way notes. The AES-256
// key lives in the URL fragment (never sent to the server), so even
// the ciphertext is unreadable to us. Reads go through the `readSend`
// Cloud Function, which atomically decrements views and deletes the
// document on the final read. The Admin SDK bypasses these rules,
// which is how the function reaches the bytes.
match /sends/{sendId} {
allow read: if false; // Only readSend (Admin SDK) reads these.
allow create: if isValidSend(request.resource.data);
allow update, delete: if false; // No client mutations after creation.
}
}
// Known read-only-on-server vault IDs. Keep this list tiny — every
// entry freezes a vault against client writes. Hash derivation:
// sha256('flowvault:v1:' + slug)
// (see src/lib/crypto/siteId.ts). We hard-code the hex digest here
// because Firestore rules have no hashing primitives.
//
// 'demo' -> ffd558c4506367d79b56ee14ba09546c2e80c20ec70e31f75efc56b87aeb8876
function isDemoSite(siteId) {
return siteId == 'ffd558c4506367d79b56ee14ba09546c2e80c20ec70e31f75efc56b87aeb8876';
}
function isValidCreate(d) {
return d.keys().hasAll([
'ciphertext', 'kdfSalt', 'kdfParams', 'volume',
'version', 'createdAt', 'updatedAt'
])
&& !d.keys().hasAny(['deadman']) // deadman is configured post-create
&& d.version == 1
&& d.ciphertext is bytes
&& d.ciphertext.size() <= 1048576
&& d.kdfSalt is bytes
&& d.kdfSalt.size() >= 16 && d.kdfSalt.size() <= 64
&& d.kdfParams is map
&& d.kdfParams.algorithm == 'argon2id'
&& d.volume is map
&& d.volume.slotCount is int
&& d.volume.slotSize is int
&& d.volume.slotCount * d.volume.slotSize == d.ciphertext.size();
}
function isValidUpdate(oldD, newD) {
return newD.version == oldD.version + 1
&& newD.ciphertext is bytes
&& newD.ciphertext.size() == oldD.ciphertext.size()
&& newD.kdfSalt == oldD.kdfSalt
&& newD.kdfParams == oldD.kdfParams
&& newD.volume == oldD.volume
&& newD.createdAt == oldD.createdAt
&& isValidDeadmanTransition(oldD, newD);
}
// Deadman state machine the client is allowed to drive:
// absent -> absent (normal save)
// absent -> configured (configure)
// configured -> configured (heartbeat OR replace)
// configured -> absent (disable)
// released -> released (frozen — no client writes at all)
//
// Clients can never set released=true or releasedAt; only the
// deadmanSweep Cloud Function (Admin SDK, bypasses rules) can.
function isValidDeadmanTransition(oldD, newD) {
let oldHas = oldD.keys().hasAny(['deadman']);
let newHas = newD.keys().hasAny(['deadman']);
let wasReleased = oldHas && oldD.deadman.released == true;
return !wasReleased
&& (!newHas || isValidDeadmanShape(newD.deadman));
}
function isValidDeadmanShape(dm) {
return dm is map
&& dm.keys().hasAll([
'wrappedKey', 'beneficiarySalt',
'intervalMs', 'graceMs', 'lastHeartbeatAt', 'released'
])
&& dm.wrappedKey is bytes
&& dm.wrappedKey.size() >= 28 && dm.wrappedKey.size() <= 256
&& dm.beneficiarySalt is bytes
&& dm.beneficiarySalt.size() >= 16 && dm.beneficiarySalt.size() <= 64
&& dm.intervalMs is int
&& dm.intervalMs >= 60000 // at least 1 minute
&& dm.intervalMs <= 31536000000 // at most 1 year
&& dm.graceMs is int
&& dm.graceMs >= 0
&& dm.graceMs <= 31536000000
&& dm.released == false; // clients may never set true
}
function isValidTimelock(d) {
return d.keys().hasAll(['ciphertext', 'round', 'chainHash', 'createdAt'])
&& d.ciphertext is bytes
&& d.ciphertext.size() <= 262144
&& d.round is int
&& d.round > 0
&& d.chainHash is string
&& (!d.keys().hasAny(['passwordProtected'])
|| d.passwordProtected is bool);
}
// Send document invariants. The ciphertext cap is 256 KiB (same as
// timelocks) which after AEAD overhead leaves ~256 KiB plaintext,
// well above the 128 KiB UX cap. Expiry must land in the future but
// not past ~30 days (with a small clock-drift buffer). Clients
// cannot create a document that's already consumed.
function isValidSend(d) {
return d.keys().hasAll([
'ciphertext', 'expiresAt', 'maxViews', 'viewCount', 'createdAt'
])
&& d.ciphertext is bytes
&& d.ciphertext.size() > 0
&& d.ciphertext.size() <= 262144
&& d.expiresAt is timestamp
&& d.expiresAt > request.time
&& d.expiresAt < request.time + duration.value(31, 'd')
&& d.maxViews is int
&& d.maxViews >= 1
&& d.maxViews <= 100
&& d.viewCount == 0
&& (!d.keys().hasAny(['passwordProtected'])
|| d.passwordProtected is bool);
}
}