Repository navigation
fix(direct): pad 1:1 application plaintexts inside MLS - #8
Merged
Merged
Conversation
The wrap pads each frame to whole 4096-byte blocks, but that padding sits outside MLS: whoever holds a wrap key sees each MLS message's exact length, and so the exact message length. Pad the application plaintext before MLS encrypts it, to 4096·k − C bytes, so the wrapped frame already fills k blocks and its length shows only k. C = APPLICATION_RESERVE = 228: the largest MLS application overhead under the one ciphersuite (198, with a group id of up to 64 bytes and every length prefix at its largest), the frame tag (1) and the wrap overhead (29). - lane_wrap: APPLICATION_RESERVE, pad_application_plaintext and strip_application_padding (marker 0x80 then zeros, as the wrap does). - direct: encrypt_direct_application pads every 1:1 application kind; send_text_message, build_protected_app_envelopes and resend_unconfirmed_frames go through it. Stored unconfirmed plaintexts stay unpadded. evaluate_direct_application_plaintext strips the padding and refuses any plaintext without it. - Commits and Welcomes are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
koabula
marked this pull request as draft
November 12, 2026 08:19
…erve A 1-byte MLS length prefix covers lengths 0 to 63 only, so the 64-byte Ed25519 signature takes a 2-byte prefix. The MLS overhead bound becomes 199 and APPLICATION_RESERVE (C) 229; one block now holds an application plaintext of up to 3866 bytes. The real-MLS test now also asserts that, at a group id of MAX_DIRECT_GROUP_ID_LEN bytes and with both 4-byte prefixes, the measured overhead equals the bound exactly, so it checks the bound rather than the slack a 32-byte group id leaves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
direct_pcs_persisted_live_cannot_rebuild_consumed_next_epoch_keys read the plaintext MLS returned as UTF-8; it now ends in the 0x80 marker and zeros the send path adds, so strip that first, as the receive path does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Every 1:1 application plaintext handed to
MlsAdapter::encrypt_applicationis now4096·k − Cbytes long, for a whole number k ≥ 1 and one fixed C. The plaintext length therefore shows the block count and nothing else, even to someone holding a wrap key.Before this change, the padding sat only in the lane wrap, outside MLS. A copied device's wrap keys open the records still at the host, and the wrap's end marker then shows each MLS message's exact length.
The constant
C = APPLICATION_RESERVE = 229(insrc/lane_wrap.rs, besideWRAP_BLOCK):MAX_DIRECT_GROUP_ID_LEN= 64direct_frametagBlock count
For a protected payload of n bytes (before padding), the frame takes k = ⌈(n + 230) / 4096⌉ blocks: n, plus the padding marker, plus C. So one block holds n ≤ 3866. The smallest message is still one block.
Changes
lane_wrap:APPLICATION_RESERVE,MAX_DIRECT_GROUP_ID_LEN,MAX_ONE_BLOCK_APPLICATION,pad_application_plaintextandstrip_application_padding. The padding is a 0x80 marker then zeros, as the wrap uses. The wrap's own padding is unchanged; it now adds only the few bytes the real MLS overhead falls short of the bound.ffi_api/engine/direct.rs:encrypt_direct_applicationpads every kind on the send path.send_text_message,build_protected_app_envelopes(lane notice, ContactAccepted and other kinds) andresend_unconfirmed_framesnow call it.evaluate_direct_application_plaintextstrips the padding and rejects a plaintext without it.Legacy conversation ids
A 1:1 conversation sends only once it has lanes. It gets them only when created under a
random_opaque_id(32 bytes) or by joining a Welcome, andinspect_welcomerefuses any group id not of that shape. An olderconv:{user}:{user}conversation (80 bytes) has no lanes, so building its envelope fails and none of its frames is sent. The 64-byte bound therefore covers every frame that reaches the host.Tests
lane_wrap: every padded length is4096·k − C, and the strip refuses wrong lengths, a missing marker and all zeros.lane_wrapthrough real MLS:decryptedhelper now pads the way the send path does, and a new test checks that an unpadded protected plaintext is rejected.This was not built or tested locally (this machine has no Rust toolchain), so CI is the first build and test run.
🤖 Generated with Claude Code