Add nostr chat notes, update favicon, add test.txt
This commit is contained in:
parent
b1240c13e5
commit
878965924b
267
kez-chat/web/NOSTR-CHAT.md
Normal file
267
kez-chat/web/NOSTR-CHAT.md
Normal 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 (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>`.
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 535 B After Width: | Height: | Size: 504 B |
Loading…
x
Reference in New Issue
Block a user