Skip to main content

max / makenotwork

synckit: seal AEAD associated data behind AeadContext Deep A+ pass, crypto axis (D4). The encrypt/decrypt helpers took a raw &[u8] AAD, so a caller could pass empty or mismatched associated data and silently reopen the X1 placement-integrity hole. Replace it with a sealed AeadContext { Entry{table,row_id} | Blob{hash} }; aad_for_entry is now private. The context is the only way to derive AAD, so a relocated/empty-AAD ciphertext is unrepresentable at the type level, not a per-call-site discipline. Wire format unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-22 22:38 UTC
Commit: 915078fe2e47948005b0959af03b8a77dea4c523
Parent: d83d18c
4 files changed, +100 insertions, -56 deletions
@@ -60,7 +60,7 @@ impl SyncKitClient {
60 60 pub async fn blob_upload(&self, hash: &str, presigned_url: &str, data: Vec<u8>) -> Result<()> {
61 61 let master_key = self.require_master_key()?;
62 62 let encrypted =
63 - Bytes::from(crypto::encrypt_bytes_aad(&data, &master_key, hash.as_bytes())?);
63 + Bytes::from(crypto::encrypt_bytes_aad(&data, &master_key, &crypto::AeadContext::blob(hash))?);
64 64
65 65 self.retry_request(Idempotency::Safe, || {
66 66 let req = self
@@ -155,7 +155,7 @@ impl SyncKitClient {
155 155 )));
156 156 }
157 157 let master_key = self.require_master_key()?;
158 - let plaintext = crypto::decrypt_bytes_aad(&encrypted, &master_key, expected_hash.as_bytes())?;
158 + let plaintext = crypto::decrypt_bytes_aad(&encrypted, &master_key, &crypto::AeadContext::blob(expected_hash))?;
159 159
160 160 let actual = format!("{:x}", Sha256::digest(&plaintext));
161 161 if actual != expected_hash {
@@ -205,9 +205,9 @@ impl SyncKitClient {
205 205 /// `(table, row_id)` address via AEAD associated data, so a server cannot
206 206 /// relocate it to another row/table without the open failing closed.
207 207 pub(super) fn encrypt_change_with_key(entry: ChangeEntry, master_key: &[u8; 32]) -> Result<WireChangeEntry> {
208 - let aad = crypto::aad_for_entry(&entry.table, &entry.row_id);
208 + let ctx = crypto::AeadContext::entry(&entry.table, &entry.row_id);
209 209 let envelope = Self::hlc_envelope(&entry.hlc, &entry.data);
210 - let encrypted_data = Some(crypto::encrypt_json_aad(&envelope, master_key, &aad)?);
210 + let encrypted_data = Some(crypto::encrypt_json_aad(&envelope, master_key, &ctx)?);
211 211
212 212 Ok(WireChangeEntry {
213 213 table: entry.table,
@@ -271,10 +271,10 @@ impl SyncKitClient {
271 271 /// Inner decryption that borrows the entry (for fallback logic).
272 272 #[allow(dead_code)]
273 273 fn decrypt_change_with_key_inner(entry: &PullChangeEntry, master_key: &[u8; 32]) -> Result<ChangeEntry> {
274 - let aad = crypto::aad_for_entry(&entry.table, &entry.row_id);
274 + let ctx = crypto::AeadContext::entry(&entry.table, &entry.row_id);
275 275 let (hlc, data) = match entry.data {
276 276 Some(ref value) => {
277 - let decrypted = crypto::decrypt_json_aad(value, master_key, &aad)?;
277 + let decrypted = crypto::decrypt_json_aad(value, master_key, &ctx)?;
278 278 Self::split_hlc_envelope(decrypted, entry.device_id.as_uuid(), entry.timestamp.timestamp_millis())
279 279 }
280 280 None => (
@@ -295,10 +295,10 @@ impl SyncKitClient {
295 295
296 296 /// Decrypt with a pre-loaded key. Used by `pull()` to avoid per-entry lock acquisition.
297 297 pub(super) fn decrypt_change_with_key(entry: PullChangeEntry, master_key: &[u8; 32]) -> Result<ChangeEntry> {
298 - let aad = crypto::aad_for_entry(&entry.table, &entry.row_id);
298 + let ctx = crypto::AeadContext::entry(&entry.table, &entry.row_id);
299 299 let (hlc, data) = match entry.data {
300 300 Some(ref value) => {
301 - let decrypted = crypto::decrypt_json_aad(value, master_key, &aad)?;
301 + let decrypted = crypto::decrypt_json_aad(value, master_key, &ctx)?;
302 302 Self::split_hlc_envelope(decrypted, entry.device_id.as_uuid(), entry.timestamp.timestamp_millis())
303 303 }
304 304 None => (
@@ -160,9 +160,9 @@ impl SyncKitClient {
160 160 // same (table, row_id) AAD on both ends. A legacy untagged
161 161 // entry decrypts with empty AAD and re-emits as a v2 tagged,
162 162 // position-bound payload (opportunistic upgrade).
163 - let aad = crypto::aad_for_entry(&entry.table, &entry.row_id);
164 - let plaintext = crypto::decrypt_json_aad(encrypted_value, old_key, &aad)?;
165 - let reencrypted = crypto::encrypt_json_aad(&plaintext, new_key, &aad)?;
163 + let ctx = crypto::AeadContext::entry(&entry.table, &entry.row_id);
164 + let plaintext = crypto::decrypt_json_aad(encrypted_value, old_key, &ctx)?;
165 + let reencrypted = crypto::encrypt_json_aad(&plaintext, new_key, &ctx)?;
166 166 Some(reencrypted)
167 167 }
168 168 None => None, // DELETE entries have no data
@@ -272,15 +272,51 @@ const WIRE_V2_TAG: &str = "sk2:";
272 272 /// Raw-bytes form of [`WIRE_V2_TAG`] for blob payloads.
273 273 const WIRE_V2_TAG_BYTES: &[u8] = b"sk2:";
274 274
275 + /// The binding context for an AEAD operation — *what* a ciphertext belongs to.
276 + ///
277 + /// The associated data is derived from this value, so a caller cannot pass
278 + /// empty or mismatched AAD: the only way to seal or open an entry or a blob is
279 + /// to name its logical position. That makes the untrusted-server placement
280 + /// attack (relocating a valid ciphertext to a different row/table/address)
281 + /// unrepresentable at the type level rather than a per-call-site discipline.
282 + #[derive(Clone, Copy, Debug)]
283 + pub enum AeadContext<'a> {
284 + /// A sync change entry, bound to its `(table, row_id)` address.
285 + Entry { table: &'a str, row_id: &'a str },
286 + /// A content-addressed blob, bound to its content hash.
287 + Blob { hash: &'a str },
288 + }
289 +
290 + impl<'a> AeadContext<'a> {
291 + /// Bind a change entry to its `(table, row_id)` address.
292 + pub fn entry(table: &'a str, row_id: &'a str) -> Self {
293 + AeadContext::Entry { table, row_id }
294 + }
295 +
296 + /// Bind a blob to its content hash.
297 + pub fn blob(hash: &'a str) -> Self {
298 + AeadContext::Blob { hash }
299 + }
300 +
301 + /// The associated-data bytes for this context. A server that relocates a
302 + /// ciphertext changes its context, hence its AAD, so decryption fails
303 + /// closed — the AEAD tag no longer authenticates the moved bytes.
304 + fn aad(&self) -> Vec<u8> {
305 + match self {
306 + AeadContext::Entry { table, row_id } => aad_for_entry(table, row_id),
307 + AeadContext::Blob { hash } => hash.as_bytes().to_vec(),
308 + }
309 + }
310 + }
311 +
275 312 /// Canonical associated-data encoding binding an entry's ciphertext to its
276 - /// `(table, row_id)` address. A server that relocates a ciphertext to a
277 - /// different row or table changes the AAD, so decryption fails closed — the
278 - /// AEAD tag no longer authenticates the moved bytes.
313 + /// `(table, row_id)` address.
279 314 ///
280 315 /// The unit separator (`0x1f`) cannot appear in the encoded form of either
281 316 /// component's length boundary ambiguously, so `("a", "bc")` and `("ab", "c")`
282 - /// produce distinct AAD.
283 - pub fn aad_for_entry(table: &str, row_id: &str) -> Vec<u8> {
317 + /// produce distinct AAD. Private: callers reach it only through
318 + /// [`AeadContext::Entry`], which is the point of the seal.
319 + fn aad_for_entry(table: &str, row_id: &str) -> Vec<u8> {
284 320 let mut aad = Vec::with_capacity(table.len() + row_id.len() + 1);
285 321 aad.extend_from_slice(table.as_bytes());
286 322 aad.push(0x1f);
@@ -333,25 +369,25 @@ pub fn decrypt_data(encoded: &str, master_key: &[u8; KEY_SIZE]) -> Result<Vec<u8
333 369 open(&B64.decode(encoded)?, master_key, &[])
334 370 }
335 371
336 - /// Encrypt a data entry, binding `aad`, and emit the [`WIRE_V2_TAG`]-prefixed form.
372 + /// Encrypt a data entry, binding it to `ctx`, and emit the [`WIRE_V2_TAG`]-prefixed form.
337 373 pub fn encrypt_data_aad(
338 374 plaintext: &[u8],
339 375 master_key: &[u8; KEY_SIZE],
340 - aad: &[u8],
376 + ctx: &AeadContext,
341 377 ) -> Result<String> {
342 - Ok(format!("{WIRE_V2_TAG}{}", B64.encode(seal(plaintext, master_key, aad)?)))
378 + Ok(format!("{WIRE_V2_TAG}{}", B64.encode(seal(plaintext, master_key, &ctx.aad())?)))
343 379 }
344 380
345 381 /// Decrypt a data entry, auto-detecting the wire version. A `sk2:`-tagged
346 - /// payload is opened with `aad`; an untagged (legacy) payload is opened with
347 - /// empty AAD. This lets v1 and v2 ciphertext coexist with no flag-day.
382 + /// payload is opened with `ctx`'s AAD; an untagged (legacy) payload is opened
383 + /// with empty AAD. This lets v1 and v2 ciphertext coexist with no flag-day.
348 384 pub fn decrypt_data_aad(
349 385 encoded: &str,
350 386 master_key: &[u8; KEY_SIZE],
351 - aad: &[u8],
387 + ctx: &AeadContext,
352 388 ) -> Result<Vec<u8>> {
353 389 match encoded.strip_prefix(WIRE_V2_TAG) {
354 - Some(rest) => open(&B64.decode(rest)?, master_key, aad),
390 + Some(rest) => open(&B64.decode(rest)?, master_key, &ctx.aad()),
355 391 None => open(&B64.decode(encoded)?, master_key, &[]),
356 392 }
357 393 }
@@ -367,14 +403,14 @@ pub fn decrypt_bytes(encrypted: &[u8], master_key: &[u8; KEY_SIZE]) -> Result<Ve
367 403 open(encrypted, master_key, &[])
368 404 }
369 405
370 - /// Encrypt raw bytes binding `aad`, emitting the `sk2:`-prefixed raw form.
371 - /// Used for blobs, where `aad` is the content hash.
406 + /// Encrypt raw bytes binding them to `ctx`, emitting the `sk2:`-prefixed raw
407 + /// form. Used for blobs, where `ctx` is [`AeadContext::Blob`].
372 408 pub fn encrypt_bytes_aad(
373 409 plaintext: &[u8],
374 410 master_key: &[u8; KEY_SIZE],
375 - aad: &[u8],
411 + ctx: &AeadContext,
376 412 ) -> Result<Vec<u8>> {
377 - let sealed = seal(plaintext, master_key, aad)?;
413 + let sealed = seal(plaintext, master_key, &ctx.aad())?;
378 414 let mut out = Vec::with_capacity(WIRE_V2_TAG_BYTES.len() + sealed.len());
379 415 out.extend_from_slice(WIRE_V2_TAG_BYTES);
380 416 out.extend_from_slice(&sealed);
@@ -382,14 +418,14 @@ pub fn encrypt_bytes_aad(
382 418 }
383 419
384 420 /// Decrypt raw bytes, auto-detecting the wire version. A `sk2:`-tagged blob is
385 - /// opened with `aad`; an untagged (legacy) blob is opened with empty AAD.
421 + /// opened with `ctx`'s AAD; an untagged (legacy) blob is opened with empty AAD.
386 422 pub fn decrypt_bytes_aad(
387 423 encrypted: &[u8],
388 424 master_key: &[u8; KEY_SIZE],
389 - aad: &[u8],
425 + ctx: &AeadContext,
390 426 ) -> Result<Vec<u8>> {
391 427 match encrypted.strip_prefix(WIRE_V2_TAG_BYTES) {
392 - Some(rest) => open(rest, master_key, aad),
428 + Some(rest) => open(rest, master_key, &ctx.aad()),
393 429 None => open(encrypted, master_key, &[]),
394 430 }
395 431 }
@@ -418,35 +454,42 @@ pub fn decrypt_json(
418 454 encrypted_value: &serde_json::Value,
419 455 master_key: &[u8; KEY_SIZE],
420 456 ) -> Result<serde_json::Value> {
421 - decrypt_json_aad(encrypted_value, master_key, &[])
457 + use zeroize::Zeroize;
458 + let encoded = encrypted_value
459 + .as_str()
460 + .ok_or_else(|| SyncKitError::Crypto("data field is not a string".into()))?;
461 + let mut plaintext = decrypt_data(encoded, master_key)?;
462 + let result = serde_json::from_slice(&plaintext);
463 + plaintext.zeroize();
464 + result.map_err(Into::into)
422 465 }
423 466
424 - /// Encrypt a JSON value binding `aad`, emitting the `sk2:`-tagged form.
467 + /// Encrypt a JSON value binding it to `ctx`, emitting the `sk2:`-tagged form.
425 468 pub fn encrypt_json_aad(
426 469 value: &serde_json::Value,
427 470 master_key: &[u8; KEY_SIZE],
428 - aad: &[u8],
471 + ctx: &AeadContext,
429 472 ) -> Result<serde_json::Value> {
430 473 use zeroize::Zeroize;
431 474 let mut plaintext = serde_json::to_vec(value)?;
432 - let encrypted = encrypt_data_aad(&plaintext, master_key, aad);
475 + let encrypted = encrypt_data_aad(&plaintext, master_key, ctx);
433 476 plaintext.zeroize();
434 477 Ok(serde_json::Value::String(encrypted?))
435 478 }
436 479
437 480 /// Decrypt a JSON string from the `data` field, auto-detecting the wire
438 - /// version (tagged → use `aad`, untagged → empty AAD).
481 + /// version (tagged → use `ctx`'s AAD, untagged → empty AAD).
439 482 pub fn decrypt_json_aad(
440 483 encrypted_value: &serde_json::Value,
441 484 master_key: &[u8; KEY_SIZE],
442 - aad: &[u8],
485 + ctx: &AeadContext,
443 486 ) -> Result<serde_json::Value> {
444 487 use zeroize::Zeroize;
445 488 let encoded = encrypted_value
446 489 .as_str()
447 490 .ok_or_else(|| SyncKitError::Crypto("data field is not a string".into()))?;
448 491
449 - let mut plaintext = decrypt_data_aad(encoded, master_key, aad)?;
492 + let mut plaintext = decrypt_data_aad(encoded, master_key, ctx)?;
450 493 let result = serde_json::from_slice(&plaintext);
451 494 plaintext.zeroize();
452 495 result.map_err(Into::into)
@@ -1168,10 +1211,10 @@ mod tests {
1168 1211 #[test]
1169 1212 fn v2_data_is_tagged_and_roundtrips() {
1170 1213 let key = generate_master_key();
1171 - let aad = aad_for_entry("tasks", "row-1");
1172 - let wire = encrypt_data_aad(b"hello", &key, &aad).unwrap();
1214 + let ctx = AeadContext::entry("tasks", "row-1");
1215 + let wire = encrypt_data_aad(b"hello", &key, &ctx).unwrap();
1173 1216 assert!(wire.starts_with("sk2:"), "v2 payload must carry the wire tag: {wire}");
1174 - let pt = decrypt_data_aad(&wire, &key, &aad).unwrap();
1217 + let pt = decrypt_data_aad(&wire, &key, &ctx).unwrap();
1175 1218 assert_eq!(pt, b"hello");
1176 1219 }
1177 1220
@@ -1180,21 +1223,22 @@ mod tests {
1180 1223 // The headline X1 guarantee: a ciphertext sealed for (tasks,row-1)
1181 1224 // must not decrypt when the server presents it under (tasks,row-2).
1182 1225 let key = generate_master_key();
1183 - let sealed = encrypt_data_aad(b"secret", &key, &aad_for_entry("tasks", "row-1")).unwrap();
1184 - let relocated = decrypt_data_aad(&sealed, &key, &aad_for_entry("tasks", "row-2"));
1226 + let sealed =
1227 + encrypt_data_aad(b"secret", &key, &AeadContext::entry("tasks", "row-1")).unwrap();
1228 + let relocated = decrypt_data_aad(&sealed, &key, &AeadContext::entry("tasks", "row-2"));
1185 1229 assert!(matches!(relocated, Err(SyncKitError::DecryptionFailed)));
1186 - let relocated_table = decrypt_data_aad(&sealed, &key, &aad_for_entry("notes", "row-1"));
1230 + let relocated_table = decrypt_data_aad(&sealed, &key, &AeadContext::entry("notes", "row-1"));
1187 1231 assert!(matches!(relocated_table, Err(SyncKitError::DecryptionFailed)));
1188 1232 }
1189 1233
1190 1234 #[test]
1191 1235 fn legacy_untagged_still_decrypts_under_aad_reader() {
1192 1236 // A v1 (untagged, empty-AAD) payload must keep decrypting through the
1193 - // tag-aware reader, regardless of the AAD passed — no flag-day.
1237 + // tag-aware reader, regardless of the context passed — no flag-day.
1194 1238 let key = generate_master_key();
1195 1239 let legacy = encrypt_data(b"old data", &key).unwrap();
1196 1240 assert!(!legacy.starts_with("sk2:"));
1197 - let pt = decrypt_data_aad(&legacy, &key, &aad_for_entry("tasks", "row-1")).unwrap();
1241 + let pt = decrypt_data_aad(&legacy, &key, &AeadContext::entry("tasks", "row-1")).unwrap();
1198 1242 assert_eq!(pt, b"old data");
1199 1243 }
1200 1244
@@ -1202,25 +1246,25 @@ mod tests {
1202 1246 fn v2_json_roundtrip_and_relocation_fails() {
1203 1247 let key = generate_master_key();
1204 1248 let value = serde_json::json!({"title": "Buy milk"});
1205 - let aad = aad_for_entry("tasks", "row-1");
1206 - let wire = encrypt_json_aad(&value, &key, &aad).unwrap();
1249 + let ctx = AeadContext::entry("tasks", "row-1");
1250 + let wire = encrypt_json_aad(&value, &key, &ctx).unwrap();
1207 1251 assert!(wire.as_str().unwrap().starts_with("sk2:"));
1208 - assert_eq!(decrypt_json_aad(&wire, &key, &aad).unwrap(), value);
1209 - let moved = decrypt_json_aad(&wire, &key, &aad_for_entry("tasks", "row-2"));
1252 + assert_eq!(decrypt_json_aad(&wire, &key, &ctx).unwrap(), value);
1253 + let moved = decrypt_json_aad(&wire, &key, &AeadContext::entry("tasks", "row-2"));
1210 1254 assert!(moved.is_err());
1211 1255 }
1212 1256
1213 1257 #[test]
1214 1258 fn v2_bytes_tagged_roundtrip_and_relocation_fails() {
1215 1259 let key = generate_master_key();
1216 - let hash_a = b"sha256-aaa";
1217 - let hash_b = b"sha256-bbb";
1218 - let blob = encrypt_bytes_aad(b"blob bytes", &key, hash_a).unwrap();
1260 + let ctx_a = AeadContext::blob("sha256-aaa");
1261 + let ctx_b = AeadContext::blob("sha256-bbb");
1262 + let blob = encrypt_bytes_aad(b"blob bytes", &key, &ctx_a).unwrap();
1219 1263 assert!(blob.starts_with(b"sk2:"), "v2 blob must carry the raw tag");
1220 - assert_eq!(decrypt_bytes_aad(&blob, &key, hash_a).unwrap(), b"blob bytes");
1221 - // Substituting a different hash's AAD fails closed.
1264 + assert_eq!(decrypt_bytes_aad(&blob, &key, &ctx_a).unwrap(), b"blob bytes");
1265 + // Substituting a different hash's context fails closed.
1222 1266 assert!(matches!(
1223 - decrypt_bytes_aad(&blob, &key, hash_b),
1267 + decrypt_bytes_aad(&blob, &key, &ctx_b),
1224 1268 Err(SyncKitError::DecryptionFailed)
1225 1269 ));
1226 1270 }
@@ -1230,7 +1274,7 @@ mod tests {
1230 1274 let key = generate_master_key();
1231 1275 let legacy = encrypt_bytes(b"old blob", &key).unwrap();
1232 1276 assert!(!legacy.starts_with(b"sk2:"));
1233 - let pt = decrypt_bytes_aad(&legacy, &key, b"sha256-whatever").unwrap();
1277 + let pt = decrypt_bytes_aad(&legacy, &key, &AeadContext::blob("sha256-whatever")).unwrap();
1234 1278 assert_eq!(pt, b"old blob");
1235 1279 }
1236 1280 }