feat(kez-chat/web): Nostr-relay chat transport (behind VITE_TRANSPORT)

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 <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-05-28 13:30:20 -06:00
parent e1f2514fae
commit 41f9442650
6 changed files with 343 additions and 2 deletions

9
kez-chat/web/.env Normal file
View File

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

View File

@ -27,7 +27,7 @@ import {
streamInbox,
type InboxMessage,
type StreamHandle,
} from "./messages.js";
} from "./transport.js";
import { lookupByPrimary } from "./api.js";
import {
appendInbound,

View File

@ -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 (10009999 → 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));
}

View File

@ -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<string> {
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";

View File

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

View File

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