Kez/kez-chat/web/NOSTR-CHAT.md
2026-06-01 13:31:48 -06:00

268 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 (10009999), so relays persist it |
| `tags` | `[["h", <recipient addr hex>]]``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": ["<my addr hex>"], "since": <unix-seconds> }
```
---
## 4. Message lifecycle
### Sending (`nostr-transport.ts` → `sendMessage`)
1. Resolve the recipient handle → ed25519 primary via the directory
(`/v1/u/<handle>` — 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:<handle>`** — 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:<handle>`** — 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/<handle>/stream` (those were the server transport).
- The only remaining server call is the directory lookup `GET /v1/u/<handle>`.