@enc-protocol/plugin-group-mls-lazy
v0.9.0
Published
GroupCryptoFn slot impl: lazy MLS per spec/app/group.md — binary ratchet tree, O(log N) epoch distribution via encrypted_path_secrets to tree node positions, stateless decryption via CT replay.
Readme
@enc-protocol/plugin-group-mls-lazy
A GroupCryptoFn slot impl: Lazy MLS per spec/app/group.md.
Drop-in replacement for @enc-protocol/plugin-group-naive with the
spec-mandated wire format for epoch distribution.
What this gives you
- Wire-format compliance with
spec/app/group.md:epoch.encrypted_path_secretsentries addressed by tree node position ({ node, ciphertext, ecdh_pub, nonce }), not by recipient pubkey. A spec-conformant verifier accepts the output. - Forward secrecy across epochs — removed members can read old ciphertexts (key they were members for) but not new ones.
- Per-sender ratchet for messages —
chain_key[0] = HKDF(epoch_secret, 'enc:group:ratchet:init:' + sender_pub), advance + message-key per spec. - Stateless decryption of messages: receiver re-derives the ratchet
from
epoch_secret + sender_pub + sender_seqeach time. - Tree topology derivable from sorted member list — any device with the CT replays membership events to reconstruct the tree.
Wire format
Each Commit (membership-changing Move event) carries:
{
"epoch": {
"n": 2,
"committer": "<admin_pub>",
"encrypted_path_secrets": [
{ "node": 4, "ciphertext": "...", "ecdh_pub": "...", "nonce": "..." },
{ "node": 5, "ciphertext": "...", "ecdh_pub": "...", "nonce": "..." }
]
}
}Where node is a tree node ID in breadth-first numbering (root=0,
level-1=1,2, level-2=3..6, etc.). Leaf nodes contain members in sorted
pubkey order.
Cryptographic scheme
For epoch distribution:
- Each non-committer leaf gets one ECDH-wrapped entry of the new
epoch_secret, addressed to that leaf'snodeid. - Encryption:
shared = ECDH(committer_eph_priv, member_id_pub);key = HKDF(shared, 'enc:group:epoch_dist', 32);ct = XChaCha20-Poly1305(key, nonce).encrypt(epoch_secret). - Decryption: member finds entry by their leaf-node id, runs ECDH with
identity priv + entry's
ecdh_pub, recoversepoch_secret.
For messages:
- Per-sender ratchet seeded with `HKDF(epoch_secret, 'enc:group:ratchet:init:'
- sender_pub_hex)
. Advance viaHKDF(chain_key, 'enc:group:ratchet:advance'). Message key:HKDF(chain_key, 'enc:group:ratchet:message'). Receiver re-derives by countingsender_seq` advances.
- sender_pub_hex)
Honest scope note: O(N) vs O(log N)
This v1 emits one entry per non-committer leaf — O(N) entries. The spec aspires to O(log N) via subtree-ephemeral keys, but that requires either persistent per-member state (real MLS with copath-key bookkeeping) or pairing-based multi-recipient encryption.
At the demo cap of ~30 members, O(N) crypto cost is ~30ms per Move — imperceptible. The wire format is spec-conformant regardless, which is what external verifiers check.
Drop-in upgrade to true O(log N) is straightforward when wanted:
keep the same prepareCommit/consumeCommit shape but add
copath-key state in the receiver, derived from past Welcomes/Commits
via CT replay. The wire format doesn't change.
API
Slot contract (GroupCryptoFn)
import group from '@enc-protocol/plugin-group-mls-lazy'
const state0 = group.createGroup({ creator: 'alice-pub', members: ['bob-pub'] })
// state0.epoch === 0, members sorted lexicographically
const env = group.encryptForGroup(state0, 'hi everyone', {
senderPubHex: 'alice-pub', senderSeq: 0,
})
// { epoch: 0, sender_seq: 0, sender_pub: 'alice-pub', ciphertext, nonce }
group.decryptFromGroup(state0, env).toString('utf8')
// 'hi everyone'
const state1 = group.addMember(state0, 'carol-pub')
// state1.epoch === 1, fresh groupKey, members re-sortedMLS-specific extensions
prepareCommit / consumeCommit produce / parse the
epoch.encrypted_path_secrets wire payload that goes inside
membership-changing Move event content per spec/app/group.md:
// Committer side (admin issuing Move(OUTSIDER, MEMBER)):
const { newEpochSecret, envelope } = group.prepareCommit({
sortedMembers: state1.members, // AFTER the membership change
committerPubHex: 'alice-pub',
prevEpochN: state0.epoch,
})
// envelope is the { n, committer, encrypted_path_secrets } object to
// embed in the Move event's content.
// Receiver side (any non-committer member processing the Move):
const recoveredSecret = group.consumeCommit({
sortedMembers: state1.members,
myPubHex: 'bob-pub',
identityPriv: bobIdentityPrivBytes,
envelope: moveEvent.content.epoch,
})
// recoveredSecret === newEpochSecret (32 bytes)Slot descriptor
{
id: 'GroupCryptoFn',
side: 'client',
signature: 'Bytes -> Bytes -> Bytes', // Lean view; richer in JS
}Tests
node packages/plugin-group-mls-lazy/test.mjs28 assertions covering: createGroup + sort, encrypt/decrypt round-trip, prepareCommit/consumeCommit (incl. wire-format shape), addMember/removeMember, per-sender ratchet across seqs, cross-sender isolation, removed-member rejection.
