/** * Cordn native — MLS primitives. * ───────────────────────────────────────────────────────────────────────────── * The browser-safe piece of a real Cordn client: RFC 9420 MLS group state, * key packages, welcomes, and the ChaCha20-Poly1305 "group payload" wrapper the * coordinator relays but cannot read. * * This mirrors Cordn's own reference client (`@cordn/cli`), reimplemented here * against the shared wire definitions in `@cordn/core` and the `ts-mls` engine — * we do not copy its source. Everything in this module is isomorphic: no Node * built-ins, no DOM. Persistence lives in `session.ts` behind an injected store. */ import { createApplicationMessage, createCommit, createGroup, defaultCredentialTypes, defaultProposalTypes, encode as tlsEncode, generateKeyPackage, getCiphersuiteImpl, joinGroup, makeKeyPackageRef, mlsExporter, mlsMessageDecoder, mlsMessageEncoder, nobleCryptoProvider, processMessage, unsafeTestingAuthenticationService, clientStateDecoder, clientStateEncoder, keyPackageEncoder, privateKeyPackageEncoder, type ClientState, type IncomingMessageCallback, type KeyPackage, type PrivateKeyPackage, type Welcome, } from 'ts-mls'; import { CORDN_GROUP_METADATA_EXTENSION_TYPE, APP_DATA_DICTIONARY_EXTENSION_TYPE, createCordnMetadataCapabilities, decodeBase64, encodeBase64, ensureLastResortKeyPackageExtension, getCordnGroupMetadataExtension, isLastResortKeyPackage, makeCordnGroupMetadataExtension, type CordnGroupMetadata, } from '@cordn/core'; import { getEventHash, getPublicKey } from 'nostr-tools'; import { chacha20poly1305 } from '@noble/ciphers/chacha.js'; import { concatBytes, randomBytes } from '@noble/ciphers/utils.js'; /** Cordn's ciphersuite — must match the coordinator and every other client. */ export const CORDN_CIPHERSUITE = 'MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519' as const; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); /* ── hex ──────────────────────────────────────────────────────────────────── */ const HEX = '0123456789abcdef'; export function bytesToHex(bytes: Uint8Array): string { let out = ''; for (const b of bytes) out += HEX[b >> 4] + HEX[b & 15]; return out; } export function hexToBytes(hex: string): Uint8Array { const clean = hex.trim().toLowerCase(); if (clean.length % 2 !== 0 || !/^[0-9a-f]*$/.test(clean)) { throw new Error('Invalid hex string'); } const out = new Uint8Array(clean.length / 2); for (let i = 0; i < out.length; i += 1) { out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); } return out; } /* ── identity ─────────────────────────────────────────────────────────────── */ /** A device identity: the Nostr pubkey is the MLS credential identity. */ export interface CordnMlsIdentity { /** The "stable pubkey" — the Nostr pubkey that owns this identity. */ pubkey: string; /** 32-byte secret, hex — present only for local/test identities. */ privateKeyHex?: string; } export function createIdentity(privateKeyHex?: string): CordnMlsIdentity { const secret = privateKeyHex ? hexToBytes(privateKeyHex) : randomBytes(32); const hex = bytesToHex(secret); return { privateKeyHex: hex, pubkey: getPublicKey(secret) }; } /** An identity backed by the viewer's signer (no secret is held). */ export function identityFromPubkey(pubkey: string): CordnMlsIdentity { return { pubkey }; } /* ── ciphersuite (cached) ─────────────────────────────────────────────────── */ let cipherSuitePromise: ReturnType | undefined; export function getCipherSuite(): ReturnType { cipherSuitePromise ??= getCiphersuiteImpl(CORDN_CIPHERSUITE, nobleCryptoProvider); return cipherSuitePromise; } /* ── key packages ─────────────────────────────────────────────────────────── */ export interface CordnKeyPackage { keyPackage: KeyPackage; privateKeyPackage: PrivateKeyPackage; keyPackageRef: string; keyPackageBase64: string; isLastResort: boolean; } function createCredential(stablePubkey: string) { return { credentialType: defaultCredentialTypes.basic, identity: textEncoder.encode(stablePubkey), }; } function keyPackageCapabilities(lastResort: boolean) { const capabilities = createCordnMetadataCapabilities(); if (lastResort) { capabilities.extensions = [ ...new Set([...capabilities.extensions, APP_DATA_DICTIONARY_EXTENSION_TYPE]), ]; } return capabilities; } export async function createKeyPackage( stablePubkey: string, options: { lastResort?: boolean } = {}, ): Promise { const cipherSuite = await getCipherSuite(); const generated = await generateKeyPackage({ credential: createCredential(stablePubkey), cipherSuite, capabilities: keyPackageCapabilities(Boolean(options.lastResort)), extensions: options.lastResort ? ensureLastResortKeyPackageExtension([]) : undefined, }); const ref = bytesToHex(await makeKeyPackageRef(generated.publicPackage, cipherSuite.hash)); return { keyPackage: generated.publicPackage, privateKeyPackage: generated.privatePackage, keyPackageRef: ref, keyPackageBase64: encodeBase64(tlsEncode(keyPackageEncoder, generated.publicPackage)), isLastResort: isLastResortKeyPackage(generated.publicPackage), }; } /** True when a peer's key package advertises Cordn group-metadata support. */ export function keyPackageSupportsCordnMetadata(keyPackage: KeyPackage): boolean { return keyPackage.leafNode.capabilities.extensions.includes( CORDN_GROUP_METADATA_EXTENSION_TYPE, ); } /** The stable pubkey (credential identity) a key package belongs to. */ export function keyPackageStablePubkey(keyPackage: KeyPackage): string { const credential = keyPackage.leafNode.credential; if (credential && 'identity' in credential && credential.identity) { return textDecoder.decode(credential.identity); } throw new Error('Key package has no basic credential identity'); } /** Compute a key package's reference (the coordinator's `kp_ref`). */ export async function keyPackageRefOf(keyPackage: KeyPackage): Promise { const cipherSuite = await getCipherSuite(); return bytesToHex(await makeKeyPackageRef(keyPackage, cipherSuite.hash)); } /* ── state serialization ──────────────────────────────────────────────────── */ export function serializeState(state: ClientState): string { return encodeBase64(tlsEncode(clientStateEncoder, state)); } export function deserializeState(base64: string): ClientState { const decoded = clientStateDecoder(decodeBase64(base64), 0); if (!decoded) throw new Error('Failed to decode MLS client state'); return decoded[0]; } export function serializeKeyPackage(kp: KeyPackage): string { return encodeBase64(tlsEncode(keyPackageEncoder, kp)); } export function serializePrivateKeyPackage(kp: PrivateKeyPackage): string { return encodeBase64(tlsEncode(privateKeyPackageEncoder, kp)); } /* ── group lifecycle ──────────────────────────────────────────────────────── */ /** The delivery group id — decoded verbatim from the MLS group context. */ export function groupIdOf(state: ClientState): string { return textDecoder.decode(state.groupContext.groupId); } export function groupMetadataOf(state: ClientState): CordnGroupMetadata | undefined { return getCordnGroupMetadataExtension(state); } export async function createGroupState(params: { groupId: string; keyPackage: KeyPackage; privateKeyPackage: PrivateKeyPackage; metadata?: CordnGroupMetadata; }): Promise { const cipherSuite = await getCipherSuite(); return createGroup({ context: { cipherSuite, authService: unsafeTestingAuthenticationService }, groupId: textEncoder.encode(params.groupId), keyPackage: params.keyPackage, privateKeyPackage: params.privateKeyPackage, extensions: params.metadata ? [makeCordnGroupMetadataExtension(params.metadata)] : [], }); } export interface MemberArtifacts { newState: ClientState; /** Base64 of the framed MLS Commit. */ commitMessageBase64: string; /** Raw Welcome (needed by the added member to join). */ welcome: Welcome; } export async function addMemberToGroup(params: { state: ClientState; memberKeyPackage: KeyPackage; }): Promise { const cipherSuite = await getCipherSuite(); const result = await createCommit({ context: { cipherSuite, authService: unsafeTestingAuthenticationService }, state: params.state, ratchetTreeExtension: true, extraProposals: [{ proposalType: defaultProposalTypes.add, add: { keyPackage: params.memberKeyPackage } }], }); if (!result.welcome) throw new Error('MLS commit produced no Welcome'); return { newState: result.newState, commitMessageBase64: encodeMlsMessage(result.commit), welcome: result.welcome.welcome, }; } /** Remove a member leaf from the group and produce the commit to broadcast. */ export async function removeMemberFromGroup(params: { state: ClientState; leafIndex: number; }): Promise<{ newState: ClientState; commitMessageBase64: string }> { const cipherSuite = await getCipherSuite(); const result = await createCommit({ context: { cipherSuite, authService: unsafeTestingAuthenticationService }, state: params.state, ratchetTreeExtension: true, extraProposals: [ { proposalType: defaultProposalTypes.remove, remove: { removed: params.leafIndex }, }, ], }); return { newState: result.newState, commitMessageBase64: encodeMlsMessage(result.commit), }; } export async function joinFromWelcome(params: { welcome: Welcome; keyPackage: KeyPackage; privateKeyPackage: PrivateKeyPackage; }): Promise { const cipherSuite = await getCipherSuite(); return joinGroup({ context: { cipherSuite, authService: unsafeTestingAuthenticationService }, welcome: params.welcome, keyPackage: params.keyPackage, privateKeys: params.privateKeyPackage, }); } /** All non-blank members of the current ratchet tree. */ export function listGroupMembers(state: ClientState): Array<{ leafIndex: number; pubkey: string }> { const tree = state.ratchetTree; if (!tree) return []; const members: Array<{ leafIndex: number; pubkey: string }> = []; for (let index = 0; index < tree.length; index += 1) { const node = tree[index]; if (node?.nodeType !== 1) continue; const credential = node.leaf?.credential; if (credential && 'identity' in credential && credential.identity) { members.push({ leafIndex: index / 2, pubkey: textDecoder.decode(credential.identity) }); } } return members; } /* ── messages ─────────────────────────────────────────────────────────────── */ export function encodeMlsMessage(message: Parameters[0]): string { return encodeBase64(tlsEncode(mlsMessageEncoder, message)); } export function decodeMlsMessage(base64: string) { const decoded = mlsMessageDecoder(decodeBase64(base64), 0); if (!decoded) throw new Error('Invalid MLS message'); return decoded[0]; } /* -- application envelope (the kind:9 event carried inside MLS) ------------- */ export interface CordnEnvelope { pubkey: string; created_at: number; kind: number; tags: string[][]; content: string; id?: string; } export function createEnvelope(params: { pubkey: string; content: string; tags?: string[][]; createdAt?: number; }): CordnEnvelope { return { pubkey: params.pubkey, created_at: params.createdAt ?? Math.floor(Date.now() / 1000), kind: 9, tags: params.tags ?? [], content: params.content, }; } export function finalizeEnvelope(envelope: CordnEnvelope): CordnEnvelope { const unsigned = { pubkey: envelope.pubkey, created_at: envelope.created_at, kind: envelope.kind, tags: envelope.tags, content: envelope.content, }; return { ...unsigned, id: getEventHash(unsigned) }; } export function encodeEnvelope(envelope: CordnEnvelope): Uint8Array { return textEncoder.encode(JSON.stringify(envelope)); } export function decodeEnvelope(bytes: Uint8Array): CordnEnvelope { const parsed: unknown = JSON.parse(textDecoder.decode(bytes)); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error('Invalid cordn message envelope'); } const candidate = parsed as Record; if ('sig' in candidate) throw new Error('Cordn envelope must not include sig'); if (typeof candidate.id !== 'string') throw new Error('Invalid cordn envelope'); const unsigned = { pubkey: String(candidate.pubkey ?? ''), created_at: Number(candidate.created_at ?? 0), kind: Number(candidate.kind ?? 0), tags: Array.isArray(candidate.tags) ? (candidate.tags as string[][]) : [], content: String(candidate.content ?? ''), }; if (getEventHash(unsigned) !== candidate.id) { throw new Error('Cordn envelope id mismatch'); } return { ...unsigned, id: candidate.id }; } export function prevLinksOf(tags: string[][]): string[] { return tags.filter((tag) => tag[0] === 'prev').map((tag) => tag[1]); } /** Tips of the known causal DAG: ids no known record links as a parent. */ export function causalPrevTags(ids: string[], prevLinks: string[][]): string[][] { const referenced = new Set(); for (const links of prevLinks) for (const id of links) referenced.add(id); return ids.filter((id) => !referenced.has(id)).map((id) => ['prev', id]); } export interface CordnAppMessage { envelope: CordnEnvelope; /** Proven MLS sender (from the authenticated data), not the envelope's claim. */ sender: string; } /** * Build and MLS-encrypt an application message. Returns the new local state * (the sender's ratchet advances here — it must NOT re-process its own * message from the stream) and the framed MLS message (base64). */ export async function createGroupApplicationMessage(params: { state: ClientState; sender: string; content: string; tags?: string[][]; /** Optional image URL, carried inside the encrypted envelope. */ imageUrl?: string; }): Promise<{ newState: ClientState; messageBase64: string; envelope: CordnEnvelope }> { const cipherSuite = await getCipherSuite(); const tags = [ ...(params.tags ?? []), ...(params.imageUrl ? [['image', params.imageUrl]] : []), ]; const envelope = finalizeEnvelope( createEnvelope({ pubkey: params.sender, content: params.content, tags }), ); const result = await createApplicationMessage({ context: { cipherSuite, authService: unsafeTestingAuthenticationService }, state: params.state, message: encodeEnvelope(envelope), authenticatedData: textEncoder.encode(params.sender), }); return { newState: result.newState, messageBase64: encodeMlsMessage(result.message), envelope }; } /** * Process a framed MLS message. Application messages yield the decrypted * envelope plus the proven sender (from the authenticated data); commits and * proposals advance state and carry no payload. */ export async function processGroupMessage(params: { state: ClientState; messageBase64: string; callback?: IncomingMessageCallback; }): Promise< | { kind: 'application'; newState: ClientState; message: CordnAppMessage } | { kind: 'state'; newState: ClientState } > { const cipherSuite = await getCipherSuite(); const message = decodeMlsMessage(params.messageBase64); if (message.wireformat !== 2 && message.wireformat !== 1) { throw new Error('Expected framed MLS message'); } const result = await processMessage({ context: { cipherSuite, authService: unsafeTestingAuthenticationService }, state: params.state, message, callback: params.callback, }); if (result.kind === 'applicationMessage') { const envelope = decodeEnvelope(result.message); const sender = textDecoder.decode(result.aad); return { kind: 'application', newState: result.newState, message: { envelope, sender } }; } return { kind: 'state', newState: result.newState }; } /* ── coordinator payload wrapper ──────────────────────────────────────────── */ /** * Seal a framed MLS message for the coordinator: ChaCha20-Poly1305 under a key * derived from the current epoch's MLS exporter secret. The coordinator stores * opaque bytes; only group members can open them. */ export async function sealForCoordinator(params: { state: ClientState; messageBase64: string; }): Promise { const cipherSuite = await getCipherSuite(); const key = await mlsExporter( params.state.keySchedule.exporterSecret, 'cordn', textEncoder.encode('group-payload'), 32, cipherSuite, ); const nonce = randomBytes(12); const ciphertext = chacha20poly1305(key, nonce, new Uint8Array(0)).encrypt( decodeBase64(params.messageBase64), ); return encodeBase64(concatBytes(nonce, ciphertext)); } /** Open a sealed coordinator payload back into framed MLS bytes (base64). */ export async function openFromCoordinator(params: { state: ClientState; sealedBase64: string; }): Promise { const cipherSuite = await getCipherSuite(); const payload = decodeBase64(params.sealedBase64); const nonce = payload.subarray(0, 12); const ciphertext = payload.subarray(12); const key = await mlsExporter( params.state.keySchedule.exporterSecret, 'cordn', textEncoder.encode('group-payload'), 32, cipherSuite, ); const plaintext = chacha20poly1305(key, nonce, new Uint8Array(0)).decrypt(ciphertext); return encodeBase64(plaintext); }