From 41f9442650a508ce0460a1cd5c1778f877aa73e4 Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Thu, 28 May 2026 13:30:20 -0600 Subject: [PATCH] feat(kez-chat/web): Nostr-relay chat transport (behind VITE_TRANSPORT) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the chat transport from the kez-chat server inbox to Nostr relays without touching the identity model or the E2E crypto. The existing SealedEnvelope (ed25519/x25519 + AES-GCM, our own key) is unchanged and becomes the content of a Nostr event — Nostr only moves the bytes. - nostr-id.ts: derive a secp256k1 signing key from the ed25519 seed (HKDF, domain-separated — internal transport credential, never the user's real Nostr account); route by a hash of the recipient's public ed25519 primary since the curves can't be cross-derived. - nostr-transport.ts: send/poll/stream mirroring messages.ts, via SimplePool; per-handle time cursor + seen-id dedupe in localStorage. - transport.ts: facade selecting server vs nostr via VITE_TRANSPORT (code default stays "server"; this branch's .env flips it to nostr). - inbox-service + Messages import from the facade. Directory lookup (handle->primary) still runs on the kez-chat server; identity stays internal. Metadata privacy is at parity with the server transport (relay sees the from/to graph, body stays confidential). Co-Authored-By: Claude Opus 4.7 --- kez-chat/web/.env | 9 + kez-chat/web/src/lib/inbox-service.svelte.ts | 2 +- kez-chat/web/src/lib/nostr-id.ts | 61 +++++ kez-chat/web/src/lib/nostr-transport.ts | 243 +++++++++++++++++++ kez-chat/web/src/lib/transport.ts | 28 +++ kez-chat/web/src/routes/Messages.svelte | 2 +- 6 files changed, 343 insertions(+), 2 deletions(-) create mode 100644 kez-chat/web/.env create mode 100644 kez-chat/web/src/lib/nostr-id.ts create mode 100644 kez-chat/web/src/lib/nostr-transport.ts create mode 100644 kez-chat/web/src/lib/transport.ts diff --git a/kez-chat/web/.env b/kez-chat/web/.env new file mode 100644 index 0000000..bc06b47 --- /dev/null +++ b/kez-chat/web/.env @@ -0,0 +1,9 @@ +# Nostr branch: chat transport runs over Nostr relays instead of the +# kez-chat server inbox. The code default (see src/lib/transport.ts) is +# still "server", so main/other branches are unaffected — this file is +# what flips this branch to Nostr. +VITE_TRANSPORT=nostr + +# Relays to publish to / read from (comma-separated). Optional — the +# transport falls back to these same defaults if unset. +VITE_NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net diff --git a/kez-chat/web/src/lib/inbox-service.svelte.ts b/kez-chat/web/src/lib/inbox-service.svelte.ts index e15de70..e955a92 100644 --- a/kez-chat/web/src/lib/inbox-service.svelte.ts +++ b/kez-chat/web/src/lib/inbox-service.svelte.ts @@ -27,7 +27,7 @@ import { streamInbox, type InboxMessage, type StreamHandle, -} from "./messages.js"; +} from "./transport.js"; import { lookupByPrimary } from "./api.js"; import { appendInbound, diff --git a/kez-chat/web/src/lib/nostr-id.ts b/kez-chat/web/src/lib/nostr-id.ts new file mode 100644 index 0000000..a6cd0b1 --- /dev/null +++ b/kez-chat/web/src/lib/nostr-id.ts @@ -0,0 +1,61 @@ +// Bridges the KEZ ed25519 identity onto Nostr's secp256k1 world. +// +// Nostr signs with secp256k1 (Schnorr); KEZ identities are ed25519. +// The two curves can't be cross-derived, so we can't turn someone's +// ed25519 primary into "their" Nostr pubkey. Instead: +// +// • The *signing* key is a secp256k1 key derived deterministically +// from the user's own ed25519 seed (HKDF, domain-separated). It is +// a pure transport credential — internal to this app, never the +// user's real Nostr account, and its pubkey is never advertised. +// +// • *Addressing* is done by a tag derived from the recipient's +// ed25519 PRIMARY (which is public, so any sender can compute it). +// The recipient subscribes to relays filtering on the same tag. +// +// This keeps the whole thing "internal": nothing Nostr-specific leaks +// into the UI or the KEZ identity model. Nostr is just the pipe. + +import { hkdf } from "@noble/hashes/hkdf"; +import { sha256 } from "@noble/hashes/sha2"; +import { bytesToHex } from "@noble/hashes/utils"; +import type { Identity } from "./kez.js"; + +/** Regular event kind (1000–9999 → relays persist it, which the inbox needs). */ +export const KEZ_DM_KIND = 4242; + +/** Tag name carrying the recipient address. `#h` filter on the relay side. */ +export const ADDR_TAG = "h"; + +const SIGNKEY_SALT = new TextEncoder().encode("kez-chat:nostr-signkey"); +const SIGNKEY_INFO = new TextEncoder().encode("v1"); +const ADDR_SALT = new TextEncoder().encode("kez-chat:nostr-addr"); +const ADDR_INFO = new TextEncoder().encode("v1"); + +/** + * Derive the secp256k1 secret key this identity signs Nostr events with. + * Deterministic from the 32-byte ed25519 seed, so the same account always + * produces the same Nostr signer — but it reveals nothing about the seed. + * + * HKDF output is a uniformly random 32-byte value; the probability it is + * not a valid secp256k1 scalar (≥ curve order n) is ~2⁻¹²⁸, i.e. never. + */ +export function nostrSecretFromSeed(seed: Uint8Array): Uint8Array { + if (seed.length !== 32) throw new Error(`seed must be 32 bytes, got ${seed.length}`); + return hkdf(sha256, seed, SIGNKEY_SALT, SIGNKEY_INFO, 32); +} + +/** + * Deterministic 32-byte (hex) address for a recipient, derived from their + * public ed25519 primary. Both parties can compute it: the sender from the + * directory lookup, the recipient from their own primary. Used as the value + * of the `#h` tag so a relay subscription can fan messages to the right box. + * + * It is *not* the recipient's Nostr pubkey (can't be — wrong curve); it is an + * opaque routing label. Using a hash also means the raw primary isn't sitting + * in a relay-queryable tag. + */ +export function addrFromPrimary(primary: Identity): string { + const bytes = new TextEncoder().encode(primary); + return bytesToHex(hkdf(sha256, bytes, ADDR_SALT, ADDR_INFO, 32)); +} diff --git a/kez-chat/web/src/lib/nostr-transport.ts b/kez-chat/web/src/lib/nostr-transport.ts new file mode 100644 index 0000000..c3eaea7 --- /dev/null +++ b/kez-chat/web/src/lib/nostr-transport.ts @@ -0,0 +1,243 @@ +// Nostr-relay transport. Drop-in replacement for the server inbox in +// messages.ts — same public surface (sendMessage / pollInbox / +// streamInbox / decrypt) so inbox-service and the Messages UI don't know +// or care which pipe is in use. +// +// What changes vs. the server transport: instead of POSTing the sealed +// envelope to /v1/messages and polling /v1/inbox, we publish it as the +// content of a Nostr event and subscribe to relays for events addressed +// to us. The envelope itself is byte-identical — it's still produced by +// crypto.ts (our own ed25519/x25519 + AES-GCM layer). Nostr only moves +// the bytes; our key still does the encrypting. +// +// Addressing: events carry an `#h` tag = addrFromPrimary(recipient). We +// subscribe with a `{ "#h": [myAddr] }` filter. Events are signed by a +// secp256k1 key derived from our ed25519 seed (see nostr-id.ts) purely so +// relays accept them — that key is never surfaced to the user. + +import { SimplePool, finalizeEvent, type Event, type EventTemplate } from "nostr-tools"; +import { sealMessage, type SealedEnvelope } from "./crypto.js"; +import { lookup } from "./api.js"; +import { identityFromSeed, type Identity } from "./kez.js"; +import { nostrSecretFromSeed, addrFromPrimary, KEZ_DM_KIND, ADDR_TAG } from "./nostr-id.js"; +// Decryption is transport-agnostic (just our crypto), so reuse it verbatim. +import { decrypt, type InboxMessage, type StreamHandle } from "./messages.js"; + +export { decrypt }; +export type { InboxMessage, StreamHandle }; + +/** Relays to publish to / read from. Override with VITE_NOSTR_RELAYS (csv). */ +const RELAYS: string[] = ( + import.meta.env.VITE_NOSTR_RELAYS ?? + "wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net" +) + .split(",") + .map((r: string) => r.trim()) + .filter(Boolean); + +/** One pool for the whole session — relay connections are reused. */ +let _pool: SimplePool | null = null; +function pool(): SimplePool { + if (!_pool) _pool = new SimplePool(); + return _pool; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Per-handle cursor + dedupe (localStorage, survives reloads) +// ───────────────────────────────────────────────────────────────────────────── + +const SINCE_KEY = (h: string) => `kez-chat:nostr:since:${h}`; +const SEEN_KEY = (h: string) => `kez-chat:nostr:seen:${h}`; +const SEEN_CAP = 500; + +/** Relay `since` filter (unix seconds). Start a little in the past so a + * fresh device still catches very recent messages. */ +function readSince(handle: string): number { + try { + const v = localStorage.getItem(SINCE_KEY(handle)); + return v ? parseInt(v, 10) : Math.floor(Date.now() / 1000) - 7 * 24 * 3600; + } catch { + return 0; + } +} +function bumpSince(handle: string, createdAt: number) { + try { + if (createdAt > readSince(handle)) { + localStorage.setItem(SINCE_KEY(handle), String(createdAt)); + } + } catch { + /* private mode — fine */ + } +} + +function readSeen(handle: string): Set { + try { + return new Set(JSON.parse(localStorage.getItem(SEEN_KEY(handle)) ?? "[]")); + } catch { + return new Set(); + } +} +/** Returns true if this id is new (and records it); false if already seen. */ +function markSeen(handle: string, id: string): boolean { + const seen = readSeen(handle); + if (seen.has(id)) return false; + seen.add(id); + // Bound the set so localStorage doesn't grow forever. + const arr = [...seen].slice(-SEEN_CAP); + try { + localStorage.setItem(SEEN_KEY(handle), JSON.stringify(arr)); + } catch { + /* ignore */ + } + return true; +} + +/** + * Map a Nostr event → InboxMessage. `seq` must be monotonic across sessions + * (the inbox-service notification watermark and conversations-store dedupe + * both rely on it), so we base it on created_at and spread within a second + * using the event id to avoid same-second collisions between distinct msgs. + */ +function toInboxMessage(ev: Event): InboxMessage | null { + let envelope: SealedEnvelope; + try { + envelope = JSON.parse(ev.content) as SealedEnvelope; + } catch { + return null; // not one of ours / malformed + } + const subMilli = parseInt(ev.id.slice(0, 3), 16) % 1000; + const seq = ev.created_at * 1000 + subMilli; + return { + seq, + envelope, + created_at: new Date(ev.created_at * 1000).toISOString(), + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Send +// ───────────────────────────────────────────────────────────────────────────── + +export async function sendMessage(opts: { + senderHandle: string; + senderSeed: Uint8Array; + senderPrimary: Identity; + recipient: string; + body: string; +}): Promise<{ seq: number }> { + const recipientHandle = opts.recipient.split("@")[0]; + const record = await lookup(recipientHandle); // throws on 404 + const recipientPrimary = record.primary as Identity; + + // Our own encryption layer — identical to the server transport. + const envelope = await sealMessage({ + senderSeed: opts.senderSeed, + senderPrimary: opts.senderPrimary, + recipientHandle, + recipientPrimary, + body: opts.body, + }); + + const sk = nostrSecretFromSeed(opts.senderSeed); + const tmpl: EventTemplate = { + kind: KEZ_DM_KIND, + created_at: Math.floor(Date.now() / 1000), + tags: [[ADDR_TAG, addrFromPrimary(recipientPrimary)]], + content: JSON.stringify(envelope), + }; + const signed = finalizeEvent(tmpl, sk); + + // Succeed if at least one relay accepts. + const results = await Promise.allSettled(pool().publish(RELAYS, signed)); + if (!results.some((r) => r.status === "fulfilled")) { + const why = results + .map((r) => (r.status === "rejected" ? String(r.reason) : "")) + .filter(Boolean) + .join("; "); + throw new Error(`no relay accepted the message${why ? `: ${why}` : ""}`); + } + return { seq: signed.created_at }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Inbox poll (one-shot relay query — the heartbeat catch-up path) +// ───────────────────────────────────────────────────────────────────────────── + +export async function pollInbox(opts: { + handle: string; + seed: Uint8Array; + since: number; // ignored: server uses a seq cursor, we keep a time cursor + limit?: number; +}): Promise<{ messages: InboxMessage[]; cursor: number }> { + const myPrimary = identityFromSeed(opts.seed).identity; + const addr = addrFromPrimary(myPrimary); + const since = readSince(opts.handle); + + const events = await pool().querySync(RELAYS, { + kinds: [KEZ_DM_KIND], + [`#${ADDR_TAG}`]: [addr], + since, + ...(opts.limit ? { limit: opts.limit } : {}), + }); + + const messages: InboxMessage[] = []; + let maxSeq = 0; + for (const ev of events.sort((a, b) => a.created_at - b.created_at)) { + if (!markSeen(opts.handle, ev.id)) continue; + const m = toInboxMessage(ev); + if (!m) continue; + messages.push(m); + bumpSince(opts.handle, ev.created_at); + if (m.seq > maxSeq) maxSeq = m.seq; + } + return { messages, cursor: maxSeq }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Live subscription (the SSE-equivalent push path) +// ───────────────────────────────────────────────────────────────────────────── + +export function streamInbox(opts: { + handle: string; + seed: Uint8Array; + onMessage: (msg: InboxMessage) => void; + onStatus?: (status: "connecting" | "live" | "reconnecting") => void; +}): StreamHandle { + const myPrimary = identityFromSeed(opts.seed).identity; + const addr = addrFromPrimary(myPrimary); + let closed = false; + + opts.onStatus?.("connecting"); + const sub = pool().subscribeMany( + RELAYS, + { kinds: [KEZ_DM_KIND], [`#${ADDR_TAG}`]: [addr], since: readSince(opts.handle) }, + { + onevent(ev: Event) { + if (closed) return; + if (!markSeen(opts.handle, ev.id)) return; + const m = toInboxMessage(ev); + if (!m) return; + bumpSince(opts.handle, ev.created_at); + opts.onMessage(m); + }, + oneose() { + // End of stored events from all relays → we're now live-tailing. + opts.onStatus?.("live"); + }, + }, + ); + + return { + close() { + closed = true; + sub.close(); + }, + get readyState() { + // EventSource-compatible: OPEN(1) while subscribed, CLOSED(2) after. + return closed ? 2 : 1; + }, + }; +} + +export type { SealedEnvelope }; +export type { MessagePlaintext } from "./crypto.js"; diff --git a/kez-chat/web/src/lib/transport.ts b/kez-chat/web/src/lib/transport.ts new file mode 100644 index 0000000..af7a379 --- /dev/null +++ b/kez-chat/web/src/lib/transport.ts @@ -0,0 +1,28 @@ +// Transport facade. The rest of the app (inbox-service, Messages) imports +// send/poll/stream/decrypt from here and never learns which pipe carries +// the bytes. Both transports expose an identical surface and both ship the +// same sealed envelope from crypto.ts — only the delivery mechanism differs. +// +// VITE_TRANSPORT=server (default) → kez-chat server inbox over HTTP/SSE +// VITE_TRANSPORT=nostr → Nostr relays +// +// Set it in .env / .env.local. Switching transports is build-time; there's +// no reason to flip it at runtime and keeping it static lets Vite tree-shake +// the unused transport out of the bundle. + +import * as server from "./messages.js"; +import * as nostr from "./nostr-transport.js"; + +const TRANSPORT = (import.meta.env.VITE_TRANSPORT ?? "server") as "server" | "nostr"; + +const impl = TRANSPORT === "nostr" ? nostr : server; + +export const sendMessage = impl.sendMessage; +export const pollInbox = impl.pollInbox; +export const streamInbox = impl.streamInbox; +export const decrypt = impl.decrypt; + +export type { InboxMessage, StreamHandle, SealedEnvelope, MessagePlaintext } from "./messages.js"; + +/** Which transport this build is using — handy for a debug line in the UI. */ +export const activeTransport = TRANSPORT; diff --git a/kez-chat/web/src/routes/Messages.svelte b/kez-chat/web/src/routes/Messages.svelte index 2d80e95..74152f1 100644 --- a/kez-chat/web/src/routes/Messages.svelte +++ b/kez-chat/web/src/routes/Messages.svelte @@ -2,7 +2,7 @@ import { onMount, onDestroy } from "svelte"; import { push } from "svelte-spa-router"; import { session } from "../lib/store.svelte.js"; - import { sendMessage } from "../lib/messages.js"; + import { sendMessage } from "../lib/transport.js"; import { lookup, lookupByPrimary, ApiError } from "../lib/api.js"; import { inboxService } from "../lib/inbox-service.svelte.js"; import { verifySubject } from "../lib/verify.js";