# 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": [""], "since": } ``` --- ## 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": "", "mlkem_pub": "", "created_at": "", "sig": "" } ``` 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/` 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:`** — 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:`** — 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//stream`. - The only chat-server calls are the directory (`/v1/u/…`, `/v1/by-primary/…`), push subscription, and sigchain sync.