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

11 KiB
Raw Blame History

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:

{ "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:

{ "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.


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.