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>
11 KiB
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_atmore than 7 days old or 5 minutes in the future are ignored. - Decrypted messages whose
sent_atis 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)
- Use the recipient's primary from the conversation row (or look up
/v1/u/<handle>for a new contact). - Resolve their heartbeat (cache → relays), then seal a v4 (or v3) envelope.
- Build a kind-4242 event with
["q", addrFromPrimary(recipient)], sign it with the derived Nostr key. - 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:
- Checked for a sane
created_at, then de-duplicated by event id. - Parsed and decrypted (
openMessage), which verifies the sender's signature. For v3/v4 it looks up the matching heartbeat private key. - Appended to the local conversation (or routed to file reassembly).
- 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>— thesincefilter. 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, soseq = 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
qtag, 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
qtags 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/messagesand no/v1/inbox/<handle>/stream. - The only chat-server calls are the directory (
/v1/u/…,/v1/by-primary/…), push subscription, and sigchain sync.