511 lines
18 KiB
TypeScript
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);
|
|
}
|