Plugin: mls-lazy
Role: Shared-secret confidentiality for an N-party enclave. A binary ratchet tree keyed to sorted member pubkeys distributes each epoch's root secret with O(log N) per-commit envelope cost, plus a flat OR-wrap fallback (epoch_or_wraps) for members whose operating key differs from the parent id_pub keyed into the tree (sub-key wallets, e.g. MetaMask). Per-message confidentiality uses a per-sender HKDF ratchet over the shared epoch secret.
Reference consumer: enclaves/group.md — confidentiality for message, reaction, notice, and the epoch payload on admin-created Move / rotate. Any future app whose enclave has a dynamic N-party membership and needs end-to-end shared-secret messaging (forum, project room, channel, multi-party registry) is a candidate consumer.
Slot type (suggested): MlsLazyCryptoFn (client-side). Implementations MAY alias to their app-specific slot name (the reference impl uses GroupCryptoFn for historical reasons).
This document is the complete wire-and-key contract. Two compliant implementations MUST agree byte-for-byte on the envelope and the message-key derivation for any input set.
Table of Contents
- 1. Primitives (fixed; no negotiation)
- 2. Domain separators (exhaustive)
- 3. Tree topology
- 4. Node key derivation
- 5. Commit envelope —
prepareCommit/consumeCommit - 6. Multi-key OR-wrap (
epoch_or_wraps) — additive fallback - 7. Per-sender message ratchet
- 8. Monotonicity (client-validated)
- 9. Recovery (CT replay)
- 10. Error & rejection rules (normative)
- 11. Security properties
- 12. Compliance vectors (required in plugin tests)
- 13. Versioning
1. Primitives (fixed; no negotiation)
| Primitive | Concrete choice |
|---|---|
| AEAD | ChaCha20-Poly1305 (RFC 8439) — 12-byte nonce, 16-byte Poly1305 tag. (Note: ChaCha20-Poly1305 with 12-byte nonce, not XChaCha20; matches MLS RFC 9420 conventions.) |
| KDF | HKDF-SHA-256 — salt = undefined (i.e. SHA-256 zero block), info = ASCII domain separator, L = 32 bytes |
| ECDH | secp256k1. shared = bytes [1..33] of compressed point (= 32-byte x-coordinate). x-only inputs are extended with 0x02 prefix per BIP-340 even-y convention. |
| CSPRNG | globalThis.crypto.getRandomValues |
| Pubkey encoding | lowercase hex; x-only 32 bytes (64 chars) |
| Hex / binary on the wire | Ciphertext + nonce per-entry as lowercase hex strings (not base64 — this differs from ratchet-pair) |
Forbidden: substituting any other AEAD, different nonce length, alternate hex case, non-deterministic member ordering.
2. Domain separators (exhaustive)
enc:mls:node-priv # HKDF → 32-byte secret → mod-n → secp256k1 scalar
enc:mls:child:left # HKDF parent_secret → left-child secret
enc:mls:child:right # HKDF parent_secret → right-child secret
enc:mls:path-wrap # HKDF ECDH(eph_priv, node_pub) → AEAD key for path-secret wrap
enc:mls:epoch # HKDF root_secret → epoch_secret
enc:group:ratchet:init:<sender_pub_hex> # HKDF epoch_secret → sender chain seed
enc:group:ratchet:advance # HKDF chain[i] → chain[i+1]
enc:group:ratchet:message # HKDF chain[i] → message_key
enc:group:epoch_dist # HKDF ECDH(committer_priv, op_pub) → AEAD key for OR-wrap entry (§6)No other separators are used. Forbidden additions or aliases.
3. Tree topology
Members are sorted by hex pubkey ascending to deterministic leaf positions; padded to the next power of two.
paddedLeafCount(N) = next_power_of_2(N) (1 if N≤1)
totalNodes(N) = 2 · L − 1 where L = paddedLeafCount(N)
treeDepth(N) = log2(L)Node IDs are breadth-first, 0-indexed: root = 0, level-1 = 1, 2, level-2 = 3, 4, 5, 6, ….
parent(n) = (n − 1) >> 1 (− if n == 0)
sibling(n) = n + 1 if n odd else n − 1
leafNodeId(i) = (L − 1) + i where i is the leaf index in the sorted member listdirectPath(leaf) is the inclusive list [leaf, parent(leaf), …, 0].
copath(leaf) is [sibling(leaf), sibling(parent(leaf)), …] — length = treeDepth(N).
subtreeLeafIndices(node) returns the sorted leaf indices reachable from node. A padded slot (index ≥ N) is treated as absent — implementations MUST NOT emit an entry for an empty subtree.
Tree topology is fully derivable from the sorted member list; any device replaying the CT reconstructs it bit-for-bit.
4. Node key derivation
Each tree node has a 32-byte secret that derives an ephemeral secp256k1 keypair. Direction: parent → child via HKDF; secret → keypair via HKDF + curve-order reduction.
deriveChildSecret(parent_secret, side): # side ∈ {"left", "right"}
HKDF(IKM=parent_secret, info="enc:mls:child:" || side, L=32)
keypairFromSecret(secret):
expanded = HKDF(IKM=secret, info="enc:mls:node-priv", L=32)
bn = int_be(expanded)
bn = bn mod n # secp256k1 order; if bn == 0, set bn = 1
priv = 32-byte big-endian of bn
pub = x_only(secp256k1.getPublicKey(priv, compressed=true)) # 32 bytes
return { priv, pub }buildTreeSecrets(root_secret, N) populates Map<nodeId, secret> going DOWN by BFS:
secrets[0] = root_secret
for n in [0 .. L−2]:
secrets[2n+1] = deriveChildSecret(secrets[n], "left")
secrets[2n+2] = deriveChildSecret(secrets[n], "right")epoch_secret = HKDF(root_secret, info="enc:mls:epoch", L=32) — the input to the per-sender ratchet (§7). The separation is non-negotiable: root_secret and epoch_secret MUST NOT be reused interchangeably.
5. Commit envelope — prepareCommit / consumeCommit
5.1 Wire shape
The commit envelope is the value of a top-level epoch field on the carrying event content (Move with epoch, standalone rotate, etc.):
{
"epoch": {
"n": <int ≥ 0>, // strictly monotonic per §8
"committer": "<committer_pub_hex_lowercase>",
"encrypted_path_secrets": [
{ "node": <int>, "ciphertext": "<hex>", "nonce": "<hex>", "ecdh_pub": "<hex>" },
...
]
},
"epoch_or_wraps": [ // OPTIONAL; see §6
{ "recipient": "<op_pub_hex>", "ecdh_pub": "<committer_pub_hex>", "ciphertext": "<hex>", "nonce": "<hex>" },
...
]
}wireEnvelope(envelope) returns the inner {epoch:{…}} shape; parseWireEnvelope(content) is its inverse (returns null for content without a well-formed epoch).
5.2 prepareCommit (committer)
Inputs:
sortedMembers : string[] # current member pubs AFTER the membership change; MUST be sorted ascending
committerPubHex : string # MUST be in sortedMembers
prevEpochN : int # the highest existing epoch.n; -1 for the very first commit
prevTreeState : TreeState? # {members: string[], secrets: Map<nodeId, secret>} from previous commit; null if none
newMembers : string[] # pubs newly added in this commit (need Welcome entries)Algorithm:
1. Validate sortedMembers ≠ ∅ ; committerPubHex ∈ sortedMembers ; sortedMembers is strictly ascending hex.
2. reusableSecrets = prevTreeState (if its members == sortedMembers, EXACTLY) else null.
3. ephPriv = CSPRNG(32) (valid secp256k1 scalar) ; ephPub = x_only(getPublicKey(ephPriv))
4. newRootSecret = CSPRNG(32)
5. newTreeSecrets = buildTreeSecrets(newRootSecret, |sortedMembers|)
6. newEpochSecret = HKDF(newRootSecret, "enc:mls:epoch", 32)
7. entries = []
8. for cpNode in copath(leafNodeId(committerLeafIdx)):
subLeaves = subtreeLeafIndices(cpNode)
if subLeaves empty: continue # padded subtree
if reusableSecrets ≠ null AND cpNode ∈ reusableSecrets:
cpPub = keypairFromSecret(reusableSecrets[cpNode]).pub # steady-state copath
else: # bootstrap
cpPub = sortedMembers[ subLeaves[0] ] # encrypt to leftmost subtree leaf's identity pub
entries += ecdhWrap(newRootSecret, cpPub, ephPriv, ephPub, cpNode)
9. Emit Welcome entries (identity-pub-keyed leaf entries):
if reusableSecrets == null: # full bootstrap
for i in [0..|sortedMembers|):
if i == committerLeafIdx: continue
myLeafN = leafNodeId(i)
# skip if covered as leftmost-of-subtree above
ancestor = first cp in committer copath whose subtree contains i
if ancestor exists AND subtreeLeafIndices(ancestor)[0] == i: continue
entries += ecdhWrap(newRootSecret, sortedMembers[i], ephPriv, ephPub, myLeafN)
else: # steady state: only newMembers need bootstrap
for newPub in newMembers:
i = sortedMembers.indexOf(newPub)
if i < 0 OR i == committerLeafIdx: continue
myLeafN = leafNodeId(i)
if myLeafN already in entries: continue
entries += ecdhWrap(newRootSecret, newPub, ephPriv, ephPub, myLeafN)
10. return:
newEpochSecret
newTreeState = { members: clone(sortedMembers), secrets: newTreeSecrets }
envelope = { n: prevEpochN + 1, committer: committerPubHex, encrypted_path_secrets: entries }ecdhWrap(plaintext, recipientPub, ephPriv, ephPub, nodeId):
fullPub = 0x02 || recipientPub # x-only → even-y full point
shared = secp256k1.getSharedSecret(ephPriv, fullPub)[1..33]
key = HKDF(shared, info="enc:mls:path-wrap", L=32)
nonce = CSPRNG(12)
ct = ChaCha20-Poly1305(key, nonce).encrypt(plaintext)
return { node: nodeId, ciphertext: hex(ct), nonce: hex(nonce), ecdh_pub: hex(ephPub) }5.3 consumeCommit (receiver)
Inputs:
sortedMembers, myPubHex, identityPriv
prevTreeState : TreeState? # null for bootstrap or stale member list
envelope : { n, committer, encrypted_path_secrets[] }
prevHighestN : int? # if provided, enforce envelope.n > prevHighestN strictly
expectedCommitter : string? # if provided, enforce envelope.committer == expectedCommitterAlgorithm:
1. Validate: envelope.n ≥ 0 integer; if prevHighestN given, envelope.n > prevHighestN; expectedCommitter equality.
2. myLeafIdx = sortedMembers.indexOf(myPubHex); MUST be ≥ 0
3. myLeafNode = leafNodeId(myLeafIdx)
4. myPath = set(directPath(myLeafNode))
5. reusableSecrets = prevTreeState if its members == sortedMembers else null
6. for entry in envelope.encrypted_path_secrets:
if entry.node ∉ myPath: continue
candidatePrivs = []
if reusableSecrets[entry.node] exists:
candidatePrivs += keypairFromSecret(reusableSecrets[entry.node]).priv # steady-state path-priv
# identity priv is only legal for our own leaf entry or the leftmost-leaf-of-subtree bootstrap entry:
if entry.node == myLeafNode:
candidatePrivs += identityPriv
else:
subL = subtreeLeafIndices(entry.node)
if subL non-empty AND subL[0] == myLeafIdx:
candidatePrivs += identityPriv
for priv in candidatePrivs:
try:
rootSecret = ecdhUnwrap(entry, priv) # MUST be 32 bytes
newTreeSecrets = buildTreeSecrets(rootSecret, |sortedMembers|)
return {
newEpochSecret: HKDF(rootSecret, "enc:mls:epoch", 32),
newTreeState: { members: clone(sortedMembers), secrets: newTreeSecrets },
}
except AEAD failure: continue
7. if no entry decrypted: try OR-wrap fallback (§6); if still none → throw NOT_DECRYPTABLEecdhUnwrap(entry, recipientPriv):
fullPub = 0x02 || fromHex(entry.ecdh_pub)
shared = secp256k1.getSharedSecret(recipientPriv, fullPub)[1..33]
key = HKDF(shared, "enc:mls:path-wrap", 32)
return ChaCha20-Poly1305(key, fromHex(entry.nonce)).decrypt(fromHex(entry.ciphertext))5.4 Tree-state cache invalidation
prevTreeState is reusable only when members is EXACTLY the same list (order-equal, length-equal) as sortedMembers for this commit. Any add, remove, or reorder shifts leaf positions → every cached node priv references a stale slot → ignore them and bootstrap from identityPriv. A bare Map<nodeId, secret> (v2 legacy shape) MUST be treated as stale.
6. Multi-key OR-wrap (epoch_or_wraps) — additive fallback
The tree-keyed encrypted_path_secrets distribute to each member's parent identity pub (id_pub). Members whose operating key is a derived sub_pub (sub-key wallets — see spec.md §Delegated Sub-keys) cannot ECDH against id_pub and cannot decrypt their tree entry. The committer additively emits a flat per-recipient OR-wrap of the same root_secret to each member's operating key(s):
for member in sortedMembers:
ops = unique([ member.id_pub, member.sub_pub ]) # 1 entry if parent == sub
for op_pub in ops:
shared = ECDH(committer_priv, op_pub)
dist_key = HKDF(shared, "enc:group:epoch_dist", 32)
nonce = CSPRNG(12) # 12-byte nonce, matching §1
ct = ChaCha20-Poly1305(dist_key, nonce, root_secret)
push: { recipient: op_pub, ecdh_pub: hex(committer_pub), ciphertext: hex(ct), nonce: hex(nonce) }The plaintext wrapped is the same root_secret the tree wraps; receivers feed it through the same buildTreeSecrets + HKDF(…, "enc:mls:epoch") chain. epoch_or_wraps rides alongside epoch on the same event content (Move-with-epoch, rotate, or a separate Welcome).
Receiver fallback (continuation of §5.3 step 6):
7. for w in content.epoch_or_wraps:
if w.recipient ≠ my_op_pub: continue
try:
shared = ECDH(my_op_priv, fromHex(w.ecdh_pub))
dist_key = HKDF(shared, "enc:group:epoch_dist", 32)
rootSecret = ChaCha20-Poly1305(dist_key, fromHex(w.nonce)).decrypt(fromHex(w.ciphertext))
return { newEpochSecret: HKDF(rootSecret, "enc:mls:epoch", 32),
newTreeState: { members: clone(sortedMembers), secrets: buildTreeSecrets(rootSecret, |sortedMembers|) } }
except AEAD: continue6.1 Required recipients (REVISED)
The committer's own leaf is NOT included in encrypted_path_secrets (the tree wraps target the committer's copath, not the committer themselves — see §5.2 step 8). Without a dedicated entry, a fresh device replaying the CT under the committer's identity_priv cannot recover any epoch the prior device authored. Therefore epoch_or_wraps MUST always carry at least:
- An entry for the committer's own operating key (
committer_op_pub). When the committer has no distinct sub-key, this is theircommitter_pubitself. Self-ECDH (ECDH(committer_priv, committer_pub)) produces a well-defined 32-byte shared and lets a fresh committer device unwrap on CT replay. - One entry per member whose operating key differs from their parent
id_pubkeyed into the tree (the sub-key-wallet case described in §6).
Entries for members whose parent == sub (ECDH-capable wallets — dev / passkey / Nostr / NFC) are OPTIONAL — they recover via the tree path. Implementations MAY emit an entry for every member's operating key plus the committer's own pub, accepting the bandwidth cost for simplicity; minimal compliant implementations MAY restrict to (1) + (2) only.
epoch_or_wraps MUST NOT be omitted entirely from a commit. Implementations MUST tolerate epoch_or_wraps arrays of any length ≥ 1.
The prior wording — "when every member has parent == sub, the OR-wrap field MAY be omitted entirely" — was wrong: it omitted the committer's self-wrap and broke fresh-device CT replay. Implementations conforming to the prior wording MUST be updated.
7. Per-sender message ratchet
For a member sending sender_seq = i ≥ 0 under epoch_secret:
chain[0] = HKDF(IKM=epoch_secret, info="enc:group:ratchet:init:" || sender_pub_hex_lowercase, L=32)
chain[i+1] = HKDF(IKM=chain[i], info="enc:group:ratchet:advance", L=32)
message_key = HKDF(IKM=chain[i], info="enc:group:ratchet:message", L=32)Each sender has an independent chain seeded with their own pub — multiple members sending in the same epoch never collide. Receivers re-derive on demand from epoch_secret + sender_pub_hex + sender_seq; no persistent ratchet state is required for read.
7.1 Message envelope
{
"epoch_n": <int>, // the epoch.n that produced message_key
"sender_pub": "<sender_pub_hex>",
"sender_seq": <int ≥ 0>, // sender's own per-epoch counter
"ciphertext": "<hex>",
"nonce": "<hex>" // 12 bytes
}The application wraps this envelope as it sees fit (in message.content, etc.). The receiver resolves epoch_secret from epoch_n via their per-group epoch map (§9).
7.2 encryptMessage / decryptMessage
encryptMessage(epoch_secret, epoch_n, sender_pub, sender_seq, plaintext_bytes):
key = deriveSenderMessageKey(epoch_secret, sender_pub, sender_seq) # §7
nonce = CSPRNG(12)
ct = ChaCha20-Poly1305(key, nonce).encrypt(plaintext_bytes)
return { epoch_n, sender_pub, sender_seq, ciphertext: hex(ct), nonce: hex(nonce) }
decryptMessage(epoch_secret, envelope):
key = deriveSenderMessageKey(epoch_secret, envelope.sender_pub, envelope.sender_seq)
return ChaCha20-Poly1305(key, fromHex(envelope.nonce)).decrypt(fromHex(envelope.ciphertext))8. Monotonicity (client-validated)
envelope.n MUST be > prevHighestN # strictly greaterprevHighestN is the highest epoch.n the client has observed for this group from any valid commit. Implementations MUST reject commits whose n is ≤ prevHighestN. Replay defense.
9. Recovery (CT replay)
To rebuild the per-group epoch map from cold storage:
state: { n → epoch_secret }
prevTreeState ← null
prevHighestN ← -1
for evt in CT (in seq order):
env = parseWireEnvelope(evt.content)
if env == null: continue
try:
result = consumeCommit({
sortedMembers: members-at-time-of-evt,
myPubHex, identityPriv,
prevTreeState, envelope: env,
prevHighestN, expectedCommitter: env.committer,
})
state[env.n] = result.newEpochSecret
prevTreeState = result.newTreeState
prevHighestN = env.n
except NOT_DECRYPTABLE:
# try epoch_or_wraps fallback inline (§6); if still none, this member was kicked
# at this epoch — DO NOT advance prevTreeState; future commits may re-include them
skipsortedMembers-at-time-of-evt is recomputed by replaying RBAC Move events up to the bundle containing evt. Membership is fully derivable from the CT — no plugin state needs persisting across reloads (the secrets MAY be cached for prover speed, but MUST be reconstructable from identityPriv + CT).
10. Error & rejection rules (normative)
An implementation MUST:
- Throw / reject when
envelope.nis not a non-negative integer or violates §8. - Throw / reject when
envelope.committeris missing or, ifexpectedCommitterwas provided, does not match. - Throw / reject when
envelope.encrypted_path_secretsis not an array. - Throw
NOT_DECRYPTABLEwhen no entry on this member'sdirectPathdecrypts under either the node-derived priv (fromprevTreeState) oridentityPriv, and noepoch_or_wraps[i].recipient == my_op_pubdecrypts either. - Validate
sortedMembersis strictly ascending; reject otherwise (a malformed group is unspecified). - Reject any candidate decryption whose recovered plaintext — whether a tree-path
rootSecret(§5.3) or anepoch_or_wrapsrootSecret(§6) — is not exactly 32 bytes; continue iterating other entries before raisingNOT_DECRYPTABLE.
Implementations SHOULD log AEAD failures only at debug; iteration is expected.
11. Security properties
| Property | Status |
|---|---|
| Per-message key isolation | YES — unique key per (sender_pub, sender_seq) via per-sender chain |
| Per-sender chain isolation | YES — sender_pub is bound in the seed (enc:group:ratchet:init:<sender_pub>) |
| Per-epoch forward secrecy | YES — independent random root_secret/epoch_secret per commit |
| Forward secrecy of wrap envelopes | YES — every wrap uses a fresh ephemeral (ephPriv, ephPub). Compromising a node priv later does NOT reveal past wraps. |
| Forward secrecy on member removal | YES — removed member is not in the new sortedMembers, has no entry in the new envelope, and the tree topology shifts so their cached node privs are stale. |
| Backward secrecy on member add | YES — new member's first epoch is the one created by the commit that adds them; they have no key material from prior epochs (and the CT-stored prior wraps were not encrypted to them). |
| Post-compromise security against identity-key compromise | NO — identityPriv recovers every Welcome entry and (via OR-wrap §6) every fallback wrap from the CT. CT-replay tradeoff. |
| Stateless decryption | YES — epoch_secret + sender_pub + sender_seq is sufficient; no persisted ratchet state required for read. |
| Multi-device support | YES — identityPriv (and sub_priv when applicable) is sufficient. |
| Sub-key wallet support | YES (OR-wrap §6) — additive, additive-only, never replaces the tree. |
| Strict commit monotonicity (replay defense) | YES — §8. |
12. Compliance vectors (required in plugin tests)
Implementations MUST publish KAT vectors for:
- Tree shape for
N ∈ {1, 2, 3, 4, 7, 8}— node IDs, copath, directPath, subtreeLeafIndices. keypairFromSecret(fixed 32-byte secret)— deterministic priv + x-only pub.buildTreeSecrets(fixed root_secret, N=4)— full secrets map.prepareCommitround-trip: each ofN ∈ {2,3,4,8}members on a fresh bootstrap, then on a steady-state add, then a remove.consumeCommitrecovers the sameepoch_secretandnewTreeStateasprepareCommit.- Per-sender ratchet:
message_keyderivation for(epoch, sender, seq) ∈ {(E1, S1, 0), (E1, S1, 5), (E1, S2, 0), (E1, S2, 5)}. - OR-wrap (
epoch_or_wraps): bootstrap fails on tree path for a sub-key recipient; OR-wrap fallback recovers sameepoch_secret. - Strict monotonicity:
consumeCommitwithprevHighestN ≥ envelope.nis rejected.
Reference test vectors are published alongside the plugin package and are authoritative for conformance.
13. Versioning
This plugin is version 1. Any change to a domain separator, the AEAD primitive, the nonce length, the KDF, member-sort order, or tree topology requires a new plugin (mls-lazy-v2) and a new manifest field — clients negotiate by manifest, never by tag-sniffing.