From 075d83c50e0013165f83b2ca555f62b364d5a8e3 Mon Sep 17 00:00:00 2001 From: koala Date: Tue, 10 Nov 2026 16:08:48 +0800 Subject: [PATCH 1/2] feat(wrap): pad every 1:1 frame to whole 4096-byte blocks The host could tell a rotation, a rebuild Welcome and ContactAccepted from a message by length alone: a rotation's announcement carries the whole signed bundle, about five times an ordinary message. The lane wrap now pads the frame with 0x80 and zeros so that nonce, ciphertext and tag fill the fewest whole blocks of WRAP_BLOCK = 4096 bytes. Every record that fits one block has the same length; a longer message shows only its block count. A sender whose bundle lists two or more devices still sends a multi-block announcement. unwrap_frame refuses any length that is not a whole number of blocks before decrypting, and examines the padding only after the AEAD has verified, so there is no padding oracle. No client is deployed, so the labels stay at v1 and unpadded frames are refused. WRAP_BLOCK equals the inbox's default MAX_INLINE_BYTES, so a one-block record is still stored inline. only_a_payloads_size_reaches_the_host now asserts that every wrapped payload is a whole number of blocks, that every record the script wraps (the rotation's commit and announcement included) is one block, and that a one-block payload's length is constant across the matrix. The ledger notes follow. Not compiled or tested locally: this machine has no Rust toolchain. Co-Authored-By: Claude Opus 5.5 --- contracts/leakage-ledger.json | 6 +- src/direct_frame.rs | 5 + src/lane_wrap.rs | 240 +++++++++++++++++++++++++++++++++- src/leakage_corpus.rs | 109 ++++++++++++++- 4 files changed, 344 insertions(+), 16 deletions(-) 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..c30c72e 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,104 @@ 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 + // at `a_to_b` measured as carrying Alice's message. Now the length of + // every one-block payload is constant across the matrix. + 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_to_b", 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" ); } From a04175d1b5ec3874ebbd8c50fcdd415738418bc6 Mon Sep 17 00:00:00 2001 From: koala Date: Tue, 10 Nov 2026 16:27:09 +0800 Subject: [PATCH 2/2] test(corpus): measure the varied body where it is sent only_a_payloads_size_reaches_the_host looked for Alice's message at `a_to_b`, but that step appends nothing: Alice holds the message until Bob's ContactAccepted reaches her and sends it on inside `a_fetch`. The guard now checks `a_rotate`, which sends the same varied body with nothing held. Co-Authored-By: Claude Opus 5.5 --- src/leakage_corpus.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/leakage_corpus.rs b/src/leakage_corpus.rs index c30c72e..9fa27ce 100644 --- a/src/leakage_corpus.rs +++ b/src/leakage_corpus.rs @@ -1521,8 +1521,13 @@ fn only_a_payloads_size_reaches_the_host() { // 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 - // at `a_to_b` measured as carrying Alice's message. Now the length of - // every one-block payload is constant across the matrix. + // 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!( @@ -1530,7 +1535,7 @@ fn only_a_payloads_size_reaches_the_host() { "the matrix no longer varies a body's length, so a length leak could not show" ); assert!( - wrapped_at.contains(&("a_to_b", Surface::Ledger("append_request"))), + 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() {