diff --git a/contracts/leakage-ledger.json b/contracts/leakage-ledger.json index 2d28668..3dbcd48 100644 --- a/contracts/leakage-ledger.json +++ b/contracts/leakage-ledger.json @@ -98,7 +98,7 @@ { "channel": "ciphertext_length", "checked": true, - "note": "Measured, not conceded: the corpus derives a `#len` leaf for every string a host sees, and a payload's length reports as carrying the message it wraps. The assertion is the ideal's own statement about `|m|` - content may reach the host as a length and in no other shape. Rotation is now the loudest thing on a lane. A rotation carries the rotating party's whole signed bundle rather than a reference to it — roughly four to six times an ordinary message, separable by size alone — and rotation is triggered by how many messages a party has itself sent, so the two directions of one conversation rotate on correlated schedules. Both inboxes therefore show a size-distinctive, time-correlated periodic event. This stays inside what tab:leak already records (volume, timing and record kinds), and it bought the counterparty callback and user-determined hosting (def:userdet); it is recorded because a repair that moves a conceded parameter should be written down, and it is the first quantity the partition test should measure rather than estimate. A direct commit also carries a fresh KeyPackage in its MLS authenticated data, inside the wrap, which adds a few hundred bytes to the commit record and no field; with it, a rotation and a race re-entry (a Welcome under the losing commit's key) are each a size class of their own, as tab:leak states.", + "note": "Measured, not conceded: the corpus derives a `#len` leaf for every string a host sees. The lane wrap pads every wrapped 1:1 frame so that nonce, ciphertext and tag fill a whole number of 4096-byte blocks (`lane_wrap::WRAP_BLOCK`), so a length reaches the host only as a block count. Records that fit one block (short messages, rotation commits, rebuild Welcomes, ContactAccepted) have the same length and are not separable by size; a longer message reveals how many blocks it takes. The corpus measures every record its script wraps, a rotation's commit and announcement among them, as exactly one block, and the length of each as constant across a matrix that varies the message's length as well as its content. Content may still reach the host as a length and in no other shape, which is the ideal's own statement about `|m|`. A rotation's announcement carries the sender's whole signed bundle, which grows with the sender's device count: one device fits a block, and a bundle that does not makes the announcement a multi-block record. Rotation still sends a commit and an announcement beside the message that triggered it, so it remains visible in record count and timing, which tab:leak records as volume. Attachment sizes are unchanged: the blob's size reaches the host as `sizeBytes` and `storageRef.size`. The first-contact Welcome travels unwrapped and is not padded.", "measuredBy": "only_a_payloads_size_reaches_the_host" }, { @@ -108,7 +108,7 @@ { "channel": "commit_frequency", "checked": false, - "note": "Doubled by the R1 rotation change; recorded there as a traffic-pattern cost. Also directly readable today from PrivateMessage.content_type and epoch; after the R3 wrap only size class remains." + "note": "Doubled by the R1 rotation change; recorded there as a traffic-pattern cost. Once readable from PrivateMessage.content_type and epoch; the R3 wrap hid those, and the block padding of the wrap hides the size class, so what remains is record count and timing." }, { "channel": "mls_frame_header_epoch_and_content_type", @@ -286,7 +286,7 @@ "carries": [], "parameter": "L_ibx", "fate": "routing", - "note": "Wrapped MLS frame (nonce || ChaCha20-Poly1305). Check X must stay empty, and since the fixture frame now carries the shared conversation sentinel in its PrivateMessage group_id, an empty result is evidence that the wrap hides the frame header rather than an artefact of the id being random. Unwrapping it would report conversation." + "note": "Wrapped MLS frame (nonce || ChaCha20-Poly1305 over the frame padded to a whole number of 4096-byte blocks). Check X must stay empty, and since the fixture frame now carries the shared conversation sentinel in its PrivateMessage group_id, an empty result is evidence that the wrap hides the frame header rather than an artefact of the id being random. Unwrapping it would report conversation." }, { "surface": "append_request", diff --git a/src/direct_frame.rs b/src/direct_frame.rs index 03803bd..41cd0df 100644 --- a/src/direct_frame.rs +++ b/src/direct_frame.rs @@ -7,6 +7,11 @@ //! 0x01 || signature(64) || MLS bytes a commit, or a race loser's rebuild Welcome //! ``` //! +//! The MLS bytes run to the end of the frame, with no length prefix. The wrap +//! pads the frame to a whole number of blocks and strips that padding before +//! the frame reaches [`decode`] (see [`crate::lane_wrap`]), so no padding byte +//! is ever read as part of the MLS message. +//! //! This replaces a JSON object carrying the same two things. That object held //! the MLS message as base64, so every frame paid a third of its own size to //! make room for a field only a commit ever uses; the message now goes in as diff --git a/src/lane_wrap.rs b/src/lane_wrap.rs index 121412d..6a44bdc 100644 --- a/src/lane_wrap.rs +++ b/src/lane_wrap.rs @@ -1,10 +1,27 @@ //! Epoch-and-direction wrap around MLS frames so the host never sees -//! RFC 9420 PrivateMessage headers. +//! RFC 9420 PrivateMessage headers, and cannot tell 1:1 record kinds apart by +//! their length. //! //! `K(e, dir) = Exporter_e("tapchat.lane-wrap.v1", dir)` for every frame but //! a commit, `K_c(e, dir) = Exporter_e("tapchat.commit-wrap.v1", dir)` for a //! commit. The adapter exports only the current epoch; the previous epoch's //! keys are kept on the conversation sidecar to match `max_past_epochs(1)`. +//! +//! On the wire: +//! +//! ```text +//! nonce(12) || ChaCha20-Poly1305(K, frame || 0x80 || 0x00*) +//! ``` +//! +//! The zeros run until the whole output, nonce and 16-byte tag included, is +//! the smallest positive multiple of [`WRAP_BLOCK`]. A frame of up to +//! [`MAX_ONE_BLOCK_FRAME`] bytes therefore travels as exactly one block, +//! whatever kind of record it is, so a short message, a rotation commit, a +//! rebuild Welcome and `ContactAccepted` cannot be told apart by length. A +//! longer frame shows the host its block count and nothing finer. The `0x80` +//! marks where the frame ends, because a `direct_frame` runs to the end of its +//! plaintext and may itself end in zero bytes. A wrapped payload of any other +//! length is refused unopened. use chacha20poly1305::aead::{Aead, KeyInit}; use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; @@ -20,30 +37,75 @@ pub const COMMIT_WRAP_LABEL: &str = "tapchat.commit-wrap.v1"; pub const WRAP_DIR_C1: u8 = 0x00; pub const WRAP_DIR_C2: u8 = 0x01; pub const WRAP_KEY_LEN: usize = 32; +/// Every wrapped frame is a whole number of these bytes, nonce and tag +/// included. +/// +/// Must not exceed the inbox's `MAX_INLINE_BYTES` (4096 by default, see +/// `services/cloudflare/src/inbox/durable.ts`): the inbox keeps a record +/// inline only up to that many decoded bytes, so a larger block would spill +/// every record to R2. +pub const WRAP_BLOCK: usize = 4096; const NONCE_LEN: usize = 12; +const TAG_LEN: usize = 16; +/// Ends the frame inside the padded plaintext; only zeros may follow it. +const PAD_MARKER: u8 = 0x80; +/// The bytes a wrap adds to a frame before padding: nonce, tag and marker. +const WRAP_OVERHEAD: usize = NONCE_LEN + TAG_LEN + 1; +/// The longest frame that still travels as a single block. +pub const MAX_ONE_BLOCK_FRAME: usize = WRAP_BLOCK - WRAP_OVERHEAD; + +const _: () = assert!(WRAP_BLOCK > WRAP_OVERHEAD); pub fn wrap_frame(key: &[u8; WRAP_KEY_LEN], frame: &[u8]) -> CoreResult> { + let too_long = || CoreError::invalid_input("lane wrap frame is too long to pad"); + let blocks = frame + .len() + .checked_add(WRAP_OVERHEAD) + .and_then(|unpadded| unpadded.checked_add(WRAP_BLOCK - 1)) + .map(|rounded_up| rounded_up / WRAP_BLOCK) + .ok_or_else(too_long)?; + let total = blocks.checked_mul(WRAP_BLOCK).ok_or_else(too_long)?; + // `total >= WRAP_BLOCK > NONCE_LEN + TAG_LEN`, so this cannot underflow. + let padded_len = total - NONCE_LEN - TAG_LEN; + let mut plaintext = Vec::with_capacity(padded_len); + plaintext.extend_from_slice(frame); + plaintext.push(PAD_MARKER); + plaintext.resize(padded_len, 0); + let cipher = ChaCha20Poly1305::new(Key::from_slice(key)); let mut nonce_bytes = [0u8; NONCE_LEN]; rand::rngs::OsRng.fill_bytes(&mut nonce_bytes); let ciphertext = cipher - .encrypt(Nonce::from_slice(&nonce_bytes), frame) + .encrypt(Nonce::from_slice(&nonce_bytes), plaintext.as_slice()) .map_err(|_| CoreError::invalid_state("lane wrap encrypt failed"))?; - let mut out = Vec::with_capacity(NONCE_LEN + ciphertext.len()); + let mut out = Vec::with_capacity(total); out.extend_from_slice(&nonce_bytes); out.extend_from_slice(&ciphertext); + debug_assert_eq!(out.len(), total); Ok(out) } pub fn unwrap_frame(key: &[u8; WRAP_KEY_LEN], wrapped: &[u8]) -> Option> { - if wrapped.len() <= NONCE_LEN { + // Every wrap is a whole number of blocks, so any other length was not made + // by `wrap_frame` and is refused without decrypting. + if wrapped.len() < WRAP_BLOCK || wrapped.len() % WRAP_BLOCK != 0 { return None; } let (nonce_bytes, ciphertext) = wrapped.split_at(NONCE_LEN); let cipher = ChaCha20Poly1305::new(Key::from_slice(key)); - cipher + // Authenticate first and only then look at the padding. The padding of a + // plaintext that failed authentication is never examined, so whether and + // how the padding check fails tells a forger nothing: there is no padding + // oracle. Keep this order. + let mut plaintext = cipher .decrypt(Nonce::from_slice(nonce_bytes), ciphertext) - .ok() + .ok()?; + let marker = plaintext.iter().rposition(|byte| *byte != 0)?; + if plaintext[marker] != PAD_MARKER { + return None; + } + plaintext.truncate(marker); + Some(plaintext) } pub fn unwrap_with_cached_keys( @@ -59,3 +121,169 @@ pub fn key_from_exporter(bytes: &[u8]) -> CoreResult<[u8; WRAP_KEY_LEN]> { .try_into() .map_err(|_| CoreError::invalid_state("lane wrap exporter returned the wrong length")) } + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: [u8; WRAP_KEY_LEN] = [0x42; WRAP_KEY_LEN]; + const B: usize = WRAP_BLOCK; + + /// A frame that ends in the two byte values the unpadding looks for, the + /// marker and then zeros, so that unpadding which ate into the frame + /// would fail the round trip. + fn frame(len: usize) -> Vec { + let mut frame: Vec = (0..len).map(|index| (index % 251) as u8 + 1).collect(); + let tail = [PAD_MARKER, 0x00, 0x00]; + let from = len.saturating_sub(tail.len()); + frame[from..].copy_from_slice(&tail[..len - from]); + frame + } + + /// `nonce || ChaCha20-Poly1305(KEY, plaintext)` with no padding applied: + /// how a forger holding the key, or an older wrap, would build a payload. + fn seal(plaintext: &[u8]) -> Vec { + let nonce = [7_u8; NONCE_LEN]; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&KEY)); + let sealed = cipher + .encrypt(Nonce::from_slice(&nonce), plaintext) + .expect("seal"); + [nonce.as_slice(), sealed.as_slice()].concat() + } + + /// The plaintext length that makes a sealed payload exactly `blocks` + /// blocks long. + fn sealed_plaintext_len(blocks: usize) -> usize { + blocks * B - NONCE_LEN - TAG_LEN + } + + #[test] + fn a_frame_survives_the_wrap_at_every_block_boundary() { + for len in [0, 1, B - 29, B - 28, 2 * B - 29, 2 * B - 28, 100_000] { + let frame = frame(len); + let wrapped = wrap_frame(&KEY, &frame).expect("wrap"); + assert_eq!( + unwrap_frame(&KEY, &wrapped).as_deref(), + Some(frame.as_slice()), + "a frame of {len} bytes" + ); + } + } + + #[test] + fn the_wrap_is_the_fewest_whole_blocks_that_hold_the_frame() { + assert_eq!(MAX_ONE_BLOCK_FRAME, B - 29); + for len in [0, 1, 2, 64, 1000, B - 30, B - 29] { + assert_eq!( + wrap_frame(&KEY, &frame(len)).expect("wrap").len(), + B, + "a frame of {len} bytes fits one block" + ); + } + for (len, blocks) in [(B - 28, 2), (2 * B - 29, 2), (100_000, 25)] { + assert_eq!( + wrap_frame(&KEY, &frame(len)).expect("wrap").len(), + blocks * B, + "a frame of {len} bytes" + ); + } + for len in (0..3 * B).step_by(97) { + let wrapped = wrap_frame(&KEY, &frame(len)).expect("wrap"); + assert_eq!(wrapped.len() % B, 0, "a frame of {len} bytes"); + assert!( + wrapped.len() >= len + 29 && wrapped.len() - B < len + 29, + "a frame of {len} bytes took {} bytes", + wrapped.len() + ); + } + } + + #[test] + fn a_length_that_is_not_a_whole_number_of_blocks_is_refused() { + let wrapped = wrap_frame(&KEY, b"frame").expect("wrap"); + let mut longer = wrapped.clone(); + longer.push(0); + assert_eq!(unwrap_frame(&KEY, &longer), None, "one byte over a block"); + + let two_blocks = wrap_frame(&KEY, &frame(B)).expect("wrap"); + assert_eq!(two_blocks.len(), 2 * B); + assert_eq!( + unwrap_frame(&KEY, &two_blocks[..2 * B - 1]), + None, + "one byte short of two blocks" + ); + // Authentic under the key and correctly padded, but a length between + // blocks: refused on its length alone. + let mut plaintext = b"frame".to_vec(); + plaintext.push(PAD_MARKER); + plaintext.resize(B + 6, 0); + let between = seal(&plaintext); + assert_eq!(between.len(), B + 34); + assert_eq!(unwrap_frame(&KEY, &between), None); + } + + #[test] + fn a_length_shorter_than_one_block_is_refused() { + let wrapped = wrap_frame(&KEY, b"frame").expect("wrap"); + for len in [0, 1, NONCE_LEN, NONCE_LEN + TAG_LEN, 29, B / 2, B - 1] { + assert_eq!(unwrap_frame(&KEY, &wrapped[..len]), None, "{len} bytes"); + } + // An unpadded wrap, the layout before padding, opens under the key and + // is still refused. + let unpadded = seal(b"frame"); + assert!(unpadded.len() < B); + assert_eq!(unwrap_frame(&KEY, &unpadded), None); + } + + #[test] + fn a_hand_padded_plaintext_opens_so_the_refusals_below_are_not_vacuous() { + let mut plaintext = b"frame".to_vec(); + plaintext.push(PAD_MARKER); + plaintext.resize(sealed_plaintext_len(1), 0); + assert_eq!( + unwrap_frame(&KEY, &seal(&plaintext)).as_deref(), + Some(b"frame".as_slice()) + ); + } + + #[test] + fn an_authentic_plaintext_without_the_marker_is_refused() { + // Ends in a byte that is neither zero nor the marker. + let mut no_marker = vec![0x01_u8; sealed_plaintext_len(1)]; + assert_eq!(unwrap_frame(&KEY, &seal(&no_marker)), None, "no padding"); + // Zero padding after something other than the marker. + no_marker[100] = 0x7f; + no_marker[101..].fill(0); + assert_eq!(unwrap_frame(&KEY, &seal(&no_marker)), None, "not 0x80"); + // Two blocks, so the check is not confined to the first. + let two = vec![0x01_u8; sealed_plaintext_len(2)]; + assert_eq!(unwrap_frame(&KEY, &seal(&two)), None, "two blocks"); + } + + #[test] + fn an_authentic_all_zero_plaintext_is_refused() { + for blocks in [1, 2] { + let zeros = vec![0_u8; sealed_plaintext_len(blocks)]; + assert_eq!(unwrap_frame(&KEY, &seal(&zeros)), None, "{blocks} blocks"); + } + } + + #[test] + fn a_tampered_or_misaddressed_wrap_is_refused() { + let wrapped = wrap_frame(&KEY, b"frame").expect("wrap"); + assert!(unwrap_frame(&KEY, &wrapped).is_some()); + // Nonce, the frame, the padding, the tag. + for at in [0, NONCE_LEN, NONCE_LEN + 5, B / 2, B - 1] { + let mut tampered = wrapped.clone(); + tampered[at] ^= 0x01; + assert_eq!(unwrap_frame(&KEY, &tampered), None, "bit flipped at {at}"); + } + let other = [0x43; WRAP_KEY_LEN]; + assert_eq!(unwrap_frame(&other, &wrapped), None, "wrong key"); + assert_eq!( + unwrap_with_cached_keys(&other, Some(&KEY), &wrapped).as_deref(), + Some(b"frame".as_slice()), + "the previous epoch's key still opens it" + ); + } +} diff --git a/src/leakage_corpus.rs b/src/leakage_corpus.rs index c51a4b1..9fa27ce 100644 --- a/src/leakage_corpus.rs +++ b/src/leakage_corpus.rs @@ -250,8 +250,8 @@ fn derive( if !is_string || depth == 0 || text.is_empty() { return; } - // Length is what makes |m| and the size-distinctive rotation measurable - // rather than conceded. + // Length is what makes |m|, and whether a rotation can be told from a + // message by its size, measurable rather than conceded. out.insert(format!("{path}#len"), text.len().to_string()); // A percent-encoded `:` hides a device id's segments as surely as base64 @@ -1434,8 +1434,16 @@ fn host_view_is_a_function_of_l() { /// The ideal leaks `|m|` and nothing else about `m`. So a payload's content /// may reach the host as a length and in no other shape. +/// +/// The wrap narrows the length itself: every wrapped payload is a whole number +/// of [`crate::lane_wrap::WRAP_BLOCK`]s, so a payload that fits one block has +/// the length of every other one-block payload. What a host learns of `|m|` is +/// a block count, and of a short message, a rotation or `ContactAccepted`, +/// nothing that tells them apart. #[test] fn only_a_payloads_size_reaches_the_host() { + use crate::lane_wrap::WRAP_BLOCK; + let corpus = corpus(); let mut escapes = Vec::new(); for (step, surface, path) in corpus.observed_leaves() { @@ -1456,17 +1464,109 @@ fn only_a_payloads_size_reaches_the_host() { "a message reached the host as something other than its length:\n{}", escapes.join("\n") ); - // And the size channel must actually be observed, or the assertion above - // is about an empty set. + + // The decoded length of a wrapped payload. The first-contact Welcome is + // the one frame that travels unwrapped, and it is told apart the way a + // host would tell it: it parses as an MLS message. + let wrapped_len = |record: &Record| -> Option { + let bytes = record.view.pointer("/envelope/bytes")?.as_str()?; + if record.leaves.contains_key("envelope.bytes#mls.wire_format") { + return None; + } + let decoded = base64::engine::general_purpose::STANDARD + .decode(bytes) + .expect("an envelope payload is base64"); + Some(decoded.len()) + }; + + // Every wrapped payload is a whole number of blocks, and every record this + // script wraps fits one: a message each way, `ContactAccepted`, an + // attachment's manifest, and a rotation's commit and announcement are all + // the same length to the host. + let mut lengths: BTreeMap = BTreeMap::new(); + let mut wrapped_at: BTreeSet<(&'static str, Surface)> = BTreeSet::new(); + let mut unwrapped_at: BTreeSet<(&'static str, Surface)> = BTreeSet::new(); + for record in corpus.runs.iter().flatten() { + if record.view.pointer("/envelope/bytes").is_none() { + continue; + } + let Some(len) = wrapped_len(record) else { + unwrapped_at.insert((record.step, record.surface)); + continue; + }; + assert!( + len >= WRAP_BLOCK && len % WRAP_BLOCK == 0, + "{}: a wrapped payload of {len} bytes is not a whole number of blocks", + record.step + ); + *lengths.entry(len).or_default() += 1; + wrapped_at.insert((record.step, record.surface)); + } + assert_eq!( + lengths.keys().copied().collect::>(), + vec![WRAP_BLOCK], + "every record the script wraps must fit one block; seen (length, count): {lengths:?}" + ); + for (index, records) in corpus.runs.iter().enumerate() { + let at_rotation = records + .iter() + .filter(|record| record.step == "a_rotate" && wrapped_len(record).is_some()) + .count(); + assert!( + at_rotation >= 3, + "run {index}: the rotation step wrapped {at_rotation} record(s), so its \ + commit, its announcement and the message are not all measured" + ); + } + + // The partition view of the same fact. The matrix varies a body's length + // as well as its content, and before the padding the length of the payload + // carrying Alice's message measured as carrying it. Now the length of every + // one-block payload is constant across the matrix. + // + // `a_to_b` appends nothing: Alice holds that message until Bob's + // `ContactAccepted` reaches her, and sends it on inside `a_fetch`. So the + // varied body is measured at `a_rotate`, which sends it again with nothing + // held. + let (inputs, _) = matrix(); + let body_lengths: BTreeSet = inputs.iter().map(|run| run.body_a_to_b.len()).collect(); + assert!( + body_lengths.len() > 1, + "the matrix no longer varies a body's length, so a length leak could not show" + ); + assert!( + wrapped_at.contains(&("a_rotate", Surface::Ledger("append_request"))), + "the message whose body varies was not measured" + ); + for (step, surface, path) in corpus.observed_leaves() { + if path != "envelope.bytes#len" + || !wrapped_at.contains(&(step, surface)) + || unwrapped_at.contains(&(step, surface)) + { + continue; + } + let class = corpus.classify(step, surface, &path); + assert!( + matches!(class, Class::Constant(_)), + "{step}/{surface:?}/{path}: a one-block payload's length measured as {class:?}" + ); + } + + // The size channel the padding leaves open is still measured: an + // attachment's length reaches the host as its size. Without this the + // assertions above could pass on a classifier that sees no size at all. assert!( corpus .observed_leaves() .iter() .any(|(step, surface, path)| { - path.ends_with("#len") - && matches!(corpus.classify(step, *surface, path), Class::Carries(_)) + (path.ends_with(".size") || path.ends_with("izeBytes")) + && matches!( + corpus.classify(step, *surface, path), + Class::Carries(secrets) if secrets.contains(&Secret::AttachmentLen) + ) }), - "no payload length was measured at all" + "no attachment size was measured at all" ); }