12 KiB
Nostr Chat
How the nostr branch carries kez-chat messages over Nostr relays instead
of the kez-chat server inbox — without changing the identity model or the
end-to-end encryption.
One-line summary: the chat transport is swapped from an HTTP/SSE server inbox to Nostr relays. Everything else — your ed25519 identity, the
SealedEnvelopeencryption, the UI — is untouched. Nostr only moves bytes.
1. The core idea
kez-chat already had its own end-to-end encryption. A message is sealed by
crypto.ts into a SealedEnvelope (AES-256-GCM body + an ed25519 sender
signature, keyed off the user's ed25519 identity). The original transport
(messages.ts) just POSTs that opaque envelope to the server's
/v1/messages endpoint and reads it back from /v1/inbox.
Because the envelope is already encrypted and self-authenticating, the transport is interchangeable. The Nostr build keeps the exact same envelope and changes only how it travels:
┌─────────────────────── unchanged ───────────────────────┐
plaintext ─► sealMessage() ─► SealedEnvelope ──► [ TRANSPORT ] ──► peer
│ (ed25519/x25519 + AES-GCM, crypto.ts) │
└──────────────────────────────────────────────────────────┘
server transport: POST /v1/messages ┐
GET /v1/inbox (poll+SSE) ┘ ← kez-chat server + SQLite
nostr transport: publish event to relays ┐
subscribe by #h tag ┘ ← public Nostr relays
The SealedEnvelope is the "extra layer of encryption using our own key" —
it exists independently of Nostr and is what actually protects the message
body. Nostr is a dumb pipe underneath it.
2. Identity: bridging ed25519 onto Nostr
KEZ identities are ed25519. Nostr signs events with secp256k1
(Schnorr). The two curves cannot be cross-derived — you cannot turn someone's
ed25519 public key into "their" Nostr public key. The bridge
(nostr-id.ts) solves this in two halves:
2a. Signing key (derived from your own seed)
Every account needs a secp256k1 key to sign Nostr events (relays reject unsigned events). We derive it deterministically from the user's 32-byte ed25519 seed:
nostrSecret = HKDF-SHA256(
ikm = ed25519_seed,
salt = "kez-chat:nostr-signkey",
info = "v1",
len = 32,
)
Properties:
- Deterministic — the same account always produces the same Nostr signer, on any device, with no extra storage.
- Internal — it is a pure transport credential. It is not the user's real Nostr account, it is never surfaced in the UI, and its public key is never advertised or used for addressing.
- One-way — HKDF means the Nostr key reveals nothing about the ed25519 seed (the actual secret).
2b. Addressing (derived from the recipient's public primary)
Since we can't compute a recipient's Nostr pubkey, we don't address events to a pubkey at all. Instead each event carries a routing label derived from the recipient's public ed25519 primary (which any sender can look up in the directory):
addr = HKDF-SHA256(
ikm = utf8(recipient_primary), // e.g. "ed25519:abc123…"
salt = "kez-chat:nostr-addr",
info = "v1",
len = 32,
) // → 32-byte hex
The sender stamps this on the event as a tag; the recipient subscribes for
events carrying their own addr. Both sides compute the same value from the
same public primary — the sender from a directory lookup, the recipient from
their own identity. Using a hash (rather than the raw primary) keeps the
plaintext primary out of a relay-queryable tag.
3. The event format
| Field | Value |
|---|---|
kind |
4242 (KEZ_DM_KIND) — a regular kind (1000–9999), so relays persist it |
tags |
[["h", <recipient addr hex>]] — h = ADDR_TAG, the routing label |
content |
JSON.stringify(SealedEnvelope) — our encrypted, signed envelope |
pubkey |
the sender's derived secp256k1 pubkey (transport credential) |
sig |
Schnorr signature over the event (so relays accept it) |
created_at |
unix seconds |
A subscriber filters with:
{ "kinds": [4242], "#h": ["<my addr hex>"], "since": <unix-seconds> }
4. Message lifecycle
Sending (nostr-transport.ts → sendMessage)
- Resolve the recipient handle → ed25519 primary via the directory
(
/v1/u/<handle>— still served by the kez-chat server). sealMessage(...)→SealedEnvelope(identical to the server transport).- Build an event:
kind 4242, tag["h", addrFromPrimary(recipientPrimary)],content = JSON.stringify(envelope). - Sign it with
nostrSecretFromSeed(senderSeed)(finalizeEvent). pool.publish(RELAYS, event)— succeeds if at least one relay accepts. If every relay rejects,sendMessagethrows"no relay accepted the message".
Receiving (streamInbox + pollInbox)
The global inbox-service runs both, exactly as it did for the server
transport:
streamInboxopens a live subscription (pool.subscribeMany) filtered on the user's ownaddr. Each event firesonevent; after the relays finish replaying stored events,oneoseflips the UI status to live.pollInboxis a one-shotpool.querySyncused as a heartbeat catch-up (every 30s and on startup), so nothing is missed if the subscription drops.
Each incoming event is:
- De-duplicated by event id (see §5).
- Parsed:
content→SealedEnvelope. - Decrypted by the unchanged
decrypt()(crypto.ts→openMessage), which verifies the sender's ed25519 signature and AES-GCM-decrypts the body. - Appended to the local conversation store and rendered.
5. Cursors & de-duplication
Relays can resend events, and two transports (live sub + heartbeat poll) can
deliver the same event. Both are made idempotent with per-handle
localStorage state:
kez-chat:nostr:since:<handle>— the relaysincefilter (unix seconds). Advances as events arrive, so reloads resume instead of re-fetching. A fresh device defaults tonow − 7 daysto catch recent history.kez-chat:nostr:seen:<handle>— a capped set (last500ids) of processed event ids. An id already in the set is skipped.
Mapping to seq
The conversation store and the notification watermark were built around a
monotonic server seq. Nostr has no per-recipient sequence, so the transport
synthesizes one from the event's timestamp:
seq = created_at * 1000 + (parseInt(event.id.slice(0,3), 16) % 1000)
This is monotonic-by-time across sessions (so the unread/notify watermark keeps working) and spreads messages within the same one-second granularity using the event id, avoiding collisions between distinct same-second messages.
6. Configuration
Build-time, via Vite env (.env / .env.local):
| Variable | Default | Meaning |
|---|---|---|
VITE_TRANSPORT |
server |
server or nostr — which pipe to use |
VITE_NOSTR_RELAYS |
wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net |
comma-separated relay list |
The code default is
server, somainand other branches are unaffected. This branch ships a committed.envthat setsVITE_TRANSPORT=nostr.
The facade (transport.ts)
inbox-service and the Messages route import sendMessage / pollInbox /
streamInbox / decrypt from transport.ts, which re-exports either the
server (messages.ts) or Nostr (nostr-transport.ts) implementation based on
VITE_TRANSPORT. Neither consumer knows which pipe is active. Switching
transports is a one-line env change + rebuild.
7. Privacy model
- Confidential: the message body. It is AES-256-GCM encrypted inside the
SealedEnvelopebefore it ever reaches a relay. Relays (and anyone reading them) see only ciphertext. - Visible to relays: the social-graph metadata. The envelope carries the
sender's primary (
from) and recipient handle (to) in its JSON, and the#htag routes by recipient. A relay can therefore observe who is talking to whom and when — the same metadata the kez-chat server saw with the old transport. This is parity, not a regression. - Authenticity: every envelope is signed by the sender's ed25519 key and verified on decrypt, so a relay (or anyone) cannot forge or tamper with a message without detection.
If hiding the social graph from relays becomes a requirement, the next step is an outer NIP-44 wrap around the envelope. It was deliberately left out to keep this a clean, minimal transport swap.
8. Known limitations
- No backfill of old server-era messages. Messages delivered over the old server transport were never published to relays and cannot be fetched over Nostr. Locally cached history (IndexedDB) still renders, but it is a stale snapshot, not relay-backed.
- Relay acceptance varies. Some public relays restrict event kinds or rate.
If all configured relays reject
kind 4242, sends fail loudly. Run your own relay or pick permissive ones viaVITE_NOSTR_RELAYSfor reliability. - Eventual, not guaranteed, delivery. Delivery depends on the recipient and sender sharing at least one reachable relay. More relays = more resilience, more metadata exposure.
- Same-second ordering is approximated (see §5), not exact.
9. File map
| File | Role |
|---|---|
src/lib/crypto.ts |
Unchanged. The SealedEnvelope E2E layer (our own key). |
src/lib/nostr-id.ts |
Derive the secp256k1 signing key; compute recipient addr. |
src/lib/nostr-transport.ts |
Nostr send/poll/stream; cursor + dedupe. |
src/lib/transport.ts |
Facade selecting server vs nostr via VITE_TRANSPORT. |
src/lib/messages.ts |
Unchanged. The original server transport. |
src/lib/inbox-service.svelte.ts |
Imports from the facade; otherwise unchanged. |
src/routes/Messages.svelte |
Imports sendMessage from the facade. |
.env |
Flips this branch to VITE_TRANSPORT=nostr. |
10. How to verify it's really on Nostr
Open DevTools → Network → WS on the running app:
- You should see live WebSocket connections to the configured relays
(
wss://relay.damus.io, etc.). - Sending a message emits an outgoing
["EVENT", …]frame; receiving arrives as an incoming event frame. - You should not see
POST /v1/messagesor an SSE connection to/v1/inbox/<handle>/stream(those were the server transport). - The only remaining server call is the directory lookup
GET /v1/u/<handle>.