Cordn crew chat: ephemeral envelopes (coordinator can't see the sender)

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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-03 18:14:58 +07:00
parent 739469aa1d
commit e843bd526a
4 changed files with 158 additions and 43 deletions

47
NIP.md
View file

@ -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": { "<memberPubkeyHex>": "<nip44-ciphertext>", "...": "..." }
}
```
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

View file

@ -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));

View file

@ -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<NostrEvent> {
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,

View file

@ -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<string>;
decrypt: (pubkey: string, ciphertext: string) => Promise<string>;
};
signEvent: (event: {
kind: number;
content: string;
created_at: number;
tags: string[][];
}) => Promise<NostrEvent>;
}
/** 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<string, string>;
}
@ -54,8 +87,25 @@ export async function sealGroupMessage(
senderPk: string,
roster: string[],
text: string,
groupId: string,
): Promise<CordnSealed> {
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<CordnPlaintext>;
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<CordnSealed>;
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;