Skip to main content

max / synckit

17.2 KB · 508 lines History Blame Raw
1 //! Group key rotation: the membership batch the client builds, and reading a
2 //! pull that spans GCK generations.
3
4 /// Rotation is the removal primitive: the batch an admin posts becomes the new
5 /// membership, so the server drops anyone absent from it and re-keys in the same
6 /// transaction. These tests pin the batch the client builds, because everything
7 /// the server can enforce depends on the client getting that batch right.
8 mod membership_batch {
9 use crate::common::*;
10 use synckit_client::{
11 GroupId, IdentityKeypair, IdentityPublicKey, generate_group_key, open_gck_grant,
12 seal_gck_to_member,
13 };
14 use wiremock::matchers::path_regex;
15
16 /// A member we control both halves of, so a grant sealed to them can be
17 /// opened and checked rather than merely counted.
18 struct Member {
19 user_id: UserId,
20 keypair: IdentityKeypair,
21 }
22
23 impl Member {
24 fn new() -> Self {
25 Self {
26 user_id: UserId::new(Uuid::new_v4()),
27 keypair: IdentityKeypair::generate(),
28 }
29 }
30
31 fn pubkey_json(&self) -> serde_json::Value {
32 json!({
33 "user_id": self.user_id,
34 "pubkey": self.keypair.public_key().to_base64(),
35 })
36 }
37 }
38
39 /// The client whose master key seeds the admin identity, plus that identity.
40 fn admin_client(kit: &MockKit) -> (SyncKitClient, IdentityKeypair) {
41 let (client, master) = kit.keyed();
42 let identity = IdentityKeypair::from_master_key(&master);
43 (client, identity)
44 }
45
46 /// Mount the two reads a rotation makes: the admin's own grant (for the
47 /// current generation) and the member pubkey list (the re-seal inputs).
48 async fn mount_reads(
49 kit: &MockKit,
50 group_id: GroupId,
51 gck: &[u8; 32],
52 admin: &IdentityKeypair,
53 admin_id: UserId,
54 version: i32,
55 members: &[&Member],
56 ) {
57 let sealed = seal_gck_to_member(gck, &admin.public_key(), &group_id.to_string(), version)
58 .expect("seal admin grant");
59 kit.get(&format!("/api/v1/sync/groups/{group_id}/grant"))
60 .json(json!({
61 "sealed_gck": sealed,
62 "gck_version": version,
63 }))
64 .await;
65
66 let mut pubkeys = vec![json!({
67 "user_id": admin_id,
68 "pubkey": admin.public_key().to_base64(),
69 })];
70 pubkeys.extend(members.iter().map(|m| m.pubkey_json()));
71 kit.get(&format!("/api/v1/sync/groups/{group_id}/pubkeys"))
72 .json(pubkeys)
73 .await;
74 }
75
76 async fn mount_rotate(kit: &MockKit) {
77 kit.matching("POST", path_regex(r"^/api/v1/sync/groups/[^/]+/rotate$"))
78 .code(204)
79 .empty()
80 .await;
81 }
82
83 /// The body the client POSTed to `/rotate`.
84 async fn posted_batch(kit: &MockKit, group_id: GroupId) -> serde_json::Value {
85 kit.body(&format!("/api/v1/sync/groups/{group_id}/rotate"))
86 .await
87 }
88
89 #[tokio::test]
90 async fn removing_a_member_rekeys_and_reseals_to_everyone_who_stays() {
91 let kit = MockKit::start().await;
92 let (client, admin_identity) = admin_client(&kit);
93 let (admin_id, _) = test_ids();
94 let group_id = GroupId::new(Uuid::new_v4());
95 let old_gck = generate_group_key();
96
97 let bob = Member::new();
98 let carol = Member::new();
99 mount_reads(
100 &kit,
101 group_id,
102 &old_gck,
103 &admin_identity,
104 admin_id,
105 7,
106 &[&bob, &carol],
107 )
108 .await;
109 mount_rotate(&kit).await;
110
111 client
112 .remove_member(group_id, carol.user_id)
113 .await
114 .expect("remove member");
115
116 let batch = posted_batch(&kit, group_id).await;
117 assert_eq!(
118 batch["gck_version"], 8,
119 "the generation must advance past the one our grant reports"
120 );
121
122 let grants = batch["grants"].as_array().expect("grants array");
123 assert_eq!(grants.len(), 2, "admin and bob, not carol: {grants:?}");
124 let recipients: Vec<&str> = grants
125 .iter()
126 .map(|g| g["user_id"].as_str().expect("user_id"))
127 .collect();
128 assert!(recipients.contains(&admin_id.to_string().as_str()));
129 assert!(recipients.contains(&bob.user_id.to_string().as_str()));
130 assert!(
131 !recipients.contains(&carol.user_id.to_string().as_str()),
132 "the removed member must not be re-granted"
133 );
134
135 // The grants are real seals of one new key, not placeholders: Bob's opens,
136 // and what comes out is neither the old GCK nor something private to the
137 // admin's copy.
138 let bobs = grants
139 .iter()
140 .find(|g| g["user_id"].as_str() == Some(&bob.user_id.to_string()))
141 .expect("bob's grant");
142 let new_gck = open_gck_grant(
143 bobs["sealed_gck"].as_str().expect("sealed_gck"),
144 &bob.keypair,
145 &group_id.to_string(),
146 8,
147 )
148 .expect("bob opens his grant");
149 assert_ne!(new_gck, old_gck, "rotation must mint a fresh key");
150
151 let admins = grants
152 .iter()
153 .find(|g| g["user_id"].as_str() == Some(&admin_id.to_string()))
154 .expect("admin's grant");
155 let admin_copy = open_gck_grant(
156 admins["sealed_gck"].as_str().expect("sealed_gck"),
157 &admin_identity,
158 &group_id.to_string(),
159 8,
160 )
161 .expect("admin opens their own grant");
162 assert_eq!(
163 admin_copy, new_gck,
164 "every member must be sealed the same new key"
165 );
166 }
167
168 #[tokio::test]
169 async fn a_grant_cannot_be_opened_by_the_member_it_was_not_sealed_to() {
170 let kit = MockKit::start().await;
171 let (client, admin_identity) = admin_client(&kit);
172 let (admin_id, _) = test_ids();
173 let group_id = GroupId::new(Uuid::new_v4());
174
175 let bob = Member::new();
176 let carol = Member::new();
177 mount_reads(
178 &kit,
179 group_id,
180 &generate_group_key(),
181 &admin_identity,
182 admin_id,
183 1,
184 &[&bob, &carol],
185 )
186 .await;
187 mount_rotate(&kit).await;
188
189 client
190 .rotate_group_key(group_id, &[])
191 .await
192 .expect("rotate without removing anyone");
193
194 let batch = posted_batch(&kit, group_id).await;
195 let bobs = batch["grants"]
196 .as_array()
197 .expect("grants")
198 .iter()
199 .find(|g| g["user_id"].as_str() == Some(&bob.user_id.to_string()))
200 .expect("bob's grant")["sealed_gck"]
201 .as_str()
202 .expect("sealed_gck")
203 .to_string();
204
205 assert!(
206 open_gck_grant(&bobs, &carol.keypair, &group_id.to_string(), 2).is_err(),
207 "a grant sealed to bob must not open under carol's key"
208 );
209 }
210
211 #[tokio::test]
212 async fn an_empty_removal_set_rekeys_without_dropping_anyone() {
213 let kit = MockKit::start().await;
214 let (client, admin_identity) = admin_client(&kit);
215 let (admin_id, _) = test_ids();
216 let group_id = GroupId::new(Uuid::new_v4());
217
218 let bob = Member::new();
219 mount_reads(
220 &kit,
221 group_id,
222 &generate_group_key(),
223 &admin_identity,
224 admin_id,
225 3,
226 &[&bob],
227 )
228 .await;
229 mount_rotate(&kit).await;
230
231 client
232 .rotate_group_key(group_id, &[])
233 .await
234 .expect("rekey after a suspected compromise");
235
236 let batch = posted_batch(&kit, group_id).await;
237 assert_eq!(batch["gck_version"], 4);
238 assert_eq!(
239 batch["grants"].as_array().expect("grants").len(),
240 2,
241 "a bare re-key keeps the whole membership"
242 );
243 }
244
245 #[tokio::test]
246 async fn a_member_pubkey_the_client_cannot_parse_aborts_the_rotation() {
247 let kit = MockKit::start().await;
248 let (client, admin_identity) = admin_client(&kit);
249 let (admin_id, _) = test_ids();
250 let group_id = GroupId::new(Uuid::new_v4());
251
252 let sealed = seal_gck_to_member(
253 &generate_group_key(),
254 &admin_identity.public_key(),
255 &group_id.to_string(),
256 1,
257 )
258 .expect("seal admin grant");
259 kit.get(&format!("/api/v1/sync/groups/{group_id}/grant"))
260 .json(json!({
261 "sealed_gck": sealed,
262 "gck_version": 1,
263 }))
264 .await;
265 kit.get(&format!("/api/v1/sync/groups/{group_id}/pubkeys"))
266 .json(json!([
267 { "user_id": admin_id, "pubkey": admin_identity.public_key().to_base64() },
268 { "user_id": Uuid::new_v4(), "pubkey": "not-a-key" },
269 ]))
270 .await;
271 mount_rotate(&kit).await;
272
273 client
274 .rotate_group_key(group_id, &[])
275 .await
276 .expect_err("an unreadable member key must not produce a partial rotation");
277
278 assert_eq!(
279 kit.hits(&format!("/api/v1/sync/groups/{group_id}/rotate"))
280 .await,
281 0,
282 "nothing may be posted when the batch could not be built in full"
283 );
284 }
285
286 /// `IdentityPublicKey` round-trips through the wire form the pubkey list uses.
287 /// If this ever stops holding, every rotation silently degrades to the error
288 /// path above.
289 #[test]
290 fn member_pubkeys_round_trip_through_base64() {
291 let identity = IdentityKeypair::generate();
292 let encoded = identity.public_key().to_base64();
293 let decoded = IdentityPublicKey::from_base64(&encoded).expect("round-trip");
294 assert_eq!(decoded.as_bytes(), identity.public_key().as_bytes());
295 }
296 }
297
298 /// The client half of history-survives-rotation: one pull can span GCK
299 /// generations, and each entry is opened under the key it was sealed with.
300 mod generations {
301 use crate::common::*;
302 use synckit_client::{
303 ChangeEntry, GroupId, IdentityKeypair, generate_group_key, seal_gck_to_member,
304 };
305 use wiremock::matchers::{path_regex, query_param};
306
307 fn change(row: &str, title: &str) -> ChangeEntry {
308 ChangeEntry {
309 table: "tasks".to_string(),
310 op: ChangeOp::Insert,
311 row_id: row.to_string(),
312 timestamp: Utc::now(),
313 hlc: Hlc::zero(DeviceId::nil()),
314 data: Some(json!({ "title": title })),
315 extra: serde_json::Map::default(),
316 }
317 }
318
319 /// Push one change under `gck` and return the ciphertext the client produced,
320 /// so it can be served straight back in a pull. Going through the real push
321 /// path keeps the fixture honest: no test-local reimplementation of the AAD
322 /// binding to drift from the one the client uses.
323 async fn sealed_entry(
324 client: &SyncKitClient,
325 kit: &MockKit,
326 group_id: GroupId,
327 gck: &[u8; 32],
328 device: DeviceId,
329 row: &str,
330 title: &str,
331 ) -> serde_json::Value {
332 let push_path = format!("/api/v1/sync/groups/{group_id}/push");
333 let before = kit.hits(&push_path).await;
334 client
335 .group_push(group_id, gck, device, vec![change(row, title)])
336 .await
337 .expect("group push");
338 let pushes = kit.requests_to(&push_path).await;
339 assert!(pushes.len() > before, "a push was sent");
340 let body: serde_json::Value =
341 serde_json::from_slice(&pushes[before].body).expect("push body");
342 body["changes"][0].clone()
343 }
344
345 #[tokio::test]
346 async fn a_pull_spanning_two_generations_opens_each_under_its_own_key() {
347 let kit = MockKit::start().await;
348 let (client, master) = kit.keyed();
349 let identity = IdentityKeypair::from_master_key(&master);
350
351 let group_id = GroupId::new(Uuid::new_v4());
352 let device = DeviceId::new(Uuid::new_v4());
353 let group_ref = group_id.to_string();
354 let gck_v1 = generate_group_key();
355 let gck_v2 = generate_group_key();
356
357 kit.matching("POST", path_regex(r"^/api/v1/sync/groups/[^/]+/push$"))
358 .json(json!({ "cursor": 1 }))
359 .await;
360
361 let old = sealed_entry(
362 &client,
363 &kit,
364 group_id,
365 &gck_v1,
366 device,
367 "r-old",
368 "before rotation",
369 )
370 .await;
371 let new = sealed_entry(
372 &client,
373 &kit,
374 group_id,
375 &gck_v2,
376 device,
377 "r-new",
378 "after rotation",
379 )
380 .await;
381
382 // Each generation's grant is fetched by version. Serving only these two
383 // means a client that ignored the per-entry version and asked for one key
384 // would still get an answer, and then fail to decrypt half the batch.
385 for (version, gck) in [(1, &gck_v1), (2, &gck_v2)] {
386 let sealed =
387 seal_gck_to_member(gck, &identity.public_key(), &group_ref, version).expect("seal");
388 kit.get(&format!("/api/v1/sync/groups/{group_id}/grant"))
389 .and(query_param("version", version.to_string()))
390 .json(json!({
391 "sealed_gck": sealed,
392 "gck_version": version,
393 }))
394 .await;
395 }
396
397 kit.matching("POST", path_regex(r"^/api/v1/sync/groups/[^/]+/pull$"))
398 .json(json!({
399 "changes": [
400 {
401 "seq": 1,
402 "device_id": device,
403 "table": old["table"],
404 "op": old["op"],
405 "row_id": old["row_id"],
406 "timestamp": old["timestamp"],
407 "data": old["data"],
408 "gck_version": 1,
409 },
410 {
411 "seq": 2,
412 "device_id": device,
413 "table": new["table"],
414 "op": new["op"],
415 "row_id": new["row_id"],
416 "timestamp": new["timestamp"],
417 "data": new["data"],
418 "gck_version": 2,
419 },
420 ],
421 "cursor": 2,
422 "has_more": false,
423 }))
424 .await;
425
426 let (changes, cursor, has_more) = client
427 .group_pull_rich(group_id, 2, device, 0)
428 .await
429 .expect("a pull spanning generations must succeed");
430
431 assert_eq!(cursor, 2);
432 assert!(!has_more);
433 assert_eq!(changes.len(), 2);
434 assert_eq!(
435 changes[0].entry.data.as_ref().expect("old plaintext"),
436 &json!({ "title": "before rotation" }),
437 "the pre-rotation entry must open under generation 1"
438 );
439 assert_eq!(
440 changes[1].entry.data.as_ref().expect("new plaintext"),
441 &json!({ "title": "after rotation" }),
442 "the post-rotation entry must open under generation 2"
443 );
444 }
445
446 /// An entry with no generation is what a server predating per-generation
447 /// grants returns. The caller's current generation is the fallback, so an old
448 /// server keeps working rather than failing every pull.
449 #[tokio::test]
450 async fn an_entry_without_a_generation_falls_back_to_the_current_one() {
451 let kit = MockKit::start().await;
452 let (client, master) = kit.keyed();
453 let identity = IdentityKeypair::from_master_key(&master);
454
455 let group_id = GroupId::new(Uuid::new_v4());
456 let device = DeviceId::new(Uuid::new_v4());
457 let gck = generate_group_key();
458
459 kit.matching("POST", path_regex(r"^/api/v1/sync/groups/[^/]+/push$"))
460 .json(json!({ "cursor": 1 }))
461 .await;
462 let entry = sealed_entry(
463 &client,
464 &kit,
465 group_id,
466 &gck,
467 device,
468 "r-legacy",
469 "legacy row",
470 )
471 .await;
472
473 let sealed = seal_gck_to_member(&gck, &identity.public_key(), &group_id.to_string(), 5)
474 .expect("seal");
475 kit.get(&format!("/api/v1/sync/groups/{group_id}/grant"))
476 .json(json!({
477 "sealed_gck": sealed,
478 "gck_version": 5,
479 }))
480 .await;
481
482 kit.matching("POST", path_regex(r"^/api/v1/sync/groups/[^/]+/pull$"))
483 .json(json!({
484 "changes": [{
485 "seq": 1,
486 "device_id": device,
487 "table": entry["table"],
488 "op": entry["op"],
489 "row_id": entry["row_id"],
490 "timestamp": entry["timestamp"],
491 "data": entry["data"],
492 }],
493 "cursor": 1,
494 "has_more": false,
495 }))
496 .await;
497
498 let (changes, _, _) = client
499 .group_pull_rich(group_id, 5, device, 0)
500 .await
501 .expect("a generation-less entry must not fail the pull");
502 assert_eq!(
503 changes[0].entry.data.as_ref().expect("plaintext"),
504 &json!({ "title": "legacy row" })
505 );
506 }
507 }
508