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>
261 lines
11 KiB
Markdown
261 lines
11 KiB
Markdown
# 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.
|