// Cordn — MLS group chats delivered through a coordinator // https://github.com/Cordn-msg/cordn (spec/00..03, applications/) // // The shape of the thing: members hold MLS state (RFC 9420, one ciphersuite, // src/mls.js); a coordinator stores KeyPackages, Welcomes and an ordered // stream of sealed messages per group, and reads none of it. Messages are // sealed with a key the group derives from its epoch (the MLS exporter), so // the coordinator sees bytes and cursors. Inside, an application message is // a nostr-shaped event with no signature: MLS already says who sent it. // // This module is the protocol: KeyPackages, the group metadata extension, // sealing, envelopes, group references, and the record engine that turns a // coordinator's stream into delivered events. Network and storage live in // cordn-client.js. Interop was proven against @cordn/cli (ts-mls) both ways // on 2026-10-06; the gotchas met there are noted where they bit. import { sha256 } from '@noble/hashes/sha256'; import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils'; import { chacha20poly1305 } from '@noble/ciphers/chacha.js'; import { base64, bech32 } from '@scure/base'; import { getEventHash } from 'nostr-tools/pure'; import * as mls from './mls.js'; export const EXT = { METADATA: 0xc04d, APP_DATA_DICTIONARY: 0x0006 }; const COMP_LAST_RESORT = 0x0004; export const KIND = { CHAT: 9, REACTION: 7, DELETE: 5, EDIT: 1009, SYSTEM: 1210 }; // The public coordinator Cordn's own client and Amethyst default to. export const DEFAULT_COORDINATOR = { pubkey: '92753cbe63e943d0c4a0c61d745437892af6e98f179ce04a7a863aad4e00b1a5', relays: ['wss://relay.contextvm.org', 'wss://relay2.contextvm.org', 'wss://relay.primal.net'], }; const utf8 = (s) => new TextEncoder().encode(s); const text = (b) => new TextDecoder().decode(b); const now = () => Math.floor(Date.now() / 1000); const HEX64 = /^[0-9a-f]{64}$/; // ---- KeyPackages ---- // The BasicCredential identity is the nostr pubkey AS HEX TEXT (64 bytes), // not the 32 raw bytes Marmot uses. The leaf has to say it understands the // metadata extension or no Cordn group will admit it. export const capabilities = () => ({ versions: [1], ciphersuites: [1], extensions: [EXT.METADATA, EXT.APP_DATA_DICTIONARY], proposals: [], credentials: [1], }); // Last-resort by default: the coordinator hands a regular package to the // first inviter and deletes it, a last-resort one to everyone. One of those // keeps us invitable between visits. export function newKeyPackage(sig, pubkey, { lastResort = true } = {}) { const kp = mls.generateKeyPackage({ sig, identity: utf8(pubkey), capabilities: capabilities(), extensions: lastResort ? [{ type: EXT.APP_DATA_DICTIONARY, data: mls.encodeDict([{ id: COMP_LAST_RESORT, data: new Uint8Array() }]) }] : [], }); return { ref: bytesToHex(kp.ref), keyPackage: kp.keyPackage, secrets: kp.secrets, at: now(), lastResort }; } export const keyPackageBase64 = (kp) => base64.encode(kp.keyPackage); export const welcomeBase64 = (bytes) => base64.encode(bytes); export const newSigner = () => mls.generateSignatureKeyPair(); // Someone else's package as the coordinator served it, checked (both // signatures) and bound to the pubkey it claims. export function readKeyPackage(b64, pubkey) { const p = mls.parseKeyPackage(base64.decode(b64)); if (identityOf(p.leaf) !== pubkey) throw new Error('key package is not theirs'); return { pubkey, ref: bytesToHex(p.ref), keyPackage: p.keyPackage }; } export const identityOf = (leaf) => { let s; try { s = text(leaf.credential.identity); } catch { return null; } return HEX64.test(s) ? s : null; }; // ---- group metadata (spec/01): a GroupContext extension ---- // u16 version=1, then name, description, admin pubkeys (raw 32-byte // concatenation; empty = everyone is an admin), icon, image URL — each a // u16-length byte string. export function encodeMetadata({ name = '', description = '', admins = [], icon = '', imageUrl = '' } = {}) { const field = (bytes) => { if (bytes.length > 0xffff) throw new Error('metadata field too long'); return [bytes.length >> 8, bytes.length & 0xff, ...bytes]; }; const adm = new Uint8Array(admins.length * 32); admins.forEach((pk, i) => { if (!HEX64.test(pk)) throw new Error('bad admin pubkey'); adm.set(hexToBytes(pk), i * 32); }); return Uint8Array.from([0, 1, ...field(utf8(name)), ...field(utf8(description)), ...field(adm), ...field(utf8(icon)), ...field(utf8(imageUrl))]); } export function decodeMetadata(b) { let o = 0; const u16 = () => { if (o + 2 > b.length) throw new Error('short metadata'); const v = (b[o] << 8) | b[o + 1]; o += 2; return v; }; const field = () => { const n = u16(); if (o + n > b.length) throw new Error('short metadata'); const v = b.subarray(o, o + n); o += n; return v; }; const version = u16(); if (version !== 1) throw new Error('unknown metadata version ' + version); const m = { name: text(field()), description: text(field()) }; const adm = field(); if (adm.length % 32) throw new Error('bad admin list'); m.admins = []; for (let i = 0; i < adm.length; i += 32) m.admins.push(bytesToHex(adm.subarray(i, i + 32))); m.icon = o < b.length ? text(field()) : ''; m.imageUrl = o < b.length ? text(field()) : ''; return m; } export const metadataExtension = (m) => ({ type: EXT.METADATA, data: encodeMetadata(m) }); // What a group's signed state says about itself. const viewMemo = new WeakMap(); export function groupView(state) { let v = viewMemo.get(state); if (v) return v; const ext = state.extensions.find((e) => e.type === EXT.METADATA); let m = { name: '', description: '', admins: [], icon: '', imageUrl: '' }; if (ext) { try { m = decodeMetadata(ext.data); } catch {} } v = { ...m, egalitarian: m.admins.length === 0 }; viewMemo.set(state, v); return v; } export const roster = (state) => mls.members(state).map((m) => ({ leaf: m.index, pubkey: identityOf(m.leaf) })).filter((m) => m.pubkey); export const accounts = (state) => [...new Set(roster(state).map((m) => m.pubkey))]; export const isAdmin = (g) => { const v = groupView(g.tip); return v.egalitarian || v.admins.includes(g.me); }; // ---- sealing (spec/03) ---- // key = MLS-Exporter("cordn", "group-payload", 32) of the epoch; the payload // is nonce(12) || ChaCha20-Poly1305 ciphertext, base64. A commit is sealed // under the epoch it leaves, so everyone still there can open it. export const sealKey = (state) => mls.exportSecret(state, 'cordn', utf8('group-payload'), 32); export function seal(state, bytes) { const nonce = randomBytes(12); const ct = chacha20poly1305(sealKey(state), nonce).encrypt(bytes); const out = new Uint8Array(12 + ct.length); out.set(nonce); out.set(ct, 12); return base64.encode(out); } export function unseal(state, b64) { const p = base64.decode(b64); if (p.length < 28) throw new Error('sealed payload too short'); return chacha20poly1305(sealKey(state), p.subarray(0, 12)).decrypt(p.subarray(12)); } // ---- envelopes (spec/02) ---- // A nostr event with an id and no signature. Their client wants the sender's // hex pubkey as the MLS authenticated data too, and refuses a message // without it ("missing authenticated sender"). export function appEvent(pubkey, kind, content, tags = [], created_at = now()) { const e = { pubkey, created_at, kind, tags, content }; return { id: getEventHash(e), ...e }; } export const senderAad = (pubkey) => utf8(pubkey); const APP_KEYS = ['content', 'created_at', 'id', 'kind', 'pubkey', 'tags']; export function readAppEvent(bytes, sender, aad) { let e; try { e = JSON.parse(text(bytes)); } catch { return null; } if (!e || typeof e !== 'object' || Object.keys(e).sort().join() !== APP_KEYS.join()) return null; if (typeof e.content !== 'string' || !Number.isInteger(e.created_at) || !Number.isInteger(e.kind) || !Array.isArray(e.tags)) return null; if (!e.tags.every((t) => Array.isArray(t) && t.every((x) => typeof x === 'string'))) return null; // MLS authenticated the leaf; the event (and the AAD, when given) must name that leaf's account if (e.pubkey !== sender) return null; if (aad && aad.length && text(aad) !== sender) return null; try { if (getEventHash(e) !== e.id) return null; } catch { return null; } return e; } // ---- group references (applications/group-ref.md): cordn1… ---- // NIP-19-style TLV: 0 = gid (UTF-8), 1 = coordinator pubkey (32 bytes), 2 = relay. export function encodeGroupRef({ gid, coordinator, relays = [] }) { const parts = []; const tlv = (type, bytes) => { if (bytes.length > 255) throw new Error('group ref field too long'); parts.push(type, bytes.length, ...bytes); }; if (coordinator) for (const r of relays) tlv(2, utf8(r)); if (coordinator) tlv(1, hexToBytes(coordinator)); tlv(0, utf8(gid)); return bech32.encode('cordn', bech32.toWords(Uint8Array.from(parts)), 5000); } export function decodeGroupRef(str) { const m = /cordn1[a-z0-9]+/i.exec(String(str || '')); if (!m) return null; let d; try { d = bech32.decode(m[0].toLowerCase(), 5000); } catch { return null; } if (d.prefix !== 'cordn') return null; const b = bech32.fromWords(d.words); const out = { gid: null, coordinator: null, relays: [] }; for (let o = 0; o + 2 <= b.length;) { const type = b[o], n = b[o + 1], v = b.subarray(o + 2, o + 2 + n); o += 2 + n; if (v.length !== n) return null; if (type === 0) { if (out.gid !== null || !n) return null; out.gid = text(v); } else if (type === 1) { if (out.coordinator || n !== 32) return null; out.coordinator = bytesToHex(v); } else if (type === 2) out.relays.push(text(v)); } if (!out.gid || (out.relays.length && !out.coordinator)) return null; return out; } // ---- the group engine ---- // // A record is plain data, persisted whole: // id the gid (the MLS group id as text, a UUID in practice) // tip the canonical MLS state; past: a few epochs behind it (a late // message sealed for one of them still opens) // pending a commit of ours that is posted but not yet seen back // cursor the coordinator cursor processed up to // seen digests of records already processed (the stream repeats // itself: identical bytes at consecutive cursors were seen live) // log delivered app events, oldest first const SEEN_MAX = 2000, LOG_MAX = 400, PAST_MAX = 3; export const record = (state, me, extra = {}) => ({ id: text(state.groupId), cordn: true, me, tip: state, past: [], pending: null, cursor: 0, seen: [], log: [], removed: false, leaving: false, fresh: false, inviter: null, at: now(), ...extra, }); const digestOf = (msg_64) => bytesToHex(sha256(utf8(msg_64))).slice(0, 16); const see = (g, d) => { g.seen.push(d); if (g.seen.length > SEEN_MAX) g.seen.splice(0, g.seen.length - SEEN_MAX); }; function deliver(g, out, ev, epoch) { if (g.log.some((m) => m.id === ev.id)) return; ev = { ...ev, epoch }; g.log.push(ev); if (g.log.length > LOG_MAX) g.log.splice(0, g.log.length - LOG_MAX); out.push(ev); } // Membership and settings changes between two epochs, as kind-1210 rows // for the transcript (local, derived from signed state). function changes(before, after, actor, at) { const out = []; const row = (type, data) => out.push(appEvent(actor || '', KIND.SYSTEM, JSON.stringify({ v: 1, system_type: type, text: type.replace(/_/g, ' '), data: { actor, ...data } }), [['system', type]], at)); const a = accounts(before), b = accounts(after); for (const pk of b) if (!a.includes(pk)) row('member_added', { subject: pk }); for (const pk of a) if (!b.includes(pk)) row(pk === actor ? 'member_left' : 'member_removed', { subject: pk }); const va = groupView(before), vb = groupView(after); for (const pk of vb.admins) if (!va.admins.includes(pk) && a.includes(pk)) row('admin_added', { subject: pk }); for (const pk of va.admins) if (!vb.admins.includes(pk) && b.includes(pk)) row('admin_removed', { subject: pk }); if (va.name !== vb.name) row('group_renamed', { name: vb.name }); return out; } const leafOwner = (state, leafIndex) => { const m = mls.members(state).find((x) => x.index === leafIndex); return m ? identityOf(m.leaf) : null; }; function advance(g, out, parent, state, actor, at) { g.past.unshift(parent); g.past.length = Math.min(g.past.length, PAST_MAX); g.tip = state; for (const ev of changes(parent, state, actor, at)) deliver(g, out, ev, state.epoch); out.push({ epoch: state.epoch }); } // One coordinator record in. Returns what the app needs to hear about: // delivered app events, { epoch } on a state change, { removed }, { invalid }. export function receive(g, rec) { const out = []; if (g.removed) return out; if (rec.cursor <= g.cursor) return out; g.cursor = rec.cursor; const d = digestOf(rec.msg_64); if (g.seen.includes(d)) return out; see(g, d); const at = rec.at ? Math.floor(rec.at / 1000) : now(); // our own commit coming back: it is group state now if (g.pending && g.pending.msg_64 === rec.msg_64) { confirm(g, out, at); return out; } let bytes = null, state = null; for (const s of [g.tip, ...g.past]) { try { bytes = unseal(s, rec.msg_64); state = s; break; } catch {} } if (!bytes) { out.push({ invalid: 'sealed for an epoch we do not hold' }); return out; } let head; try { head = mls.decodeMessage(bytes); } catch (e) { out.push({ invalid: e.message }); return out; } if (head.epoch !== state.epoch) { state = [g.tip, ...g.past].find((s) => s.epoch === head.epoch) || null; if (!state) { out.push({ invalid: 'framed for an epoch we do not hold' }); return out; } } let r; try { r = mls.processMessage(state, bytes); } catch (e) { out.push({ invalid: e.message }); return out; } if (r.type === 'application') { const sender = leafOwner(state, r.sender); const ev = sender && readAppEvent(r.data, sender, r.aad); if (ev) deliver(g, out, ev, state.epoch); else out.push({ invalid: 'bad envelope' }); if (state === g.tip) g.tip = r.state; else g.past[g.past.indexOf(state)] = r.state; } else if (r.type === 'commit') { if (state !== g.tip) { out.push({ invalid: 'commit for an old epoch' }); return out; } // a peer's commit from our epoch beat ours: ours is void if (g.pending) { g.pending = null; out.push({ dropped: true }); } if (r.removedSelf) { g.removed = true; out.push({ removed: true }); return out; } advance(g, out, g.tip, r.state, leafOwner(g.tip, r.sender), at); } else if (r.type === 'proposal' && state === g.tip) g.tip = r.state; return out; } // Our application message: encrypted, sealed, logged. The client posts msg_64. export function send(g, inner) { const r = mls.encryptApplication(g.tip, utf8(JSON.stringify(inner)), senderAad(g.me)); const msg_64 = seal(g.tip, r.message); g.tip = r.state; see(g, digestOf(msg_64)); const out = []; deliver(g, out, inner, g.tip.epoch); return { msg_64, out }; } // Stage a commit. Not group state until the coordinator echoes it back // (receive → confirm); drop() if it never got there. export function stage(g, proposals) { if (g.pending) throw new Error('a change is already on its way'); const r = mls.createCommit(g.tip, { proposals }); g.pending = { state: r.state, msg_64: seal(g.tip, r.message), welcome: r.welcome, at: now() }; return g.pending; } export function confirm(g, out = [], at = now()) { const p = g.pending; if (!p) return out; g.pending = null; advance(g, out, g.tip, p.state, g.me, at); return out; } export const drop = (g) => { g.pending = null; }; // Proposals for the common changes. export const invite = (cands) => cands.map((c) => mls.proposeAdd(c.keyPackage)); export const remove = (g, pubkey) => roster(g.tip).filter((m) => m.pubkey === pubkey).map((m) => mls.proposeRemove(m.leaf)); export function setMetadata(g, patch) { const v = groupView(g.tip); const next = { name: v.name, description: v.description, admins: v.admins, icon: v.icon, imageUrl: v.imageUrl, ...patch }; const others = g.tip.extensions.filter((e) => e.type !== EXT.METADATA); return mls.proposeGroupContextExtensions([...others, metadataExtension(next)]); } // Found a group: us at epoch 0 with the metadata, then one commit that adds // everyone (its Welcome is theirs to fetch from the coordinator). export function found(sig, me, { name = '', icon = '', description = '', admins = [], invitees = [] }) { const gid = crypto.randomUUID(); const state = mls.createGroup({ groupId: utf8(gid), sig, identity: utf8(me), capabilities: capabilities(), extensions: [metadataExtension({ name, icon, description, admins })], }); const g = record(state, me); const pending = invitees.length ? stage(g, invite(invitees)) : null; return { g, pending }; } // A Welcome the coordinator held for one of our KeyPackages. export function join(sig, me, welcome, kps, { cursor = 0 } = {}) { const bytes = base64.decode(welcome.welcome_64); let state = null, used = null, err = null; const first = kps.filter((k) => k.ref === welcome.kp_ref); for (const k of [...first, ...kps.filter((k) => !first.includes(k))]) { try { state = mls.joinWelcome(bytes, { keyPackage: k.keyPackage, secrets: k.secrets, sig }); used = k; break; } catch (e) { err = e; } } if (!state) throw err || new Error('welcome is not for this device'); const inviter = leafOwner(state, state.joinedVia); return { g: record(state, me, { fresh: true, inviter, cursor }), used }; }