From 878965924b75117d9599785157ec086c5a07da4d Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Mon, 1 Jun 2026 13:31:48 -0600 Subject: [PATCH] Add nostr chat notes, update favicon, add test.txt --- kez-chat/web/NOSTR-CHAT.md | 267 ++++++++++++++++++++++++++++++++ kez-chat/web/public/favicon.ico | Bin 535 -> 504 bytes test.txt | 2 + 3 files changed, 269 insertions(+) create mode 100644 kez-chat/web/NOSTR-CHAT.md create mode 100644 test.txt diff --git a/kez-chat/web/NOSTR-CHAT.md b/kez-chat/web/NOSTR-CHAT.md new file mode 100644 index 0000000..6c5e7d4 --- /dev/null +++ b/kez-chat/web/NOSTR-CHAT.md @@ -0,0 +1,267 @@ +# Nostr Chat + +How the `nostr` branch carries kez-chat messages over Nostr relays instead +of the kez-chat server inbox — without changing the identity model or the +end-to-end encryption. + +> **One-line summary:** the chat transport is swapped from an HTTP/SSE server +> inbox to Nostr relays. Everything else — your ed25519 identity, the +> `SealedEnvelope` encryption, the UI — is untouched. Nostr only moves bytes. + +--- + +## 1. The core idea + +kez-chat already had its own end-to-end encryption. A message is sealed by +`crypto.ts` into a **`SealedEnvelope`** (AES-256-GCM body + an ed25519 sender +signature, keyed off the user's ed25519 identity). The original transport +(`messages.ts`) just `POST`s that opaque envelope to the server's +`/v1/messages` endpoint and reads it back from `/v1/inbox`. + +Because the envelope is already encrypted and self-authenticating, the +transport is interchangeable. The Nostr build keeps the exact same envelope +and changes only **how it travels**: + +``` + ┌─────────────────────── unchanged ───────────────────────┐ + plaintext ─► sealMessage() ─► SealedEnvelope ──► [ TRANSPORT ] ──► peer + │ (ed25519/x25519 + AES-GCM, crypto.ts) │ + └──────────────────────────────────────────────────────────┘ + + server transport: POST /v1/messages ┐ + GET /v1/inbox (poll+SSE) ┘ ← kez-chat server + SQLite + + nostr transport: publish event to relays ┐ + subscribe by #h tag ┘ ← public Nostr relays +``` + +The `SealedEnvelope` is the **"extra layer of encryption using our own key"** — +it exists independently of Nostr and is what actually protects the message +body. Nostr is a dumb pipe underneath it. + +--- + +## 2. Identity: bridging ed25519 onto Nostr + +KEZ identities are **ed25519**. Nostr signs events with **secp256k1** +(Schnorr). The two curves cannot be cross-derived — you cannot turn someone's +ed25519 public key into "their" Nostr public key. The bridge +(`nostr-id.ts`) solves this in two halves: + +### 2a. Signing key (derived from your own seed) + +Every account needs a secp256k1 key to sign Nostr events (relays reject +unsigned events). We derive it deterministically from the user's 32-byte +ed25519 seed: + +``` +nostrSecret = HKDF-SHA256( + ikm = ed25519_seed, + salt = "kez-chat:nostr-signkey", + info = "v1", + len = 32, +) +``` + +Properties: + +- **Deterministic** — the same account always produces the same Nostr signer, + on any device, with no extra storage. +- **Internal** — it is a pure transport credential. It is *not* the user's + real Nostr account, it is never surfaced in the UI, and its public key is + never advertised or used for addressing. +- **One-way** — HKDF means the Nostr key reveals nothing about the ed25519 + seed (the actual secret). + +### 2b. Addressing (derived from the recipient's *public* primary) + +Since we can't compute a recipient's Nostr pubkey, we don't address events to +a pubkey at all. Instead each event carries a routing label derived from the +recipient's **public** ed25519 primary (which any sender can look up in the +directory): + +``` +addr = HKDF-SHA256( + ikm = utf8(recipient_primary), // e.g. "ed25519:abc123…" + salt = "kez-chat:nostr-addr", + info = "v1", + len = 32, +) // → 32-byte hex +``` + +The sender stamps this on the event as a tag; the recipient subscribes for +events carrying their own `addr`. Both sides compute the same value from the +same public primary — the sender from a directory lookup, the recipient from +their own identity. Using a hash (rather than the raw primary) keeps the +plaintext primary out of a relay-queryable tag. + +--- + +## 3. The event format + +| Field | Value | +|--------------|--------------------------------------------------------------------| +| `kind` | `4242` (`KEZ_DM_KIND`) — a *regular* kind (1000–9999), so relays persist it | +| `tags` | `[["h", ]]` — `h` = `ADDR_TAG`, the routing label | +| `content` | `JSON.stringify(SealedEnvelope)` — our encrypted, signed envelope | +| `pubkey` | the sender's derived secp256k1 pubkey (transport credential) | +| `sig` | Schnorr signature over the event (so relays accept it) | +| `created_at` | unix seconds | + +A subscriber filters with: + +```json +{ "kinds": [4242], "#h": [""], "since": } +``` + +--- + +## 4. Message lifecycle + +### Sending (`nostr-transport.ts` → `sendMessage`) + +1. Resolve the recipient handle → ed25519 primary via the directory + (`/v1/u/` — still served by the kez-chat server). +2. `sealMessage(...)` → `SealedEnvelope` (identical to the server transport). +3. Build an event: `kind 4242`, tag `["h", addrFromPrimary(recipientPrimary)]`, + `content = JSON.stringify(envelope)`. +4. Sign it with `nostrSecretFromSeed(senderSeed)` (`finalizeEvent`). +5. `pool.publish(RELAYS, event)` — succeeds if **at least one** relay accepts. + If every relay rejects, `sendMessage` throws `"no relay accepted the message"`. + +### Receiving (`streamInbox` + `pollInbox`) + +The global `inbox-service` runs both, exactly as it did for the server +transport: + +- **`streamInbox`** opens a live subscription + (`pool.subscribeMany`) filtered on the user's own `addr`. Each event fires + `onevent`; after the relays finish replaying stored events, `oneose` flips + the UI status to **live**. +- **`pollInbox`** is a one-shot `pool.querySync` used as a heartbeat catch-up + (every 30s and on startup), so nothing is missed if the subscription drops. + +Each incoming event is: + +1. De-duplicated by event id (see §5). +2. Parsed: `content` → `SealedEnvelope`. +3. Decrypted by the **unchanged** `decrypt()` (`crypto.ts` → `openMessage`), + which verifies the sender's ed25519 signature and AES-GCM-decrypts the body. +4. Appended to the local conversation store and rendered. + +--- + +## 5. Cursors & de-duplication + +Relays can resend events, and two transports (live sub + heartbeat poll) can +deliver the same event. Both are made idempotent with per-handle +`localStorage` state: + +- **`kez-chat:nostr:since:`** — the relay `since` filter (unix + seconds). Advances as events arrive, so reloads resume instead of + re-fetching. A fresh device defaults to `now − 7 days` to catch recent + history. +- **`kez-chat:nostr:seen:`** — a capped set (last `500` ids) of + processed event ids. An id already in the set is skipped. + +### Mapping to `seq` + +The conversation store and the notification watermark were built around a +monotonic server `seq`. Nostr has no per-recipient sequence, so the transport +synthesizes one from the event's timestamp: + +``` +seq = created_at * 1000 + (parseInt(event.id.slice(0,3), 16) % 1000) +``` + +This is monotonic-by-time across sessions (so the unread/notify watermark +keeps working) and spreads messages within the same one-second granularity +using the event id, avoiding collisions between distinct same-second messages. + +--- + +## 6. Configuration + +Build-time, via Vite env (`.env` / `.env.local`): + +| Variable | Default | Meaning | +|----------------------|-----------------------------------------------------------|------------------------------------------| +| `VITE_TRANSPORT` | `server` | `server` or `nostr` — which pipe to use | +| `VITE_NOSTR_RELAYS` | `wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net` | comma-separated relay list | + +> The **code default is `server`**, so `main` and other branches are +> unaffected. This branch ships a committed `.env` that sets +> `VITE_TRANSPORT=nostr`. + +### The facade (`transport.ts`) + +`inbox-service` and the `Messages` route import `sendMessage` / `pollInbox` / +`streamInbox` / `decrypt` from `transport.ts`, which re-exports either the +server (`messages.ts`) or Nostr (`nostr-transport.ts`) implementation based on +`VITE_TRANSPORT`. Neither consumer knows which pipe is active. Switching +transports is a one-line env change + rebuild. + +--- + +## 7. Privacy model + +- **Confidential:** the message *body*. It is AES-256-GCM encrypted inside the + `SealedEnvelope` before it ever reaches a relay. Relays (and anyone reading + them) see only ciphertext. +- **Visible to relays:** the social-graph metadata. The envelope carries the + sender's primary (`from`) and recipient handle (`to`) in its JSON, and the + `#h` tag routes by recipient. A relay can therefore observe who is talking to + whom and when — **the same metadata the kez-chat server saw with the old + transport.** This is parity, not a regression. +- **Authenticity:** every envelope is signed by the sender's ed25519 key and + verified on decrypt, so a relay (or anyone) cannot forge or tamper with a + message without detection. + +If hiding the social graph from relays becomes a requirement, the next step is +an outer NIP-44 wrap around the envelope. It was deliberately left out to keep +this a clean, minimal transport swap. + +--- + +## 8. Known limitations + +- **No backfill of old server-era messages.** Messages delivered over the old + server transport were never published to relays and cannot be fetched over + Nostr. Locally cached history (IndexedDB) still renders, but it is a stale + snapshot, not relay-backed. +- **Relay acceptance varies.** Some public relays restrict event kinds or rate. + If all configured relays reject `kind 4242`, sends fail loudly. Run your own + relay or pick permissive ones via `VITE_NOSTR_RELAYS` for reliability. +- **Eventual, not guaranteed, delivery.** Delivery depends on the recipient and + sender sharing at least one reachable relay. More relays = more resilience, + more metadata exposure. +- **Same-second ordering** is approximated (see §5), not exact. + +--- + +## 9. File map + +| File | Role | +|----------------------------|-------------------------------------------------------------| +| `src/lib/crypto.ts` | **Unchanged.** The `SealedEnvelope` E2E layer (our own key).| +| `src/lib/nostr-id.ts` | Derive the secp256k1 signing key; compute recipient `addr`. | +| `src/lib/nostr-transport.ts` | Nostr `send`/`poll`/`stream`; cursor + dedupe. | +| `src/lib/transport.ts` | Facade selecting server vs nostr via `VITE_TRANSPORT`. | +| `src/lib/messages.ts` | **Unchanged.** The original server transport. | +| `src/lib/inbox-service.svelte.ts` | Imports from the facade; otherwise unchanged. | +| `src/routes/Messages.svelte` | Imports `sendMessage` from the facade. | +| `.env` | Flips this branch to `VITE_TRANSPORT=nostr`. | + +--- + +## 10. How to verify it's really on Nostr + +Open DevTools → **Network → WS** on the running app: + +- You should see live WebSocket connections to the configured relays + (`wss://relay.damus.io`, etc.). +- Sending a message emits an outgoing `["EVENT", …]` frame; receiving arrives + as an incoming event frame. +- You should **not** see `POST /v1/messages` or an SSE connection to + `/v1/inbox//stream` (those were the server transport). +- The only remaining server call is the directory lookup `GET /v1/u/`. diff --git a/kez-chat/web/public/favicon.ico b/kez-chat/web/public/favicon.ico index bfa4c206286339503ccddb1a115b0826e4def47d..c086dacff3503e2603fc6a5ef7c2c407ff285898 100644 GIT binary patch delta 453 zcmV;$0XqJd1o#6G000310RS*C000312ms;%kq|$Blu1NERCt{2nz3rbFc5}4`%Z>H z7ehK2O6Ut@%v>lp_X)Z*Tj#8Oi4J{(ZibFs+lT0qt#6@nqAHFX2MZ)yr)co^0Yn)a9FEfgfQ0D4K$L9t+t5F#mW{r+y)#;@H$J-1b^(INhmBu< zej7C}Z{A{57=Q^{?i~OtodMvS0F>`Vj1vQd5M=-q4xpBe$uoCKuh9UkeogU0b%`2Z+dJ?WK?ne@sx`!&a5?w5s&2?iVi0NQjc vMoc7q$VVxRSxNe!n^JhxfKqtagM{e^-2p5@6DFx#00000NkvXXu0mjfbQZ@D delta 484 zcmVBkq|$Bvq?ljRCt{2nmulVFc^lJs*YF; zAQl!dOT-a6W{5mT$`Kg5XX6C90LmGfElaPEE3owr5pwkbCKhV=iTLqHJ-mt(8S_5h z54JD{i$4v_GXDD0^SpOr(}QW#i)qu$a%oPSkk2X0r8(RiJb)MQ)C-_8p3QBTbg1xT z(?ctN01Q|n+GIdhnhD9=pr$e*r`jl+az84;o(d z*CZu0b`#5x`(3#xin&odMi&7fBQ9iOMZccPt;cKtQ*9-ZDsyn{qySTH=C(bXcN73i z6~r`vqXD=EU>YD?9>Bu>a@WNEdfmi+z(KGdF&RPdhWe0?VLxVNYdGj;YxvZFt>I%2 ahD^T!*KH1*dmD)W0000