SkateSpots/src/lib/cordn/native/mls.ts

511 lines
18 KiB
TypeScript

/**
* 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<typeof getCiphersuiteImpl> | undefined;
export function getCipherSuite(): ReturnType<typeof getCiphersuiteImpl> {
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<CordnKeyPackage> {
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<string> {
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<ClientState> {
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<MemberArtifacts> {
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<ClientState> {
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<typeof mlsMessageEncoder>[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<string, unknown>;
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<string>();
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<string> {
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<string> {
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);
}