From e843bd526a76618dfb92b4b9dc7a0fd37fb6943b Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 3 Sep 2026 18:14:58 +0700 Subject: [PATCH] Cordn crew chat: ephemeral envelopes (coordinator can't see the sender) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the metadata gap: the outer kind-4497 envelope was signed with the sender's real key, so a coordinator/relay could see exactly who was talking. - buildEnvelope now signs each envelope with a fresh ephemeral key (NIP-59 style) — the coordinator never learns the sender. - Authorship moves inside the ciphertext: a new v2 sealed payload (`nip44-group-v2`) carries a fully-signed inner author-proof event (kind 4498) bound to the crew via an `a` tag. openGroupMessage verifies the signature, author, content match, and group binding — so authorship stays unforgeable and a proof can't be replayed into another crew. - Transport trusts the verified v2 proof and surfaces the real sender; legacy v1 envelopes remain readable (outer-key binding) for back-compat. - NIP.md updated with the v2 format + an honest note on the residual verbatim-replay caveat (full replay/forward-secrecy hardening lands with MLS). Scope decision: kept to crew chat only. Regular 1:1/group DMs already use NIP-17 gift wrap (ephemeral keys, sealed per recipient) and have no coordinator, so they need no change and stay idiot-proof. Co-authored-by: shakespeare.diy --- NIP.md | 47 +++++++++++------ src/hooks/useCordnChat.ts | 13 +++-- src/lib/cordn/client.ts | 34 ++++++++---- src/lib/cordn/crypto.ts | 107 ++++++++++++++++++++++++++++++++++---- 4 files changed, 158 insertions(+), 43 deletions(-) diff --git a/NIP.md b/NIP.md index 5ec5dcc..fc71829 100644 --- a/NIP.md +++ b/NIP.md @@ -2526,30 +2526,47 @@ light poll for live delivery. Messages are ordered by the `cursor` tag (event id as tiebreaker). The ciphertext-declared sender is bound to the event author and mismatches are rejected (no spoofing). -**Sealed content** (`content` field), JSON: +**Envelope signing (metadata privacy).** The outer kind-`4497` event is signed +with a **fresh ephemeral key per message** (NIP-59 style), never the sender's +real key — so the coordinator/relay never learns who is talking. Authorship is +proven from *inside* the ciphertext (see the v2 author proof below), so hiding +the outer identity costs nothing in trust. + +**Sealed content** (`content` field), JSON — current version is v2: ```json { - "v": 1, - "alg": "nip44-group", + "v": 2, + "alg": "nip44-group-v2", "recipients": { "": "", "...": "..." } } ``` -The inner plaintext (`{ t: body, s: senderPubkey, ts }`) is encrypted **once per -roster recipient** (and to self) with the sender's real **NIP-44**. Any roster -member opens the entry addressed to them. This gives genuine end-to-end +The inner plaintext is `{ t: body, s: senderPubkey, ts, proof }`, encrypted +**once per roster recipient** (and to self) with the sender's real **NIP-44**. +Any roster member opens the entry addressed to them — genuine end-to-end confidentiality against the coordinator and relays. -> **Crypto seam — honest status.** The `alg` marker is `nip44-group` today: -> real E2E encryption, but **not yet MLS** — no forward secrecy / post-compromise -> security, and O(n) recipient sealing per message. The client is deliberately -> split into a **transport** layer (envelope publish/fetch/cursor) and a **crypto** -> layer (`sealGroupMessage` / `openGroupMessage`). Upgrading to a full RFC 9420 -> ratchet via `ts-mls` swaps only the crypto layer (`alg: "mls"`, -> encrypt-to-epoch / decrypt-from-epoch) — transport, envelope, cursors and UI are -> unchanged. Clients must treat unknown `alg` values as unreadable rather than -> erroring. +`proof` is a **fully-signed inner event** (kind `4498`) authored by the real +sender, whose `content` equals the message body and which carries an `["a", +groupId]` tag. On open, a client verifies the proof's signature, that its author +equals the claimed sender, that its content matches, and that its `a` tag equals +this crew — so **authorship is unforgeable even though the outer envelope is +anonymous**, and a proof can't be replayed into a different crew. + +> **Crypto seam — honest status.** `alg: "nip44-group-v2"` gives real E2E +> encryption **plus** coordinator-blind sender metadata, but it is **not MLS**: +> no forward secrecy / post-compromise security, and O(n) recipient sealing per +> message. Known residual: a member could replay another member's *verbatim* +> past message within the same crew (they cannot forge new words); full nonce/ +> replay hardening and forward secrecy arrive with the MLS upgrade. The client is +> split into a **transport** layer (envelope publish/fetch/cursor) and a +> **crypto** layer (`sealGroupMessage` / `openGroupMessage`); upgrading to a full +> RFC 9420 ratchet via `ts-mls` swaps only the crypto layer (`alg: "mls"`) — +> transport, envelope, cursors and UI unchanged. The legacy `v: 1` +> (`nip44-group`, sender-signed outer envelope) stays readable for backward +> compatibility; clients must treat unknown `alg` values as unreadable rather +> than erroring. ### NIP-05 Verification diff --git a/src/hooks/useCordnChat.ts b/src/hooks/useCordnChat.ts index e8989d6..8358855 100644 --- a/src/hooks/useCordnChat.ts +++ b/src/hooks/useCordnChat.ts @@ -11,7 +11,7 @@ import { } from '@/lib/cordn/client'; import { sealGroupMessage, type Nip44Signer } from '@/lib/cordn/crypto'; import type { CordnEndpoint, CordnMessage } from '@/lib/cordn/types'; -import type { NostrEvent, NostrSigner } from '@nostrify/nostrify'; +import type { NostrEvent } from '@nostrify/nostrify'; interface UseCordnChatParams { /** Resolved coordinator endpoint (relay + group id). `null` disables the chat. */ @@ -67,12 +67,11 @@ export function useCordnChat({ endpoint, roster, enabled }: UseCordnChatParams) if (!canEncrypt) throw new Error('Your signer does not support NIP-44 encryption.'); const signer = user.signer as unknown as Nip44Signer; - const sealed = await sealGroupMessage(signer, user.pubkey, roster, body); - const envelope: NostrEvent = await buildEnvelope( - user.signer as unknown as NostrSigner, - endpoint, - JSON.stringify(sealed), - ); + const sealed = await sealGroupMessage(signer, user.pubkey, roster, body, endpoint.groupId); + // Envelope is signed with a throwaway ephemeral key inside buildEnvelope — + // the coordinator never sees the sender. Authorship lives in the sealed + // v2 author proof instead. + const envelope: NostrEvent = await buildEnvelope(endpoint, JSON.stringify(sealed)); const relay = nostr.relay(endpoint.relayUrl) as unknown as CordnRelay; await publishEnvelope(relay, envelope, AbortSignal.timeout(15000)); diff --git a/src/lib/cordn/client.ts b/src/lib/cordn/client.ts index 1a6033d..ca97ba0 100644 --- a/src/lib/cordn/client.ts +++ b/src/lib/cordn/client.ts @@ -10,7 +10,8 @@ * plaintext. The relay/coordinator only ever routes ciphertext. */ -import type { NostrEvent, NostrSigner } from '@nostrify/nostrify'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { finalizeEvent, generateSecretKey } from 'nostr-tools'; import { CORDN_APP_TAG, CORDN_MESSAGE_KIND, type CordnEndpoint, type CordnMessage } from './types'; import { openGroupMessage, parseSealed, type Nip44Signer } from './crypto'; @@ -21,16 +22,22 @@ export interface CordnRelay { } /** - * Build the sealed envelope event for a group message and sign it with the - * sender's signer. The `cursor` is set to the current time (ms) so ordering is - * stable across coordinators (per-group monotonic; ties broken by event id). + * Build the sealed envelope event for a group message. + * + * The envelope is signed with a **fresh ephemeral key** (NIP-59 style), never + * the sender's real key — so the coordinator/relay never learns who is talking. + * Authorship is proven from *inside* the ciphertext (v2 signed author proof), + * so hiding the outer identity costs nothing in trust. + * + * The `cursor` is set to the current time (ms) so ordering is stable across + * coordinators (per-group monotonic; ties broken by event id). */ export async function buildEnvelope( - signer: NostrSigner, endpoint: CordnEndpoint, sealedContent: string, ): Promise { const cursor = Date.now(); + const ephemeralSk = generateSecretKey(); const tmpl = { kind: CORDN_MESSAGE_KIND, content: sealedContent, @@ -43,7 +50,7 @@ export async function buildEnvelope( ['alt', 'Encrypted crew chat message (Cordn)'], ] as string[][], }; - return signer.signEvent(tmpl); + return finalizeEvent(tmpl, ephemeralSk) as NostrEvent; } /** Publish a pre-built, signed envelope to the coordinator's relay. */ @@ -86,15 +93,22 @@ export async function fetchGroupMessages(params: { for (const ev of events) { const sealed = parseSealed(ev.content); if (!sealed) continue; - const opened = await openGroupMessage(signer, viewerPk, sealed); + const opened = await openGroupMessage(signer, viewerPk, sealed, endpoint.groupId); if (!opened) continue; - // Bind the ciphertext-declared sender to the event author — reject spoofing. - if (opened.sender !== ev.pubkey.toLowerCase()) continue; + + // Establish the true sender without trusting the coordinator: + // - v2 (`verified`): the crypto layer already checked a signed author proof + // inside the ciphertext, so the outer envelope key is an anonymous + // ephemeral and we simply trust `opened.sender`. + // - v1 (legacy): authorship was only bound to the outer envelope pubkey, so + // we keep the original guard and reject mismatches. + if (!opened.verified && opened.sender !== ev.pubkey.toLowerCase()) continue; + const cursorTag = ev.tags.find(([t]) => t === 'cursor')?.[1]; const cursor = cursorTag ? Number(cursorTag) : ev.created_at * 1000; out.push({ id: ev.id, - pubkey: ev.pubkey, + pubkey: opened.sender, // real sender (proven), not the ephemeral envelope key content: opened.content, createdAt: opened.ts || ev.created_at, cursor: Number.isFinite(cursor) ? cursor : ev.created_at * 1000, diff --git a/src/lib/cordn/crypto.ts b/src/lib/cordn/crypto.ts index 469fef0..801a0fc 100644 --- a/src/lib/cordn/crypto.ts +++ b/src/lib/cordn/crypto.ts @@ -14,14 +14,30 @@ * ts-mls upgrade. The seam (this file's two functions) does not change. */ -/** The subset of a NIP-44-capable signer we need. Matches window.nostr / NUser. */ +import type { NostrEvent } from '@nostrify/nostrify'; +import { verifyEvent } from 'nostr-tools'; + +/** + * The signer subset we need: NIP-44 encrypt/decrypt for confidentiality, plus + * `signEvent` so the sender can produce a signed **author proof** that survives + * an ephemeral (throwaway) outer envelope key. Matches window.nostr / NUser. + */ export interface Nip44Signer { nip44: { encrypt: (pubkey: string, plaintext: string) => Promise; decrypt: (pubkey: string, ciphertext: string) => Promise; }; + signEvent: (event: { + kind: number; + content: string; + created_at: number; + tags: string[][]; + }) => Promise; } +/** Kind of the inner "author proof" rumor the sender signs (never published raw). */ +const CORDN_INNER_KIND = 4498; + /** Inner plaintext payload before encryption. */ interface CordnPlaintext { /** Message body. */ @@ -30,13 +46,30 @@ interface CordnPlaintext { s: string; /** Client timestamp (unix seconds). */ ts: number; + /** + * Author proof (v2 only): a fully-signed Nostr event by the REAL sender whose + * `content` is the message body. Because it carries the sender's own schnorr + * signature, any roster member can verify authorship after decrypting — even + * though the outer Cordn envelope is signed by a throwaway ephemeral key the + * coordinator can't link to anyone. Absent in v1 (which trusted the outer + * envelope signature and therefore leaked the sender to the coordinator). + */ + proof?: NostrEvent; } /** The sealed content shape stored in the envelope event's `content`. */ export interface CordnSealed { - v: 1; - /** Encryption scheme marker. `nip44-group` today; `mls` after the upgrade. */ - alg: 'nip44-group'; + v: 1 | 2; + /** + * Encryption scheme marker. + * - `nip44-group` (v1): sender-signed outer envelope; coordinator sees the + * sender pubkey. Kept readable for backward compatibility. + * - `nip44-group-v2` (v2): ephemeral-signed outer envelope (coordinator never + * learns who is talking) + a signed in-ciphertext author + * proof so authorship is still unforgeable. + * - `mls` (future): full RFC 9420 ratchet. + */ + alg: 'nip44-group' | 'nip44-group-v2'; /** recipientPubkey → NIP-44 ciphertext of the same CordnPlaintext. */ recipients: Record; } @@ -54,8 +87,25 @@ export async function sealGroupMessage( senderPk: string, roster: string[], text: string, + groupId: string, ): Promise { - const plaintext: CordnPlaintext = { t: text, s: senderPk, ts: Math.floor(Date.now() / 1000) }; + const ts = Math.floor(Date.now() / 1000); + + // Author proof: the REAL sender signs an inner rumor carrying the message. + // This is what makes authorship unforgeable once the outer envelope is signed + // by a throwaway ephemeral key (so the coordinator can't see the sender). The + // `a` tag binds the proof to THIS crew, so it can't be replayed into another. + const proof = await signer.signEvent({ + kind: CORDN_INNER_KIND, + content: text, + created_at: ts, + tags: [ + ['a', groupId], + ['alt', 'Cordn crew chat author proof'], + ], + }); + + const plaintext: CordnPlaintext = { t: text, s: senderPk.toLowerCase(), ts, proof }; const json = JSON.stringify(plaintext); // Encrypt once per unique recipient (roster ∪ {sender}). @@ -67,26 +117,59 @@ export async function sealGroupMessage( }), ); - return { v: 1, alg: 'nip44-group', recipients }; + return { v: 2, alg: 'nip44-group-v2', recipients }; } /** * Open a sealed group message as `viewerPk`. Returns the plaintext body + the - * (verified) sender, or null if this viewer isn't a recipient / decryption fails. + * (verified) sender, or null if this viewer isn't a recipient / decryption fails + * / the author proof doesn't check out. + * + * Handles both envelope versions: + * - v1 (`nip44-group`): trusts the caller to bind the sender to the outer + * envelope pubkey (the transport layer does this). + * - v2 (`nip44-group-v2`): verifies the embedded signed author proof, so the + * real sender is established from the ciphertext alone — the outer envelope + * can safely be signed by a throwaway ephemeral key. */ export async function openGroupMessage( signer: Nip44Signer, viewerPk: string, sealed: CordnSealed, -): Promise<{ content: string; sender: string; ts: number } | null> { - if (sealed.v !== 1 || sealed.alg !== 'nip44-group') return null; + groupId: string, +): Promise<{ content: string; sender: string; ts: number; verified: boolean } | null> { + const alg = sealed.alg; + if (alg !== 'nip44-group' && alg !== 'nip44-group-v2') return null; const ct = sealed.recipients[viewerPk.toLowerCase()]; if (!ct) return null; try { const json = await signer.nip44.decrypt(viewerPk, ct); const parsed = JSON.parse(json) as Partial; if (typeof parsed.t !== 'string' || typeof parsed.s !== 'string') return null; - return { content: parsed.t, sender: parsed.s.toLowerCase(), ts: parsed.ts ?? Math.floor(Date.now() / 1000) }; + const sender = parsed.s.toLowerCase(); + const ts = parsed.ts ?? Math.floor(Date.now() / 1000); + + if (alg === 'nip44-group-v2') { + // Verify the signed author proof: it must be a valid event, authored by + // the claimed sender, whose content matches the message body, and bound to + // THIS crew (so a proof can't be lifted from another group). + const proof = parsed.proof; + const proofGroup = proof?.tags.find(([t]) => t === 'a')?.[1]; + if ( + !proof || + proof.kind !== CORDN_INNER_KIND || + proof.pubkey?.toLowerCase() !== sender || + proof.content !== parsed.t || + proofGroup !== groupId || + !verifyEvent(proof) + ) { + return null; + } + return { content: parsed.t, sender, ts, verified: true }; + } + + // v1: authorship is verified by the transport layer against the outer key. + return { content: parsed.t, sender, ts, verified: false }; } catch { return null; } @@ -96,7 +179,9 @@ export async function openGroupMessage( export function parseSealed(content: string): CordnSealed | null { try { const obj = JSON.parse(content) as Partial; - if (obj.v !== 1 || obj.alg !== 'nip44-group' || typeof obj.recipients !== 'object' || !obj.recipients) { + const algOk = obj.alg === 'nip44-group' || obj.alg === 'nip44-group-v2'; + const vOk = obj.v === 1 || obj.v === 2; + if (!algOk || !vOk || typeof obj.recipients !== 'object' || !obj.recipients) { return null; } return obj as CordnSealed;