Skip to main content

max / makenotwork

20.5 KB · 585 lines History Blame Raw
1 //! HTTP tests: rotating a group's Group Content Key.
2 //!
3 //! The property under test is that removal and re-key are one transaction. The
4 //! grant set an admin posts *is* the new membership, so there is no window in
5 //! which a removed member's key is still the group's current one. Everything the
6 //! server rejects here (a generation that does not advance, a batch missing the
7 //! admin, a grant for a non-member) exists to stop a rotation that would leave
8 //! the group readable by someone it just removed, or unreadable by its admin.
9 //!
10 //! Design: wiki synckit-groups-design.
11
12 use serde_json::json;
13
14 use super::synckit_paid_sync::{
15 auth_as, create_internal_app, harness_with_blobs, seed_subscription,
16 };
17 use crate::harness::TestHarness;
18
19 const GIB: i64 = 1024 * 1024 * 1024;
20
21 async fn create_group(h: &mut TestHarness, name: &str) -> String {
22 let resp = h
23 .client
24 .post_json(
25 "/api/sync/groups",
26 &json!({ "id": uuid::Uuid::new_v4().to_string(), "name": name, "admin_sealed_gck": "sealed_admin_v1", "admin_pubkey": "pk_admin" }).to_string(),
27 )
28 .await;
29 assert_eq!(resp.status, 200, "create group: {}", resp.text);
30 resp.json::<serde_json::Value>()["id"]
31 .as_str()
32 .expect("group id")
33 .to_string()
34 }
35
36 /// Seed a verified account and add it to `group_id` as a member.
37 async fn add_member(
38 h: &mut TestHarness,
39 group_id: &str,
40 username: &str,
41 email: &str,
42 ) -> makenotwork::db::UserId {
43 let user = h.signup(username, email, "Password1!").await;
44 sqlx::query("UPDATE users SET email_verified = true WHERE id = $1")
45 .bind(user)
46 .execute(&h.db)
47 .await
48 .expect("verify member");
49
50 let resp = h
51 .client
52 .post_json(
53 &format!("/api/sync/groups/{group_id}/members"),
54 &json!({
55 "member_email": email,
56 "sealed_gck": format!("sealed_{username}_v1"),
57 "member_pubkey": format!("pk_{username}"),
58 })
59 .to_string(),
60 )
61 .await;
62 assert_eq!(resp.status, 204, "add member: {}", resp.text);
63 user
64 }
65
66 /// The caller's own grant and the generation it was sealed under.
67 async fn grant(h: &mut TestHarness, group_id: &str) -> (String, i64) {
68 let resp = h
69 .client
70 .get(&format!("/api/sync/groups/{group_id}/grant"))
71 .await;
72 assert_eq!(resp.status, 200, "get grant: {}", resp.text);
73 let body: serde_json::Value = resp.json();
74 (
75 body["sealed_gck"].as_str().expect("sealed_gck").to_string(),
76 body["gck_version"].as_i64().expect("gck_version"),
77 )
78 }
79
80 async fn member_ids(h: &mut TestHarness, group_id: &str) -> Vec<String> {
81 let resp = h
82 .client
83 .get(&format!("/api/sync/groups/{group_id}/members"))
84 .await;
85 assert_eq!(resp.status, 200, "list members: {}", resp.text);
86 resp.json::<serde_json::Value>()
87 .as_array()
88 .expect("member array")
89 .iter()
90 .map(|m| m["user_id"].as_str().expect("user_id").to_string())
91 .collect()
92 }
93
94 async fn register_device(h: &mut TestHarness, name: &str) -> String {
95 let resp = h
96 .client
97 .post_json(
98 "/api/sync/devices",
99 &json!({ "device_name": name, "platform": "macos" }).to_string(),
100 )
101 .await;
102 assert_eq!(resp.status, 200, "register device: {}", resp.text);
103 resp.json::<serde_json::Value>()["id"]
104 .as_str()
105 .expect("device id")
106 .to_string()
107 }
108
109 /// Push one group entry, returning nothing: these tests care about what a later
110 /// pull says the entry was sealed under, not about the cursor.
111 async fn push_entry(h: &mut TestHarness, group_id: &str, device_id: &str, row: &str) {
112 let resp = h
113 .client
114 .post_json(
115 &format!("/api/sync/groups/{group_id}/push"),
116 &json!({
117 "device_id": device_id,
118 "batch_id": uuid::Uuid::new_v4(),
119 "changes": [{
120 "table": "tasks",
121 "op": "INSERT",
122 "row_id": row,
123 "timestamp": "2026-01-01T00:00:00Z",
124 "data": { "ciphertext": format!("sealed-{row}") },
125 }],
126 })
127 .to_string(),
128 )
129 .await;
130 assert_eq!(resp.status, 200, "group push: {}", resp.text);
131 }
132
133 async fn pull_entries(
134 h: &mut TestHarness,
135 group_id: &str,
136 device_id: &str,
137 ) -> Vec<serde_json::Value> {
138 let resp = h
139 .client
140 .post_json(
141 &format!("/api/sync/groups/{group_id}/pull"),
142 &json!({ "device_id": device_id, "cursor": 0 }).to_string(),
143 )
144 .await;
145 assert_eq!(resp.status, 200, "group pull: {}", resp.text);
146 resp.json::<serde_json::Value>()["changes"]
147 .as_array()
148 .expect("changes")
149 .clone()
150 }
151
152 /// A rotation that drops one member: the generation advances, everyone kept is
153 /// re-granted under it, and the removed member is gone from the group in the same
154 /// operation.
155 #[tokio::test]
156 async fn rotation_advances_the_generation_regrants_and_drops_the_removed_member() {
157 let (mut h, _blobs) = harness_with_blobs().await;
158 let admin = h
159 .signup("gr_admin", "gr_admin@example.com", "Password1!")
160 .await;
161 let (app, _key) = create_internal_app(&h.db, admin).await;
162 auth_as(&mut h, admin, app, "admin-key");
163 seed_subscription(&h.db, admin, app, "active", 10 * GIB).await;
164
165 let group_id = create_group(&mut h, "Team").await;
166 let bob = add_member(&mut h, &group_id, "gr_bob", "gr_bob@example.com").await;
167 let carol = add_member(&mut h, &group_id, "gr_carol", "gr_carol@example.com").await;
168
169 auth_as(&mut h, admin, app, "admin-key");
170 let (_, before) = grant(&mut h, &group_id).await;
171 assert_eq!(before, 1, "groups start at generation 1");
172
173 // Drop Carol: her grant is simply absent from the batch.
174 let resp = h
175 .client
176 .post_json(
177 &format!("/api/sync/groups/{group_id}/rotate"),
178 &json!({
179 "gck_version": 2,
180 "grants": [
181 { "user_id": admin.to_string(), "sealed_gck": "sealed_admin_v2" },
182 { "user_id": bob.to_string(), "sealed_gck": "sealed_bob_v2" },
183 ],
184 })
185 .to_string(),
186 )
187 .await;
188 assert_eq!(resp.status, 204, "rotate: {}", resp.text);
189
190 let (admin_grant, admin_version) = grant(&mut h, &group_id).await;
191 assert_eq!(
192 admin_version, 2,
193 "admin is re-granted at the new generation"
194 );
195 assert_eq!(admin_grant, "sealed_admin_v2");
196
197 let remaining = member_ids(&mut h, &group_id).await;
198 assert_eq!(remaining.len(), 2, "carol is gone: {remaining:?}");
199 assert!(remaining.contains(&admin.to_string()));
200 assert!(remaining.contains(&bob.to_string()));
201 assert!(!remaining.contains(&carol.to_string()));
202
203 // Bob kept his access and holds the new generation.
204 auth_as(&mut h, bob, app, "bob-key");
205 let (bob_grant, bob_version) = grant(&mut h, &group_id).await;
206 assert_eq!(bob_version, 2);
207 assert_eq!(bob_grant, "sealed_bob_v2");
208
209 // Carol is no longer a member, so she cannot read the group at all.
210 auth_as(&mut h, carol, app, "carol-key");
211 let resp = h
212 .client
213 .get(&format!("/api/sync/groups/{group_id}/grant"))
214 .await;
215 assert_eq!(
216 resp.status, 403,
217 "a removed member must lose group access: {}",
218 resp.text
219 );
220 }
221
222 /// A generation that does not advance is refused. Accepting one would re-point
223 /// every member at a key a previously-removed member may still hold, and a
224 /// replayed rotation would silently roll the group back.
225 #[tokio::test]
226 async fn rotation_requires_the_generation_to_advance() {
227 let (mut h, _blobs) = harness_with_blobs().await;
228 let admin = h
229 .signup("gr2_admin", "gr2_admin@example.com", "Password1!")
230 .await;
231 let (app, _key) = create_internal_app(&h.db, admin).await;
232 auth_as(&mut h, admin, app, "admin-key");
233 let group_id = create_group(&mut h, "Team").await;
234
235 for stale in [1, 0, -5] {
236 let resp = h
237 .client
238 .post_json(
239 &format!("/api/sync/groups/{group_id}/rotate"),
240 &json!({
241 "gck_version": stale,
242 "grants": [{ "user_id": admin.to_string(), "sealed_gck": "sealed_admin_replay" }],
243 })
244 .to_string(),
245 )
246 .await;
247 assert_eq!(resp.status, 400, "stale generation {stale}: {}", resp.text);
248 }
249
250 // A committed rotation cannot be replayed at its own generation.
251 let resp = h
252 .client
253 .post_json(
254 &format!("/api/sync/groups/{group_id}/rotate"),
255 &json!({
256 "gck_version": 2,
257 "grants": [{ "user_id": admin.to_string(), "sealed_gck": "sealed_admin_v2" }],
258 })
259 .to_string(),
260 )
261 .await;
262 assert_eq!(resp.status, 204, "first rotation: {}", resp.text);
263
264 let resp = h
265 .client
266 .post_json(
267 &format!("/api/sync/groups/{group_id}/rotate"),
268 &json!({
269 "gck_version": 2,
270 "grants": [{ "user_id": admin.to_string(), "sealed_gck": "sealed_admin_replay" }],
271 })
272 .to_string(),
273 )
274 .await;
275 assert_eq!(resp.status, 400, "replayed rotation: {}", resp.text);
276
277 let (sealed, version) = grant(&mut h, &group_id).await;
278 assert_eq!(version, 2, "the replay changed nothing");
279 assert_eq!(sealed, "sealed_admin_v2");
280 }
281
282 /// The batch must carry the admin's own re-sealed grant. Without it the rotation
283 /// would delete the admin along with everyone else omitted, orphaning the group.
284 #[tokio::test]
285 async fn rotation_without_the_admins_own_grant_is_refused() {
286 let (mut h, _blobs) = harness_with_blobs().await;
287 let admin = h
288 .signup("gr3_admin", "gr3_admin@example.com", "Password1!")
289 .await;
290 let (app, _key) = create_internal_app(&h.db, admin).await;
291 auth_as(&mut h, admin, app, "admin-key");
292 let group_id = create_group(&mut h, "Team").await;
293 let bob = add_member(&mut h, &group_id, "gr3_bob", "gr3_bob@example.com").await;
294
295 auth_as(&mut h, admin, app, "admin-key");
296 let resp = h
297 .client
298 .post_json(
299 &format!("/api/sync/groups/{group_id}/rotate"),
300 &json!({
301 "gck_version": 2,
302 "grants": [{ "user_id": bob.to_string(), "sealed_gck": "sealed_bob_v2" }],
303 })
304 .to_string(),
305 )
306 .await;
307 assert_eq!(resp.status, 400, "admin omitted: {}", resp.text);
308
309 // An empty batch is the same mistake with nothing left standing.
310 let resp = h
311 .client
312 .post_json(
313 &format!("/api/sync/groups/{group_id}/rotate"),
314 &json!({ "gck_version": 2, "grants": [] }).to_string(),
315 )
316 .await;
317 assert_eq!(resp.status, 400, "empty batch: {}", resp.text);
318
319 let (_, version) = grant(&mut h, &group_id).await;
320 assert_eq!(version, 1, "no rejected rotation touched the generation");
321 assert_eq!(member_ids(&mut h, &group_id).await.len(), 2);
322 }
323
324 /// Rotation re-seals; it does not recruit. A grant naming a non-member would be a
325 /// silent no-op in the db layer's UPDATE, so the admin must not be able to believe
326 /// someone was added by rotating.
327 #[tokio::test]
328 async fn rotation_grant_for_a_non_member_is_refused() {
329 let (mut h, _blobs) = harness_with_blobs().await;
330 let admin = h
331 .signup("gr4_admin", "gr4_admin@example.com", "Password1!")
332 .await;
333 let (app, _key) = create_internal_app(&h.db, admin).await;
334 auth_as(&mut h, admin, app, "admin-key");
335 let group_id = create_group(&mut h, "Team").await;
336
337 let outsider = h
338 .signup("gr4_dave", "gr4_dave@example.com", "Password1!")
339 .await;
340 auth_as(&mut h, admin, app, "admin-key");
341
342 let resp = h
343 .client
344 .post_json(
345 &format!("/api/sync/groups/{group_id}/rotate"),
346 &json!({
347 "gck_version": 2,
348 "grants": [
349 { "user_id": admin.to_string(), "sealed_gck": "sealed_admin_v2" },
350 { "user_id": outsider.to_string(), "sealed_gck": "sealed_dave_v2" },
351 ],
352 })
353 .to_string(),
354 )
355 .await;
356 assert_eq!(resp.status, 400, "grant for a non-member: {}", resp.text);
357
358 // A duplicate grant for the same member is refused for the same reason: the
359 // batch would not mean what it appears to.
360 let resp = h
361 .client
362 .post_json(
363 &format!("/api/sync/groups/{group_id}/rotate"),
364 &json!({
365 "gck_version": 2,
366 "grants": [
367 { "user_id": admin.to_string(), "sealed_gck": "sealed_admin_v2" },
368 { "user_id": admin.to_string(), "sealed_gck": "sealed_admin_v2_again" },
369 ],
370 })
371 .to_string(),
372 )
373 .await;
374 assert_eq!(resp.status, 400, "duplicate grant: {}", resp.text);
375 }
376
377 /// Only the admin may rotate. A member holding a valid grant must not be able to
378 /// re-key the group, which would let them lock the admin out.
379 #[tokio::test]
380 async fn only_the_admin_may_rotate() {
381 let (mut h, _blobs) = harness_with_blobs().await;
382 let admin = h
383 .signup("gr5_admin", "gr5_admin@example.com", "Password1!")
384 .await;
385 let (app, _key) = create_internal_app(&h.db, admin).await;
386 auth_as(&mut h, admin, app, "admin-key");
387 let group_id = create_group(&mut h, "Team").await;
388 let bob = add_member(&mut h, &group_id, "gr5_bob", "gr5_bob@example.com").await;
389
390 auth_as(&mut h, bob, app, "bob-key");
391 let resp = h
392 .client
393 .post_json(
394 &format!("/api/sync/groups/{group_id}/rotate"),
395 &json!({
396 "gck_version": 2,
397 "grants": [{ "user_id": bob.to_string(), "sealed_gck": "sealed_bob_v2" }],
398 })
399 .to_string(),
400 )
401 .await;
402 assert_eq!(resp.status, 403, "member rotating: {}", resp.text);
403
404 // A non-member gets the same answer, and learns nothing about the group.
405 let carol = h
406 .signup("gr5_carol", "gr5_carol@example.com", "Password1!")
407 .await;
408 auth_as(&mut h, carol, app, "carol-key");
409 let resp = h
410 .client
411 .post_json(
412 &format!("/api/sync/groups/{group_id}/rotate"),
413 &json!({
414 "gck_version": 2,
415 "grants": [{ "user_id": carol.to_string(), "sealed_gck": "sealed_carol_v2" }],
416 })
417 .to_string(),
418 )
419 .await;
420 assert_eq!(resp.status, 403, "non-member rotating: {}", resp.text);
421 }
422
423 /// The property the whole per-generation design exists for: entries written
424 /// before a rotation stay readable afterwards.
425 ///
426 /// A rotation bumps the generation without re-encrypting the log (the server
427 /// cannot, it never sees plaintext), so every entry records the generation it was
428 /// sealed under and members keep the grant for every generation they lived
429 /// through. Without both halves a single removal would orphan the group's whole
430 /// history.
431 #[tokio::test]
432 async fn entries_written_before_a_rotation_stay_readable_after_it() {
433 let (mut h, _blobs) = harness_with_blobs().await;
434 let admin = h
435 .signup("gr6_admin", "gr6_admin@example.com", "Password1!")
436 .await;
437 let (app, _key) = create_internal_app(&h.db, admin).await;
438 auth_as(&mut h, admin, app, "admin-key");
439 seed_subscription(&h.db, admin, app, "active", 10 * GIB).await;
440
441 let group_id = create_group(&mut h, "Team").await;
442 let bob = add_member(&mut h, &group_id, "gr6_bob", "gr6_bob@example.com").await;
443 let carol = add_member(&mut h, &group_id, "gr6_carol", "gr6_carol@example.com").await;
444
445 auth_as(&mut h, admin, app, "admin-key");
446 let device = register_device(&mut h, "admin-dev").await;
447 push_entry(&mut h, &group_id, &device, "before-rotation").await;
448
449 let resp = h
450 .client
451 .post_json(
452 &format!("/api/sync/groups/{group_id}/rotate"),
453 &json!({
454 "gck_version": 2,
455 "grants": [
456 { "user_id": admin.to_string(), "sealed_gck": "sealed_admin_v2" },
457 { "user_id": bob.to_string(), "sealed_gck": "sealed_bob_v2" },
458 ],
459 })
460 .to_string(),
461 )
462 .await;
463 assert_eq!(resp.status, 204, "rotate: {}", resp.text);
464
465 push_entry(&mut h, &group_id, &device, "after-rotation").await;
466
467 // A pull spans both generations, and each entry says which key opens it.
468 let changes = pull_entries(&mut h, &group_id, &device).await;
469 assert_eq!(changes.len(), 2, "both entries pull: {changes:?}");
470 let by_row = |row: &str| -> i64 {
471 changes
472 .iter()
473 .find(|c| c["row_id"] == row)
474 .unwrap_or_else(|| panic!("{row} missing from {changes:?}"))["gck_version"]
475 .as_i64()
476 .expect("gck_version on a group entry")
477 };
478 assert_eq!(by_row("before-rotation"), 1, "the old entry keeps its key");
479 assert_eq!(
480 by_row("after-rotation"),
481 2,
482 "the new entry uses the new key"
483 );
484
485 // A member who lived through the rotation can still fetch the old generation's
486 // grant, which is what makes the old entry decryptable rather than merely
487 // present.
488 auth_as(&mut h, bob, app, "bob-key");
489 let resp = h
490 .client
491 .get(&format!("/api/sync/groups/{group_id}/grant?version=1"))
492 .await;
493 assert_eq!(resp.status, 200, "bob's generation-1 grant: {}", resp.text);
494 assert_eq!(
495 resp.json::<serde_json::Value>()["sealed_gck"],
496 "sealed_gr6_bob_v1"
497 );
498
499 let resp = h
500 .client
501 .get(&format!("/api/sync/groups/{group_id}/grant?version=2"))
502 .await;
503 assert_eq!(resp.status, 200, "bob's generation-2 grant: {}", resp.text);
504 assert_eq!(
505 resp.json::<serde_json::Value>()["sealed_gck"],
506 "sealed_bob_v2"
507 );
508
509 // Without a version he gets the newest, which is what a writer wants.
510 let (newest, version) = grant(&mut h, &group_id).await;
511 assert_eq!(version, 2);
512 assert_eq!(newest, "sealed_bob_v2");
513
514 // The removed member loses every generation, not just the new one.
515 auth_as(&mut h, carol, app, "carol-key");
516 for version in [1, 2] {
517 let resp = h
518 .client
519 .get(&format!(
520 "/api/sync/groups/{group_id}/grant?version={version}"
521 ))
522 .await;
523 assert_eq!(
524 resp.status, 403,
525 "a removed member must not fetch generation {version}: {}",
526 resp.text
527 );
528 }
529 }
530
531 /// A member added after a rotation gets the current generation only. They see the
532 /// group from when they joined, not before, which is the same rule as a removed
533 /// member losing the old generations.
534 #[tokio::test]
535 async fn a_member_added_after_a_rotation_does_not_get_earlier_generations() {
536 let (mut h, _blobs) = harness_with_blobs().await;
537 let admin = h
538 .signup("gr7_admin", "gr7_admin@example.com", "Password1!")
539 .await;
540 let (app, _key) = create_internal_app(&h.db, admin).await;
541 auth_as(&mut h, admin, app, "admin-key");
542 let group_id = create_group(&mut h, "Team").await;
543
544 let resp = h
545 .client
546 .post_json(
547 &format!("/api/sync/groups/{group_id}/rotate"),
548 &json!({
549 "gck_version": 2,
550 "grants": [{ "user_id": admin.to_string(), "sealed_gck": "sealed_admin_v2" }],
551 })
552 .to_string(),
553 )
554 .await;
555 assert_eq!(resp.status, 204, "rotate: {}", resp.text);
556
557 let bob = add_member(&mut h, &group_id, "gr7_bob", "gr7_bob@example.com").await;
558
559 auth_as(&mut h, bob, app, "bob-key");
560 let (_, version) = grant(&mut h, &group_id).await;
561 assert_eq!(version, 2, "a new member joins at the current generation");
562
563 let resp = h
564 .client
565 .get(&format!("/api/sync/groups/{group_id}/grant?version=1"))
566 .await;
567 assert_eq!(
568 resp.status, 403,
569 "a generation predating the member must not be fetchable: {}",
570 resp.text
571 );
572
573 // The admin, who was there for generation 1, still holds it.
574 auth_as(&mut h, admin, app, "admin-key");
575 let resp = h
576 .client
577 .get(&format!("/api/sync/groups/{group_id}/grant?version=1"))
578 .await;
579 assert_eq!(resp.status, 200, "admin keeps generation 1: {}", resp.text);
580 assert_eq!(
581 resp.json::<serde_json::Value>()["sealed_gck"],
582 "sealed_admin_v1"
583 );
584 }
585