aboutsummaryrefslogtreecommitdiffstats
path: root/src/cordn.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-06 11:22:06 -0700
committerAdam Soltys <[email protected]>2026-10-06 11:22:06 -0700
commitd657d79bc95062a8fc5e128e795e23bcefd0bf04 (patch)
tree04a9d39281ce7784f1a4a966f0a532194ee7ded7 /src/cordn.js
parentf571e46937d423d9ba44154a92aeb4136466daaa (diff)
downloadcoinosv3-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.js368
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 };
+}