Skip to main content

max / synckit

Relate a pull after a rotation to a pull before it The orchestration tests count requests: they see that begin, re-encrypt, batch and complete all fired, and nothing about what came out the other end. Rotation re-keys ciphertext and must change nothing a caller can observe, so relating two pulls states the whole obligation without an expected-value table. The post-rotation pull is fed the bytes the client itself produced: the batch it pushed to /keys/rotate/batch is replayed as the pull body. The new key is the server's committed pending_key rather than a minted one, which is the resume path and lets the test build a client around it. Checked by reversing the seq-to-row mapping on the replay, which fails closed on the (table, row_id) AAD binding rather than returning the wrong row. Phase 2 of wiki `testing-posture`, the last relation in it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-05 16:35 UTC
Signed with PGP, not checked
Commit: 5316d283a3ed35553c6d6f2e01ff3857ffbf418b
Parent: eafe59b
1 file changed, +265 insertions, -0 deletions
@@ -173,3 +173,268 @@
173 173 "straggler round re-pulled entries"
174 174 );
175 175 }
176 +
177 + // ── Rotation changes the key and nothing else ──
178 +
179 + /// The rows the relation below carries across a rotation. Two tables, so a
180 + /// re-encryption that crossed the `(table, row_id)` AAD binding would fail to
181 + /// open rather than quietly return the wrong row.
182 + fn relation_rows() -> Vec<(&'static str, &'static str, serde_json::Value)> {
183 + vec![
184 + (
185 + "tasks",
186 + "row-1",
187 + json!({"title": "write the relation", "n": 1}),
188 + ),
189 + (
190 + "tasks",
191 + "row-2",
192 + json!({"title": "keep the plaintext", "n": 2}),
193 + ),
194 + (
195 + "notes",
196 + "row-3",
197 + json!({"body": "unicode: \u{1f6ab} \u{4f60}\u{597d}", "n": 3}),
198 + ),
199 + ]
200 + }
201 +
202 + /// Seal one row the way `push` seals it: a v2 HLC envelope, bound to
203 + /// `(table, row_id)` as associated data.
204 + fn sealed_envelope(
205 + key: &[u8; 32],
206 + device_id: DeviceId,
207 + table: &str,
208 + row_id: &str,
209 + payload: &serde_json::Value,
210 + ) -> serde_json::Value {
211 + let ctx = synckit_client::crypto::AeadContext::entry(table, row_id);
212 + let envelope = json!({
213 + "__skver": 2,
214 + "__skhlc": Hlc::zero(device_id),
215 + "data": payload,
216 + });
217 + synckit_client::crypto::encrypt_json_aad(&envelope, key, &ctx).unwrap()
218 + }
219 +
220 + /// Wrap a sealed payload in the pull wire shape the server returns.
221 + fn pull_wire(
222 + device_id: DeviceId,
223 + seq: i64,
224 + table: &str,
225 + row_id: &str,
226 + data: &serde_json::Value,
227 + key_id: i32,
228 + ) -> serde_json::Value {
229 + json!({
230 + "seq": seq,
231 + "device_id": device_id,
232 + "table": table,
233 + "op": "INSERT",
234 + "row_id": row_id,
235 + "timestamp": "2025-06-01T12:00:00Z",
236 + "key_id": key_id,
237 + "data": data,
238 + })
239 + }
240 +
241 + /// **Metamorphic relation:** a pull spanning a master-key rotation returns the
242 + /// same plaintext as a pull before it. Rotation re-keys the ciphertext and must
243 + /// change nothing a caller can observe, so any difference between the two pulls
244 + /// is a bug, and relating the runs states that without an expected-value table
245 + /// (Chen et al. 1998).
246 + ///
247 + /// The post-rotation run is fed the bytes the client itself produced: the
248 + /// re-encrypted batch it pushed to `/keys/rotate/batch` is replayed back as the
249 + /// body of the second pull. A re-encryption that dropped a row, crossed the
250 + /// `(table, row_id)` AAD binding or mangled a payload therefore fails here,
251 + /// where the orchestration tests above only count requests.
252 + ///
253 + /// The new key is not left to chance: the server offers a committed
254 + /// `pending_key`, which is the resume path, so `rotate_key` adopts a key this
255 + /// test knows and the second client can be built around it.
256 + #[tokio::test]
257 + async fn a_pull_spanning_a_rotation_yields_what_a_pull_before_it_yielded() {
258 + let old_key = synckit_client::crypto::generate_master_key();
259 + let new_key = synckit_client::crypto::generate_master_key();
260 + let device_id = DeviceId::new(Uuid::new_v4());
261 + let rows = relation_rows();
262 +
263 + let sealed_under_old: Vec<serde_json::Value> = rows
264 + .iter()
265 + .map(|(table, row_id, payload)| {
266 + sealed_envelope(&old_key, device_id, table, row_id, payload)
267 + })
268 + .collect();
269 +
270 + // Run A: pull before the rotation, everything under the old key.
271 + let before = {
272 + let server = MockServer::start().await;
273 + let client = authed_client(&server);
274 + client.set_master_key_raw(old_key);
275 + let wire: Vec<serde_json::Value> = rows
276 + .iter()
277 + .zip(&sealed_under_old)
278 + .enumerate()
279 + .map(|(i, ((table, row_id, _), data))| {
280 + pull_wire(device_id, i as i64 + 1, table, row_id, data, 1)
281 + })
282 + .collect();
283 + Mock::given(method("POST"))
284 + .and(path("/api/v1/sync/pull"))
285 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({
286 + "changes": wire,
287 + "cursor": rows.len(),
288 + "has_more": false,
289 + })))
290 + .mount(&server)
291 + .await;
292 +
293 + let (changes, _, _) = client.pull(device_id, 0).await.unwrap();
294 + changes
295 + };
296 +
297 + // The rotation itself, driven through the full protocol. `pending_key` makes
298 + // it the resume path, so the client adopts `new_key` instead of minting one.
299 + let reencrypted = {
300 + let server = MockServer::start().await;
301 + let keys_body = json!({
302 + "encrypted_key": synckit_client::crypto::wrap_master_key(&old_key, ROTATE_PW).unwrap(),
303 + "key_version": 1,
304 + "key_id": 1,
305 + "pending_key": {
306 + "encrypted_key": synckit_client::crypto::wrap_master_key(&new_key, ROTATE_PW).unwrap(),
307 + "key_id": 2,
308 + },
309 + });
310 + Mock::given(method("GET"))
311 + .and(path(KEYS_PATH))
312 + .respond_with(ResponseTemplate::new(200).set_body_json(keys_body))
313 + .mount(&server)
314 + .await;
315 + Mock::given(method("POST"))
316 + .and(path(ROTATE_PATH))
317 + .respond_with(ResponseTemplate::new(200).set_body_json(
318 + json!({ "rotation_id": Uuid::new_v4(), "target_seq": rows.len(), "new_key_id": 2 }),
319 + ))
320 + .mount(&server)
321 + .await;
322 + let entries: Vec<serde_json::Value> = rows
323 + .iter()
324 + .zip(&sealed_under_old)
325 + .enumerate()
326 + .map(|(i, ((table, row_id, _), data))| {
327 + json!({ "seq": i as i64 + 1, "table": table, "row_id": row_id, "data": data })
328 + })
329 + .collect();
330 + Mock::given(method("POST"))
331 + .and(path(ENTRIES_PATH))
332 + .respond_with(
333 + ResponseTemplate::new(200)
334 + .set_body_json(json!({ "entries": entries, "has_more": false })),
335 + )
336 + .up_to_n_times(1)
337 + .mount(&server)
338 + .await;
339 + Mock::given(method("POST"))
340 + .and(path(ENTRIES_PATH))
341 + .respond_with(
342 + ResponseTemplate::new(200)
343 + .set_body_json(json!({ "entries": [], "has_more": false })),
344 + )
345 + .mount(&server)
346 + .await;
347 + Mock::given(method("POST"))
348 + .and(path(BATCH_PATH))
349 + .respond_with(
350 + ResponseTemplate::new(200).set_body_json(json!({ "updated_count": rows.len() })),
351 + )
352 + .mount(&server)
353 + .await;
354 + Mock::given(method("POST"))
355 + .and(path(COMPLETE_PATH))
356 + .respond_with(ResponseTemplate::new(200))
357 + .mount(&server)
358 + .await;
359 +
360 + let client = authed_client(&server);
361 + client.set_master_key_raw(old_key);
362 + client
363 + .rotate_key(device_id, ROTATE_PW)
364 + .await
365 + .expect("the rotation should complete");
366 +
367 + // Take back what the client re-encrypted, keyed by seq.
368 + let reqs = server.received_requests().await.unwrap();
369 + let batch = reqs
370 + .iter()
371 + .find(|r| r.url.path() == BATCH_PATH)
372 + .expect("the client should have pushed a re-encrypted batch");
373 + let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
374 + let entries = body["entries"]
375 + .as_array()
376 + .expect("batch body should carry entries")
377 + .clone();
378 + assert_eq!(
379 + entries.len(),
380 + rows.len(),
381 + "the re-encrypted batch dropped a row before the pull below could see it"
382 + );
383 + entries
384 + };
385 +
386 + // Run B: pull after the rotation, replaying the re-encrypted bytes.
387 + let after = {
388 + let server = MockServer::start().await;
389 + let client = authed_client(&server);
390 + client.set_master_key_raw(new_key);
391 + let wire: Vec<serde_json::Value> = reencrypted
392 + .iter()
393 + .map(|entry| {
394 + let seq = entry["seq"].as_i64().expect("batch entry keeps its seq");
395 + let (table, row_id, _) = &rows[seq as usize - 1];
396 + pull_wire(device_id, seq, table, row_id, &entry["data"], 2)
397 + })
398 + .collect();
399 + Mock::given(method("POST"))
400 + .and(path("/api/v1/sync/pull"))
401 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({
402 + "changes": wire,
403 + "cursor": rows.len(),
404 + "has_more": false,
405 + })))
406 + .mount(&server)
407 + .await;
408 +
409 + let (changes, _, _) = client.pull(device_id, 0).await.unwrap();
410 + changes
411 + };
412 +
413 + // Guard against a vacuous pass: the pre-rotation run has to have delivered
414 + // every row, with a payload, before comparing the two proves anything.
415 + assert_eq!(
416 + before.len(),
417 + rows.len(),
418 + "the pre-rotation pull delivered nothing, so the comparison below is vacuous"
419 + );
420 + assert!(
421 + before.iter().all(|c| c.data.is_some()),
422 + "the pre-rotation pull returned a row with no payload"
423 + );
424 + assert_eq!(
425 + before.len(),
426 + after.len(),
427 + "the rotation changed how many changes a pull returns: {} vs {}",
428 + before.len(),
429 + after.len()
430 + );
431 + for (a, b) in before.iter().zip(after.iter()) {
432 + assert_eq!(
433 + a.row_id, b.row_id,
434 + "the rotation reordered or dropped a row"
435 + );
436 + assert_eq!(a.table, b.table);
437 + assert_eq!(a.data, b.data, "the rotation changed a decrypted payload");
438 + assert_eq!(a.hlc, b.hlc, "the rotation changed a row's clock");
439 + }
440 + }