# 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 `POST`s 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", ]]` — `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: ```json { "kinds": [4242], "#h": [""], "since": } ``` --- ## 4. Message lifecycle ### Sending (`nostr-transport.ts` → `sendMessage`) 1. Resolve the recipient handle → ed25519 primary via the directory (`/v1/u/` — 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: `content` → `SealedEnvelope`. 3. Decrypted by the **unchanged** `decrypt()` (`crypto.ts` → `openMessage`), 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:`** — 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:`** — 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//stream` (those were the server transport). - The only remaining server call is the directory lookup `GET /v1/u/`.