| 433 |
433 |
|
/// Encryption overhead in bytes (24-byte nonce + 16-byte Poly1305 tag).
|
| 434 |
434 |
|
pub const ENCRYPTION_OVERHEAD: usize = NONCE_SIZE + 16;
|
| 435 |
435 |
|
|
|
436 |
+ |
// ── Chunked blob format (v3) ──
|
|
437 |
+ |
//
|
|
438 |
+ |
// A blob is split into fixed-size plaintext chunks, each sealed independently
|
|
439 |
+ |
// with AAD binding (content_hash, chunk_index, chunk_count). This lets a reader
|
|
440 |
+ |
// decrypt and verify one chunk at a time — bounding peak memory to a single
|
|
441 |
+ |
// chunk instead of the whole blob — while making chunk reorder, truncation,
|
|
442 |
+ |
// duplication, and cross-blob substitution all fail closed at the AEAD layer.
|
|
443 |
+ |
//
|
|
444 |
+ |
// Layout: `sk3:` | version(u8=3) | chunk_size(u32 LE) | total_len(u64 LE) | sealed_chunk*
|
|
445 |
+ |
// where each `sealed_chunk` is `nonce[24] || ciphertext || tag[16]`. A zero-length
|
|
446 |
+ |
// blob is encoded as a single empty sealed chunk so it is still authenticated.
|
|
447 |
+ |
|
|
448 |
+ |
/// Wire tag for the v3 chunked blob format.
|
|
449 |
+ |
const WIRE_V3_TAG_BYTES: &[u8] = b"sk3:";
|
|
450 |
+ |
/// Fixed header length after the tag: version(1) + chunk_size(4) + total_len(8).
|
|
451 |
+ |
const BLOB_V3_HEADER_LEN: usize = 1 + 4 + 8;
|
|
452 |
+ |
/// Plaintext bytes per blob chunk (1 MiB). Bounds per-chunk working memory.
|
|
453 |
+ |
pub const BLOB_CHUNK_SIZE: usize = 1024 * 1024;
|
|
454 |
+ |
|
|
455 |
+ |
/// AAD binding a blob chunk to its content hash and position. `chunk_count` is
|
|
456 |
+ |
/// included so dropping the final chunk (truncation) changes every chunk's AAD.
|
|
457 |
+ |
fn blob_chunk_aad(hash: &str, index: u32, chunk_count: u32) -> Vec<u8> {
|
|
458 |
+ |
let mut aad = Vec::with_capacity(hash.len() + 1 + 8);
|
|
459 |
+ |
aad.extend_from_slice(hash.as_bytes());
|
|
460 |
+ |
aad.push(0x1f);
|
|
461 |
+ |
aad.extend_from_slice(&index.to_le_bytes());
|
|
462 |
+ |
aad.extend_from_slice(&chunk_count.to_le_bytes());
|
|
463 |
+ |
aad
|
|
464 |
+ |
}
|
|
465 |
+ |
|
|
466 |
+ |
/// Number of chunks for a `total_len` plaintext at `chunk_size` (min 1).
|
|
467 |
+ |
fn blob_chunk_count(total_len: usize, chunk_size: usize) -> u32 {
|
|
468 |
+ |
if total_len == 0 {
|
|
469 |
+ |
1
|
|
470 |
+ |
} else {
|
|
471 |
+ |
total_len.div_ceil(chunk_size) as u32
|
|
472 |
+ |
}
|
|
473 |
+ |
}
|
|
474 |
+ |
|
|
475 |
+ |
/// Parsed v3 blob header.
|
|
476 |
+ |
#[derive(Clone, Copy, Debug)]
|
|
477 |
+ |
pub struct BlobHeader {
|
|
478 |
+ |
/// Plaintext bytes per chunk (last chunk may be shorter).
|
|
479 |
+ |
pub chunk_size: usize,
|
|
480 |
+ |
/// Total plaintext length.
|
|
481 |
+ |
pub total_len: usize,
|
|
482 |
+ |
/// Number of sealed chunks.
|
|
483 |
+ |
pub chunk_count: u32,
|
|
484 |
+ |
}
|
|
485 |
+ |
|
|
486 |
+ |
impl BlobHeader {
|
|
487 |
+ |
/// Sealed length of chunk `index` (`nonce + plaintext_slice + tag`).
|
|
488 |
+ |
pub fn sealed_chunk_len(&self, index: u32) -> usize {
|
|
489 |
+ |
let plain = if self.total_len == 0 {
|
|
490 |
+ |
0
|
|
491 |
+ |
} else if index == self.chunk_count - 1 {
|
|
492 |
+ |
self.total_len - self.chunk_size * index as usize
|
|
493 |
+ |
} else {
|
|
494 |
+ |
self.chunk_size
|
|
495 |
+ |
};
|
|
496 |
+ |
ENCRYPTION_OVERHEAD + plain
|
|
497 |
+ |
}
|
|
498 |
+ |
}
|
|
499 |
+ |
|
|
500 |
+ |
/// Whether `data` begins with the v3 chunked-blob tag.
|
|
501 |
+ |
pub fn is_chunked_blob(data: &[u8]) -> bool {
|
|
502 |
+ |
data.starts_with(WIRE_V3_TAG_BYTES)
|
|
503 |
+ |
}
|
|
504 |
+ |
|
|
505 |
+ |
/// Total wire overhead [`encrypt_blob_chunked`] adds for a `plaintext_len`-byte
|
|
506 |
+ |
/// blob: the tag + header, plus one nonce + tag per chunk.
|
|
507 |
+ |
pub fn chunked_blob_overhead(plaintext_len: usize) -> usize {
|
|
508 |
+ |
let chunk_count = blob_chunk_count(plaintext_len, BLOB_CHUNK_SIZE) as usize;
|
|
509 |
+ |
WIRE_V3_TAG_BYTES.len() + BLOB_V3_HEADER_LEN + chunk_count * ENCRYPTION_OVERHEAD
|
|
510 |
+ |
}
|
|
511 |
+ |
|
|
512 |
+ |
/// Parse the v3 header, returning it plus the number of bytes consumed (tag +
|
|
513 |
+ |
/// header). The remaining input is the sealed-chunk stream.
|
|
514 |
+ |
pub fn parse_blob_header(data: &[u8]) -> Result<(BlobHeader, usize)> {
|
|
515 |
+ |
let body = data
|
|
516 |
+ |
.strip_prefix(WIRE_V3_TAG_BYTES)
|
|
517 |
+ |
.ok_or_else(|| SyncKitError::Crypto("not a v3 chunked blob".into()))?;
|
|
518 |
+ |
if body.len() < BLOB_V3_HEADER_LEN {
|
|
519 |
+ |
return Err(SyncKitError::Crypto("v3 blob header truncated".into()));
|
|
520 |
+ |
}
|
|
521 |
+ |
if body[0] != 3 {
|
|
522 |
+ |
return Err(SyncKitError::Crypto(format!(
|
|
523 |
+ |
"unknown blob format version {}; this client is too old to read it",
|
|
524 |
+ |
body[0]
|
|
525 |
+ |
)));
|
|
526 |
+ |
}
|
|
527 |
+ |
let chunk_size = u32::from_le_bytes(body[1..5].try_into().unwrap()) as usize;
|
|
528 |
+ |
let total_len = u64::from_le_bytes(body[5..13].try_into().unwrap()) as usize;
|
|
529 |
+ |
if chunk_size == 0 {
|
|
530 |
+ |
return Err(SyncKitError::Crypto("v3 blob chunk_size is zero".into()));
|
|
531 |
+ |
}
|
|
532 |
+ |
let chunk_count = blob_chunk_count(total_len, chunk_size);
|
|
533 |
+ |
let consumed = WIRE_V3_TAG_BYTES.len() + BLOB_V3_HEADER_LEN;
|
|
534 |
+ |
Ok((BlobHeader { chunk_size, total_len, chunk_count }, consumed))
|
|
535 |
+ |
}
|
|
536 |
+ |
|
|
537 |
+ |
/// Decrypt one sealed chunk, binding `(hash, index, chunk_count)`.
|
|
538 |
+ |
pub fn decrypt_blob_chunk(
|
|
539 |
+ |
sealed: &[u8],
|
|
540 |
+ |
master_key: &[u8; KEY_SIZE],
|
|
541 |
+ |
hash: &str,
|
|
542 |
+ |
index: u32,
|
|
543 |
+ |
chunk_count: u32,
|
|
544 |
+ |
) -> Result<Vec<u8>> {
|
|
545 |
+ |
open(sealed, master_key, &blob_chunk_aad(hash, index, chunk_count))
|
|
546 |
+ |
}
|
|
547 |
+ |
|
|
548 |
+ |
/// Encrypt a blob into the v3 chunked wire format (see the module note above).
|
|
549 |
+ |
pub fn encrypt_blob_chunked(
|
|
550 |
+ |
plaintext: &[u8],
|
|
551 |
+ |
master_key: &[u8; KEY_SIZE],
|
|
552 |
+ |
hash: &str,
|
|
553 |
+ |
) -> Result<Vec<u8>> {
|
|
554 |
+ |
let total_len = plaintext.len();
|
|
555 |
+ |
let chunk_count = blob_chunk_count(total_len, BLOB_CHUNK_SIZE);
|
|
556 |
+ |
let mut out = Vec::with_capacity(
|
|
557 |
+ |
WIRE_V3_TAG_BYTES.len()
|
|
558 |
+ |
+ BLOB_V3_HEADER_LEN
|
|
559 |
+ |
+ total_len
|
|
560 |
+ |
+ chunk_count as usize * ENCRYPTION_OVERHEAD,
|
|
561 |
+ |
);
|
|
562 |
+ |
out.extend_from_slice(WIRE_V3_TAG_BYTES);
|
|
563 |
+ |
out.push(3);
|
|
564 |
+ |
out.extend_from_slice(&(BLOB_CHUNK_SIZE as u32).to_le_bytes());
|
|
565 |
+ |
out.extend_from_slice(&(total_len as u64).to_le_bytes());
|
|
566 |
+ |
|
|
567 |
+ |
if total_len == 0 {
|
|
568 |
+ |
out.extend_from_slice(&seal(&[], master_key, &blob_chunk_aad(hash, 0, 1))?);
|
|
569 |
+ |
return Ok(out);
|
|
570 |
+ |
}
|
|
571 |
+ |
for (i, chunk) in plaintext.chunks(BLOB_CHUNK_SIZE).enumerate() {
|
|
572 |
+ |
out.extend_from_slice(&seal(chunk, master_key, &blob_chunk_aad(hash, i as u32, chunk_count))?);
|
|
573 |
+ |
}
|
|
574 |
+ |
Ok(out)
|
|
575 |
+ |
}
|
|
576 |
+ |
|
|
577 |
+ |
/// Decrypt a complete v3 chunked blob from one buffer. Used by the non-streaming
|
|
578 |
+ |
/// fallback and tests; the streaming download path parses chunks incrementally
|
|
579 |
+ |
/// via [`parse_blob_header`] + [`decrypt_blob_chunk`].
|
|
580 |
+ |
pub fn decrypt_blob_chunked(
|
|
581 |
+ |
encrypted: &[u8],
|
|
582 |
+ |
master_key: &[u8; KEY_SIZE],
|
|
583 |
+ |
hash: &str,
|
|
584 |
+ |
) -> Result<Vec<u8>> {
|
|
585 |
+ |
let (header, consumed) = parse_blob_header(encrypted)?;
|
|
586 |
+ |
let mut rest = &encrypted[consumed..];
|
|
587 |
+ |
let mut out = Vec::with_capacity(header.total_len);
|
|
588 |
+ |
for i in 0..header.chunk_count {
|
|
589 |
+ |
let len = header.sealed_chunk_len(i);
|
|
590 |
+ |
if rest.len() < len {
|
|
591 |
+ |
return Err(SyncKitError::Crypto("v3 blob truncated mid-chunk".into()));
|
|
592 |
+ |
}
|
|
593 |
+ |
let (sealed, tail) = rest.split_at(len);
|
|
594 |
+ |
out.extend_from_slice(&decrypt_blob_chunk(sealed, master_key, hash, i, header.chunk_count)?);
|
|
595 |
+ |
rest = tail;
|
|
596 |
+ |
}
|
|
597 |
+ |
if !rest.is_empty() {
|
|
598 |
+ |
return Err(SyncKitError::Crypto("v3 blob has trailing bytes".into()));
|
|
599 |
+ |
}
|
|
600 |
+ |
Ok(out)
|
|
601 |
+ |
}
|
|
602 |
+ |
|
| 436 |
603 |
|
/// Overhead of a v2 (AAD-bound, `sk2:`-tagged) blob: the wire tag plus the
|
| 437 |
604 |
|
/// nonce + Poly1305 tag.
|
| 438 |
605 |
|
pub const ENCRYPTION_OVERHEAD_V2: usize = WIRE_V2_TAG_BYTES.len() + ENCRYPTION_OVERHEAD;
|
| 1277 |
1444 |
|
let pt = decrypt_bytes_aad(&legacy, &key, &AeadContext::blob("sha256-whatever")).unwrap();
|
| 1278 |
1445 |
|
assert_eq!(pt, b"old blob");
|
| 1279 |
1446 |
|
}
|
|
1447 |
+ |
|
|
1448 |
+ |
// ── chunked blob format (v3) ──
|
|
1449 |
+ |
|
|
1450 |
+ |
#[test]
|
|
1451 |
+ |
fn chunked_blob_roundtrips_across_chunk_boundaries() {
|
|
1452 |
+ |
let key = generate_master_key();
|
|
1453 |
+ |
// Spans three chunks (two full + a partial), exercising the boundary math.
|
|
1454 |
+ |
let plaintext: Vec<u8> = (0..(BLOB_CHUNK_SIZE * 2 + 123)).map(|i| i as u8).collect();
|
|
1455 |
+ |
let hash = "sha256-abc";
|
|
1456 |
+ |
let wire = encrypt_blob_chunked(&plaintext, &key, hash).unwrap();
|
|
1457 |
+ |
assert!(is_chunked_blob(&wire), "must carry the sk3: tag");
|
|
1458 |
+ |
let (header, _) = parse_blob_header(&wire).unwrap();
|
|
1459 |
+ |
assert_eq!(header.total_len, plaintext.len());
|
|
1460 |
+ |
assert_eq!(header.chunk_count, 3);
|
|
1461 |
+ |
assert_eq!(decrypt_blob_chunked(&wire, &key, hash).unwrap(), plaintext);
|
|
1462 |
+ |
}
|
|
1463 |
+ |
|
|
1464 |
+ |
#[test]
|
|
1465 |
+ |
fn chunked_blob_handles_empty_and_single_chunk() {
|
|
1466 |
+ |
let key = generate_master_key();
|
|
1467 |
+ |
for pt in [vec![], b"small blob".to_vec()] {
|
|
1468 |
+ |
let wire = encrypt_blob_chunked(&pt, &key, "h").unwrap();
|
|
1469 |
+ |
assert_eq!(parse_blob_header(&wire).unwrap().0.chunk_count, 1);
|
|
1470 |
+ |
assert_eq!(decrypt_blob_chunked(&wire, &key, "h").unwrap(), pt);
|
|
1471 |
+ |
}
|
|
1472 |
+ |
}
|
|
1473 |
+ |
|
|
1474 |
+ |
#[test]
|
|
1475 |
+ |
fn chunked_blob_wrong_hash_fails_closed() {
|
|
1476 |
+ |
let key = generate_master_key();
|
|
1477 |
+ |
let pt = b"bound to its address".to_vec();
|
|
1478 |
+ |
let wire = encrypt_blob_chunked(&pt, &key, "hash-a").unwrap();
|
|
1479 |
+ |
// A different content hash changes every chunk's AAD -> open fails.
|
|
1480 |
+ |
assert!(matches!(
|
|
1481 |
+ |
decrypt_blob_chunked(&wire, &key, "hash-b"),
|
|
1482 |
+ |
Err(SyncKitError::DecryptionFailed)
|
|
1483 |
+ |
));
|
|
1484 |
+ |
}
|
|
1485 |
+ |
|
|
1486 |
+ |
#[test]
|
|
1487 |
+ |
fn chunked_blob_tamper_and_truncation_fail_closed() {
|
|
1488 |
+ |
let key = generate_master_key();
|
|
1489 |
+ |
let pt: Vec<u8> = (0..(BLOB_CHUNK_SIZE + 50)).map(|i| i as u8).collect();
|
|
1490 |
+ |
let wire = encrypt_blob_chunked(&pt, &key, "h").unwrap();
|
|
1491 |
+ |
|
|
1492 |
+ |
// Flip a ciphertext byte -> AEAD tag mismatch.
|
|
1493 |
+ |
let mut tampered = wire.clone();
|
|
1494 |
+ |
let last = tampered.len() - 1;
|
|
1495 |
+ |
tampered[last] ^= 0x01;
|
|
1496 |
+ |
assert!(decrypt_blob_chunked(&tampered, &key, "h").is_err());
|
|
1497 |
+ |
|
|
1498 |
+ |
// Drop the final chunk's bytes -> truncation detected.
|
|
1499 |
+ |
let header = parse_blob_header(&wire).unwrap().0;
|
|
1500 |
+ |
let truncated = &wire[..wire.len() - header.sealed_chunk_len(header.chunk_count - 1)];
|
|
1501 |
+ |
assert!(decrypt_blob_chunked(truncated, &key, "h").is_err());
|
|
1502 |
+ |
}
|
|
1503 |
+ |
|
|
1504 |
+ |
#[test]
|
|
1505 |
+ |
fn parse_blob_header_rejects_non_v3() {
|
|
1506 |
+ |
let key = generate_master_key();
|
|
1507 |
+ |
let v2 = encrypt_bytes_aad(b"x", &key, &AeadContext::blob("h")).unwrap();
|
|
1508 |
+ |
assert!(parse_blob_header(&v2).is_err());
|
|
1509 |
+ |
assert!(!is_chunked_blob(&v2));
|
|
1510 |
+ |
}
|
| 1280 |
1511 |
|
}
|