Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

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. 1. Primitives (fixed; no negotiation)
  2. 2. Domain separators (exhaustive)
  3. 3. Tree topology
  4. 4. Node key derivation
  5. 5. Commit envelope — prepareCommit / consumeCommit
  6. 6. Multi-key OR-wrap (epoch_or_wraps) — additive fallback
  7. 7. Per-sender message ratchet
  8. 8. Monotonicity (client-validated)
  9. 9. Recovery (CT replay)
  10. 10. Error & rejection rules (normative)
  11. 11. Security properties
  12. 12. Compliance vectors (required in plugin tests)
  13. 13. Versioning

1. Primitives (fixed; no negotiation)

PrimitiveConcrete choice
AEADChaCha20-Poly1305 (RFC 8439) — 12-byte nonce, 16-byte Poly1305 tag. (Note: ChaCha20-Poly1305 with 12-byte nonce, not XChaCha20; matches MLS RFC 9420 conventions.)
KDFHKDF-SHA-256 — salt = undefined (i.e. SHA-256 zero block), info = ASCII domain separator, L = 32 bytes
ECDHsecp256k1. 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.
CSPRNGglobalThis.crypto.getRandomValues
Pubkey encodinglowercase hex; x-only 32 bytes (64 chars)
Hex / binary on the wireCiphertext + 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 list

directPath(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 == expectedCommitter

Algorithm:

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_DECRYPTABLE

ecdhUnwrap(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: continue

6.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:

  1. An entry for the committer's own operating key (committer_op_pub). When the committer has no distinct sub-key, this is their committer_pub itself. Self-ECDH (ECDH(committer_priv, committer_pub)) produces a well-defined 32-byte shared and lets a fresh committer device unwrap on CT replay.
  2. One entry per member whose operating key differs from their parent id_pub keyed 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 greater

prevHighestN 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
        skip

sortedMembers-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:

  1. Throw / reject when envelope.n is not a non-negative integer or violates §8.
  2. Throw / reject when envelope.committer is missing or, if expectedCommitter was provided, does not match.
  3. Throw / reject when envelope.encrypted_path_secrets is not an array.
  4. Throw NOT_DECRYPTABLE when no entry on this member's directPath decrypts under either the node-derived priv (from prevTreeState) or identityPriv, and no epoch_or_wraps[i].recipient == my_op_pub decrypts either.
  5. Validate sortedMembers is strictly ascending; reject otherwise (a malformed group is unspecified).
  6. Reject any candidate decryption whose recovered plaintext — whether a tree-path rootSecret (§5.3) or an epoch_or_wraps rootSecret (§6) — is not exactly 32 bytes; continue iterating other entries before raising NOT_DECRYPTABLE.

Implementations SHOULD log AEAD failures only at debug; iteration is expected.


11. Security properties

PropertyStatus
Per-message key isolationYES — unique key per (sender_pub, sender_seq) via per-sender chain
Per-sender chain isolationYES — sender_pub is bound in the seed (enc:group:ratchet:init:<sender_pub>)
Per-epoch forward secrecyYES — independent random root_secret/epoch_secret per commit
Forward secrecy of wrap envelopesYES — every wrap uses a fresh ephemeral (ephPriv, ephPub). Compromising a node priv later does NOT reveal past wraps.
Forward secrecy on member removalYES — 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 addYES — 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 compromiseNO — identityPriv recovers every Welcome entry and (via OR-wrap §6) every fallback wrap from the CT. CT-replay tradeoff.
Stateless decryptionYES — epoch_secret + sender_pub + sender_seq is sufficient; no persisted ratchet state required for read.
Multi-device supportYES — identityPriv (and sub_priv when applicable) is sufficient.
Sub-key wallet supportYES (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:

  1. Tree shape for N ∈ {1, 2, 3, 4, 7, 8} — node IDs, copath, directPath, subtreeLeafIndices.
  2. keypairFromSecret(fixed 32-byte secret) — deterministic priv + x-only pub.
  3. buildTreeSecrets(fixed root_secret, N=4) — full secrets map.
  4. prepareCommit round-trip: each of N ∈ {2,3,4,8} members on a fresh bootstrap, then on a steady-state add, then a remove.
  5. consumeCommit recovers the same epoch_secret and newTreeState as prepareCommit.
  6. Per-sender ratchet: message_key derivation for (epoch, sender, seq) ∈ {(E1, S1, 0), (E1, S1, 5), (E1, S2, 0), (E1, S2, 5)}.
  7. OR-wrap (epoch_or_wraps): bootstrap fails on tree path for a sub-key recipient; OR-wrap fallback recovers same epoch_secret.
  8. Strict monotonicity: consumeCommit with prevHighestN ≥ envelope.n is 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.