268 lines
12 KiB
Markdown
268 lines
12 KiB
Markdown
# 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", <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>`.
|