Kez/kez-chat/web/NOSTR-CHAT.md
2026-06-01 13:31:48 -06:00

12 KiB
Raw Blame History

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 SealedEnvelope encryption, 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 (10009999), 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.tssendMessage)

  1. Resolve the recipient handle → ed25519 primary via the directory (/v1/u/<handle> — still served by the kez-chat server).
  2. sealMessage(...)SealedEnvelope (identical to the server transport).
  3. Build an event: kind 4242, tag ["h", addrFromPrimary(recipientPrimary)], content = JSON.stringify(envelope).
  4. Sign it with nostrSecretFromSeed(senderSeed) (finalizeEvent).
  5. pool.publish(RELAYS, event) — succeeds if at least one relay accepts. If every relay rejects, sendMessage throws "no relay accepted the message".

Receiving (streamInbox + pollInbox)

The global inbox-service runs both, exactly as it did for the server transport:

  • streamInbox opens a live subscription (pool.subscribeMany) filtered on the user's own addr. Each event fires onevent; after the relays finish replaying stored events, oneose flips the UI status to live.
  • pollInbox is a one-shot pool.querySync used as a heartbeat catch-up (every 30s and on startup), so nothing is missed if the subscription drops.

Each incoming event is:

  1. De-duplicated by event id (see §5).
  2. Parsed: contentSealedEnvelope.
  3. Decrypted by the unchanged decrypt() (crypto.tsopenMessage), which verifies the sender's ed25519 signature and AES-GCM-decrypts the body.
  4. 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 relay since filter (unix seconds). Advances as events arrive, so reloads resume instead of re-fetching. A fresh device defaults to now 7 days to catch recent history.
  • kez-chat:nostr:seen:<handle> — a capped set (last 500 ids) 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, so main and other branches are unaffected. This branch ships a committed .env that sets VITE_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 SealedEnvelope before 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 #h tag 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 via VITE_NOSTR_RELAYS for 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/messages or 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>.