Skip to content

fix(direct): pad 1:1 application plaintexts inside MLS - #8

Merged
koabula merged 4 commits into
mainfrom
feat/plaintext-padding
Nov 12, 2026
Merged

koabula merged 4 commits into
mainfrom
feat/plaintext-padding

Conversation

@koabula

@koabula koabula commented Nov 12, 2026 •

Copy link
Copy Markdown
Owner

What

Every 1:1 application plaintext handed to MlsAdapter::encrypt_application is now 4096·k − C bytes 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 (in src/lane_wrap.rs, beside WRAP_BLOCK):

part bytes
MLSMessage version and wire format 4
group_id with a 2-byte prefix, up to MAX_DIRECT_GROUP_ID_LEN = 64 66
epoch, content type, empty authenticated data 10
encrypted sender data (prefix, 12, tag) 29
ciphertext prefix and application_data prefix, 4 bytes each at their largest 8
Ed25519 signature with a 2-byte prefix (1 byte covers 0–63 only) 66
content AEAD tag 16
MLS overhead bound 199
direct_frame tag 1
wrap overhead (nonce, tag, marker) 29
C 229

Block 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_plaintext and strip_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:
    • New encrypt_direct_application pads every kind on the send path.
    • send_text_message, build_protected_app_envelopes (lane notice, ContactAccepted and other kinds) and resend_unconfirmed_frames now call it.
    • Stored unconfirmed plaintexts stay unpadded and are padded again when resent.
    • evaluate_direct_application_plaintext strips the padding and rejects a plaintext without it.
  • Commits and Welcomes are out of scope and unchanged.

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, and inspect_welcome refuses any group id not of that shape. An older conv:{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 is 4096·k − C, and the strip refuses wrong lengths, a missing marker and all zeros.
  • lane_wrap through real MLS:
    • With group ids of 64 bytes and 32 bytes, no padded plaintext's frame needs k + 1 blocks. This covers k = 1..8, k = 64, and either side of the 64-byte and 16384-byte length-prefix boundaries. At a 64-byte group id with both 4-byte prefixes, the measured overhead must equal the bound exactly.
    • The smallest message is one block.
    • A round trip through pad, encrypt, wrap, unwrap, decrypt and strip returns the message.
    • A Text, a ContactAccepted and a LaneRotation of one block give frames of the same length.
  • Engine: the shared decrypted helper 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

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
koabula marked this pull request as draft November 12, 2026 08:19
koala and others added 3 commits November 12, 2026 16:22
…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>
@koabula
koabula marked this pull request as ready for review November 12, 2026 12:57
@koabula
koabula merged commit 6d203e9 into main Nov 12, 2026
7 checks passed
@koabula
koabula deleted the feat/plaintext-padding branch November 12, 2026 13:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant