max / synckit
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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 | + | } |