From 1dcaf8cea3687609fe0e57624e92146c5f133ec6 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Mon, 12 Oct 2026 22:14:20 +0700 Subject: [PATCH] Cordn native: real MLS client (Level 2 foundation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add src/lib/cordn/native/ — a browser-safe Cordn client doing real RFC 9420 MLS over a ContextVM coordinator, mirroring Cordn's wire contract (reimplemented, not copied from @cordn/cli). - mls.ts: identity, key packages, group create/add/join, application messages, the ChaCha20-Poly1305 coordinator wrapper, causal prev tags. - coordinator.ts: ContextVM/MCP-over-Nostr client on @contextvm/sdk, validated with @cordn/core schemas. - session.ts: CordnSession (key packages, createGroup, addMember->Welcome, joinGroup, send, fetch) with persistence behind an injectable CordnStore (MemoryCordnStore today; IndexedDB adapter TODO). - Verified end-to-end against our own coordinator: a two-member round trip (CORDN_LIVE=1 vitest run native.live.test.ts) — create, add, join, and bidirectional encrypted messages with proven senders. Deps: @cordn/core, @contextvm/mcp-sdk, @noble/ciphers pinned 2.2.0 (fixes the previously invalid tree: ts-mls wants 2.2.0, a stale root copy was 0.5.3). src/test/setup.ts is now guarded for node-environment test files. Not wired to any UI yet; nothing imports the module, so the web bundle is unchanged. --- AGENTS.md | 23 ++ package-lock.json | 189 ++++++++-- package.json | 5 +- src/lib/cordn/native/coordinator.ts | 152 ++++++++ src/lib/cordn/native/index.ts | 45 +++ src/lib/cordn/native/mls.ts | 462 +++++++++++++++++++++++ src/lib/cordn/native/native.live.test.ts | 54 +++ src/lib/cordn/native/session.ts | 449 ++++++++++++++++++++++ src/test/setup.ts | 45 ++- 9 files changed, 1382 insertions(+), 42 deletions(-) create mode 100644 src/lib/cordn/native/coordinator.ts create mode 100644 src/lib/cordn/native/index.ts create mode 100644 src/lib/cordn/native/mls.ts create mode 100644 src/lib/cordn/native/native.live.test.ts create mode 100644 src/lib/cordn/native/session.ts diff --git a/AGENTS.md b/AGENTS.md index 3370f64..78db36d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1323,6 +1323,29 @@ session with **no other memory of this project**. So: say this honestly (2026-09-29). **Still future:** real MLS (`ts-mls`) instead of group-key crypto, plus Cordn RPC interop. **Lesson: grep `src` for the feature before building it — this was nearly duplicated.** +- **Cordn native — real MLS (Level 2 foundation, 2026-10-12):** + `src/lib/cordn/native/` is a **browser-safe Cordn client doing real RFC 9420 + MLS** (not the sealed-relay stand-in above), mirroring Cordn's wire contract — + reimplemented, we do not copy `@cordn/cli`. Files: `mls.ts` (identity, key + packages, group create/add/join, application messages, the ChaCha20-Poly1305 + coordinator wrapper, causal `prev` tags), `coordinator.ts` (ContextVM client + over `@contextvm/sdk`, validated with `@cordn/core` schemas), `session.ts` + (`CordnSession`: `generateKeyPackage`/`publishKeyPackage`/`createGroup`/ + `addMember`→Welcome/`joinGroup`/`send`/`fetch`; persistence behind a + `CordnStore`, `MemoryCordnStore` today — IndexedDB adapter still TODO) and + `index.ts`. Deps added: `@cordn/core`, `@contextvm/mcp-sdk`, and + `@noble/ciphers` pinned **2.2.0** (the tree was `invalid`: `ts-mls` wants 2.2.0 + while a stale root copy was 0.5.3). ⚠️ **Verified end-to-end against our own + coordinator** — `CORDN_LIVE=1 npx vitest run + src/lib/cordn/native/native.live.test.ts` does a **two-member round trip** (A + creates group + adds B → Welcome → B joins → A↔B messages encrypt/decrypt with + proven senders). ⚠️ That live test runs in the **node** vitest environment + (`// @vitest-environment node`) — jsdom's realm makes a `Uint8Array` fail + `instanceof` inside the SDK's nested nostr-tools/@noble/hashes; `src/test/setup.ts` + is now guarded with `typeof window !== 'undefined'` so node-env files collect. + **Not wired to any UI yet** — the app still uses the sealed-relay crew chat. + Next: IndexedDB store + a crew chat panel + the coordinator welcome/join-request + flow. Nothing imports the native module, so the web bundle is unchanged. - **Map site-links strip (2026-10-05):** the desktop footer is hidden on the map (full-bleed), so `components/MapFooterStrip.tsx` surfaces the same links (Newsletter/License/Privacy/Collaborators + "not sponsored by OpenSats") as an diff --git a/package-lock.json b/package-lock.json index e41d6c3..08a61b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,9 @@ "@capacitor/geolocation": "^8.2.0", "@capacitor/local-notifications": "^8.3.1", "@cashu/cashu-ts": "2.8.1", - "@contextvm/sdk": "^0.12.0", + "@contextvm/mcp-sdk": "^1.30.0", + "@contextvm/sdk": "^0.12.5", + "@cordn/core": "^0.6.0", "@fontsource-variable/inter": "^5.2.6", "@fontsource-variable/outfit": "^5.2.8", "@fontsource/barlow-condensed": "^5.2.8", @@ -28,6 +30,7 @@ "@hookform/resolvers": "^3.9.0", "@modelcontextprotocol/sdk": "^1.29.0", "@netlify/blobs": "^11.1.1", + "@noble/ciphers": "2.2.0", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", "@nostrify/nostrify": "^0.47.1", @@ -517,14 +520,14 @@ "license": "MIT" }, "node_modules/@contextvm/sdk": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@contextvm/sdk/-/sdk-0.12.0.tgz", - "integrity": "sha512-1mQbe337rXhorjJWsXv3+5LuyjWgoeFpC6M5j5mDT9uczQ8aGDtW+qKAf9OOKlGrvLt3chC61uuEDB/G4ArP4g==", + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@contextvm/sdk/-/sdk-0.12.5.tgz", + "integrity": "sha512-csX9vqjSaMDuPBsgYAK3gk60tV+2n02UZ4A2anl2aByTSRrCLcYkX7+XCi0JQ7lNjpXR9JPlNFlSz9LcvbQNCA==", "license": "LGPL-3.0-1", "dependencies": { - "@contextvm/mcp-sdk": "^1.29.1", + "@contextvm/mcp-sdk": "^1.30.0", "@noble/hashes": "^2.2.0", - "applesauce-relay": "^5.2.0", + "applesauce-relay": "^6.0.3", "canonicalize": "^2.1.0", "nostr-tools": "~2.18.2", "pino": "^10.3.1", @@ -539,6 +542,15 @@ "typescript": "^5.9.3" } }, + "node_modules/@contextvm/sdk/node_modules/@noble/ciphers": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz", + "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@contextvm/sdk/node_modules/@noble/curves": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", @@ -694,6 +706,52 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/@cordn/core": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@cordn/core/-/core-0.6.0.tgz", + "integrity": "sha512-rhsF4r2kwQ4/EmYoU+bi+clFDGnXpUHx8IDldIiwzKPQ8PxCKM8CDdJ6CeF+lDuyt719uUK/butTjwwdqWiwDg==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "2.2.0", + "@noble/hashes": "2.2.0", + "@scure/base": "^2.2.0", + "ts-mls": "2.0.0-rc.12", + "zod": "^4.6.5" + }, + "engines": { + "node": ">=20.12" + } + }, + "node_modules/@cordn/core/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@cordn/core/node_modules/@scure/base": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.4.0.tgz", + "integrity": "sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@cordn/core/node_modules/zod": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.6.tgz", + "integrity": "sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", @@ -1583,6 +1641,27 @@ "react-hook-form": "^7.0.0" } }, + "node_modules/@hpke/common": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@hpke/common/-/common-1.10.1.tgz", + "integrity": "sha512-moJwhmtLtuxiUzzNp1jpfBfx8yefKoO9D/RCR9dmwrnc7qjJqId1rEtQz+lSlU5cabX8daToMSx/7HayXOiaFw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@hpke/core": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@hpke/core/-/core-1.9.0.tgz", + "integrity": "sha512-pFxWl1nNJeQCSUFs7+GAblHvXBCjn9EPN65vdKlYQil2aURaRxfGMO6vBKGqm1YHTKwiAxJQNEI70PbSowMP9Q==", + "license": "MIT", + "dependencies": { + "@hpke/common": "^1.10.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@html-eslint/eslint-plugin": { "version": "0.41.0", "resolved": "https://registry.npmjs.org/@html-eslint/eslint-plugin/-/eslint-plugin-0.41.0.tgz", @@ -2164,10 +2243,13 @@ } }, "node_modules/@noble/ciphers": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz", - "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==", "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, "funding": { "url": "https://paulmillr.com/funding/" } @@ -5473,9 +5555,9 @@ } }, "node_modules/applesauce-core": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/applesauce-core/-/applesauce-core-5.2.0.tgz", - "integrity": "sha512-aSuM6q6/Gs2FGUqytlHDjKZpSst2xKaT0vMXUQFWUctECNIxvwy6/hTDDInukMuI9mrQdjnO781ZJJgghI7RNw==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/applesauce-core/-/applesauce-core-6.2.0.tgz", + "integrity": "sha512-O6AlVyzqcuIhTOIuexm6UWmx7mRIa2D98gJP7K7vFGf90YdtvSH78AsKQWZXJ0Pk/K14at+9zkwfCkFr7ghgNw==", "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -5490,6 +5572,15 @@ "url": "lightning:nostrudel@geyser.fund" } }, + "node_modules/applesauce-core/node_modules/@noble/ciphers": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz", + "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/applesauce-core/node_modules/@noble/curves": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", @@ -5607,13 +5698,13 @@ } }, "node_modules/applesauce-relay": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/applesauce-relay/-/applesauce-relay-5.2.0.tgz", - "integrity": "sha512-ty8PzHenocGdTr3x3It8Ql0rMD9rxB6VGCzGRfL5QF6epdstv2YHKuTyr8QdPBvf7yxfc7oZcMi6djSwNxXqkQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/applesauce-relay/-/applesauce-relay-6.2.1.tgz", + "integrity": "sha512-YIUHEtL2Fl5FZjeKYe/IuwL3OnMUNP2j4ydSr3iZDeHEZSEda4d1PoChefiNhtjE/U/F5OBHEbAX1zCHRZoxpg==", "license": "MIT", "dependencies": { - "@noble/hashes": "^1.7.1", - "applesauce-core": "^5.2.0", + "@noble/hashes": "^2.2.0", + "applesauce-core": "^6.2.0", "nanoid": "^5.0.9", "nostr-tools": "~2.19", "rxjs": "^7.8.1" @@ -5623,6 +5714,15 @@ "url": "lightning:nostrudel@geyser.fund" } }, + "node_modules/applesauce-relay/node_modules/@noble/ciphers": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz", + "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/applesauce-relay/node_modules/@noble/curves": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", @@ -5648,12 +5748,12 @@ } }, "node_modules/applesauce-relay/node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", "license": "MIT", "engines": { - "node": "^14.21.3 || >=16" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -9469,6 +9569,15 @@ } } }, + "node_modules/nostr-tools/node_modules/@noble/ciphers": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz", + "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/nostr-tools/node_modules/@noble/curves": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", @@ -12218,6 +12327,44 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/ts-mls": { + "version": "2.0.0-rc.12", + "resolved": "https://registry.npmjs.org/ts-mls/-/ts-mls-2.0.0-rc.12.tgz", + "integrity": "sha512-VAjd6uvRuydk529B/On9GgIinrUiDGGENkf7k8nDCaYZzAN7aOmJIQs9IsG8A2q7o+Ldp1bm0Ds2hhlgeqL7cg==", + "license": "MIT", + "dependencies": { + "@hpke/core": "1.9.0" + }, + "peerDependencies": { + "@hpke/chacha20poly1305": "1.8.0", + "@hpke/dhkem-x448": "1.8.0", + "@hpke/hybridkem-x-wing": "0.7.0", + "@hpke/ml-kem": "0.3.0", + "@noble/ciphers": "2.2.0", + "@noble/curves": "2.2.0", + "@noble/post-quantum": "0.6.1" + }, + "peerDependenciesMeta": { + "@hpke/chacha20poly1305": { + "optional": true + }, + "@hpke/dhkem-x448": { + "optional": true + }, + "@hpke/hybridkem-x-wing": { + "optional": true + }, + "@hpke/ml-kem": { + "optional": true + }, + "@noble/curves": { + "optional": true + }, + "@noble/post-quantum": { + "optional": true + } + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index e9ad678..12828d8 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,9 @@ "@capacitor/geolocation": "^8.2.0", "@capacitor/local-notifications": "^8.3.1", "@cashu/cashu-ts": "2.8.1", - "@contextvm/sdk": "^0.12.0", + "@contextvm/mcp-sdk": "^1.30.0", + "@contextvm/sdk": "^0.12.5", + "@cordn/core": "^0.6.0", "@fontsource-variable/inter": "^5.2.6", "@fontsource-variable/outfit": "^5.2.8", "@fontsource/barlow-condensed": "^5.2.8", @@ -48,6 +50,7 @@ "@hookform/resolvers": "^3.9.0", "@modelcontextprotocol/sdk": "^1.29.0", "@netlify/blobs": "^11.1.1", + "@noble/ciphers": "2.2.0", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", "@nostrify/nostrify": "^0.47.1", diff --git a/src/lib/cordn/native/coordinator.ts b/src/lib/cordn/native/coordinator.ts new file mode 100644 index 0000000..793356b --- /dev/null +++ b/src/lib/cordn/native/coordinator.ts @@ -0,0 +1,152 @@ +/** + * Cordn native — coordinator transport (ContextVM / MCP over Nostr). + * ───────────────────────────────────────────────────────────────────────────── + * A browser-safe Cordn coordinator client: the same ContextVM request/response + * the reference `cordn` CLI speaks, built on `@contextvm/sdk` and validated with + * `@cordn/core`'s wire schemas. The coordinator routes **opaque MLS ciphertext** + * plus metadata (which group, when) and never sees plaintext. + * + * Reimplemented from the Cordn wire contract — not copied from `@cordn/cli`. + */ + +import { Client } from '@contextvm/mcp-sdk/client'; +import { + ApplesauceRelayPool, + EncryptionMode, + NostrClientTransport, + PrivateKeySigner, +} from '@contextvm/sdk'; +import { + COORDINATOR_METHODS, + consumeKeyPackageOutputSchema, + decodeBase64, + decodeKeyPackage, + fetchManyGroupMessagesOutputSchema, + groupMessageSchema, + listAvailableKeyPackagesOutputSchema, + postGroupMessageOutputSchema, + publishKeyPackageOutputSchema, + type FetchManyGroupMessagesOutput, + type GroupMessage, + type ListAvailableKeyPackagesOutput, + type PostGroupMessageOutput, + type PublishKeyPackageOutput, +} from '@cordn/core'; +import type { KeyPackage } from 'ts-mls'; + +/** The coordinator's consumed-key-package record (its publication event). */ +interface ConsumedKeyPackageRecord { + pk: string; + kp_ref: string; + last_resort: boolean; + at: number; + event: { content: string }; +} + +export interface CordnCoordinatorConfig { + /** Coordinator service pubkey (hex). */ + serverPubkey: string; + /** ContextVM relays that reach the coordinator. */ + relays: string[]; + /** Client identity secret (hex). */ + privateKeyHex: string; + /** Gift-wrap requests so relays cannot read the method/group id. */ + encryptTransport?: boolean; +} + +export class CordnCoordinatorClient { + private readonly client: Client; + private readonly transport: NostrClientTransport; + private readonly connected: Promise; + + constructor(config: CordnCoordinatorConfig) { + this.client = new Client({ name: 'SkateSpotsCordn', version: '1.0.0' }); + this.transport = new NostrClientTransport({ + serverPubkey: config.serverPubkey, + signer: new PrivateKeySigner(config.privateKeyHex), + relayHandler: new ApplesauceRelayPool(config.relays), + isStateless: true, + logLevel: 'silent', + ...(config.encryptTransport + ? { encryptionMode: EncryptionMode.REQUIRED } + : { encryptionMode: EncryptionMode.DISABLED }), + openStream: { enabled: true }, + oversizedTransfer: { enabled: true }, + }); + this.connected = Promise.resolve(this.client.connect(this.transport)).then(() => undefined); + } + + private async call( + name: string, + args: Record, + schema: { parse: (value: unknown) => S }, + ): Promise { + await this.connected; + const result = await this.client.callTool( + { name, arguments: args }, + undefined, + { onprogress: () => undefined, resetTimeoutOnProgress: true }, + ); + return schema.parse(result.structuredContent); + } + + /** Publish a key package for others to add this identity to a group. */ + async publishKeyPackage(kpRef: string, keyPackageBase64: string): Promise { + return this.call( + COORDINATOR_METHODS.publishKeyPackage, + { kp_ref: kpRef, kp_64: keyPackageBase64 }, + publishKeyPackageOutputSchema, + ); + } + + /** List the key packages other members have published. */ + async listAvailableKeyPackages(): Promise { + return this.call(COORDINATOR_METHODS.listAvailableKeyPackages, {}, listAvailableKeyPackagesOutputSchema); + } + + /** Fetch a member's full key package and mark it consumed for this identity. */ + async consumeKeyPackage(id: string): Promise { + const result = await this.call( + COORDINATOR_METHODS.consumeKeyPackage, + { id }, + consumeKeyPackageOutputSchema, + ); + if (!result.keyPackage) throw new Error(`Coordinator has no key package for ${id}`); + return decodeConsumedKeyPackage(result.keyPackage); + } + + /** Post a sealed MLS message to a group; returns the stream cursor. */ + async postGroupMessage(gid: string, sealedBase64: string): Promise { + return this.call( + COORDINATOR_METHODS.postGroupMessage, + { msg_64: sealedBase64, gid }, + postGroupMessageOutputSchema, + ); + } + + /** Fetch group messages after a cursor (one entry per group). */ + async fetchManyGroupMessages( + groups: Array<{ gid: string; after?: number }>, + ): Promise { + const result = await this.call<{ messages: unknown[] }>( + COORDINATOR_METHODS.fetchManyGroupMessages, + { groups }, + fetchManyGroupMessagesOutputSchema, + ); + return { messages: result.messages.map((m) => groupMessageSchema.parse(m)) as GroupMessage[] }; + } + + async disconnect(): Promise { + await this.connected.catch(() => undefined); + await this.transport.close().catch(() => undefined); + } +} + +/** Decode a consumed key package (its publication event carries the kp_64). */ +export function decodeConsumedKeyPackage(consumed: ConsumedKeyPackageRecord): KeyPackage { + const parsed: unknown = JSON.parse(consumed.event.content); + const args = (parsed as { params?: { arguments?: Record } })?.params?.arguments; + const kp64 = args?.kp_64 ?? args?.keyPackageBase64; + if (typeof kp64 !== 'string') throw new Error('Consumed key package has no kp_64'); + return decodeKeyPackage(decodeBase64(kp64), 'consumed key package'); +} diff --git a/src/lib/cordn/native/index.ts b/src/lib/cordn/native/index.ts new file mode 100644 index 0000000..165f477 --- /dev/null +++ b/src/lib/cordn/native/index.ts @@ -0,0 +1,45 @@ +/** + * Cordn native (Level 2) — a browser-safe Cordn/MLS client. + * + * Replaces the sealed-relay stand-in in `../client.ts` with real RFC 9420 MLS + * (via `ts-mls`) delivered through a Cordn coordinator over ContextVM. + * + * Usage: + * const session = await CordnSession.open({ coordinator, store }); + * await session.generateKeyPackage(); + * await session.publishKeyPackage(); + * const group = await session.createGroup({ name: 'Crew' }); + * const { welcomeBase64 } = await session.addMember(group.gid, memberKeyPackage); + * await session.send(group.gid, 'hello'); + * await session.fetch(group.gid); + * + * Everything is isomorphic; persistence is injected via {@link CordnStore}. + * See `native.live.test.ts` for a verified two-member round trip. + */ + +export { + CordnSession, + MemoryCordnStore, + type CordnStore, + type CordnSnapshot, + type CordnSessionOptions, + type CordnChatMessage, + type CordnGroupInfo, + type StoredKeyPackage, +} from './session'; + +export { + CordnCoordinatorClient, + type CordnCoordinatorConfig, +} from './coordinator'; + +export { + createIdentity, + createKeyPackage, + serializeState, + deserializeState, + groupIdOf, + listGroupMembers, + type CordnMlsIdentity, + type CordnKeyPackage, +} from './mls'; diff --git a/src/lib/cordn/native/mls.ts b/src/lib/cordn/native/mls.ts new file mode 100644 index 0000000..9dadc74 --- /dev/null +++ b/src/lib/cordn/native/mls.ts @@ -0,0 +1,462 @@ +/** + * 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 secret is also the MLS credential identity. */ +export interface CordnMlsIdentity { + /** 32-byte secret, hex. */ + privateKeyHex: string; + /** The "stable pubkey" — the Nostr pubkey derived from the secret. */ + pubkey: string; +} + +export function createIdentity(privateKeyHex?: string): CordnMlsIdentity { + const secret = privateKeyHex + ? hexToBytes(privateKeyHex) + : randomBytes(32); + const hex = bytesToHex(secret); + return { privateKeyHex: hex, pubkey: getPublicKey(secret) }; +} + +/* ── 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, + ); +} + +/* ── 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, + }; +} + +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[][]; +}): Promise<{ newState: ClientState; messageBase64: string; envelope: CordnEnvelope }> { + const cipherSuite = await getCipherSuite(); + const envelope = finalizeEnvelope( + createEnvelope({ pubkey: params.sender, content: params.content, tags: params.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); +} diff --git a/src/lib/cordn/native/native.live.test.ts b/src/lib/cordn/native/native.live.test.ts new file mode 100644 index 0000000..07b2269 --- /dev/null +++ b/src/lib/cordn/native/native.live.test.ts @@ -0,0 +1,54 @@ +// @vitest-environment node +/** + * Live Cordn-native smoke test — runs ONLY with `CORDN_LIVE=1` (it talks to the + * real coordinator at `wss://relay.skate-spots.org`). Kept out of the default + * `npm test` run so CI never depends on the network. + * + * CORDN_LIVE=1 npx vitest run src/lib/cordn/native/native.live.test.ts + * + * Runs in the Node environment (not jsdom): the ContextVM transport's nested + * nostr-tools/@noble/hashes reject a Uint8Array from another realm. + */ +import { describe, expect, it } from 'vitest'; +import { CordnSession, MemoryCordnStore } from './session'; +import { SKATESPOTS_COORDINATOR } from '../../coordinator'; + +const live = process.env.CORDN_LIVE === '1'; + +describe.skipIf(!live)('cordn native (live)', () => { + it('creates a group, adds a member, and exchanges MLS messages', async () => { + const coordinator = { + serverPubkey: SKATESPOTS_COORDINATOR.pubkey, + relays: SKATESPOTS_COORDINATOR.relays, + }; + + const a = await CordnSession.open({ coordinator, store: new MemoryCordnStore() }); + const b = await CordnSession.open({ coordinator, store: new MemoryCordnStore() }); + + await a.generateKeyPackage('a'); + const bKeyPackage = await b.generateKeyPackage('b'); + await a.publishKeyPackage('a'); + await b.publishKeyPackage('b'); + + const groupId = `skate-native-${Date.now()}`; + const group = await a.createGroup({ groupId, name: 'Native test' }); + const { welcomeBase64 } = await a.addMember(group.gid, bKeyPackage.keyPackage); + + const bGroup = await b.joinGroup({ welcomeBase64, keyPackage: bKeyPackage, name: 'Native test' }); + expect(bGroup.members.map((m) => m.pubkey)).toContain(a.pubkey); + expect(bGroup.members.map((m) => m.pubkey)).toContain(b.pubkey); + + await a.send(group.gid, 'hello from A'); + const receivedByB = await b.fetch(bGroup.gid); + expect(receivedByB.map((m) => m.content)).toContain('hello from A'); + expect(receivedByB.find((m) => m.content === 'hello from A')?.sender).toBe(a.pubkey); + + await b.send(bGroup.gid, 'hi from B'); + const receivedByA = await a.fetch(group.gid); + expect(receivedByA.map((m) => m.content)).toContain('hi from B'); + expect(receivedByA.find((m) => m.content === 'hi from B')?.sender).toBe(b.pubkey); + + await a.disconnect(); + await b.disconnect(); + }, 180_000); +}); diff --git a/src/lib/cordn/native/session.ts b/src/lib/cordn/native/session.ts new file mode 100644 index 0000000..2bfa85f --- /dev/null +++ b/src/lib/cordn/native/session.ts @@ -0,0 +1,449 @@ +/** + * Cordn native — session. + * ───────────────────────────────────────────────────────────────────────────── + * A browser-safe Cordn client session: identity, key packages, group creation, + * MLS message send/receive and bounded catch-up, all against a coordinator. This + * is the "Level 2" foundation — real RFC 9420 MLS, not the sealed-relay + * stand-in in `../client.ts`. + * + * The session is isomorphic (no Node built-ins, no DOM); persistence is behind + * the injected {@link CordnStore} so the browser can back it with IndexedDB and + * tests with {@link MemoryCordnStore}. + */ + +import { + createKeyPackage, + createGroupState, + addMemberToGroup, + joinFromWelcome, + createGroupApplicationMessage, + processGroupMessage, + sealForCoordinator, + openFromCoordinator, + groupIdOf, + groupMetadataOf, + listGroupMembers, + serializeState, + deserializeState, + serializePrivateKeyPackage, + createIdentity, + causalPrevTags, + type CordnMlsIdentity, +} from './mls'; +import { CordnCoordinatorClient, type CordnCoordinatorConfig } from './coordinator'; +import { + decodeBase64, + decodeKeyPackage, + decodePrivateKeyPackage, + decodeWelcome, + encodeBase64, + encodeKeyPackage, + encodeWelcome, +} from '@cordn/core'; +import type { ClientState, KeyPackage, PrivateKeyPackage } from 'ts-mls'; + +/* ── stored shapes ────────────────────────────────────────────────────────── */ + +export interface StoredKeyPackage { + alias: string; + keyPackage: KeyPackage; + privateKeyPackage: PrivateKeyPackage; + keyPackageRef: string; + isLastResort: boolean; + publishedAt?: number; + consumed: boolean; +} + +export interface CordnChatMessage { + id: string; + /** Proven MLS sender pubkey (from the MLS authenticated data). */ + sender: string; + content: string; + createdAt: number; + cursor: number; + direction: 'inbound' | 'outbound'; + /** Envelope ids this message causally follows (`prev` tags). */ + prev: string[]; +} + +export interface CordnGroupInfo { + gid: string; + name?: string; + members: Array<{ pubkey: string; leafIndex: number }>; + cursor: number; + messageCount: number; +} + +interface RuntimeGroup { + gid: string; + name?: string; + state: ClientState; + cursor: number; + messages: CordnChatMessage[]; + /** Sealed wrappers we posted, so self-echoes are skipped on fetch. */ + posted: Set; +} + +/* ── persistence ──────────────────────────────────────────────────────────── */ + +export interface CordnSnapshot { + version: 1; + privateKeyHex: string; + keyPackages: Array<{ + alias: string; + keyPackageBase64: string; + privateKeyPackageBase64: string; + keyPackageRef: string; + isLastResort: boolean; + publishedAt?: number; + consumed: boolean; + }>; + groups: Array<{ + gid: string; + name?: string; + stateBase64: string; + cursor: number; + messages: CordnChatMessage[]; + }>; +} + +export interface CordnStore { + load(): Promise; + save(snapshot: CordnSnapshot): Promise; + clear(): Promise; +} + +/** In-memory store — used by tests and as the default fallback. */ +export class MemoryCordnStore implements CordnStore { + private snapshot: CordnSnapshot | null = null; + async load(): Promise { + return this.snapshot; + } + async save(snapshot: CordnSnapshot): Promise { + this.snapshot = snapshot; + } + async clear(): Promise { + this.snapshot = null; + } +} + +/* ── session ──────────────────────────────────────────────────────────────── */ + +export interface CordnSessionOptions { + coordinator: Pick; + /** Existing secret (hex); omit to mint a fresh identity. */ + privateKeyHex?: string; + store?: CordnStore; + encryptTransport?: boolean; +} + +export class CordnSession { + readonly identity: CordnMlsIdentity; + private readonly options: CordnSessionOptions; + private client: CordnCoordinatorClient | undefined; + private readonly store: CordnStore; + private keyPackages: StoredKeyPackage[] = []; + private groups = new Map(); + private saveQueue: Promise = Promise.resolve(); + + private constructor(identity: CordnMlsIdentity, options: CordnSessionOptions) { + this.identity = identity; + this.options = options; + this.store = options.store ?? new MemoryCordnStore(); + } + + + /** Open a session, restoring persisted state when a store has it. */ + static async open(options: CordnSessionOptions): Promise { + const store = options.store ?? new MemoryCordnStore(); + const snapshot = await store.load(); + const identity = createIdentity(options.privateKeyHex ?? snapshot?.privateKeyHex); + const session = new CordnSession(identity, { ...options, store }); + if (snapshot) session.restore(snapshot); + return session; + } + + get pubkey(): string { + return this.identity.pubkey; + } + + private getClient(): CordnCoordinatorClient { + this.client ??= new CordnCoordinatorClient({ + serverPubkey: this.options.coordinator.serverPubkey, + relays: this.options.coordinator.relays, + privateKeyHex: this.identity.privateKeyHex, + encryptTransport: this.options.encryptTransport, + }); + return this.client; + } + + async disconnect(): Promise { + await this.client?.disconnect(); + this.client = undefined; + } + + /* ── snapshot ───────────────────────────────────────────────────────────── */ + + private restore(snapshot: CordnSnapshot): void { + this.keyPackages = snapshot.keyPackages.map((kp) => ({ + alias: kp.alias, + keyPackage: decodeKeyPackage(decodeBase64(kp.keyPackageBase64), 'snapshot key package'), + privateKeyPackage: decodePrivateKeyPackage(decodeBase64(kp.privateKeyPackageBase64), 'snapshot private key package'), + keyPackageRef: kp.keyPackageRef, + isLastResort: kp.isLastResort, + publishedAt: kp.publishedAt, + consumed: kp.consumed, + })); + this.groups = new Map( + snapshot.groups.map((g) => [ + g.gid, + { + gid: g.gid, + name: g.name, + state: deserializeState(g.stateBase64), + cursor: g.cursor, + messages: g.messages, + posted: new Set(), + } satisfies RuntimeGroup, + ]), + ); + } + + private snapshot(): CordnSnapshot { + return { + version: 1, + privateKeyHex: this.identity.privateKeyHex, + keyPackages: this.keyPackages.map((kp) => ({ + alias: kp.alias, + keyPackageBase64: encodeBase64(encodeKeyPackage(kp.keyPackage)), + privateKeyPackageBase64: serializePrivateKeyPackage(kp.privateKeyPackage), + keyPackageRef: kp.keyPackageRef, + isLastResort: kp.isLastResort, + publishedAt: kp.publishedAt, + consumed: kp.consumed, + })), + groups: [...this.groups.values()].map((g) => ({ + gid: g.gid, + name: g.name, + stateBase64: serializeState(g.state), + cursor: g.cursor, + messages: g.messages, + })), + }; + } + + private persist(): Promise { + this.saveQueue = this.saveQueue + .catch(() => undefined) + .then(() => this.store.save(this.snapshot())); + return this.saveQueue; + } + + /* ── key packages ───────────────────────────────────────────────────────── */ + + listKeyPackages(): StoredKeyPackage[] { + return this.keyPackages; + } + + /** Generate (and store) a key package others can use to add this identity. */ + async generateKeyPackage(alias = `kp-${this.keyPackages.length + 1}`): Promise { + const created = await createKeyPackage(this.identity.pubkey); + const stored: StoredKeyPackage = { + alias, + keyPackage: created.keyPackage, + privateKeyPackage: created.privateKeyPackage, + keyPackageRef: created.keyPackageRef, + isLastResort: created.isLastResort, + consumed: false, + }; + this.keyPackages.push(stored); + await this.persist(); + return stored; + } + + /** Publish a stored key package to the coordinator. */ + async publishKeyPackage(alias?: string): Promise { + const stored = + (alias ? this.keyPackages.find((kp) => kp.alias === alias) : undefined) ?? + this.keyPackages.find((kp) => !kp.consumed && kp.publishedAt === undefined) ?? + (await this.generateKeyPackage()); + const result = await this.getClient().publishKeyPackage( + stored.keyPackageRef, + encodeBase64(encodeKeyPackage(stored.keyPackage)), + ); + stored.publishedAt = result.at; + stored.isLastResort = result.last_resort; + await this.persist(); + return stored; + } + + /* ── groups ─────────────────────────────────────────────────────────────── */ + + listGroups(): CordnGroupInfo[] { + return [...this.groups.values()].map((g) => this.describeGroup(g)); + } + + private describeGroup(g: RuntimeGroup): CordnGroupInfo { + return { + gid: g.gid, + name: g.name ?? groupMetadataOf(g.state)?.name, + members: listGroupMembers(g.state), + cursor: g.cursor, + messageCount: g.messages.length, + }; + } + + private requireGroup(gid: string): RuntimeGroup { + const group = this.groups.get(gid); + if (!group) throw new Error(`Unknown group: ${gid}`); + return group; + } + + /** Create a new MLS group. `groupId` defaults to a random id. */ + async createGroup(params: { groupId?: string; name?: string } = {}): Promise { + const keyPackage = + this.keyPackages.find((kp) => !kp.consumed) ?? (await this.generateKeyPackage()); + const groupId = params.groupId ?? crypto.randomUUID(); + const state = await createGroupState({ + groupId, + keyPackage: keyPackage.keyPackage, + privateKeyPackage: keyPackage.privateKeyPackage, + metadata: params.name ? { name: params.name } : undefined, + }); + keyPackage.consumed = true; + const group: RuntimeGroup = { + gid: groupId, + name: params.name, + state, + cursor: 0, + messages: [], + posted: new Set(), + }; + this.groups.set(group.gid, group); + await this.persist(); + return this.describeGroup(group); + } + + /** + * Add a member from their key package and return the Welcome the member needs + * to join. The commit is posted to the coordinator and the local epoch adopts + * the new state. + */ + async addMember(gid: string, memberKeyPackage: KeyPackage): Promise<{ welcomeBase64: string }> { + const group = this.requireGroup(gid); + const { newState, commitMessageBase64, welcome } = await addMemberToGroup({ + state: group.state, + memberKeyPackage, + }); + // Seal the commit with the PRE-commit epoch so every existing member can + // decrypt it (mirrors the reference client). + const sealed = await sealForCoordinator({ state: group.state, messageBase64: commitMessageBase64 }); + const posted = await this.getClient().postGroupMessage(group.gid, sealed); + group.posted.add(sealed); + group.state = newState; + group.cursor = Math.max(group.cursor, posted.cursor); + await this.persist(); + return { welcomeBase64: encodeBase64(encodeWelcome(welcome)) }; + } + + /** Join a group from a Welcome produced by {@link addMember}. */ + async joinGroup(params: { + welcomeBase64: string; + keyPackage: StoredKeyPackage; + name?: string; + }): Promise { + const state = await joinFromWelcome({ + welcome: decodeWelcome(decodeBase64(params.welcomeBase64), 'welcome'), + keyPackage: params.keyPackage.keyPackage, + privateKeyPackage: params.keyPackage.privateKeyPackage, + }); + params.keyPackage.consumed = true; + const gid = groupIdOf(state); + const group: RuntimeGroup = { + gid, + name: params.name, + state, + cursor: 0, + messages: [], + posted: new Set(), + }; + this.groups.set(gid, group); + await this.persist(); + return this.describeGroup(group); + } + + /* ── messaging ──────────────────────────────────────────────────────────── */ + + messages(gid: string): CordnChatMessage[] { + return [...this.requireGroup(gid).messages].sort((a, b) => a.cursor - b.cursor); + } + + /** Encrypt, post and locally adopt an application message. */ + async send(gid: string, content: string): Promise { + const group = this.requireGroup(gid); + const { newState, messageBase64, envelope } = await createGroupApplicationMessage({ + state: group.state, + sender: this.identity.pubkey, + content, + tags: causalPrevTags( + group.messages.map((m) => m.id), + group.messages.map((m) => m.prev), + ), + }); + const sealed = await sealForCoordinator({ state: group.state, messageBase64 }); + const posted = await this.getClient().postGroupMessage(group.gid, sealed); + group.state = newState; + group.posted.add(sealed); + const stored: CordnChatMessage = { + id: envelope.id ?? '', + sender: this.identity.pubkey, + content, + createdAt: envelope.created_at, + cursor: posted.cursor, + direction: 'outbound', + prev: envelope.tags.filter((t) => t[0] === 'prev').map((t) => t[1]), + }; + group.messages.push(stored); + group.cursor = Math.max(group.cursor, posted.cursor); + await this.persist(); + return stored; + } + + /** Bounded catch-up: fetch and decrypt everything after this group's cursor. */ + async fetch(gid: string): Promise { + const group = this.requireGroup(gid); + const result = await this.getClient().fetchManyGroupMessages([ + { gid: group.gid, after: group.cursor > 0 ? group.cursor : undefined }, + ]); + const received: CordnChatMessage[] = []; + const sorted = [...result.messages].sort((a, b) => a.cursor - b.cursor); + for (const message of sorted) { + group.cursor = Math.max(group.cursor, message.cursor); + if (group.posted.has(message.msg_64)) continue; // our own self-echo + let messageBase64: string; + try { + messageBase64 = await openFromCoordinator({ state: group.state, sealedBase64: message.msg_64 }); + } catch { + continue; // sealed under an epoch we have not joined yet + } + const processed = await processGroupMessage({ state: group.state, messageBase64 }); + group.state = processed.newState; + if (processed.kind !== 'application') continue; + const { envelope, sender } = processed.message; + const stored: CordnChatMessage = { + id: envelope.id ?? message.msg_64, + sender, + content: envelope.content, + createdAt: envelope.created_at, + cursor: message.cursor, + direction: sender === this.identity.pubkey ? 'outbound' : 'inbound', + prev: envelope.tags.filter((t) => t[0] === 'prev').map((t) => t[1]), + }; + group.messages.push(stored); + received.push(stored); + } + await this.persist(); + return received; + } +} + diff --git a/src/test/setup.ts b/src/test/setup.ts index fb9808b..ee41405 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,26 +1,31 @@ import '@testing-library/jest-dom'; import { vi } from 'vitest'; -// Mock window.matchMedia -Object.defineProperty(window, 'matchMedia', { - writable: true, - value: vi.fn().mockImplementation((query) => ({ - matches: false, - media: query, - onchange: null, - addListener: vi.fn(), // deprecated - removeListener: vi.fn(), // deprecated - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - })), -}); +// jsdom-only mocks. Node-environment test files (e.g. the live Cordn-native +// test) have no `window`, so everything here is guarded — otherwise merely +// collecting such a file throws "window is not defined". +if (typeof window !== 'undefined') { + // Mock window.matchMedia + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), // deprecated + removeListener: vi.fn(), // deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); -// Mock window.scrollTo -Object.defineProperty(window, 'scrollTo', { - writable: true, - value: vi.fn(), -}); + // Mock window.scrollTo + Object.defineProperty(window, 'scrollTo', { + writable: true, + value: vi.fn(), + }); +} // Mock IntersectionObserver global.IntersectionObserver = vi.fn().mockImplementation((_callback) => ({ @@ -37,4 +42,4 @@ global.ResizeObserver = vi.fn().mockImplementation((_callback) => ({ observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn(), -})); \ No newline at end of file +}));