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:
parent
739469aa1d
commit
e843bd526a
4 changed files with 158 additions and 43 deletions
47
NIP.md
47
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": { "<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
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue