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:
parent
e1f2514fae
commit
41f9442650
9
kez-chat/web/.env
Normal file
9
kez-chat/web/.env
Normal 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
|
||||
@ -27,7 +27,7 @@ import {
|
||||
streamInbox,
|
||||
type InboxMessage,
|
||||
type StreamHandle,
|
||||
} from "./messages.js";
|
||||
} from "./transport.js";
|
||||
import { lookupByPrimary } from "./api.js";
|
||||
import {
|
||||
appendInbound,
|
||||
|
||||
61
kez-chat/web/src/lib/nostr-id.ts
Normal file
61
kez-chat/web/src/lib/nostr-id.ts
Normal 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 (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));
|
||||
}
|
||||
243
kez-chat/web/src/lib/nostr-transport.ts
Normal file
243
kez-chat/web/src/lib/nostr-transport.ts
Normal 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";
|
||||
28
kez-chat/web/src/lib/transport.ts
Normal file
28
kez-chat/web/src/lib/transport.ts
Normal 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;
|
||||
@ -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";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user