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>
121 lines
4.8 KiB
TypeScript
121 lines
4.8 KiB
TypeScript
/**
|
|
* Cordn transport layer.
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
* Publishes and fetches crew group messages through the crew's coordinator,
|
|
* on the coordinator's ContextVM relay. Messages are addressed to the group by
|
|
* the crew coordinate (`a` tag) and carry a per-group monotonic cursor for
|
|
* bounded catch-up + live delivery (fetch-then-poll).
|
|
*
|
|
* Every payload is already sealed by the crypto layer — this file never sees
|
|
* plaintext. The relay/coordinator only ever routes ciphertext.
|
|
*/
|
|
|
|
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';
|
|
|
|
/** Minimal relay handle (the object returned by `nostr.relay(url)`). */
|
|
export interface CordnRelay {
|
|
query: (filters: unknown[], opts: { signal: AbortSignal }) => Promise<NostrEvent[]>;
|
|
event: (event: NostrEvent, opts?: { signal: AbortSignal }) => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* 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(
|
|
endpoint: CordnEndpoint,
|
|
sealedContent: string,
|
|
): Promise<NostrEvent> {
|
|
const cursor = Date.now();
|
|
const ephemeralSk = generateSecretKey();
|
|
const tmpl = {
|
|
kind: CORDN_MESSAGE_KIND,
|
|
content: sealedContent,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [
|
|
['a', endpoint.groupId], // group = crew coordinate 35006:pubkey:d
|
|
['t', CORDN_APP_TAG],
|
|
['cursor', String(cursor)],
|
|
...(endpoint.coordinatorPubkey ? [['p', endpoint.coordinatorPubkey]] : []),
|
|
['alt', 'Encrypted crew chat message (Cordn)'],
|
|
] as string[][],
|
|
};
|
|
return finalizeEvent(tmpl, ephemeralSk) as NostrEvent;
|
|
}
|
|
|
|
/** Publish a pre-built, signed envelope to the coordinator's relay. */
|
|
export async function publishEnvelope(
|
|
relay: CordnRelay,
|
|
envelope: NostrEvent,
|
|
signal: AbortSignal,
|
|
): Promise<void> {
|
|
await relay.event(envelope, { signal });
|
|
}
|
|
|
|
/**
|
|
* Fetch the group's message backlog from the coordinator, newest-capped by
|
|
* `limit`, and decrypt everything this viewer can open. Returns messages sorted
|
|
* oldest→newest by cursor.
|
|
*/
|
|
export async function fetchGroupMessages(params: {
|
|
relay: CordnRelay;
|
|
endpoint: CordnEndpoint;
|
|
signer: Nip44Signer;
|
|
viewerPk: string;
|
|
limit?: number;
|
|
/** Only fetch events created at/after this unix-seconds bound (catch-up window). */
|
|
sinceSeconds?: number;
|
|
signal: AbortSignal;
|
|
}): Promise<CordnMessage[]> {
|
|
const { relay, endpoint, signer, viewerPk, limit = 200, sinceSeconds, signal } = params;
|
|
|
|
const filter: Record<string, unknown> = {
|
|
kinds: [CORDN_MESSAGE_KIND],
|
|
'#a': [endpoint.groupId],
|
|
'#t': [CORDN_APP_TAG],
|
|
limit,
|
|
};
|
|
if (sinceSeconds) filter.since = sinceSeconds;
|
|
|
|
const events = await relay.query([filter], { signal });
|
|
|
|
const out: CordnMessage[] = [];
|
|
for (const ev of events) {
|
|
const sealed = parseSealed(ev.content);
|
|
if (!sealed) continue;
|
|
const opened = await openGroupMessage(signer, viewerPk, sealed, endpoint.groupId);
|
|
if (!opened) 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: 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,
|
|
});
|
|
}
|
|
|
|
// Oldest → newest by cursor, id as tiebreaker for determinism.
|
|
out.sort((a, b) => (a.cursor - b.cursor) || a.id.localeCompare(b.id));
|
|
return out;
|
|
}
|