Kez/kez-chat/web/NOSTR-CHAT.md
Jason Tudisco b237ac2d9e docs+fix: SPEC v0.4, Node nostr channel checks kinds 0/1/30078, NOSTR-CHAT.md current
SPEC.md → v0.4
- §3.1 BIP-39 recovery phrases (12-word hashed with "kez-bip39-12-v1",
  24-word = seed), parsing rules, test vectors.
- §7 nostr: kind 30078 canonical (MUST); verifiers SHOULD also check
  kind-1 notes and the kind-0 `about`.
- §9.1 sigchain HTTP store API; §11 BIP-39 primitive; §15 Python and
  kez-chat; §16 applications (kez-chat tags); changelog.

nodejs kez-channels
- NostrChannel now requests kinds 0, 1, 30078 and extracts the proof
  body per kind (kind-0 `about` JSON-decoded), mirroring Rust's
  extract_proof_body. Proofs posted as kind-1 notes (e.g. via NIP-07
  from kez-chat) previously failed to verify in Node.
- Tests for kind-1 Markdown notes, kind-0 about, and extractProofBody.

Docs
- kez-chat/web/NOSTR-CHAT.md rewritten for the current protocol: `q`
  routing tag, kinds 4242/4244/10142/0/10002, KEZ-signed heartbeats,
  v1–v4 envelopes, receipts, relay ordering, honest metadata model.
- Stale `#h` comments in nostr-id.ts / nostr-transport.ts.
- rust/README.md nostr channel rows list all three kinds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:00:51 -06:00

261 lines
11 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 kez-chat carries messages over public Nostr relays: addressing, event
formats, encryption, delivery, and what relays can and can't see.
> **One-line summary:** messages are sealed on the device into a
> KEZ-signed, end-to-end encrypted envelope and published as Nostr events
> routed by a tag derived from the recipient's public key. Relays are a
> dumb pipe; the kez-chat server is only a directory and never carries
> messages.
Production builds use this transport (`VITE_TRANSPORT=nostr` in `.env`).
The older HTTP mailbox on the chat-server (`messages.ts`) still exists
behind the same interface; see §8.
---
## 1. The core idea
```
plaintext ─► sealForRecipient() ─► envelope (v4) ─► kind-4242 event ─► relays
(crypto.ts: X25519 + ML-KEM-768, tag ["q", addr]
AES-256-GCM, Ed25519 signature)
│
recipient ◄─ openMessage() ◄─ envelope ◄─ subscription {"#q":[myAddr]}┘
```
The envelope does all the protecting — confidentiality, sender
authentication, integrity. Nostr only moves bytes.
---
## 2. Identity: bridging Ed25519 onto Nostr
KEZ identities are **Ed25519**. Nostr signs events with **secp256k1**
(Schnorr). The curves can't be cross-derived, so `nostr-id.ts` bridges
them in two halves.
### 2a. Signing key (derived from your own seed)
Relays only accept signed events, so every account derives a secp256k1
key from its 32-byte Ed25519 seed:
```
nostrSecret = HKDF-SHA256(ikm = ed25519_seed,
salt = "kez-chat:nostr-signkey", info = "v1", len = 32)
```
- **Deterministic** — the same account gets the same Nostr key on every
device, with nothing extra to store.
- **App-specific** — it's not the user's personal Nostr account and is
never shown in the UI. It *is* public, though: it signs every message,
heartbeat, receipt, and the account's profile / relay-list events, so
it's a stable pseudonym relays can observe (see §7).
- **One-way** — it reveals nothing about the Ed25519 seed.
### 2b. Addressing (derived from the recipient's *public* primary)
Events aren't addressed to a Nostr pubkey. Each one carries a routing
label derived from the recipient's public Ed25519 primary, which any
sender can get from the directory:
```
addr = hex(HKDF-SHA256(ikm = utf8(recipient_primary), // "ed25519:abc…"
salt = "kez-chat:nostr-addr", info = "v1", len = 32))
```
It goes in a `q` tag, and the recipient subscribes with `{"#q": [myAddr]}`.
(`q`, not `h`: NIP-29 relays treat `h` as a group id and may drop the
event.) Hashing keeps the raw primary out of a relay-queryable tag — but
anyone who knows your primary can compute your `addr`, so it hides
nothing from someone who already knows who you are.
---
## 3. Event kinds
| Kind | What | Tags | Content |
|---|---|---|---|
| `4242` | Message (`KEZ_DM_KIND`) — regular kind, so relays store it | `["q", addr]` | `JSON.stringify(envelope)` |
| `4244` | Delivery receipt (`KEZ_ACK_KIND`) | `["q", addr of original sender]`, `["e", acked event id]`, `["kez-sig", …]`, optional `["p", sender's nostr pubkey]` | empty |
| `10142` | Heartbeat — replaceable, one per author | `["d","heartbeat-v1"]` (informational), `["x25519", pub]` | `SignedHeartbeat` JSON (§4) |
| `0` | Profile | — | name, about, visually-encrypted picture + per-contact key wraps |
| `10002` | NIP-65 relay list | `["r", url]` | empty |
The subscription covers both conversation kinds:
```json
{ "kinds": [4242, 4244], "#q": ["<my addr>"], "since": <unix seconds> }
```
---
## 4. Encryption: envelopes and heartbeats
### Heartbeats
Once a day while you're active, the app generates a fresh random X25519
keypair **and** an ML-KEM-768 keypair (`heartbeat-store.ts`) and
publishes the public halves as a kind-10142 event. The content is
signed with your **KEZ Ed25519 key**:
```json
{ "pub": "<x25519 hex>", "mlkem_pub": "<ml-kem-768 hex>",
"created_at": "<ISO>", "sig": "<ed25519 over JCS({t:\"kez-heartbeat-v1\", pub, created_at, mlkem_pub})>" }
```
Senders verify that signature against the recipient's primary before
using the keys. The Nostr event signature alone isn't enough: a
recipient's Nostr pubkey is learned from the outer event of an inbound
message, which anyone can re-wrap. Heartbeats older than 7 days are
rejected, and senders cache the last verified heartbeat per contact
(5-minute refresh) so a relay withholding it can't force a downgrade.
Private halves stay on the device and are deleted a configurable number
of days (3–60, default 16) after the heartbeat is *replaced* — that's
the forward-secrecy window. The newest heartbeat is never deleted.
### Envelope versions
| v | Key agreement | Sent when |
|---|---|---|
| **4** | X25519 (sender ephemeral × recipient heartbeat) **+** ML-KEM-768 encapsulation to the heartbeat; `AES key = HKDF(x25519_ss ‖ mlkem_ss)` | recipient's heartbeat has an ML-KEM key (normal case) |
| 3 | X25519 to the heartbeat | heartbeat without ML-KEM (older client) |
| 3 `fallback` | X25519 to the recipient's identity key | no heartbeat found — first message to someone who's never opened the app |
| 1, 2 | legacy | receive-only |
Every version: AES-256-GCM body, routing fields bound as AAD, whole
envelope signed with the sender's Ed25519 key. The sender's identity
(`from`) is **inside** the ciphertext — relays never see it.
### Replay and freshness
- Events with `created_at` more than 7 days old or 5 minutes in the
future are ignored.
- Decrypted messages whose `sent_at` is older than 7 days are rejected.
- Envelopes are deduplicated by their Ed25519 signature (last 5,000), so
a replayed envelope re-wrapped in a new event is ignored.
---
## 5. Message lifecycle
### Sending (`nostr-transport.ts` → `sendMessage`)
1. Use the recipient's primary from the conversation row (or look up
`/v1/u/<handle>` for a new contact).
2. Resolve their heartbeat (cache → relays), then seal a v4 (or v3)
envelope.
3. Build a kind-4242 event with `["q", addrFromPrimary(recipient)]`, sign
it with the derived Nostr key.
4. Publish to an ordered, de-duplicated relay set (max 10): the relay
their last message arrived on, then their NIP-65 read relays, then
your configured relays. Succeeds when **one** relay accepts; the UI
shows "sent" and records which relay took it.
Files use the same path: up to 80 KB inline in one envelope, larger
(up to 10 MB) as encrypted 80 KB chunks plus a pointer message.
### Receiving (`streamInbox` + `pollInbox`)
`inbox-service` runs a live subscription plus a catch-up query every 30
seconds (and on startup). Each event is:
1. Checked for a sane `created_at`, then de-duplicated by event id.
2. Parsed and decrypted (`openMessage`), which verifies the sender's
signature. For v3/v4 it looks up the matching heartbeat private key.
3. Appended to the local conversation (or routed to file reassembly).
4. Acknowledged: a kind-4244 receipt with the recipient's KEZ signature
over the event id goes back to the sender's `addr`. Failed receipts
are retried next session.
---
## 6. Cursors, sequence numbers, relays
- **`kez-chat:nostr:since:<handle>`** — the `since` filter. Advances as
events arrive; a fresh device starts 48 hours back (most public relays
don't keep more).
- **`kez-chat:nostr:seen:<handle>`** — processed event ids (last 10,000).
- **Synthetic `seq`** — the conversation store wants a monotonic number,
so `seq = created_at * 1000 + (parseInt(id.slice(0,3), 16) % 1000)`.
- **NIP-42 AUTH** — the relay pool signs auth challenges with the derived
key, so relays that gate kinds behind AUTH still deliver.
- **Relay list** — user-editable in Settings (wss:// only; ws:// allowed
for localhost), stored in `localStorage`, applied on reload. Changes
republish your NIP-65 list.
---
## 7. Privacy model
- **Confidential:** message content, attachments, and the sender's KEZ
identity — all inside the envelope. Post-quantum hybrid (v4), so
recorded traffic stays safe against a future quantum computer.
- **Forward-secret:** heartbeat keys are random and deleted after the
retention window; stealing the seed later doesn't decrypt old
messages.
- **Visible to relays:** which Nostr key published an event, the
recipient's `q` tag, and the time. The Nostr key is stable per account
and its kind-0 profile carries your handle, so a relay can map who
talks to whom and when. There's no onion routing or padding.
- **Push:** the chat-server watches relays for `q` tags of registered
handles and sends an empty Web Push; the push provider learns only
that a device was woken.
Full threat model: [`../SECURITY.md`](../SECURITY.md).
---
## 8. Configuration and the transport facade
| Variable | Default | |
|---|---|---|
| `VITE_TRANSPORT` | `server` (`.env` sets `nostr`) | `nostr` = relays; `server` = HTTP mailbox |
| `VITE_NOSTR_RELAYS` | damus, nos.lol, primal, snort.social, nostr.wine | Default relay list; users can override in Settings |
`transport.ts` re-exports `sendMessage` / `sendFile` / `sendAck` /
`pollInbox` / `streamInbox` / `decrypt` from either `nostr-transport.ts`
or `messages.ts`. The server mailbox still seals v2 envelopes and has no
heartbeats or receipts — production doesn't use it.
---
## 9. Known limitations
- **Delivery is best-effort.** It needs one relay both sides can reach,
and public relays drop old events (hence the 48-hour lookback).
- **Relay acceptance varies.** Some relays reject unknown kinds or rate
limit; sends fail only if every relay rejects.
- **One device per account.** A second device mints its own heartbeats;
the first stops receiving messages sent to the new ones.
- **Same-second ordering** is approximate (see §6).
---
## 10. File map
| File | Role |
|---|---|
| `src/lib/nostr-id.ts` | Derived Nostr signing key; recipient `addr`; kind and tag constants |
| `src/lib/nostr-transport.ts` | Relays, send/receive, heartbeats publish + fetch + cache, receipts, files, cursors |
| `src/lib/crypto.ts` | Envelopes v1–v4, heartbeat signing/verification |
| `src/lib/heartbeat-store.ts` | Heartbeat key rotation and retention |
| `src/lib/relay-store.ts` | User relay list |
| `src/lib/inbox-service.svelte.ts` | Receive pipeline, dedupe, ack, heartbeat lifecycle |
| `src/lib/transport.ts` | Picks nostr vs server transport |
| `src/lib/messages.ts` | Legacy HTTP mailbox transport |
---
## 11. How to see it on the wire
DevTools → **Network → WS**:
- WebSocket connections to your relays.
- Sending emits `["EVENT", {kind: 4242, tags: [["q", …]], content: "{\"v\":4, …"}]`.
- No `POST /v1/messages` and no `/v1/inbox/<handle>/stream`.
- The only chat-server calls are the directory (`/v1/u/…`,
`/v1/by-primary/…`), push subscription, and sigchain sync.