| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
|
}
|