fil: pad chunk bodies to a length ladder, document the residual #196

Owner

Pad fil chunk bodies to a length ladder and document the residual.

What changed

  • src/padding.rs: FIL_BUCKETS = [512, 1024, 2048, 4096, 8192, 16384, 32768, 49152],
    FIL_MAX_BODY = 49150, fil_bucket_for(), encode_fil(). The encoding is
    byte-identical to chat's (uint16 pad_len || data || random fill); encode/
    decode were factored through one shared helper, so the chat ladder and the
    chat bucket-boundary tests are unchanged.
  • src/file.rs: encode() pads the chunk body before the one-shot Noise
    session sees it; decode_checked() unpads and returns Err for a malformed
    or overrunning pad_len (decode() keeps its old Option shape on top).
    data_cap() is clamped to FIL_MAX_BODY and accounts for the 2-byte
    pad_len. overhead() is the new public "bytes before the padded body".
  • src/node.rs: only the two fil arms — the send arm collects encode()'s
    Result, the ingest arm calls decode_checked() and logs a dropped malformed
    chunk instead of silently returning.
  • docs/PROTOCOL.md: “Length buckets” gains a fil ladder subsection,
    “Framing” stops claiming fil is unpadded, and the fil section states the
    padded layout, the 49150-byte raw cap, and the residual.

Why this ladder

Chat stops at 32 KiB because a chat body is a line of human text. A fil
chunk is bounded by the frame budget instead, so reusing chat's ceiling would
halve the chunk size and nearly double the one-shot sessions a file costs
(33 chunks for 1 MiB, 3s pacing each). The rungs below 32 KiB are chat's,
starting at 512 rather than 256 because every fil frame already carries
~115 bytes of ASCII header. The top rung is a half-step at 49152: the next
doubling (65536) cannot fit one Noise body at all (65519 plaintext max, tag
included), and 48 KiB leaves the worst-case header (128-byte name, 5-digit
idx/total, 243 bytes) more than 16 KiB of slack. 1 MiB is now 22 one-shots
instead of 17.

Deviation from the card's wording (deliberate)

The card says PROTOCOL must state that pad_len is “authenticated by the
ratchet”. fil chunks are not ratcheted — one-shot sessions are a lock, and
this PR does not add a persistent session, a channel binding, or chunk
ratcheting. The spec therefore says the true thing: pad_len rides inside the
one-shot Noise session and is authenticated by that session's
ChaCha20-Poly1305 tag, exactly as chat's padding is authenticated by the
ratchet's AEAD. Tampering with the length fails the session, not a checksum.

Interop

This is a wire-format change inside fil with no version marker of its own
(the frame version is unchanged). A peer on the old tree sees the two
pad_len bytes as chunk data and fails the file's SHA-256, so a mixed-version
pair fails closed rather than shipping a corrupted file — but both ends must be
on this change for file transfer to work. Call it out in the release notes for
the cut that ships it.

Residual (the THREAT_MODEL row this PR cannot write)

docs/THREAT_MODEL.md:221 still reads “rcp , tip , fil … are not padded
and still reveal their own sizes.” That line is stale with this PR, but that
file is scoped to another card, so the row to add there is:

fil chunk bodies are padded to a length ladder (512–49152), so a chunk's
plaintext size is hidden to a bucket. The number of chunks still reveals
the file's total size in buckets (a 1 MiB file is 22 chunks), and each chunk
is still one Noise session, so timing, circuit patterns and retry cadence
remain visible. Padding adds no persistent session and no chunk-to-chunk
binding.

Verification

  • cargo test --locked --target x86_64-unknown-linux-gnu: 38 suites,
    573 passed, 0 failed.
  • cargo clippy --locked --all-targets --target x86_64-unknown-linux-gnu -- -D warnings: clean.
  • SDK (separate workspace): cargo test --locked and clippy -D warnings clean.
  • Measured frame lengths, same filename, one chunk each: 1-byte file →
    627 bytes; 510-byte (bucket-sized) file → 627 bytes. Header is 115,
    padded body 512. Identical, so the tail chunk no longer reveals the size.
  • New tests: every_fil_frame_lands_on_the_fil_ladder (8 file sizes, every
    chunk on a rung), a_one_byte_file_and_a_bucket_sized_file_send_identical_frame_lengths,
    an_oversized_pad_len_is_a_clean_protocol_error_not_a_truncation,
    a_malformed_fil_frame_is_an_error_while_chat_is_not_a_fil_frame,
    a_padded_multi_chunk_transfer_round_trips_with_an_equal_hash, plus padding
    ladder/encoding tests.

Base is the parent card's branch (its PR #193 is still open and also touches
docs/PROTOCOL.md); merge order is #193 first.

Pad `fil ` chunk bodies to a length ladder and document the residual. ## What changed - `src/padding.rs`: `FIL_BUCKETS = [512, 1024, 2048, 4096, 8192, 16384, 32768, 49152]`, `FIL_MAX_BODY = 49150`, `fil_bucket_for()`, `encode_fil()`. The encoding is byte-identical to chat's (`uint16 pad_len || data || random fill`); `encode`/ `decode` were factored through one shared helper, so the chat ladder and the chat bucket-boundary tests are unchanged. - `src/file.rs`: `encode()` pads the chunk body before the one-shot Noise session sees it; `decode_checked()` unpads and returns `Err` for a malformed or overrunning `pad_len` (`decode()` keeps its old `Option` shape on top). `data_cap()` is clamped to `FIL_MAX_BODY` and accounts for the 2-byte `pad_len`. `overhead()` is the new public "bytes before the padded body". - `src/node.rs`: only the two `fil ` arms — the send arm collects `encode()`'s `Result`, the ingest arm calls `decode_checked()` and logs a dropped malformed chunk instead of silently returning. - `docs/PROTOCOL.md`: “Length buckets” gains a `fil ` ladder subsection, “Framing” stops claiming `fil ` is unpadded, and the `fil ` section states the padded layout, the 49150-byte raw cap, and the residual. ## Why this ladder Chat stops at 32 KiB because a chat body is a line of human text. A `fil ` chunk is bounded by the frame budget instead, so reusing chat's ceiling would halve the chunk size and nearly double the one-shot sessions a file costs (33 chunks for 1 MiB, 3s pacing each). The rungs below 32 KiB are chat's, starting at 512 rather than 256 because every `fil ` frame already carries ~115 bytes of ASCII header. The top rung is a half-step at 49152: the next doubling (65536) cannot fit one Noise body at all (65519 plaintext max, tag included), and 48 KiB leaves the worst-case header (128-byte name, 5-digit `idx`/`total`, 243 bytes) more than 16 KiB of slack. 1 MiB is now 22 one-shots instead of 17. ## Deviation from the card's wording (deliberate) The card says PROTOCOL must state that `pad_len` is “authenticated by the ratchet”. `fil ` chunks are not ratcheted — one-shot sessions are a lock, and this PR does not add a persistent session, a channel binding, or chunk ratcheting. The spec therefore says the true thing: `pad_len` rides inside the one-shot Noise session and is authenticated by that session's ChaCha20-Poly1305 tag, exactly as chat's padding is authenticated by the ratchet's AEAD. Tampering with the length fails the session, not a checksum. ## Interop This is a wire-format change inside `fil ` with no version marker of its own (the frame version is unchanged). A peer on the old tree sees the two `pad_len` bytes as chunk data and fails the file's SHA-256, so a mixed-version pair fails closed rather than shipping a corrupted file — but both ends must be on this change for file transfer to work. Call it out in the release notes for the cut that ships it. ## Residual (the THREAT_MODEL row this PR cannot write) `docs/THREAT_MODEL.md:221` still reads “`rcp `, `tip `, `fil ` … are not padded and still reveal their own sizes.” That line is stale with this PR, but that file is scoped to another card, so the row to add there is: > `fil ` chunk bodies are padded to a length ladder (512–49152), so a chunk's > plaintext size is hidden to a bucket. The **number of chunks** still reveals > the file's total size in buckets (a 1 MiB file is 22 chunks), and each chunk > is still one Noise session, so timing, circuit patterns and retry cadence > remain visible. Padding adds no persistent session and no chunk-to-chunk > binding. ## Verification - `cargo test --locked --target x86_64-unknown-linux-gnu`: 38 suites, **573 passed, 0 failed**. - `cargo clippy --locked --all-targets --target x86_64-unknown-linux-gnu -- -D warnings`: clean. - SDK (separate workspace): `cargo test --locked` and clippy `-D warnings` clean. - Measured frame lengths, same filename, one chunk each: 1-byte file → **627 bytes**; 510-byte (bucket-sized) file → **627 bytes**. Header is 115, padded body 512. Identical, so the tail chunk no longer reveals the size. - New tests: `every_fil_frame_lands_on_the_fil_ladder` (8 file sizes, every chunk on a rung), `a_one_byte_file_and_a_bucket_sized_file_send_identical_frame_lengths`, `an_oversized_pad_len_is_a_clean_protocol_error_not_a_truncation`, `a_malformed_fil_frame_is_an_error_while_chat_is_not_a_fil_frame`, `a_padded_multi_chunk_transfer_round_trips_with_an_equal_hash`, plus padding ladder/encoding tests. Base is the parent card's branch (its PR #193 is still open and also touches `docs/PROTOCOL.md`); merge order is #193 first.
fil: pad chunk bodies to a length ladder, document the residual
All checks were successful
ci / test (pull_request) Successful in 21m34s
ci / sdk (pull_request) Successful in 2m17s
30f438c394
Each `fil ` chunk carried its raw bytes in a one-shot Noise session, so the
frame length was the exact chunk size: the tail chunk of every file told an
observer the file's size, and the chunk count told them the rest.

Pad the chunk body with the chat encoding (uint16 pad_len || data || random
fill) on a `fil ` ladder that continues past chat's 32 KiB ceiling —
512..49152 — because a chunk is bounded by the frame budget (~64 KiB of
plaintext), not by a line of human text. The top rung is a half-step: 65536
cannot fit one Noise body, and 48 KiB leaves the worst-case header (128-byte
name, 5-digit counters) more than 16 KiB of slack.

The raw chunk cap becomes the top rung's body (49150 bytes), so a 1 MiB file
is 22 one-shot sessions instead of 17. `pad_len` is authenticated by the
session's ChaCha20-Poly1305 tag; `decode_checked` treats an overrunning
`pad_len` as a protocol error and drops the chunk whole rather than writing a
truncated one. `total_chunks`/`data_cap` stay the reassembly authority and the
hash check is untouched.

PROTOCOL.md gains the ladder in “Length buckets” and the padded `fil ` frame
layout, and states the honest residual: padding hides per-chunk size only —
the chunk count still reveals total size in buckets, and timing, circuit
patterns and retry cadence remain visible. No persistent session, no channel
binding, no ratcheting of chunks.
sirius merged commit 37545410f9 into onionwire/t_438331aa-close-the-legacy-signature-surface-and-d 2026-10-29 02:09:41 +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!196
No description provided.