@kumiai/mls
v0.12.0
Published
E2EE group identity and membership for Enkaku using MLS
Readme
@kumiai/mls
Credential-aware MLS (RFC 9420) group lifecycle for enkaku. Wraps ts-mls with DID-based identity and a signed control ledger, giving every member a MemberCredential that ties their MLS leaf to their DID, and a group-wide roster that decides who may add, remove, and rotate.
Publishing commits directly
Commit builders return a derived handle without advancing the source handle's epoch.
Call group.reserveHandshakeGeneration(persist) before each build.
After the build returns, call group.consumeHandshakeReservation(persist) before any journalling or publication.
Reserve alone reuses an unconsumed generation, including after restart.
Serialise reserve, build and consume against the source handle. Adopt the derived handle only after acceptance.
Persistence must complete durably and leave storage unchanged on rejection.
The @kumiai/mls-rpc adapter supplies both reservation hooks to the RPC peer.
Persisted client state
encodeClientState and decodeClientState support only format v2. Other versions throw an unsupported-version error.
Malformed or truncated v2 blobs return undefined, as does empty input.
There is no migration path from another client-state format.
The layout is a version byte (2), flags byte (0 or 1), optional reservation, body, and proposal references.
Flag 1 includes a uint64 epoch, uint32 generation, and consumed byte (0 or 1).
The ts-mls body has a uint32 byte length and a 64,000,000-byte decoding limit.
A uint32 count precedes UTF-8 references, each with a uint32 byte length.
References ascend strictly by JS string (UTF-16 code unit) ordering.
References preserve unapplied proposals whose sender is exactly leaf zero, working around ts-mls rc.13 dropping optional numeric zero.
All integers are big-endian. Invalid flags, consumed bytes, references, or trailing bytes make the blob malformed.
Decoding copies the body so ratcheting cannot zero secrets in stored bytes.
Decoding requires the body's canonical re-encoding to match its stored bytes before restoring leaf-zero senders.
encodeClientState throws ClientStateReservationError unless the reservation epoch is a uint64 bigint and its generation a uint32 integer.
Capabilities
createGroup— creator opens a new group as its sole member and first admincreateInvite+commitInvite— an admin signs the invitee's role entry and produces the MLS Commit + Welcome. In a lifecycle group, admitting a DID that still holds a stale leaf removes that leaf in the same commit when the new key package carries a strictly newer binding; two leaves for the DID throwDuplicateMemberLeafErrorprocessWelcome— invitee joins after folding the ledger the invite carries and checking its headcommitLedgerEntries— an admin promotes or demotes by committing signedkumiai.roleentriesremoveMember— an admin evicts a leaf and rotates keysrestoreGroup— rehydrate aGroupHandlefrom persistedClientStateand the stored ledger tokensGroupHandle.processMessage(message, { persist? })andbootstrapLedger(tokens, { persist? })— persist accepted commits, proposals, or a ledger bootstrap before notifications; a rejected persist restores the prior in-memory state. Application-message receive ratchets are not persisted by this option. Persist runs under the handle mutex, so it must not call back into that handle. Writes must be atomic: rejection must leave storage unchanged.encodeKeyPackage+decodeKeyPackage— convert a key package to and from the opaque string a hub stores; decode is strict and returnsnullrather than throwingencodePrivateKeyPackage+decodePrivateKeyPackage— the canonical string form of a key package's private half, for a host that must persist it across restarts (a reusable last-resort package outlives the process that made it). Secret material: never publish or log it.keyPackageRef— the base64 KeyPackageRef a Welcome names, stable across a codec round trip. Use it as the identity of a stored package.exportGroupInfo+joinGroupExternal— stale-device self-rejoin (see below)GroupHandle.decryptStaged— open an app frame while withholding its consumed-key state until a host has atomically saved that state and its pending delivery recordreadMessageAAD(bytes)— read a PrivateMessage's cleartext authenticated data without a key; returnsnullfor unreadable bytes and never throws
Persisting a staged app open
await group.decryptStaged(message, { expectedAAD }, async (stagedState, opened) => { ... })
computes the post-open state under the handle mutex but does not adopt it until the callback
resolves. stagedState is encoded ClientState, in the same format the host already stores for
restoreGroup; save these bytes as the group's handle state, together with the pending record,
in one atomic transaction. opened contains payload, authenticated senderDID, and aad.
An unnamed sender is rejected before the callback. If opening or persistence fails, the live
state and its message key remain available for retry. After persistence succeeds, the handle
adopts the staged state and the same frame cannot open again.
The callback runs while the handle mutex is held. It must not call this handle or its peer.
The host must order earlier handle saves before the staged write and reject older writes at the
same epoch using a monotonic state version; otherwise an earlier delayed save can overwrite the
consumed-key state while leaving the pending record. No database transaction or single connection
may be held while awaiting a handle or peer operation. The real @kumiai/mls-rpc port's optional
pending adapter calls decryptStaged for durable logged events.
Standard-group authority model
Authority is a roster, folded from a signed control ledger and rooted at a genesis anchor installed in the GroupContext when the group is created. There is no capability chain: a member's right to act is decided by the roster every peer computes independently, not by a token the member carries.
- The ledger is an ordered log of enactments. Each entry is a token signed by its issuer (a
kumiai.roleentry names a subject DID and grants itadminormemberof the group). The log is replayable and position-dependent: the same claim recurring later is meaningful — a demotion back to a previously-held role is exactly that. - The roster is the fold.
foldRosterapplies each entry only if its issuer was an admin in the state so far, at that entry's own position. A token signed by a since-demoted admin is dead paper whoever carries it. The fold starts from the anchor ({creator: 'admin'}), so no one can promote themselves by padding an invite. - Enactment is admin-only, structurally. Entries reach the group inside a commit, and a commit that enacts entries must extend the GroupContext ledger-head extension by exactly those ids, in order. Moving the head needs a
group_context_extensionsproposal, which needs admin. A plain member's only permitted proposal isupdate; attaching an envelope to it fails for want of a head move. The write path runs the receivers' own fold before authoring, so it never builds a commit the group would reject. GroupPermissionis'admin' | 'member'— there is no read-only tier. It cannot exist: a group member holds the epoch secrets, which is what membership is in MLS. Areadmember derives the same application keys as any other member and can decrypt everything. Read-only observers do not belong in the group; gate them outside MLS, at the transport or storage layer.
The invite carries the full ordered ledger plus the invitee's new role entry last, so a joiner folds the same roster the group holds and cannot fork from a role change made before it joined.
Management tier
Lifecycle groups bind every leaf to one controller. Creation requires controller and an authoritative controllerLog.
The first ledger entry seeds the registry, including existing denials and removed incarnations.
ControllerBinding carries a chain-only prefix and either capability or member: { add, g } plus ordering-only iat.
Member-issued capabilities carry mbr: { add, g }. The registry-held log supplies their pinned keys and leases.
Member roots are supported only in lifecycle groups. Admission refuses removed or unavailable roots.
publishControllerLog publishes effect-free log changes and settlement.
createInvite and renewLeaf can bundle controllerLog changes.
revokeWithProof(group, { log, deny?, remove?, survivors?, evidence? }) proves denials or incarnation removals.
The reset form is { reset: true, log }. Proofs carry ControllerLog and exact EffectClaim references.
References identify a chain index, epoch event or reset snapshot.
Removing an incarnation cascades through its rooted leaves. Valid holder or survivor grants can preserve them until their substitute expiry.
removalEvidence captures removal evidence before settlement. Hosts retain it beside their controller store.
Tree time combines a preserved floor, root-issued token times and effective-member attestations over distinct pinned keys.
The quorum uses the second-highest attestation, or the highest for one effective key.
Membership leaves advance time through attestTreeTime, without attesting their binding iat.
Member-rooted admission is capped at 32 applied epoch events. settleAdvisory() prompts from 24 and blocks above 32.
Existing leaves, Welcome trees, root-issued admission and lifecycle entries are exempt.
Recovery refuses candidates that discard known lifecycle effects, floors or effective-key attestations.
The lifecycle reference defines binding, proof, time and recovery contracts.
External rejoin (stale device recovery)
Each device owns its MLS leaf and ratchet state. A device that stays offline long enough for the group to advance epochs (adds, removes, key rotations) can no longer decrypt current application messages. Replaying every missed commit sequentially is expensive and may be impossible if intermediate commits are no longer available.
RFC 9420 §11.2.1 defines an external commit: a non-member (or stale member) builds a Commit using only the group's GroupInfo (carrying an external_pub key). @kumiai/mls wraps this for the stale-member case — same DID, cached MemberCredential, resync: true so the joiner's old leaf is removed in the same commit.
// On any healthy member (online, current epoch):
import { exportGroupInfo } from '@kumiai/mls'
const { groupInfo } = await exportGroupInfo({ group: aliceGroup })
// groupInfo is a Uint8Array framed as MLSMessage(GroupInfo). Ship it to the stale device.
// On the stale device:
import { joinGroupExternal } from '@kumiai/mls'
const { commitMessage, group } = await joinGroupExternal({
identity: bob, // OwnIdentity from @kokuin/token
groupInfo, // bytes received from the healthy member
credential: bobStoredCredential, // cached from the original processWelcome
resync: true,
})
// Broadcast commitMessage (Uint8Array) to the other members; each decodes and calls
// group.processMessage(decoded). The returned `group` is already at the post-commit
// epoch — ready to encrypt/decrypt.Core callers holding known state must call assertRetainsKnownEffects in beforeBinding before adoption. mls-rpc installs this check.
Trust model
An external commit is enforced as a resync of an existing roster member, never a new join. Every receiver runs the commit policy on it: the committing DID (resolved from the commit's own UpdatePath leaf credential) must be in the roster, and the commit's proposals must be exactly one external_init plus one remove of the leaf whose DID equals that committer — nothing else. A stranger in no roster who obtains a leaked GroupInfo is rejected; a legitimate member rejoining is accepted. New members never arrive this way — they join through Welcome.
Transport
@kumiai/mls ships bytes, not channels. Callers own:
- Delivering
groupInfobytes from a healthy member to the stale device (e.g. via a hub, directory service, or DM). - Broadcasting
commitMessagebytes to every other member. - Rebuilding application state (message backlog, per-member projections) — enkaku does not replay missed application messages.
Not yet supported
- Fresh external join by a new DID. Requires deciding how a non-member acquires a
MemberCredentialwithout a live inviter. - Member-proposed external add.
proposeAddExternalis not wrapped yet. - Non-resync external join.
joinGroupExternalaccepts onlyresync: true(enforced via a literal type inJoinGroupExternalParams). Non-resync external join requires a separate API.
⚠️ Security: removal is not revocation
MLS has no cryptographic member revocation. removeMember evicts a leaf; it does not erase the removed DID's kumiai.role grant — its role in the group — which lives in the roster independently of MLS membership. So a device that retains its MemberCredential can rejoin via joinGroupExternal even after being removed — its DID is still a roster member, so the external-commit policy admits the resync — provided it can still obtain a fresh GroupInfo. (The policy stops strangers, who are in no roster; it does not stop a removed member.) Consumers must assume a removed member can rejoin until role revocation lands in a follow-up spec. Mitigations available today:
- Rotate the group: create a new group, migrate non-removed members via fresh invites, abandon the old group.
- Enforce access control outside MLS: block the removed device at the transport layer (e.g. hub auth).
The follow-up revocation work will introduce a ledger entry that removes a DID's role from the roster, synced through the same GroupContext ledger-head extension the roster already rides. Once a revoked DID is no longer a roster member, the external-commit policy rejects its resync exactly as it rejects a stranger today.
⚠️ Security: retain the last-resort bundle after Welcome
createLastResortKeyPackageBundle produces a KeyPackageBundle marked with the last_resort extension (draft-ietf-mls-extensions) — reusable by design, so a hub may hand the same one to every future inviter once the owner's ordinary pool is drained. @kumiai/mls never owns private key material: processWelcome takes the bundle as a caller-supplied parameter and does not persist it, so retention is entirely the host's responsibility. Delete a last-resort privatePackage after processing a Welcome — the way a host correctly would for an ordinary, single-use bundle — and the member is silently unaddable forever, which is the exact outage this feature exists to prevent. The host must keep the last-resort bundle's private half around, distinct from ordinary bundles, for as long as it may be reused. A KeyPackageBundle carries no marker of its own, so a host that persists bundles generically can recognize one with bundle.publicPackage.extensions.some((e) => e.extensionType === LAST_RESORT_EXTENSION_TYPE) — LAST_RESORT_EXTENSION_TYPE is exported for exactly this.
⚠️ Security: rotate the last-resort bundle before it expires
A last-resort key package carries an MLS lifetime of LAST_RESORT_LIFETIME_DAYS (90). The inviter validates that lifetime when building the Add, so an expired package is refused at the far end with Current time not within Lifetime — and refused no matter what the hub does, because the hub stores opaque bytes and cannot see the expiry.
That makes the failure worse than an empty slot: the hub goes on reporting the slot as full and serving the dead package, so the availability floor looks healthy while every join through it fails. A host must generate and upload a fresh last-resort bundle before the window elapses, and keep the new private half. Uploading once at enrolment buys 90 days, not forever.
