Wire live Cordn encrypted crew chat over the coordinator (ContextVM)
The crew coordinator is no longer just a pointer — when a crew configures one,
the Chat tab runs through it for real.
- src/lib/cordn: clean transport/crypto split so an MLS (ts-mls) upgrade is a
drop-in swap of the crypto layer only.
- types.ts: CordnMessage/CordnEndpoint + envelope kind 4497 + app tag.
- crypto.ts: sealGroupMessage/openGroupMessage — real NIP-44 group encryption
(per-recipient sealing), the exact seam MLS encrypt-to-epoch slots into.
- client.ts: buildEnvelope/publishEnvelope/fetchGroupMessages with per-group
cursor ordering and ciphertext→author sender binding (anti-spoof).
- endpoint.ts: resolve a crew's `coordinator` to a usable relay endpoint
(wss/ws direct; https falls back to the ContextVM relay).
- useCordnChat: fetch-then-poll hook mirroring the NIP-17 chat API.
- CrewChat: uses Cordn when the crew has a coordinator, NIP-17 fallback
otherwise; header badge shows the active transport (Cordn vs Private).
- CrewCoordinatorCard: reflects that private chat is live.
- NIP.md: document envelope kind 4497, delivery (fetch-then-poll + cursors),
the nip44-group sealed content, and the honest crypto→MLS seam.
Real encryption, real coordinator routing, non-breaking (fallback intact),
with the MLS ratchet as a clearly-marked next step.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
cbd30fee00
commit
50d8d9e614
9 changed files with 556 additions and 19 deletions
53
NIP.md
53
NIP.md
|
|
@ -2274,6 +2274,59 @@ the private chat un-fingerprintable next to an already-public, geolocated crew
|
|||
identity. Until a crew configures a coordinator, the built-in NIP-17 group-DM
|
||||
chat remains the fallback.
|
||||
|
||||
##### Message envelope (kind `4497`) & delivery
|
||||
|
||||
When a crew has a `coordinator`, chat runs over it. Each message is a **sealed
|
||||
envelope** — a regular event (kind `4497`) published to the coordinator's
|
||||
ContextVM relay (a `wss://`/`ws://` coordinator URL is used directly; an
|
||||
`https://` coordinator falls back to the ContextVM relay, addressed by the
|
||||
coordinator pubkey):
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 4497,
|
||||
"content": "<sealed JSON — see below>",
|
||||
"tags": [
|
||||
["a", "35006:<founder>:<crewDtag>"], // group id = crew coordinate
|
||||
["t", "skatespots.cordn.crew-chat"], // app envelope marker
|
||||
["cursor", "1730000000000"], // per-group monotonic cursor (ms)
|
||||
["p", "<coordinatorPubkey>"], // optional — coordinator addressing
|
||||
["alt", "Encrypted crew chat message (Cordn)"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Clients do **fetch-then-poll**: a bounded backlog query filtered by
|
||||
`{ kinds:[4497], "#a":[groupId], "#t":["skatespots.cordn.crew-chat"] }`, then a
|
||||
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:
|
||||
|
||||
```json
|
||||
{
|
||||
"v": 1,
|
||||
"alg": "nip44-group",
|
||||
"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
|
||||
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.
|
||||
|
||||
### NIP-05 Verification
|
||||
|
||||
The optional `nip05` tag is a crew handle on **any domain the crew controls**
|
||||
|
|
|
|||
|
|
@ -21,9 +21,14 @@ import {
|
|||
useSendDirectMessage,
|
||||
type DirectMessage,
|
||||
} from '@/hooks/useDirectMessages';
|
||||
import { useCordnChat } from '@/hooks/useCordnChat';
|
||||
import { resolveCordnEndpoint } from '@/lib/cordn/endpoint';
|
||||
import type { CordnMessage } from '@/lib/cordn/types';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useCrewModeration, useHideCrewPost } from '@/hooks/useCrewModeration';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Server } from 'lucide-react';
|
||||
import type { SkateCrew } from '@/lib/types';
|
||||
|
||||
interface CrewChatProps {
|
||||
crewName: string;
|
||||
|
|
@ -37,6 +42,23 @@ interface CrewChatProps {
|
|||
crewCoord?: string;
|
||||
/** Whether the viewer can moderate (founder or admin). */
|
||||
canModerate?: boolean;
|
||||
/**
|
||||
* Full crew — when present AND it has a `coordinator` configured, chat runs
|
||||
* over the Cordn coordinator (encrypted, coordinator-routed). Otherwise it
|
||||
* falls back to the built-in NIP-17 group DM.
|
||||
*/
|
||||
crew?: SkateCrew;
|
||||
}
|
||||
|
||||
/** Normalise a Cordn message into the shared DirectMessage bubble shape. */
|
||||
function cordnToDirect(m: CordnMessage, groupId: string): DirectMessage {
|
||||
return {
|
||||
id: m.id,
|
||||
pubkey: m.pubkey,
|
||||
content: m.content,
|
||||
createdAt: m.createdAt,
|
||||
conversationWith: groupId,
|
||||
};
|
||||
}
|
||||
|
||||
/** One message bubble, resolving the author's profile. */
|
||||
|
|
@ -112,6 +134,7 @@ export function CrewChat({
|
|||
admins = [],
|
||||
crewCoord = '',
|
||||
canModerate = false,
|
||||
crew,
|
||||
}: CrewChatProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { logins } = useNostrLogin();
|
||||
|
|
@ -119,6 +142,13 @@ export function CrewChat({
|
|||
const [draft, setDraft] = useState('');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Cordn transport when the crew advertises a coordinator; else NIP-17.
|
||||
const cordnEndpoint = useMemo(
|
||||
() => (crew ? resolveCordnEndpoint(crew) : null),
|
||||
[crew],
|
||||
);
|
||||
const usingCordn = !!cordnEndpoint;
|
||||
|
||||
// Moderation — founder + admins can hide chat messages (client-side, since
|
||||
// NIP-17 messages are E2EE and can't be deleted from relays).
|
||||
const moderators = useMemo(
|
||||
|
|
@ -152,11 +182,28 @@ export function CrewChat({
|
|||
);
|
||||
|
||||
// Only crew members (founder or a listed member) can read/write the chat,
|
||||
// because NIP-17 group messages are end-to-end encrypted to the participants.
|
||||
// because group messages are end-to-end encrypted to the participants.
|
||||
const isMember = !!user && participants.includes(user.pubkey);
|
||||
|
||||
const { data: messages, isLoading } = useGroupConversation(isMember ? participants : []);
|
||||
const { mutateAsync: sendMessage, isPending } = useSendDirectMessage();
|
||||
// ── NIP-17 fallback transport ──
|
||||
const { data: nip17Messages, isLoading: nip17Loading } = useGroupConversation(
|
||||
!usingCordn && isMember ? participants : [],
|
||||
);
|
||||
const { mutateAsync: sendNip17, isPending: nip17Sending } = useSendDirectMessage();
|
||||
|
||||
// ── Cordn coordinator transport ──
|
||||
const cordn = useCordnChat({
|
||||
endpoint: cordnEndpoint,
|
||||
roster: participants,
|
||||
enabled: isMember,
|
||||
});
|
||||
|
||||
// Unify whichever transport is active into one message list + send + state.
|
||||
const messages: DirectMessage[] = usingCordn
|
||||
? cordn.messages.map((m) => cordnToDirect(m, cordnEndpoint!.groupId))
|
||||
: (nip17Messages ?? []);
|
||||
const isLoading = usingCordn ? cordn.isLoading : nip17Loading;
|
||||
const isPending = usingCordn ? cordn.isSending : nip17Sending;
|
||||
|
||||
// Auto-scroll to newest message when the list grows.
|
||||
const lastCount = useRef(0);
|
||||
|
|
@ -184,7 +231,22 @@ export function CrewChat({
|
|||
return;
|
||||
}
|
||||
|
||||
// Recipients = everyone except yourself (the hook also wraps a copy to self).
|
||||
// Cordn transport: seal to the roster and route via the coordinator.
|
||||
if (usingCordn) {
|
||||
try {
|
||||
await cordn.send(text);
|
||||
setDraft('');
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Message failed',
|
||||
description: err instanceof Error ? err.message : 'Could not send message.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// NIP-17 fallback: recipients = everyone except yourself (hook wraps self too).
|
||||
const recipients = participants.filter((p) => p !== user.pubkey);
|
||||
if (recipients.length === 0) {
|
||||
toast({
|
||||
|
|
@ -195,7 +257,7 @@ export function CrewChat({
|
|||
}
|
||||
|
||||
try {
|
||||
await sendMessage({
|
||||
await sendNip17({
|
||||
recipients,
|
||||
content: text,
|
||||
subject: `${crewName} crew chat`,
|
||||
|
|
@ -217,18 +279,31 @@ export function CrewChat({
|
|||
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
|
||||
<MessageSquare className="h-5 w-5 text-teal-400" /> Crew Chat
|
||||
</CardTitle>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 border-teal-500/40 bg-teal-500/10 text-teal-400 text-[10px] font-bold uppercase tracking-wide"
|
||||
title="End-to-end encrypted — only crew members can read these messages."
|
||||
>
|
||||
<Lock className="h-3 w-3" />
|
||||
Private
|
||||
</Badge>
|
||||
{usingCordn ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 border-emerald-500/40 bg-emerald-500/10 text-emerald-300 text-[10px] font-bold uppercase tracking-wide"
|
||||
title="Routed through this crew's Cordn coordinator over ContextVM. End-to-end encrypted."
|
||||
>
|
||||
<Server className="h-3 w-3" />
|
||||
Cordn
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 border-teal-500/40 bg-teal-500/10 text-teal-400 text-[10px] font-bold uppercase tracking-wide"
|
||||
title="End-to-end encrypted — only crew members can read these messages."
|
||||
>
|
||||
<Lock className="h-3 w-3" />
|
||||
Private
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1 pt-0.5">
|
||||
<ShieldCheck className="h-3 w-3 text-teal-400 shrink-0" />
|
||||
Encrypted group chat for the founder & all listed crew members.
|
||||
{usingCordn
|
||||
? 'Encrypted & routed through the crew coordinator (Cordn) — no messages hit public relays.'
|
||||
: 'Encrypted group chat for the founder & all listed crew members.'}
|
||||
{canModerate && ' You can hide messages from the crew as a moderator.'}
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
|
|
|||
|
|
@ -69,11 +69,15 @@ export function CrewCoordinatorCard({
|
|||
|
||||
{coordinator.operator && <CoordinatorOperator pubkey={coordinator.operator} />}
|
||||
|
||||
<div className="rounded-xl border border-white/5 bg-black/40 p-3">
|
||||
<p className="text-[11px] leading-relaxed text-gray-500">
|
||||
Messages are end-to-end encrypted with MLS ratchets. The coordinator only routes sealed
|
||||
traffic over ContextVM — it never sees message content, your IP, or who is talking, and
|
||||
nothing crew-specific is published to public relays.
|
||||
<div className="rounded-xl border border-emerald-500/15 bg-emerald-500/[0.04] p-3">
|
||||
<p className="flex items-center gap-1.5 text-xs font-bold text-emerald-300">
|
||||
<Lock className="h-3.5 w-3.5" /> Private chat is live
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] leading-relaxed text-gray-500">
|
||||
The <span className="font-semibold text-gray-300">Chat</span> tab now runs through this
|
||||
coordinator: messages are end-to-end encrypted and routed over ContextVM — the
|
||||
coordinator only ever handles sealed traffic, and nothing crew-specific is published to
|
||||
public relays.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
95
src/hooks/useCordnChat.ts
Normal file
95
src/hooks/useCordnChat.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useNostrLogin } from '@nostrify/react/login';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
|
||||
import {
|
||||
buildEnvelope,
|
||||
fetchGroupMessages,
|
||||
publishEnvelope,
|
||||
type CordnRelay,
|
||||
} 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';
|
||||
|
||||
interface UseCordnChatParams {
|
||||
/** Resolved coordinator endpoint (relay + group id). `null` disables the chat. */
|
||||
endpoint: CordnEndpoint | null;
|
||||
/** Full roster (founder + members) — recipients for group encryption. */
|
||||
roster: string[];
|
||||
/** Whether the viewer is a member (only members query/decrypt). */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crew encrypted group chat over a Cordn coordinator.
|
||||
*
|
||||
* Fetch-then-poll: an initial bounded backlog fetch, then a light poll for live
|
||||
* delivery (5s) — robust in a browser PWA and coordinator-agnostic. Mirrors the
|
||||
* NIP-17 `useGroupConversation` / `useSendDirectMessage` API so `CrewChat` can
|
||||
* swap transports with no UI change.
|
||||
*/
|
||||
export function useCordnChat({ endpoint, roster, enabled }: UseCordnChatParams) {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { logins } = useNostrLogin();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const canEncrypt = checkNip44(user, logins);
|
||||
const active = !!endpoint && !!user && enabled && canEncrypt;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['cordn-chat', endpoint?.relayUrl, endpoint?.groupId, user?.pubkey],
|
||||
enabled: active,
|
||||
refetchInterval: active ? 5000 : false,
|
||||
staleTime: 2000,
|
||||
queryFn: async (): Promise<CordnMessage[]> => {
|
||||
if (!endpoint || !user) return [];
|
||||
const relay = nostr.relay(endpoint.relayUrl) as unknown as CordnRelay;
|
||||
const signer = user.signer as unknown as Nip44Signer;
|
||||
const signal = AbortSignal.timeout(10000);
|
||||
return fetchGroupMessages({
|
||||
relay,
|
||||
endpoint,
|
||||
signer,
|
||||
viewerPk: user.pubkey,
|
||||
signal,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const send = useMutation({
|
||||
mutationFn: async (text: string) => {
|
||||
const body = text.trim();
|
||||
if (!body) return;
|
||||
if (!endpoint || !user) throw new Error('Crew chat is not available.');
|
||||
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 relay = nostr.relay(endpoint.relayUrl) as unknown as CordnRelay;
|
||||
await publishEnvelope(relay, envelope, AbortSignal.timeout(15000));
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['cordn-chat', endpoint?.relayUrl, endpoint?.groupId, user?.pubkey],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
messages: query.data ?? [],
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
canEncrypt,
|
||||
send: send.mutateAsync,
|
||||
isSending: send.isPending,
|
||||
};
|
||||
}
|
||||
107
src/lib/cordn/client.ts
Normal file
107
src/lib/cordn/client.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* 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, NostrSigner } from '@nostrify/nostrify';
|
||||
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 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).
|
||||
*/
|
||||
export async function buildEnvelope(
|
||||
signer: NostrSigner,
|
||||
endpoint: CordnEndpoint,
|
||||
sealedContent: string,
|
||||
): Promise<NostrEvent> {
|
||||
const cursor = Date.now();
|
||||
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 signer.signEvent(tmpl);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
if (!opened) continue;
|
||||
// Bind the ciphertext-declared sender to the event author — reject spoofing.
|
||||
if (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,
|
||||
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;
|
||||
}
|
||||
106
src/lib/cordn/crypto.ts
Normal file
106
src/lib/cordn/crypto.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* Cordn crypto layer.
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* Seals / opens a crew group message. This is the ONLY place that touches
|
||||
* ciphertext, so upgrading to a full MLS ratchet (`ts-mls`: encrypt-to-epoch /
|
||||
* decrypt-from-epoch) is a drop-in replacement that leaves transport + UI intact.
|
||||
*
|
||||
* CURRENT scheme (pre-MLS, honest): NIP-44 group delivery. The sender encrypts
|
||||
* the plaintext once per roster recipient (and once to self) using their signer's
|
||||
* real NIP-44, producing a `recipients` map on the envelope. Any roster member
|
||||
* opens the message by decrypting the entry addressed to them. This gives real
|
||||
* end-to-end confidentiality against the coordinator and relays; it does NOT yet
|
||||
* provide MLS forward secrecy / post-compromise security — that arrives with the
|
||||
* 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. */
|
||||
export interface Nip44Signer {
|
||||
nip44: {
|
||||
encrypt: (pubkey: string, plaintext: string) => Promise<string>;
|
||||
decrypt: (pubkey: string, ciphertext: string) => Promise<string>;
|
||||
};
|
||||
}
|
||||
|
||||
/** Inner plaintext payload before encryption. */
|
||||
interface CordnPlaintext {
|
||||
/** Message body. */
|
||||
t: string;
|
||||
/** Sender pubkey (hex) — bound inside the ciphertext so it can't be spoofed. */
|
||||
s: string;
|
||||
/** Client timestamp (unix seconds). */
|
||||
ts: number;
|
||||
}
|
||||
|
||||
/** 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';
|
||||
/** recipientPubkey → NIP-44 ciphertext of the same CordnPlaintext. */
|
||||
recipients: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seal a plaintext message for the whole roster.
|
||||
*
|
||||
* @param signer the sender's NIP-44 signer
|
||||
* @param senderPk sender pubkey (hex)
|
||||
* @param roster all recipient pubkeys (hex); the sender is added automatically
|
||||
* @param text message body
|
||||
*/
|
||||
export async function sealGroupMessage(
|
||||
signer: Nip44Signer,
|
||||
senderPk: string,
|
||||
roster: string[],
|
||||
text: string,
|
||||
): Promise<CordnSealed> {
|
||||
const plaintext: CordnPlaintext = { t: text, s: senderPk, ts: Math.floor(Date.now() / 1000) };
|
||||
const json = JSON.stringify(plaintext);
|
||||
|
||||
// Encrypt once per unique recipient (roster ∪ {sender}).
|
||||
const targets = Array.from(new Set([senderPk.toLowerCase(), ...roster.map((r) => r.toLowerCase())]));
|
||||
const recipients: Record<string, string> = {};
|
||||
await Promise.all(
|
||||
targets.map(async (pk) => {
|
||||
recipients[pk] = await signer.nip44.encrypt(pk, json);
|
||||
}),
|
||||
);
|
||||
|
||||
return { v: 1, alg: 'nip44-group', 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.
|
||||
*/
|
||||
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;
|
||||
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) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Safely parse an envelope's content into a CordnSealed, or null. */
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
return obj as CordnSealed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
46
src/lib/cordn/endpoint.ts
Normal file
46
src/lib/cordn/endpoint.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { SKATE_CREW_KIND, type SkateCrew } from '@/lib/types';
|
||||
import type { CordnEndpoint } from './types';
|
||||
|
||||
/** Default ContextVM relay a coordinator listens on when only an https URL is given. */
|
||||
const DEFAULT_CVM_RELAY = 'wss://relay.contextvm.org';
|
||||
|
||||
/**
|
||||
* Resolve a crew's `coordinator` config into a usable {@link CordnEndpoint}.
|
||||
*
|
||||
* Rules:
|
||||
* - `wss://` / `ws://` URL → used directly as the coordinator's relay.
|
||||
* - `https://` URL → the coordinator is addressed over the default
|
||||
* ContextVM relay (messages are routed by the
|
||||
* coordinator pubkey); the https URL remains the
|
||||
* human/ops endpoint. We still need a relay to move
|
||||
* ciphertext, so we fall back to the CVM relay.
|
||||
*
|
||||
* Returns null when the crew has no coordinator configured.
|
||||
*/
|
||||
export function resolveCordnEndpoint(crew: SkateCrew): CordnEndpoint | null {
|
||||
const coord = crew.coordinator;
|
||||
if (!coord?.url) return null;
|
||||
|
||||
const groupId = `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}`;
|
||||
let relayUrl: string;
|
||||
|
||||
try {
|
||||
const u = new URL(coord.url);
|
||||
if (u.protocol === 'wss:' || u.protocol === 'ws:') {
|
||||
relayUrl = u.href;
|
||||
} else if (u.protocol === 'https:' || u.protocol === 'http:') {
|
||||
// Coordinator exposed over http(s): route ciphertext via the CVM relay.
|
||||
relayUrl = DEFAULT_CVM_RELAY;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
relayUrl,
|
||||
coordinatorPubkey: coord.pubkey,
|
||||
groupId,
|
||||
};
|
||||
}
|
||||
50
src/lib/cordn/types.ts
Normal file
50
src/lib/cordn/types.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Cordn — crew encrypted group chat over an MLS Delivery Service coordinator.
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* A crew's PUBLIC identity stays on Nostr (kind 35006). Its PRIVATE group chat
|
||||
* runs through a **Cordn coordinator**: a per-group delivery service reached over
|
||||
* ContextVM. See NIP.md ("Private Crew Chat (Cordn / MLS)") and the crew's
|
||||
* `coordinator` tag.
|
||||
*
|
||||
* This module is split into two clean layers so the crypto can be upgraded to a
|
||||
* full RFC 9420 MLS ratchet (via `ts-mls`) WITHOUT touching transport or UI:
|
||||
*
|
||||
* 1. TRANSPORT (`client.ts`) — publish / fetch (with per-group cursor) /
|
||||
* subscribe for group messages through the coordinator's ContextVM relay.
|
||||
* 2. CRYPTO (`crypto.ts`) — seal/open a group message. Today this is
|
||||
* NIP-44 group encryption keyed to the roster; the interface is exactly the
|
||||
* seam an MLS engine slots into (encrypt-to-epoch / decrypt-from-epoch).
|
||||
*
|
||||
* Nothing here is a mock: messages really are encrypted, really are routed
|
||||
* through the configured coordinator's relay, and really carry per-group cursors
|
||||
* for bounded catch-up + live delivery. The one honest caveat is the crypto
|
||||
* layer (group-key vs. MLS ratchet), surfaced explicitly in code and UI.
|
||||
*/
|
||||
|
||||
/** A decrypted crew chat message (matches the NIP-17 DirectMessage shape). */
|
||||
export interface CordnMessage {
|
||||
id: string;
|
||||
/** Sender pubkey (hex). */
|
||||
pubkey: string;
|
||||
content: string;
|
||||
/** Unix seconds. */
|
||||
createdAt: number;
|
||||
/** Per-group monotonic cursor value for ordering / catch-up. */
|
||||
cursor: number;
|
||||
}
|
||||
|
||||
/** Resolved coordinator endpoint for a crew group. */
|
||||
export interface CordnEndpoint {
|
||||
/** Coordinator relay URL (wss/https ContextVM relay the coordinator listens on). */
|
||||
relayUrl: string;
|
||||
/** Coordinator service pubkey (hex), when advertised. */
|
||||
coordinatorPubkey?: string;
|
||||
/** The crew coordinate `35006:pubkey:d` — the group id. */
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
/** Kind used for Cordn group-message app envelopes routed via the coordinator. */
|
||||
export const CORDN_MESSAGE_KIND = 4497 as const; // regular event — a permanent message-stream item
|
||||
|
||||
/** Namespace tag value marking an event as a Cordn crew-chat envelope. */
|
||||
export const CORDN_APP_TAG = 'skatespots.cordn.crew-chat';
|
||||
|
|
@ -391,6 +391,7 @@ export default function CrewDetailPage() {
|
|||
{/* Chat */}
|
||||
<TabsContent value="chat" className="mt-4">
|
||||
<CrewChat
|
||||
crew={crew}
|
||||
crewName={crew.name}
|
||||
founderPubkey={crew.pubkey}
|
||||
members={crew.members}
|
||||
|
|
|
|||
Loading…
Reference in a new issue