Peers advertise what they can parse in the key package payload, the body of a
__MLS_KEY_PKG__ control frame.
| Field | Layer | Gates | Absent means |
|---|---|---|---|
wire_versions |
Hop-local | Which frame encodings we may emit to this peer | JSON only |
env_versions |
End to end | Which __MLS_ENC__ payload forms we may emit |
Legacy JSON envelope only |
rich_versions |
End to end | Whether we may seal a __RICH_V1__ body, and the v2 media envelope |
Plain text only, extras dropped |
data_versions |
End to end | Whether we may send __DATA_V1__ document sync frames, and which document encoding they carry. Entry 1 is 1:1 replication; entry 2 additionally means the peer intercepts these frames inside a group ciphertext; entry 3 additionally means the peer speaks the blob-fetch frames and routes a data-purposed media transfer into its document layer |
No replication with that peer |
ctrl_versions |
End to end | Which control-frame signing payload we build for this peer. Entry 2 means the peer verifies offline-ctrl-v2, which binds the frame's timestamp |
Build offline-ctrl-v1, which states no freshness |
nostr_pubkey |
End to end | Which key metadata is sealed to on the Nostr path | Seal to the publicly computable key |
The key package payload also carries user_id, the MLS key package itself, a
relative remaining lifetime in milliseconds, and a session_reset flag.
The lifetime is relative rather than absolute so the receiver applies it to their own clock and clock skew does not expire a valid package.
Three rules apply to every capability in this protocol. They are what make mixed-fleet deployments safe.
A receiver accepts every historical form of everything, regardless of what it advertised. Capability governs only what a sender emits.
An implementation that gates parsing on its own advertisement drops messages from peers that legitimately believed it capable, for instance after a partial upgrade, or after a capability was restored from persistence on one side and not the other.
An absent capability list decodes to empty, and empty selects the permanent floor. A legacy peer that has never heard of a field is served the floor form automatically.
ctrl_versions departs from this on a leaf node only, and only after first
contact, which is stated where that profile is described. A leaf accepts the
floor form on __MLS_KEY_PKG__, because that is the frame which carries
ctrl_versions and therefore the one a peer must be able to send before it
knows anything; it refuses the floor form on every other control frame. On an
install running the engine the rule holds unchanged: an absent ctrl_versions
selects offline-ctrl-v1, which every engine verifies.
Where a capability gates a security-relevant payload, the non-capable path drops the payload rather than sending it in a weaker form. Rich extras toward a non-capable recipient are dropped, never sent in cleartext.
env_versions, rich_versions and data_versions are end to end: they
describe what a recipient parses after an arbitrary number of relay hops. They
MUST persist across restarts and be restored before any queued send flushes.
Otherwise a restart silently downgrades every established peer until the next
key package exchange, and the queued sends that flush at startup take the
downgrade.
For data_versions the consequence is quieter than a downgrade and worse: both
sides keep accepting edits to a document neither is replicating, so the symptom
is not a dropped feature but two replicas that disagree, with nothing anywhere
reporting a problem.
data_versions entries are read independently and the list is append-only. A
peer advertising [1] is not a peer advertising [1, 2] with something
missing: it is an implementation that replicates 1:1 and would render a group
replication frame as literal text. Treating the two as one flag either sends
that peer a frame it cannot read or stops replicating with it altogether.
Entry 3 is the same argument with a louder failure. A peer without it
replicates perfectly well and has no idea what a data-purposed media transfer
is, so it hands the bytes to its user as a downloaded file. For an attachment
that is a file nobody asked for; for a document too large to fit a frame it is
a CRDT encoding presented as a document. Entry 3 gates both the need_blob and
blob_gone frames and every transfer marked for the data layer. It has no
attested sibling: blob carriage is 1:1 in this version, so there is nothing
about it for a group inviter to attest.
Entry 2 has a second source, because members of a group never exchange key
packages with each other: a group inviter MAY attest it for a member on the
Add commit and in the Welcome, exactly as it attests rich_versions. An
attestation opens the group gate only, never 1:1 replication, and any
directly received key package from that peer overrides it in both
directions. Absence of an attestation means "no information" and MUST NOT be
read as a downgrade.
wire_versions is hop-local and deliberately in-memory only. It describes
what the next hop decodes, and it is re-exchanged on connect.
Per-peer capability state is stored separately from the key package cache. The key package cache is deleted when a session is created; the capabilities must outlive it.
The capability lists are not cryptographically bound to the sender.
They ride in the plaintext key package envelope alongside the signed MLS key package data, not inside the signature. A network attacker positioned on the pre-session bootstrap can:
- strip a list, which downgrades to the floor and is harmless,
- forge a list onto a legacy peer, which makes us emit frames that peer cannot parse, which is a targeted delivery denial of service.
Neither grants a new capability. Such an attacker already controls key package delivery and could deny service outright by dropping the packet.
These are performance and feature negotiations. They are never security controls. An implementation MUST NOT derive a security decision from them.
This field selects which payload a signature is built over, so unlike the
lists above it sits next to a security decision rather than a rendering one.
Stripping it makes us sign offline-ctrl-v1 at a peer that would have verified
offline-ctrl-v2, and a frame signed that way states no freshness.
What keeps it from being a security control is the rule that does not consult it. A verifier records the first v2 signature it verifies from a peer and refuses that peer's v1 frames from then on (Control messages), so a stripped advertisement works until the first genuine v2 frame arrives and never again. The field makes the first one possible; the durable record is what makes it stick. An implementation MUST NOT decide what it accepts from this field, only what it emits.
This field rides in the same plaintext envelope but is consumed as a destination key, not as a feature hint. The distinction matters: a wrong capability costs a fallback, whereas a wrong key here means envelope metadata is sealed to whoever supplied it and is then readable off a public relay, passively, for as long as the value stands.
It is therefore honoured only from a signed key package. The canonical signing payload covers the whole key package body, and the gate verifies it against the key the sender's address derives from, so on this prefix an unsigned frame does not reach dispatch at all.
Stripping it remains possible and downgrades to the bootstrap key. That is a privacy downgrade, not a disclosure to the attacker, and one they could equally achieve by dropping the packet.
The relay advertises its own capability set, which is separate from peer capabilities and arrives in the relay's authentication answer.
Two ordering requirements:
- The capability set MUST be injected before the internet-available transition, so the flush that transition triggers already sees it.
- It MUST be cleared when internet drops.
The set is bounded (64 entries of 128 bytes in the reference implementation).
Relay capability tokens are opaque strings. The one this protocol defines is
group_delivery_v3; see Group protocol for why the version
in the token is load-bearing.
Rich payload capability in a group is established directly, or by inviter attestation when a member was added by someone else. See Encryption envelopes.
Attestation feeds only the group sealing gate. It MUST NOT feed 1:1 sealing and MUST NOT feed envelope selection. It is a second-hand claim, adequate for deciding whether to include optional context in a group message, not adequate for anything else.
A constrained device negotiates through the same key package as any other peer. This is the reason a leaf node runs MLS at all: capability advertisement rides in a key package and nowhere else, so a device with no key package to mint has no channel to advertise anything, and every frame sent to it falls to the floor forever. See ADR 0021 for the decision and Leaf node provisioning for what a device owes at pairing.
A minimal leaf advertises little and works fully:
| Field | A minimal leaf | Effect |
|---|---|---|
wire_versions |
[1] if it decodes the binary hop encoding, else empty |
Empty means peers send it JSON, which every conforming receiver parses anyway |
env_versions |
[1] if it decodes the compact envelope, else empty |
Empty means peers send the JSON envelope, which is the permanent floor |
rich_versions |
Empty | Peers send plain text and drop extras rather than sending them in a weaker form |
data_versions |
Empty | No document replication with this peer |
ctrl_versions |
[2], always |
Not optional, unlike every other row: a leaf verifies the freshness-bound payload on every control frame except __MLS_KEY_PKG__, so a peer that never learned this and kept sending the older one would have its Welcome refused and never complete a pairing |
nostr_pubkey |
Absent unless the device is reachable over that carrier | Peers seal to the publicly computable key |
Two consequences follow from the universal rules above, and both are easy to get backwards on a device where every kilobyte is argued over.
A device that advertises nothing else still interoperates. Empty lists
select the floor, and the floor is a complete conversation: a JSON-encoded
Message carrying a JSON __MLS_ENC__ envelope. Advertising one of those
capabilities buys smaller frames, never a feature the peer would otherwise
withhold.
ctrl_versions is the one exception, and a leaf MUST advertise [2]. It is
the only row whose floor a leaf cannot accept: a leaf verifies only
offline-ctrl-v2, so a peer that fell back to the older payload would have
every control frame it sent refused, and the device would be unreachable rather
than merely chattier. That is a deliberate departure from
rule 2, and it is affordable for
exactly one reason: a leaf has no legacy peer. The release that introduced the
leaf profile is the release that introduced the device, so every phone that has
ever paired with one already builds the newer payload. An implementer porting
this profile MUST NOT treat the field as optional on the grounds that the others
are.
Parsing is unconditional on a device too. A leaf MUST accept every historical form of everything it receives regardless of what it advertised, which for the envelope means all three inbound forms described in Encryption envelopes. A device that decodes only the form it advertises drops frames from a peer that legitimately believed it capable, and the shipping order in Adding a capability exists precisely to keep that from happening in a fleet where firmware updates slowly.
- Decide the layer. Hop-local capabilities are in-memory and re-exchanged; end-to-end capabilities persist.
- Add the field with a default of empty, so legacy peers decode cleanly.
- Make the receiving path accept the new form unconditionally, and ship that release first.
- Only in a later release, start emitting the new form to peers that advertise it.
Step 3 before step 4 is not optional. Reversing them means the first peer to upgrade emits a form no deployed receiver understands.