diff options
| author | Adam Soltys <[email protected]> | 2026-10-06 11:22:06 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-06 11:22:06 -0700 |
| commit | d657d79bc95062a8fc5e128e795e23bcefd0bf04 (patch) | |
| tree | 04a9d39281ce7784f1a4a966f0a532194ee7ded7 /src/cordn.js | |
| parent | f571e46937d423d9ba44154a92aeb4136466daaa (diff) | |
| download | coinosv3-d657d79bc95062a8fc5e128e795e23bcefd0bf04.tar.gz coinosv3-d657d79bc95062a8fc5e128e795e23bcefd0bf04.zip | |
Cordn group chats: MLS groups delivered through a coordinator (Amethyst 1.17 interop)
src/contextvm.js speaks ContextVM (kind 25910 inside ephemeral gift wraps)
to a coordinator; src/cordn.js is the protocol (KeyPackages with hex-text
identities, the 0xC04D metadata extension, exporter-keyed ChaCha20 sealing,
unsigned nostr-shaped envelopes with the sender as MLS AAD, cordn1 group
refs, a record engine like marmot.js); src/cordn-client.js mirrors
marmot-client.js with the same interface, so the Messages group screens
drive both — New group gains a White Noise / Cordn toggle.
Verified against @cordn/cli (ts-mls) on the public coordinator both ways,
from the engine (tools/cordn-interop-test.js) and from the real app
(tools/cordn-ui-test.js). mls.js: the OpenMLS required_capabilities rule for
GroupContextExtensions now applies only to groups that carry one.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YRC6aKhwAud231ot9FPmhi
Diffstat (limited to 'src/cordn.js')
| -rw-r--r-- | src/cordn.js | 368 |
1 files changed, 368 insertions, 0 deletions
diff --git a/src/cordn.js b/src/cordn.js new file mode 100644 index 0000000..c0e4168 --- /dev/null +++ b/src/cordn.js @@ -0,0 +1,368 @@ +// 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 }; +} |
