Add nostr chat notes, update favicon, add test.txt

This commit is contained in:
Jason Tudisco 2026-06-01 13:31:48 -06:00
parent b1240c13e5
commit 878965924b
3 changed files with 269 additions and 0 deletions

267
kez-chat/web/NOSTR-CHAT.md Normal file
View File

@ -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 (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>`.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 535 B

After

Width:  |  Height:  |  Size: 504 B

2
test.txt Normal file
View File

@ -0,0 +1,2 @@
bla