chat: length buckets and plaintext padding #184

Merged
sirius merged 1 commit from wt/t_3c0798f2 into main 2026-10-20 16:58:42 +00:00
Owner

What

Chat plaintext is now padded to a fixed length ladder inside the ratchet
AEAD, so the framed uint32 length reveals a bucket instead of the exact
message size. The padding is authenticated by ChaCha20-Poly1305 and by the
Noise session around it.

Ladder (padded-plaintext bytes): 256, 512, 1024, 2048, 4096, 8192, 16384, 32768.

Plaintext encoding:

uint16 pad_len       big-endian
uint8  body[..]      the chat line
uint8  fill[pad_len] random bytes, contents ignored

The total is the smallest ladder value at least 2 + body_len, so
pad_len = bucket - 2 - body_len; empty and short bodies both collapse to 256,
and an exact fit has pad_len = 0. A body over 32766 bytes fails closed before
the outbox.

Receiver: only pad_len is authoritative. No trailing-zero trimming and no
content-based boundary inference, so a body ending in the same byte as the fill
round-trips. A plaintext shorter than 2 bytes or with pad_len overrunning
len - 2 fails closed before any row is appended or the ratchet state is saved.
Total length is not required to be a ladder value, for interop; unpadded frames
do not decode as an empty bucket, they fail the pad_len bound.

Real minimum observable frame

rat frame overhead is 73 bytes (prefix 4 + version 1 + header 40 + nonce 12 +
tag 16); with the 256-byte bucket that is a 329-byte ratchet frame. Noise adds
its 16-byte tag and framing adds 5 bytes, so the smallest chat frame on the
wire is 350 bytes
. Largest is 32862 bytes, under the 65535 cap.

Card premise correction: the ML-KEM-768 ciphertext does not ride in the
rat header. It is the one-shot handshake prelude (version = 2, 1095 bytes
on the wire, once per session), and ratchet keys in a chat frame are still
X25519 (32 bytes). So the hybrid transport does not widen this floor. Extending
the 40-byte header to a PQ ratchet key would be a separate wire change.

fil chunks are deliberately not padded: padding would either cut the
usable chunk size or spend the 65535 frame cap on fill. Their size still leaks.

What still leaks

Timing, retry cadence, the one-shot-per-message circuits and handshake timing
are unchanged; no cover traffic or jitter. Non-chat frames (loc , prf ,
inv , rcp , tip , fil ) are still exact-sized.

Tests

  • the_ladder_is_the_documented_one, buckets_follow_the_body_plus_the_two_byte_header (tests/padding.rs) — ladder and boundary table, inclusive of 254/255 and 32766/32767.
  • chat_frames_are_padded_to_the_normative_length_ladder (src/node.rs) — frame length is 329 for empty/1/254-byte bodies and 585 for 255 bytes.
  • a_one_byte_chat_round_trips_in_the_smallest_bucket, an_oversize_chat_send_fails_closed_before_the_outbox, a_hybrid_rat_frame_round_trips_inside_the_smallest_bucket (src/node.rs).
  • a_chat_body_ending_in_the_pad_fill_byte_round_trips (src/node.rs + tests/padding.rs) — body boundary comes from pad_len, never from fill contents.
  • a_pad_len_that_overruns_the_plaintext_writes_no_chat_row (src/node.rs) — a validly authenticated frame with an overrunning pad_len is rejected and writes nothing.
  • a_pad_len_that_overruns_the_plaintext_is_rejected, a_truncated_padding_header_is_rejected, a_receiver_accepts_a_padded_plaintext_of_any_total_length (tests/padding.rs).
  • a_snapshot_heals_only_once_the_ratchet_key_it_held_has_been_replaced (tests/ratchet_adversarial.rs) now unpads the raw RatchetState decrypt it inspects; the property it proves is unchanged.

Verification: cargo test --locked exit 0 (38 targets, all green), cargo clippy --locked --all-targets -- -D warnings clean.

Docs

docs/PROTOCOL.md new normative Length buckets section plus framing and ratchet cross-references; docs/THREAT_MODEL.md's no-padding bullet replaced with bucketed-length / still-observable-timing wording; README.md one sentence in Chat ratchet.

## What Chat plaintext is now padded to a fixed length ladder **inside** the ratchet AEAD, so the framed `uint32 length` reveals a bucket instead of the exact message size. The padding is authenticated by ChaCha20-Poly1305 and by the Noise session around it. Ladder (padded-plaintext bytes): 256, 512, 1024, 2048, 4096, 8192, 16384, 32768. Plaintext encoding: ``` uint16 pad_len big-endian uint8 body[..] the chat line uint8 fill[pad_len] random bytes, contents ignored ``` The total is the smallest ladder value at least `2 + body_len`, so `pad_len = bucket - 2 - body_len`; empty and short bodies both collapse to 256, and an exact fit has `pad_len = 0`. A body over 32766 bytes fails closed before the outbox. Receiver: only `pad_len` is authoritative. No trailing-zero trimming and no content-based boundary inference, so a body ending in the same byte as the fill round-trips. A plaintext shorter than 2 bytes or with `pad_len` overrunning `len - 2` fails closed before any row is appended or the ratchet state is saved. Total length is not required to be a ladder value, for interop; unpadded frames do not decode as an empty bucket, they fail the `pad_len` bound. ## Real minimum observable frame `rat ` frame overhead is 73 bytes (prefix 4 + version 1 + header 40 + nonce 12 + tag 16); with the 256-byte bucket that is a 329-byte ratchet frame. Noise adds its 16-byte tag and framing adds 5 bytes, so the **smallest chat frame on the wire is 350 bytes**. Largest is 32862 bytes, under the 65535 cap. Card premise correction: the ML-KEM-768 ciphertext does **not** ride in the `rat ` header. It is the one-shot handshake prelude (`version = 2`, 1095 bytes on the wire, once per session), and ratchet keys in a chat frame are still X25519 (32 bytes). So the hybrid transport does not widen this floor. Extending the 40-byte header to a PQ ratchet key would be a separate wire change. `fil ` chunks are deliberately **not** padded: padding would either cut the usable chunk size or spend the 65535 frame cap on fill. Their size still leaks. ## What still leaks Timing, retry cadence, the one-shot-per-message circuits and handshake timing are unchanged; no cover traffic or jitter. Non-chat frames (`loc `, `prf `, `inv `, `rcp `, `tip `, `fil `) are still exact-sized. ## Tests - `the_ladder_is_the_documented_one`, `buckets_follow_the_body_plus_the_two_byte_header` (tests/padding.rs) — ladder and boundary table, inclusive of 254/255 and 32766/32767. - `chat_frames_are_padded_to_the_normative_length_ladder` (src/node.rs) — frame length is 329 for empty/1/254-byte bodies and 585 for 255 bytes. - `a_one_byte_chat_round_trips_in_the_smallest_bucket`, `an_oversize_chat_send_fails_closed_before_the_outbox`, `a_hybrid_rat_frame_round_trips_inside_the_smallest_bucket` (src/node.rs). - `a_chat_body_ending_in_the_pad_fill_byte_round_trips` (src/node.rs + tests/padding.rs) — body boundary comes from `pad_len`, never from fill contents. - `a_pad_len_that_overruns_the_plaintext_writes_no_chat_row` (src/node.rs) — a validly authenticated frame with an overrunning `pad_len` is rejected and writes nothing. - `a_pad_len_that_overruns_the_plaintext_is_rejected`, `a_truncated_padding_header_is_rejected`, `a_receiver_accepts_a_padded_plaintext_of_any_total_length` (tests/padding.rs). - `a_snapshot_heals_only_once_the_ratchet_key_it_held_has_been_replaced` (tests/ratchet_adversarial.rs) now unpads the raw `RatchetState` decrypt it inspects; the property it proves is unchanged. Verification: `cargo test --locked` exit 0 (38 targets, all green), `cargo clippy --locked --all-targets -- -D warnings` clean. ## Docs `docs/PROTOCOL.md` new normative **Length buckets** section plus framing and ratchet cross-references; `docs/THREAT_MODEL.md`'s no-padding bullet replaced with bucketed-length / still-observable-timing wording; `README.md` one sentence in Chat ratchet.
chat: pad plaintext to a normative length ladder
All checks were successful
ci / test (pull_request) Successful in 15m47s
ci / sdk (pull_request) Successful in 1m31s
7fa3edcdb4
Chat plaintext is padded inside the ratchet AEAD to one of
256/512/1024/2048/4096/8192/16384/32768 bytes, with an explicit big-endian
uint16 pad_len followed by the body and random fill. An observer sees only
the bucket; the padding is authenticated by ChaCha20-Poly1305 and by the
Noise session. The receiver trusts pad_len only, rejects an overrun before
appending or persisting anything, and never infers a boundary from trailing
bytes. fil chunks are unchanged.

docs/PROTOCOL.md gains a normative Length buckets section, THREAT_MODEL
replaces the no-padding limit, README notes the bucketed chat length.
sirius merged commit fec68e31ff into main 2026-10-20 16:58:42 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
sirius/onionwire!184
No description provided.