npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 admin
  • createInvite + 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 throw DuplicateMemberLeafError
  • processWelcome — invitee joins after folding the ledger the invite carries and checking its head
  • commitLedgerEntries — an admin promotes or demotes by committing signed kumiai.role entries
  • removeMember — an admin evicts a leaf and rotates keys
  • restoreGroup — rehydrate a GroupHandle from persisted ClientState and the stored ledger tokens
  • GroupHandle.processMessage(message, { persist? }) and bootstrapLedger(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 returns null rather than throwing
  • encodePrivateKeyPackage + 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 record
  • readMessageAAD(bytes) — read a PrivateMessage's cleartext authenticated data without a key; returns null for 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.role entry names a subject DID and grants it admin or member of 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. foldRoster applies 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_extensions proposal, which needs admin. A plain member's only permitted proposal is update; 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.
  • GroupPermission is '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. A read member 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 groupInfo bytes from a healthy member to the stale device (e.g. via a hub, directory service, or DM).
  • Broadcasting commitMessage bytes 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 MemberCredential without a live inviter.
  • Member-proposed external add. proposeAddExternal is not wrapped yet.
  • Non-resync external join. joinGroupExternal accepts only resync: true (enforced via a literal type in JoinGroupExternalParams). 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.