From 79e7e9536b872cb58ada87710df8b528295fe3db Mon Sep 17 00:00:00 2001
From: Jason Tudisco
Date: Sun, 27 Sep 2026 09:32:08 -0600
Subject: [PATCH] feat(kez-chat): post-quantum v4 envelopes, heartbeat ratchet
hardening, sig-server proxy
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Crypto / protocol
- v4 envelope: X25519 + ML-KEM-768 hybrid (PQXDH-style HKDF over both
secrets). Heartbeats now carry an ML-KEM key; senders emit v4 whenever
the peer's heartbeat has one, v3 otherwise. Adds @noble/post-quantum.
- Heartbeats are signed by the KEZ identity and verified against the
recipient's primary before use. Previously a spoofed nostr pubkey
(from a re-wrapped replayed envelope) let an attacker substitute their
own heartbeat and read messages.
- Heartbeat retention counts from supersession, never deletes the
newest key, and GC runs after rotation — returning after a long
offline stretch no longer destroys the key the backlog needs.
- Verified-heartbeat cache per peer: 5-min TTL (one lookup per file
instead of per chunk) and anti-downgrade to identity ECDH.
- Always republish heartbeat on session start; rotate on the poll timer;
atomic IDB updates; wipe heartbeats on "Forget this account".
- Dedupe inbound envelopes by sender_sig (replays / re-broadcasts).
Server
- Proxy /v1/sigchains/* to the internal sig-server and hand out the
public https:///v1/sigchains/... URL. Sigchain sync from the
app was failing ("Load failed") against http://sig-server:7878.
App
- Relay settings apply to profile / NIP-65 publishing (republished on
change); wss-only relays (ws:// for localhost); order-aware defaults.
- Clear chat also deletes attachments; retention changes apply
immediately with a warning when shortening.
- Photos: HEIC -> JPEG and downscale to 2048px (iOS canvas limit, strips
EXIF); snapshot the conversation at send time.
- Overlay a11y/Escape fixes, scroll-pin fix, svelte-check to 0 errors.
- Clear chat, image zoom, file-body fallback rendering, NIP-65 read
relays in publish set, relay settings UI, forward-secrecy settings.
Docs
- README rewritten to explain the whole app; SECURITY.md updated for v4;
heartbeat self-check (src/lib/heartbeat.selfcheck.ts).
Co-Authored-By: Claude Opus 5.5
---
.gitignore | 3 +
kez-chat/Cargo.toml | 3 +-
kez-chat/README.md | 455 ++++++++++++-----
kez-chat/SECURITY.md | 406 +++++++++++++++
kez-chat/TODO.md | 51 +-
kez-chat/src/api.rs | 71 ++-
kez-chat/web/.env | 7 +-
kez-chat/web/package-lock.json | 156 +++---
kez-chat/web/package.json | 1 +
kez-chat/web/src/lib/Avatar.svelte | 2 +-
kez-chat/web/src/lib/VerifiedBadge.svelte | 2 +-
kez-chat/web/src/lib/conversations-store.ts | 59 +++
kez-chat/web/src/lib/crypto.ts | 488 ++++++++++++++++++-
kez-chat/web/src/lib/heartbeat-store.ts | 261 ++++++++++
kez-chat/web/src/lib/heartbeat.selfcheck.ts | 94 ++++
kez-chat/web/src/lib/image-utils.ts | 64 +++
kez-chat/web/src/lib/inbox-service.svelte.ts | 94 +++-
kez-chat/web/src/lib/messages.ts | 27 +-
kez-chat/web/src/lib/nostr-transport.ts | 339 +++++++++++--
kez-chat/web/src/lib/peer-profile-store.ts | 81 ++-
kez-chat/web/src/lib/profile-store.ts | 33 +-
kez-chat/web/src/lib/relay-store.ts | 118 +++++
kez-chat/web/src/routes/Messages.svelte | 379 ++++++++++++--
kez-chat/web/src/routes/Settings.svelte | 307 ++++++++++++
kez-chat/web/src/routes/Unlock.svelte | 4 +
25 files changed, 3147 insertions(+), 358 deletions(-)
create mode 100644 kez-chat/SECURITY.md
create mode 100644 kez-chat/web/src/lib/heartbeat-store.ts
create mode 100644 kez-chat/web/src/lib/heartbeat.selfcheck.ts
create mode 100644 kez-chat/web/src/lib/relay-store.ts
diff --git a/.gitignore b/.gitignore
index 8e86e0f..c740719 100644
--- a/.gitignore
+++ b/.gitignore
@@ -57,3 +57,6 @@ kez-chat/deploy/prebuilt/
# the path below is just a historical fence-post in case anyone has the
# old in-repo cache lying around from before that move.
kez-chat/deploy/.buildx-cache/
+
+# Syncthing per-folder ignore config — machine-local, not project source
+.stignore
diff --git a/kez-chat/Cargo.toml b/kez-chat/Cargo.toml
index 33dac26..bb88eab 100644
--- a/kez-chat/Cargo.toml
+++ b/kez-chat/Cargo.toml
@@ -31,10 +31,11 @@ rand = "0.8"
nostr-sdk = { version = "0.36", default-features = false, features = ["all-nips"] }
hkdf = "0.12"
sha2 = "0.10"
+# Proxies /v1/sigchains/* to the internal sig-server (api.rs).
+reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
tower-http = { version = "0.6", features = ["trace", "cors", "fs"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies]
-reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
tempfile = "3"
diff --git a/kez-chat/README.md b/kez-chat/README.md
index cdd2b66..eeae984 100644
--- a/kez-chat/README.md
+++ b/kez-chat/README.md
@@ -1,168 +1,355 @@
-# kez-chat-server
+# kez-chat
-Home server for the kez-chat application. One Rust binary that hosts:
+End-to-end encrypted chat where **your account is a key, not a phone
+number or an email address.** Messages travel over public
+[nostr](https://nostr.com) relays; nobody in the middle can read them,
+and the server that hosts the app never sees them at all.
-- Handle registry (`POST /v1/register`, `GET /v1/u/:handle`)
-- WebFinger discovery (`GET /.well-known/webfinger`)
-- NATS auth callout endpoint (`POST /internal/nats/auth`) — stub in v0.1
-- Static SPA serving (`GET /`) — placeholder until the Svelte build lands
-- Healthz (`GET /v1/healthz`)
+Live at **** — installable as a PWA on iOS, Android, and
+desktop.
-Designed in [`document.md`](document.md). Spec for the underlying KEZ
-identity layer in [`../SPEC.md`](../SPEC.md).
+kez-chat is the flagship app for [KEZ](../README.md), a portable,
+decentralized identity graph. Your chat identity is a KEZ identity, so
+you can *prove* who you are by linking accounts you already own (GitHub,
+Bluesky, DNS, your website, nostr) — and your contacts
+can verify those links themselves, without trusting anyone.
-## What's in v0.1 (this is the scaffold)
+---
-✅ HTTP API end-to-end
-✅ Ed25519-signed handle registration with replay protection
-✅ SQLite-backed registry with uniqueness on both handle and primary key
-✅ WebFinger endpoint
-✅ Placeholder SPA at `/`
-✅ docker-compose for full stack (chat + nats + sig-server)
-✅ Multi-stage Dockerfiles
-✅ 13 integration tests against a live router
+## What you get
-⚠️ NATS auth callout returns 501 — wired up in v0.2
-⚠️ Svelte SPA build pipeline not yet in place — placeholder HTML for now
-⚠️ TLS terminated upstream (no cert handling in this binary)
+- **An account in seconds, no personal data.** Pick a handle
+ (`you@kez.lat`); the app generates a key on your device. Your backup is
+ a 12-word recovery phrase.
+- **End-to-end encrypted messages and files.** Text, emoji, photos, and
+ arbitrary files up to 10 MB. iPhone HEIC photos are converted to JPEG
+ and large photos are downscaled (which also strips GPS/EXIF data).
+- **Forward secrecy.** Decryption keys rotate daily and are deleted after
+ a window you choose (3–60 days, default 16). Once gone, those messages
+ can't be decrypted by anyone — including you, or someone who later
+ steals your device.
+- **Post-quantum protection.** Messages use a hybrid of X25519 and
+ ML-KEM-768 (the NIST post-quantum standard), so ciphertext harvested
+ from public relays today stays safe against a future quantum computer.
+- **Delivery receipts.** Sending → sent (a relay accepted it) →
+ delivered (the recipient's device decrypted it and sent back a
+ receipt signed with their KEZ key).
+- **Verified identity.** Link your GitHub, Bluesky, domain, website,
+ or nostr account with a signed claim (fediverse coming). Contacts see a
+ verified badge they checked themselves.
+- **Private profile pictures.** Your avatar is published *visually
+ encrypted*: strangers see colored noise, contacts see your face.
+- **Push notifications** that tell the push provider nothing — the
+ payload is empty.
+- **Unlock with Face ID / Touch ID / Windows Hello / a security key**
+ (WebAuthn passkeys), or a passphrase.
+- **Your relays, your choice.** Reorder, add, or remove nostr relays in
+ Settings.
-## Quick start (local development)
+---
+
+## How it works
+
+```
+ ┌──────────── your device (browser / PWA) ────────────┐
+ │ seed (12-word phrase) ─► Ed25519 identity │
+ │ encrypt + sign locally ─► sealed envelope │
+ └───────────────┬──────────────────────────────────────┘
+ │ publish / subscribe (encrypted only)
+ ▼
+ public nostr relays (damus, nos.lol, primal, …)
+ ▲
+ │ subscribe by routing tag
+ ┌───────────────┴──────────── their device ────────────┐
+ │ verify signature ─► decrypt ─► ack back │
+ └──────────────────────────────────────────────────────┘
+
+ kez.lat chat-server (this crate): handle directory, serves the app,
+ Web Push fan-out, proxy to the sigchain store. Never sees plaintext
+ and never carries nostr-transport messages.
+```
+
+### Identity
+
+Your account is a 32-byte **seed**, derived from your 12-word recovery
+phrase (BIP-39 wordlist, KEZ derivation — compatible with the Rust,
+Node, and Python KEZ CLIs, which also accept 24-word phrases). From the
+seed come:
+
+| Key | Used for |
+|---|---|
+| **Ed25519** | Your KEZ identity (`ed25519:`). Signs every message, heartbeat, ack, and claim. |
+| **X25519** | Identity-level key exchange (first contact only — see below). |
+| **secp256k1** (HKDF-derived) | Signs the nostr events that carry your messages. A transport credential only — never your "real" nostr account. |
+
+On the device, the seed is stored in IndexedDB encrypted under your
+passphrase (PBKDF2 → AES-GCM), and optionally under a passkey via the
+WebAuthn PRF extension. A 30-day session, wrapped under a non-extractable
+WebCrypto key, keeps you signed in while the PWA is backgrounded, so push
+notifications stay useful. **Lock** ends it; **Forget this account**
+wipes the device.
+
+Your **handle** (`you@kez.lat`) is registered with the chat-server by a
+request signed with your identity key. It's a public directory: handle
+→ public key.
+
+### Messages
+
+A message is sealed on your device into an **envelope**, then published
+as a nostr event (kind `4242`) tagged with a routing tag derived from the
+recipient's public key. The recipient's app subscribes to its own tag on
+the same relays. Relays can't read the message, and the sender's KEZ
+identity only appears *inside* the ciphertext. They do see the event's
+signing key, though — your app-specific nostr key, which is stable and
+whose public profile carries your handle — plus the recipient's routing
+tag and the time. See the metadata note under Security.
+
+Envelope versions (all still decryptable; the app sends the strongest the
+recipient supports):
+
+| v | Key agreement | Protects against |
+|---|---|---|
+| **4** | X25519 **+ ML-KEM-768** to the recipient's current heartbeat, per-message ephemeral | relays, stolen keys (forward secrecy), future quantum computers |
+| 3 | X25519 to the recipient's current heartbeat | relays, stolen keys (forward secrecy) |
+| 3 (`fallback`) | X25519 to the recipient's identity key | relays — used only for the very first message to someone who has never opened the app |
+| 1–2 | legacy | receive-only |
+
+In every version the body is AES-256-GCM, the routing fields are bound
+as authenticated data, and the whole envelope is signed with the
+sender's Ed25519 key.
+
+### Heartbeats — forward secrecy and post-quantum keys
+
+Instead of Signal's per-message Double Ratchet (which needs session state
+and a prekey server), kez-chat uses a **heartbeat ratchet** that fits
+nostr's publish-and-forget model:
+
+1. Once a day while you're active, your device generates a fresh random
+ X25519 key **and** an ML-KEM-768 key, and publishes the public halves
+ to nostr (kind `10142`) — **signed by your KEZ identity**, so a
+ sender can check the heartbeat really belongs to you.
+2. Senders encrypt to your latest heartbeat rather than your long-term
+ identity key.
+3. Your device keeps each retired heartbeat's private keys for the
+ retention window (counted from when it was *replaced*), then deletes
+ them. After that, those messages are undecryptable by anyone.
+
+Because heartbeat keys are random — not derived from your seed — someone
+who later steals your seed or recovery phrase still can't decrypt old
+messages. Senders cache your last verified heartbeat, so a relay that
+hides it can't quietly downgrade them to weaker encryption.
+
+### Files
+
+Files up to 80 KB go inline in one envelope. Larger files (up to 10 MB)
+are split into encrypted chunks plus a pointer message; the recipient
+reassembles them locally. Attachments live in IndexedDB on each device;
+**Clear chat** removes them too.
+
+### Delivery receipts and duplicates
+
+When a message decrypts, the recipient's app publishes an ack
+(kind `4244`) carrying their KEZ signature over the message id, so a
+third party can't fake "delivered". Receipts are
+retried if you were offline. Replayed or re-broadcast envelopes are
+recognized by their signature and ignored.
+
+### Verified claims (KEZ)
+
+On **Identity → Add claim** you sign a statement like "`github:you` is
+me" with your KEZ key and publish the proof where only that account's
+owner could put it: a gist, a Bluesky post, a DNS TXT record,
+`/.well-known/kez.json`, or a nostr note (a NIP-07 extension
+like Alby can sign and publish it for you). Anyone can re-verify it.
+ActivityPub verification is planned — most instances block the browser
+from fetching WebFinger.
+
+Each claim is also appended to your **sigchain** — a signed, append-only
+log of identity events — stored by the KEZ sig-server behind
+`https://kez.lat/v1/sigchains/...`. If a sync fails, retry it from the
+Claims page. The sigchain is a convenience mirror; the published proofs
+are the source of truth.
+
+### Profiles
+
+Your display name and avatar go out as a standard nostr profile
+(kind `0`). With visual encryption on (the default), the picture is
+pixel-scrambled under a key that's individually wrapped for each
+contact, so it stays private even though the profile event is public.
+A NIP-65 relay list (kind `10002`) tells other clients which relays to
+reach you on; it's republished when you change your relays.
+
+### Notifications
+
+The chat-server watches the relays for events addressed to registered
+handles and sends a Web Push (VAPID) with an **empty payload**. Your
+device wakes up, fetches from the relays, and decrypts locally. The push
+provider (Apple, Google, Mozilla) learns only that kez-chat woke a
+device.
+
+---
+
+## Security
+
+The full, plain-language threat model — what's protected and, honestly,
+what isn't — is in **[SECURITY.md](SECURITY.md)**. The short version of
+the limits:
+
+- **Metadata:** relays can see which app key sent an event to which
+ routing tag, and when — enough to map who talks to whom. There's no
+ onion routing or padding.
+- **The first message** to someone who has never opened kez-chat has no
+ forward secrecy and no post-quantum protection.
+- **Signatures are still classical** (Ed25519, secp256k1). A future
+ quantum attacker could impersonate someone in real time, but not
+ decrypt past messages.
+- **One device per account** for now — signing in on a second device
+ starts a fresh set of heartbeats.
+- A device compromised *during* the retention window exposes what's
+ stored on it.
+
+---
+
+## Repository layout
+
+```
+kez-chat/
+├── src/ chat-server (Rust, axum)
+│ ├── api.rs routes, handle directory, WebFinger, sigchain proxy
+│ ├── messages.rs server-transport mailbox (POST /v1/messages, inbox + SSE)
+│ ├── nostr_listener.rs watches relays → Web Push fan-out
+│ ├── push.rs Web Push / VAPID
+│ ├── rate_limit.rs per-IP token bucket
+│ └── store.rs SQLite handle registry
+├── web/ the app (Svelte 5 + Vite + Tailwind, PWA)
+│ └── src/lib/
+│ ├── crypto.ts envelopes v1–v4, heartbeat signing
+│ ├── nostr-transport.ts relays, heartbeats, receipts, files
+│ ├── heartbeat-store.ts rotation + retention of heartbeat keys
+│ ├── inbox-service.svelte.ts receive pipeline
+│ ├── identity-store.ts / webauthn.ts / persistent-session.ts
+│ ├── visual-crypto.ts scrambled profile pictures
+│ ├── verifiers/ github, bluesky, dns, web, nostr (ap: planned)
+│ └── heartbeat.selfcheck.ts runnable crypto self-check
+├── deploy/ Dockerfiles + docker-compose
+├── SECURITY.md threat model
+├── TODO.md roadmap + review notes
+├── document.md original design doc (partly historical)
+└── web/NOSTR-CHAT.md nostr transport deep-dive
+```
+
+---
+
+## Running it locally
+
+**Chat-server** (Rust, from `kez-chat/`):
```sh
-# Run from source
cargo run -- --bind 127.0.0.1:6969 --db ./kez-chat.db --server kez.lat
-
-# Or install once
-cargo install --path .
-kez-chat-server --bind 127.0.0.1:6969 --server kez.lat
```
-Configuration via flags or env vars:
-
-| Flag | Env | Default |
-|---|---|---|
-| `--bind` | `KEZ_CHAT_BIND` | `0.0.0.0:6969` |
-| `--db` | `KEZ_CHAT_DB` | `kez-chat.db` |
-| `--server` | `KEZ_CHAT_SERVER` | `kez.lat` |
-| `--sig-server-url` | `KEZ_CHAT_SIG_SERVER_URL` | `http://localhost:7878` |
-| `--web-dir` | `KEZ_CHAT_WEB_DIR` | _(unset → placeholder page)_ |
-
-Logging: `RUST_LOG=debug,hyper=info` etc.
-
-## Quick start (Docker compose, full stack)
+**Web app** (from `kez-chat/web/`) — Vite proxies `/v1`, `/internal`, and
+`/.well-known` to the server on `:6969`:
```sh
-cd deploy
-docker compose up -d --build
+npm ci
+npm run dev
```
-Brings up three services:
+For sigchain sync you also need the sig-server
+([`../rust-sig-server`](../rust-sig-server/)) running on `:7878`.
-| Service | Port(s) | What it does |
+### Server configuration
+
+| Flag | Env | Default | |
+|---|---|---|---|
+| `--bind` | `KEZ_CHAT_BIND` | `0.0.0.0:6969` | HTTP listen address |
+| `--db` | `KEZ_CHAT_DB` | `kez-chat.db` | SQLite file |
+| `--server` | `KEZ_CHAT_SERVER` | `kez.lat` | Handle domain; also the public host in `sigchain_url` |
+| `--sig-server-url` | `KEZ_CHAT_SIG_SERVER_URL` | `http://localhost:7878` | *Internal* sig-server address the proxy forwards to — never shown to clients |
+| `--web-dir` | `KEZ_CHAT_WEB_DIR` | unset | Built SPA to serve at `/` |
+| `--vapid-key-path` | `KEZ_CHAT_VAPID_KEY` | `vapid-key.txt` | Web Push key (auto-generated) |
+| `--vapid-subject` | `KEZ_CHAT_VAPID_SUBJECT` | `mailto:admin@kez.lat` | VAPID contact |
+| `--nostr-relays` | `KEZ_CHAT_NOSTR_RELAYS` | damus, nos.lol, primal, snort, nostr.wine | Relays watched for push; empty disables |
+
+Logging: `RUST_LOG=info` (or `debug`).
+
+### Web configuration (`web/.env`)
+
+| Variable | Default | |
|---|---|---|
-| `chat-server` | 6969 | HTTP API + SPA |
-| `nats` | 4222 (native), 8443 (WebSocket), 8222 (monitoring) | Dumb broker, JetStream enabled |
-| `sig-server` | 7878 | Sigchain storage (the existing `rust-sig-server`) |
+| `VITE_TRANSPORT` | `server` (`.env` sets `nostr`) | `nostr` = relays (production); `server` = HTTP mailbox on the chat-server |
+| `VITE_NOSTR_RELAYS` | 5 default relays | Build-time default relay list; users can override in Settings |
-Then point a reverse proxy / Cloudflare tunnel at `localhost:6969`.
+---
-## Testing
+## HTTP API
+
+| Method | Path | |
+|---|---|---|
+| GET | `/v1/healthz` | Liveness |
+| GET | `/v1/u/:handle` | Handle → primary key, `sigchain_url`, proofs |
+| GET | `/v1/by-primary/:primary` | Reverse lookup |
+| POST | `/v1/register` | Claim a handle (Ed25519-signed, ±5 min freshness) |
+| PUT | `/v1/profile/:handle/proofs` | Publish verified-proof summary (signed) |
+| GET/POST | `/v1/sigchains/:scheme/:id[/head\|/events]` | Proxied to the sig-server |
+| POST | `/v1/push/subscribe/:handle`, `/unsubscribe/:handle` | Web Push subscriptions (signed) |
+| GET | `/v1/push/vapid-public-key` | VAPID public key |
+| POST | `/v1/messages` · GET `/v1/inbox/:handle[/stream]` | Server-transport mailbox (not used when `VITE_TRANSPORT=nostr`) |
+| GET | `/.well-known/webfinger` | Fediverse-style discovery |
+
+### Nostr events
+
+| Kind | What |
+|---|---|
+| `4242` | Encrypted message envelope, routed by `q` tag |
+| `4244` | Signed delivery receipt |
+| `10142` | Heartbeat (X25519 + ML-KEM-768 public keys, KEZ-signed) |
+| `0` | Profile (optionally visually-encrypted avatar) |
+| `10002` | NIP-65 relay list |
+
+---
+
+## Deploying
+
+`deploy/docker-compose.yml` runs the stack:
+
+| Service | Port | |
+|---|---|---|
+| `chat-server` | 6969 | API + app. Put TLS (Caddy, nginx, Cloudflare tunnel) in front |
+| `sig-server` | 7878 | Sigchain store; reached publicly through the chat-server proxy |
+| `nats` | 4222 / 8443 / 8222 | Provisioned for future multi-instance fan-out; not used yet |
```sh
-cargo test # 13 integration tests (real server, real HTTP)
+cd deploy && docker compose up -d --build
```
-The tests stand up the router on a random local port and exercise it
-via `reqwest`. No mocks. They cover: healthz, lookup, registration
-(success + duplicate + wrong-server + reserved-name + tampered-sig +
-stale-timestamp), WebFinger, the placeholder SPA, and the NATS auth
-callout stub.
+Images build from the **repo root** as context (the server depends on
+`rust/crates/kez-core`). For production, kez.lat uses a local
+cross-build + rsync script (`deploy/deploy-fast.local.sh`, gitignored
+because it holds host credentials) that builds the binary and app on a
+fast machine and restarts only the runtime container.
-## Endpoints in detail
+## Checks
-### `GET /v1/healthz`
-
-```json
-{ "status": "ok", "server": "kez.lat", "version": "0.1.0" }
+```sh
+cd web
+npm run check # svelte-check + types
+npx esbuild src/lib/heartbeat.selfcheck.ts --bundle --platform=node --format=esm \
+ | node --input-type=module # crypto self-check (heartbeats, retention, v4)
```
-### `GET /v1/u/:handle`
+`cargo test` (server integration tests in `tests/http.rs`) currently
+needs updating for newer config fields before it compiles.
-Returns:
+---
-```json
-{
- "handle": "tudisco",
- "fqhn": "tudisco@kez.lat",
- "primary": "ed25519:2152f8d19b...",
- "sigchain_url": "https://sig.kez.lat/v1/sigchains/ed25519/2152f8d19b...",
- "registered_at": "2026-05-25T03:00:00Z"
-}
-```
+## More reading
-Returns 404 if the handle isn't registered.
-
-### `POST /v1/register`
-
-Request body — a signed registration envelope:
-
-```json
-{
- "kez": "handle_registration",
- "payload": {
- "type": "kez.chat.handle_registration",
- "version": 1,
- "handle": "tudisco",
- "primary": "ed25519:2152f8d19b...",
- "server": "kez.lat",
- "created_at": "2026-05-25T03:00:00Z"
- },
- "signature": {
- "alg": "ed25519-sha512-jcs",
- "key": "ed25519:2152f8d19b...",
- "sig": "<128-char-hex>"
- }
-}
-```
-
-Server validates:
-1. Envelope tag is `"handle_registration"`
-2. Payload type is `"kez.chat.handle_registration"`, version 1
-3. `signature.key` equals `payload.primary`
-4. Signature verifies against the primary key (Ed25519 only for chat)
-5. `payload.server` matches this server's configured domain
-6. `payload.handle` passes validation (length 3-32, `a-z0-9_-`,
- starts with letter/digit, not in reserved list)
-7. `payload.created_at` is within 5 minutes of server time
-
-On success: `201 Created` with the same body as `GET /v1/u/:handle`.
-
-### `GET /.well-known/webfinger?resource=acct:user@server`
-
-Standard fediverse-style discovery. Returns the user's KEZ identity
-info as a WebFinger JRD. Used by other servers (federated lookup,
-future) and by tools like fediverse browsers.
-
-### `POST /internal/nats/auth`
-
-NATS auth callout endpoint. **Stub in v0.1** — returns 501. The real
-implementation (v0.2) will: parse the NATS auth request JWT, extract
-the connecting client's nkey, look up the corresponding handle, sign
-a response permitting `kez.inbox..>` subjects.
-
-## Deployment notes
-
-- The `chat-server` Docker image is built from the **repo root** as
- context (so it can copy `rust/crates/kez-core` for the path dep).
- `docker-compose.yml` sets this correctly.
-- The `sig-server` is the existing [`../rust-sig-server`](../rust-sig-server/)
- binary, built into a separate image via `Dockerfile.sig-server`.
-- NATS config (`nats.conf`) has WebSocket enabled on port 8443 so the
- browser SPA can connect via `nats.ws`. The `issuer` field in
- `auth_callout` is a placeholder — generate a real nkey and replace
- before going to production.
-- TLS is **not** handled by this binary. Put a reverse proxy (Caddy,
- nginx, Cloudflare tunnel) in front for HTTPS.
+- [SECURITY.md](SECURITY.md) — threat model and crypto, in plain language
+- [web/NOSTR-CHAT.md](web/NOSTR-CHAT.md) — how messages ride on nostr
+- [TODO.md](TODO.md) — roadmap and design notes (heartbeat ratchet, v4)
+- [../SPEC.md](../SPEC.md) — the KEZ identity protocol
## License
diff --git a/kez-chat/SECURITY.md b/kez-chat/SECURITY.md
new file mode 100644
index 0000000..48795ca
--- /dev/null
+++ b/kez-chat/SECURITY.md
@@ -0,0 +1,406 @@
+# kez-chat Security Model
+
+A plain-language description of how kez-chat protects your messages.
+Written for the curious — read top to bottom, or skip to whichever
+section you care about.
+
+## TL;DR
+
+kez-chat is end-to-end encrypted. Only the people you're talking to
+can read what you write — not the nostr relays the messages flow
+through, not the push providers that wake your phone, nobody.
+
+Messages have **forward secrecy** (an attacker who steals your
+identity key TODAY can't decrypt messages from more than ~2 weeks ago)
+and **post-compromise security** (after a 2-week window elapses, any
+new messages are safe again, even if the attacker briefly held your
+key in the past).
+
+Message encryption is **post-quantum hybrid** (X25519 + ML-KEM-768),
+so traffic recorded today can't be decrypted by a future quantum
+computer.
+
+Profile pictures are optionally **visually encrypted** so people who
+aren't your contacts see colored noise instead of your face.
+
+This document explains how. Honestly — including where the design
+isn't quite as strong as Signal's, and why we think the trade is
+worth it for a chat built on nostr.
+
+## What kez-chat protects against
+
+- **Nostr relays reading your messages.** They see encrypted bytes
+ and routing tags. They cannot read content, even if every relay you
+ use colludes.
+- **Push providers (FCM / APNs / Mozilla) reading your messages.**
+ Push payloads are empty by design. The provider knows "kez-chat
+ woke a device," nothing about who messaged whom.
+- **A passive observer who logs everything for years.** If they later
+ compromise your KEZ identity key, they STILL cannot decrypt
+ messages older than your forward-secrecy window (default 16 days).
+ Those keys are gone.
+- **Brief device compromise** (an attacker had your phone for 30
+ seconds at a bar). Anything within the current retention window is
+ exposed, but new messages sent AFTER the compromise are safe again
+ once the window rolls over.
+- **Identity impersonation.** Every message is signed by the sender's
+ long-term Ed25519 key. Forgeries don't verify.
+- **Replay attacks.** Each event has a unique id; envelopes carry a
+ timestamp; the receiving client rejects anything more than ±5
+ minutes off the wall clock or older than the freshness window.
+- **Metadata leak in the message body.** Sender identity, recipient
+ identity, and message content all live INSIDE the encrypted blob.
+ Relays see only an opaque "to addr X" routing tag.
+- **Stranger profile-picture scraping.** If you enable visual
+ encryption (default), strangers see scrambled noise. Only people
+ who've messaged you can see your actual face.
+
+## What kez-chat does NOT protect against (be honest)
+
+- **A determined attacker on your device DURING the retention
+ window.** Anything in your IndexedDB at the moment of compromise is
+ exposed. Mitigation: shorter retention window (Settings → Forward
+ Secrecy slider) trades offline tolerance for tighter exposure.
+- **Per-message forward secrecy at Signal's granularity.** Our
+ window is days, not "until you've decrypted the next message."
+ More on this below.
+- **The chat-server operator learning who's registered with them.**
+ Handle registration is on a public directory. If you want your
+ handle private, don't register it.
+- **Traffic-analysis metadata.** Nostr relays see "Alice posted an
+ event tagged to Bob's routing addr at 14:35." That's enough to
+ build a social graph. We don't onion-route or pad messages. (If
+ you need that, use a chat that does — there are real trade-offs.)
+- **First-message-ever-to-a-brand-new-contact fallback.** When you
+ message someone who has never published a heartbeat, that ONE
+ message uses a fallback path with no receiver-side forward
+ secrecy. Every subsequent message in the conversation gets the
+ full protection.
+- **Multi-device.** Today, signing in on a second device generates a
+ fresh set of heartbeats and effectively orphans the first device's
+ decryption history. This is documented as a v0.1 limitation; the
+ fix is on the roadmap.
+- **Quantum computers — partially.** Message *encryption* is
+ post-quantum: v4 envelopes combine X25519 with ML-KEM-768 (the NIST
+ standard, same idea as Signal PQXDH and Apple PQ3), so messages
+ harvested from relays today stay safe. Two gaps remain: the very
+ first message to a contact who has never published a heartbeat is
+ classical-only, and *signatures* (Ed25519, nostr secp256k1) are still
+ classical — a future quantum attacker could impersonate someone in
+ real time, though not decrypt past messages.
+
+## How identity works
+
+Each kez-chat account is a single 32-byte random number (the "seed"),
+generated on first install. From the seed we derive:
+
+- **Ed25519 signing key** — your KEZ identity. The public half
+ (`ed25519:`) IS your long-term identity. You sign every
+ message envelope with it so recipients know it's you.
+- **X25519 key** — used for ECDH (key exchange) with peers. Derived
+ from the same seed via standard Montgomery conversion.
+- **Secp256k1 / Schnorr key** — your nostr signing key. Derived via
+ HKDF with a domain separator (`kez-chat:nostr-signkey/v1`). Used
+ only to sign nostr events so relays accept them. Never surfaced to
+ the user.
+
+The seed itself is encrypted at rest under your passphrase
+(PBKDF2 → AES-GCM) and stored in browser IndexedDB. Unlock decrypts
+it into memory; lock zeroes it.
+
+Optionally: a 30-day "persistent session" wraps the seed under a
+non-extractable WebCrypto AES key for fast re-unlocks. This is
+designed as the same trust boundary as the biometric-unlock path:
+an attacker with code execution in your browser origin can decrypt
+the seed, but cannot exfiltrate the key off the device.
+
+## How messages are encrypted (the envelope)
+
+When you send a message, kez-chat builds a "v3 envelope" that looks
+roughly like this:
+
+```json
+{
+ "v": 3,
+ "eph_pub": "<32 bytes hex>",
+ "recipient_heartbeat_pub":"<32 bytes hex>",
+ "nonce": "<12 bytes hex>",
+ "ciphertext": "",
+ "sender_sig": "<64 bytes hex>"
+}
+```
+
+The full envelope is JSON-stringified, then carried as the `content`
+field of a nostr DM event (kind 4242). The nostr event is signed
+with your nostr key and broadcast to ~5 relays.
+
+Encryption process:
+
+1. Generate a fresh ephemeral X25519 keypair just for this one
+ message. Discard the private the moment you're done.
+2. Look up the recipient's most recent **heartbeat** public key from
+ nostr (more on this below). Fall back to their identity X25519
+ pub if they've never published one.
+3. Perform X25519 ECDH using the ephemeral private + recipient's
+ heartbeat public. This produces a shared secret known only to
+ you and the recipient.
+4. Run the shared secret through HKDF-SHA256 (with the nonce as
+ salt and a domain-separated info string) to get a single-use
+ AES-256 key.
+5. Encrypt your message text with AES-256-GCM, using a 12-byte
+ random nonce and **Additional Authenticated Data** that binds
+ the envelope's `{v, eph_pub, recipient_heartbeat_pub, nonce,
+ fallback}` fields into the auth tag. A relay tampering with any
+ routing field would break decryption.
+6. Sign the envelope (minus signature) with your Ed25519 key. This
+ proves YOU sent it.
+
+The plaintext that gets encrypted carries `{from, body, sent_at}`.
+Your identity is INSIDE the ciphertext; the relay never sees it.
+
+## The Heartbeat Ratchet — kez-chat's forward secrecy
+
+This is the interesting part.
+
+Traditional end-to-end systems either (a) accept that compromise of
+your long-term key compromises ALL history (no forward secrecy) or
+(b) implement Signal's Double Ratchet (excellent forward secrecy but
+significant complexity and dependence on a central prekey server).
+
+kez-chat takes a third path that's specific to chat over nostr:
+
+### How it works
+
+- Each day, on first activity, your device generates a fresh random
+ X25519 keypair — the "heartbeat."
+- The PUBLIC half is published to nostr as a parameterized
+ replaceable event (kind 10142, d-tag `heartbeat-v1`), signed with
+ your nostr key. Anyone can fetch your latest heartbeat.
+- The PRIVATE half lives only on your device, in IndexedDB.
+- When someone messages you, their client fetches your latest
+ heartbeat and uses ITS public key as the ECDH target — not your
+ long-term identity key.
+- You decrypt by looking up the matching private key from your
+ local IDB store.
+- After your configurable retention window (default 16 days; 3–60
+ in Settings), old heartbeat privates are **deleted**.
+
+### Why this gives you forward secrecy
+
+The heartbeat privates are random bytes, NOT derived from your
+seed. The seed only signs the heartbeat publish to prove it's
+yours. So:
+
+- A passive observer who has been logging every encrypted message
+ for years, then later compromises your seed, **cannot decrypt
+ messages older than ~16 days**. The private keys for those
+ messages were random and have been deleted. The seed alone is not
+ enough.
+
+### Why this gives you post-compromise security
+
+If your device is briefly compromised (one-shot attacker, no
+persistence):
+
+- They can decrypt anything in your IDB at that moment — up to 16
+ days of stored heartbeat privates.
+- After 16 days, those privates have been swept. The captured data
+ is now permanently undecryptable, even from your seed.
+- Meanwhile, fresh heartbeats your device generates AFTER the
+ compromise are random bytes the attacker never saw. They can sign
+ fake heartbeats AS you (impersonation), but they cannot predict
+ the randomness your real device produces. Future messages to your
+ real device are safe.
+
+### Why this is simpler than Double Ratchet
+
+- **No per-peer session state.** One heartbeat keyset serves all
+ contacts. Signal has to maintain a separate ratchet state per
+ conversation.
+- **No prekey distribution server.** Signal users publish prekey
+ bundles to Signal's server; new senders fetch them. We publish
+ heartbeats to nostr — the same publish/subscribe infrastructure we
+ use for messages.
+- **No X3DH initial handshake.** First contact uses a documented
+ fallback (no FS on that one message) and the very next reply
+ bootstraps full v3.
+- **No out-of-order message bookkeeping.** Each message is
+ independently sealable to whichever heartbeat the sender saw at
+ send time.
+
+### The honest trade — granularity
+
+Signal's forward secrecy is per-message. The moment a message is
+decrypted, that key is gone. Our forward secrecy is per-window
+(default 16 days). Anything in IDB at the moment of compromise is
+readable.
+
+For chat-style threat models — "someone steals my phone, sees what's
+there" — both approaches give the attacker WHAT'S ON THE DEVICE NOW.
+Signal's edge is the brief gap between decrypting a message and the
+next one arriving. Real-world significance: small but not zero.
+
+The relevant question: is the simpler protocol worth a coarser FS
+window? We think yes, because:
+
+1. **Implementation correctness matters.** A correctly-implemented
+ simple ratchet beats a subtly-broken Double Ratchet every time.
+ Signal-style ratchet implementations are notoriously bug-prone.
+2. **It naturally fits nostr.** Signal was designed for a central
+ server; our design uses nostr's actual semantics (replaceable
+ events for keys, broadcast for messages). No fighting the
+ substrate.
+3. **The chat threat model is "phone theft" not "subtle ratchet
+ misuse."** Most real attacks fall into the category our window
+ handles correctly.
+4. **You can tune the window.** Slide it to 3 days for tighter FS,
+ or 60 days if you travel offline a lot. Signal's per-message
+ granularity isn't tunable.
+
+## Profile picture privacy (visual encryption)
+
+Your nostr `kind:0` profile event is fundamentally public — every
+nostr client can read it. Putting your face directly in your
+profile means anyone scraping nostr sees you.
+
+kez-chat's solution: by default, the profile picture you publish
+is **visually scrambled**.
+
+### How it works
+
+- You pick a picture in Settings → Profile.
+- Your device generates a random 256-bit symmetric key K and a
+ per-image nonce.
+- The image's pixels are deterministically shuffled by a PRNG
+ seeded from K — Fisher-Yates shuffle. The output is a valid PNG
+ with the same dimensions and color palette, but the content is
+ garbled.
+- The scrambled PNG goes in your kind:0 event under `picture`.
+- For each of your **contacts** (people you've ever DM'd), your
+ client wraps K under a SealedEnvelope (the same v3 envelope crypto
+ used for DMs) addressed to that contact's KEZ primary. The wrapped
+ keys go in a `kez_visual_keys` field of your kind:0.
+- A "self-wrap" is also included so any other device of yours can
+ rebuild K from your own seed.
+
+### What strangers see
+
+A scrambled PNG. Same colors, same dimensions, no recognizable
+content. They cannot decrypt — the per-recipient wraps are
+encrypted to specific contacts.
+
+### What contacts see
+
+Their client fetches your kind:0, finds the wrap addressed to them,
+opens it via your existing DM crypto, recovers K, and uses K to
+unscramble the picture. They see your real face.
+
+### What this gives you
+
+Your face is private by default and visible only to people you've
+actually talked to. No additional infrastructure, no separate image
+host, no permission requests. Same nostr publish/subscribe path.
+
+You can turn it off in Settings if you want a public profile
+picture.
+
+## Comparison with Signal Protocol
+
+| Property | Signal | kez-chat |
+|---|---|---|
+| End-to-end encryption | ✅ | ✅ |
+| Authentication / sender forgery resistance | ✅ | ✅ |
+| Replay protection | ✅ | ✅ |
+| Sender-side per-message forward secrecy | ✅ | ✅ |
+| Receiver-side forward secrecy | per-message | per-window (default 16d) |
+| Post-compromise security after seed leak | per-DH-step (~per-reply) | per-window |
+| Multi-device | first-class | single-device in v0.1 |
+| Group chat | ✅ | ❌ (future) |
+| Prekey distribution | central Signal server | nostr (decentralized) |
+| Identity / contact discovery | Signal's server | KEZ + nostr (decentralized) |
+| Per-peer session state | yes (real bookkeeping) | none |
+| Code complexity | high | moderate |
+| Post-quantum encryption | PQXDH (X25519 + ML-KEM) | v4 hybrid (X25519 + ML-KEM-768) |
+
+Where Signal wins: per-message FS granularity, multi-device today,
+groups today, longer track record of stress-testing.
+
+Where kez-chat wins: no central server, no Signal-account
+registration, no Signal-prekey-store dependence, identities
+portable across the KEZ graph, the entire system fits in a PWA you
+can run from any browser.
+
+## Trust assumptions
+
+To use kez-chat, you're trusting:
+
+- **Your browser's WebCrypto implementation.** Same as Signal Web,
+ same as iMessage Web, same as any browser-delivered crypto.
+- **Standard cryptographic primitives.** Ed25519 (signatures), X25519
+ (key exchange), AES-256-GCM (encryption), SHA-256 (hashing), HKDF
+ (key derivation). All well-studied and widely deployed.
+- **`@noble/curves` and `@noble/hashes`** — pure JavaScript crypto
+ libraries with formal audits, used by many production wallets and
+ chat tools.
+- **The `nostr-tools` library** — for relay communication. Doesn't
+ touch your private keys (we sign locally before handing off).
+- **At least ONE of the configured nostr relays staying honest
+ enough to forward your events.** Of the default 5
+ (`relay.damus.io`, `nos.lol`, `relay.primal.net`,
+ `relay.snort.social`, `nostr.wine`), only one needs to work for
+ delivery to succeed.
+
+You are NOT trusting:
+
+- The kez-chat home server (kez.lat) for message delivery. Chat
+ flows directly through nostr; the home server is only used for
+ handle directory lookups and Web Push fanout.
+- Any single nostr relay. All 5 would have to refuse independently.
+- Any push provider. Push payloads are empty.
+
+## Glossary
+
+**AAD (Additional Authenticated Data)** — extra metadata that an
+AEAD cipher includes in the authentication tag. We bind envelope
+routing fields so a relay can't tamper without breaking the auth
+check.
+
+**AEAD (Authenticated Encryption with Associated Data)** —
+encryption + integrity + tampering detection in one primitive. We
+use AES-256-GCM.
+
+**ECDH (Elliptic Curve Diffie-Hellman)** — two parties compute the
+same shared secret from their respective private keys + each other's
+public keys. The foundation of all our message encryption.
+
+**Forward Secrecy** — past messages stay safe even if your key
+leaks later.
+
+**Heartbeat** — a fresh random X25519 keypair your client publishes
+daily to nostr. Senders use the public half for ECDH instead of your
+long-term identity.
+
+**HKDF (HMAC-based Key Derivation Function)** — turns a high-entropy
+input (like an ECDH output) into one or more cryptographic keys
+suitable for use in AEAD.
+
+**KEZ identity** — your long-term Ed25519 keypair, the canonical
+"this is who I am" in the kez-chat / KEZ system.
+
+**Nostr** — a simple publish/subscribe protocol over WebSockets we
+use as the message-delivery substrate. Decentralized; you publish
+events to relays, others subscribe.
+
+**Post-Compromise Security (PCS)** — even if your key was
+compromised in the past, future messages eventually become safe
+again as keys evolve.
+
+**v3 envelope** — kez-chat's current message format. Carries a
+sender ephemeral pub, a recipient heartbeat pub, a nonce, the
+ciphertext, and the sender's signature. See `crypto.ts`.
+
+---
+
+Last updated: 2026-06-12. Source: `kez-chat/web/src/lib/crypto.ts`,
+`heartbeat-store.ts`, `nostr-transport.ts`, `visual-crypto.ts`,
+`profile-store.ts`.
diff --git a/kez-chat/TODO.md b/kez-chat/TODO.md
index 8808142..baca942 100644
--- a/kez-chat/TODO.md
+++ b/kez-chat/TODO.md
@@ -20,7 +20,7 @@ review reports are at the bottom.
## Day 1 — ~half a day of work, biggest wins
-### #1. Strip envelope metadata (ephemeral x25519 per message) [TODO]
+### #1. Strip envelope metadata (ephemeral x25519 per message) [DONE]
**Why it matters:** the `SealedEnvelope.from` (KEZ identity) and `to` (handle)
fields sit in cleartext alongside the ciphertext in `event.content`. Any
@@ -46,7 +46,7 @@ message.
**Migration:** envelope `v: 1 → v: 2`. Recipient accepts both during a
1-week window, then drops v1 support.
-### #3. Empty Web Push payload [TODO]
+### #3. Empty Web Push payload [DONE]
**Why it matters:** we send `{type, to: , seq}` to FCM/APNs/Mozilla
on every fanout. RFC 8291 encrypts the payload so the push provider can't
@@ -64,7 +64,7 @@ which navigates to the conversation list. Deep-linking to a specific peer
goes away on cold-open (acceptable trade-off — one extra tap to open the
right thread is the price of *not* exporting metadata to FCM).
-### #5. Rename the routing tag from `h` to something less claimed [TODO]
+### #5. Rename the routing tag from `h` to something less claimed [DONE]
**Why it matters:** `h` is informally used by NIP-29 (Simple Groups) as the
group id. Today's three relays don't enforce NIP-29 semantics, but the
@@ -81,7 +81,7 @@ NIP-01). Bump envelope/event `v` so the listener can tell old-tag events
from new ones during the migration window. Server-side listener subscribes
to BOTH `#h` and `#q` for one week.
-### #17. Demote handle-revealing logs to `debug!` [TODO]
+### #17. Demote handle-revealing logs to `debug!` [DONE]
**Why it matters:** every fanout currently logs `push: fanout triggered
handle= sub_count=N` at INFO level. Operator-side log
@@ -155,7 +155,48 @@ server vouches.
## Roadmap — multi-day, needs design pass
-### #6. Forward secrecy (Double Ratchet) [ROADMAP]
+### #6. Forward secrecy (Heartbeat Ratchet) [DONE — alternative design to Double Ratchet]
+
+Implemented a custom forward-secrecy protocol that fits nostr's
+shape better than Signal's Double Ratchet would. Full reasoning
+in `heartbeat-store.ts` and `crypto.ts` (search for "v3").
+
+Summary:
+ - Each user publishes a fresh random x25519 pub once per active
+ day as a replaceable event (kind 10142, NIP-01 10000–19999
+ range; d-tag "heartbeat-v1" informational). Content is signed
+ by the KEZ identity; senders verify it against the recipient's
+ primary before use.
+ - Private half kept on the publishing device only; deleted after
+ the user-configurable retention window (default 16 days,
+ settable 3–60 in Settings).
+ - Senders fetch the recipient's latest heartbeat and ECDH against
+ it instead of the recipient's long-term identity key (v3
+ envelope).
+ - First-ever message to a brand-new contact (no heartbeat
+ fetchable yet) falls back to identity ECDH with `fallback: true`
+ on the envelope. Single message, no FS. Subsequent messages get
+ full v3.
+ - Receiver-side forward secrecy at retention-window granularity:
+ after the window elapses, even seed compromise can't decrypt
+ historical messages.
+ - Sender-side FS preserved from v2 (per-message ephemeral).
+ - Post-compromise security: one-shot seed compromise (no
+ persistence) becomes invisible after retention window, because
+ fresh heartbeats are random and live only on the legitimate
+ device.
+ - Migration: dual-decrypt. v1/v2/v3 all accepted; only v3 emitted.
+ - Single-device scope for v0.1. Multi-device with shared heartbeat
+ state is future work.
+ - v4 (post-quantum hybrid): each heartbeat also carries an
+ ML-KEM-768 key (signed with the rest of the heartbeat). Senders
+ encapsulate to it and derive the AES key from
+ HKDF(x25519_ss ‖ mlkem_ss) — PQXDH-style, so harvested ciphertexts
+ need BOTH X25519 and ML-KEM broken. Emitted whenever the peer's
+ heartbeat has an ML-KEM key; v3 otherwise. Identity-key fallback
+ (first message to a new contact) stays classical-only. Signatures
+ (Ed25519, nostr secp256k1) are still classical — ML-DSA is future
+ work; forgery needs a live quantum attacker, unlike decryption.
**Why it matters:** today's static-static x25519 means whoever compromises
a seed once decrypts ALL retained history that any relay still has — and
diff --git a/kez-chat/src/api.rs b/kez-chat/src/api.rs
index 744ddc1..644531b 100644
--- a/kez-chat/src/api.rs
+++ b/kez-chat/src/api.rs
@@ -4,6 +4,8 @@
//! GET /v1/healthz liveness
//! GET /v1/u/:handle handle → primary + sigchain pointer + endpoints
//! POST /v1/register claim a handle (signed body)
+//! GET /v1/sigchains/:scheme/:id[/head] } proxied to the internal
+//! POST /v1/sigchains/:scheme/:id/events } sig-server
//! GET /.well-known/webfinger?resource=... fediverse-style discovery
//! POST /internal/nats/auth NATS auth callout (stub in v0.1)
@@ -53,6 +55,9 @@ pub fn router(state: AppState) -> axum::Router {
.route("/v1/push/subscribe/:handle", post(push_subscribe))
.route("/v1/push/unsubscribe/:handle", post(push_unsubscribe))
.route("/v1/push/subscriptions/:handle", get(push_list_subscriptions))
+ .route("/v1/sigchains/:scheme/:id", get(sigchain_proxy))
+ .route("/v1/sigchains/:scheme/:id/head", get(sigchain_proxy))
+ .route("/v1/sigchains/:scheme/:id/events", post(sigchain_proxy))
.route("/.well-known/webfinger", get(webfinger))
.route("/internal/nats/auth", post(nats_auth_callout));
@@ -149,12 +154,10 @@ fn handle_response(config: &Config, record: &HandleRecord) -> HandleResponse {
handle: record.handle.clone(),
fqhn: format!("{}@{}", record.handle, config.server),
primary: record.primary.to_string(),
- sigchain_url: format!(
- "{}/v1/sigchains/{}/{}",
- config.sig_server_url.trim_end_matches('/'),
- scheme,
- id
- ),
+ // Public address, served by `sigchain_proxy` below. The
+ // sig-server itself (`sig_server_url`) is only reachable inside
+ // the deploy network, so it must never be handed to clients.
+ sigchain_url: format!("https://{}/v1/sigchains/{}/{}", config.server, scheme, id),
registered_at: record.registered_at.to_rfc3339(),
proofs: record
.proofs
@@ -649,3 +652,59 @@ for the design doc and progress.
))
}
+
+/// Forward a sigchain request to the internal sig-server, same path and
+/// body. The sig-server sits on the private deploy network (compose
+/// service `sig-server`), so this is how browsers and external
+/// verifiers reach it via the one public origin. It re-validates every
+/// event itself (tag, seq, prev, signature), so no checks here.
+async fn sigchain_proxy(
+ State(state): State,
+ method: axum::http::Method,
+ uri: axum::http::Uri,
+ headers: axum::http::HeaderMap,
+ body: axum::body::Bytes,
+) -> axum::response::Response {
+ static CLIENT: std::sync::OnceLock = std::sync::OnceLock::new();
+ let client = CLIENT.get_or_init(|| {
+ reqwest::Client::builder()
+ .timeout(std::time::Duration::from_secs(15))
+ .build()
+ .expect("reqwest client")
+ });
+ let url = format!(
+ "{}{}",
+ state.config.sig_server_url.trim_end_matches('/'),
+ uri.path()
+ );
+ let mut req = client.request(method, &url).body(body);
+ if let Some(ct) = headers.get(header::CONTENT_TYPE) {
+ req = req.header(header::CONTENT_TYPE, ct);
+ }
+ match req.send().await {
+ Ok(resp) => {
+ let status = resp.status();
+ let ct = resp.headers().get(header::CONTENT_TYPE).cloned();
+ match resp.bytes().await {
+ Ok(bytes) => {
+ let mut out = (status, bytes).into_response();
+ if let Some(ct) = ct {
+ out.headers_mut().insert(header::CONTENT_TYPE, ct);
+ }
+ out
+ }
+ Err(e) => sig_server_unavailable(e),
+ }
+ }
+ Err(e) => sig_server_unavailable(e),
+ }
+}
+
+fn sig_server_unavailable(e: reqwest::Error) -> axum::response::Response {
+ tracing::warn!(error = %e, "sig-server proxy failed");
+ (
+ StatusCode::BAD_GATEWAY,
+ Json(json!({"error": {"code": "sig_server_unavailable", "message": "sigchain store unreachable"}})),
+ )
+ .into_response()
+}
diff --git a/kez-chat/web/.env b/kez-chat/web/.env
index bc06b47..ff8e4fd 100644
--- a/kez-chat/web/.env
+++ b/kez-chat/web/.env
@@ -5,5 +5,8 @@
VITE_TRANSPORT=nostr
# Relays to publish to / read from (comma-separated). Optional — the
-# transport falls back to these same defaults if unset.
-VITE_NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net
+# transport falls back to these same defaults if unset. The set
+# below mirrors the source-of-truth default in nostr-transport.ts,
+# profile-store.ts, peer-profile-store.ts, and the rust
+# nostr_listener config. Update all four when changing the default.
+VITE_NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr.wine
diff --git a/kez-chat/web/package-lock.json b/kez-chat/web/package-lock.json
index f31f206..3af5407 100644
--- a/kez-chat/web/package-lock.json
+++ b/kez-chat/web/package-lock.json
@@ -11,6 +11,7 @@
"@bokuweb/zstd-wasm": "^0.0.22",
"@noble/curves": "^1.6.0",
"@noble/hashes": "^1.5.0",
+ "@noble/post-quantum": "^0.7.1",
"@scure/base": "^1.1.9",
"@scure/bip39": "^2.2.0",
"canonicalize": "^2.0.0",
@@ -2148,9 +2149,6 @@
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2168,9 +2166,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2188,9 +2183,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2208,9 +2200,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2228,9 +2217,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2248,9 +2234,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2268,9 +2251,6 @@
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2294,9 +2274,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2320,9 +2297,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2346,9 +2320,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2372,9 +2343,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2398,9 +2366,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2586,6 +2551,62 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/@noble/post-quantum": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.7.1.tgz",
+ "integrity": "sha512-+P9981IiAnVh+rmcubozzVwrEy3XsN/tMhTnvsjV9VDaYpOnNCqWqKo2FLWxbu92YHfjGIlE5XnW175UK+ln+Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/ciphers": "2.4.0",
+ "@noble/curves": "2.4.0",
+ "@noble/hashes": "2.4.0"
+ },
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/post-quantum/node_modules/@noble/ciphers": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
+ "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/post-quantum/node_modules/@noble/curves": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz",
+ "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "2.4.0"
+ },
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/post-quantum/node_modules/@noble/hashes": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
+ "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/@quansync/fs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz",
@@ -2811,9 +2832,6 @@
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2828,9 +2846,6 @@
"arm"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2845,9 +2860,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2862,9 +2874,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2879,9 +2888,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2896,9 +2902,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2913,9 +2916,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2930,9 +2930,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2947,9 +2944,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2964,9 +2958,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2981,9 +2972,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2998,9 +2986,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3015,9 +3000,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3384,9 +3366,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3404,9 +3383,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3424,9 +3400,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3444,9 +3417,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5743,9 +5713,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5767,9 +5734,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5791,9 +5755,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -5815,9 +5776,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
diff --git a/kez-chat/web/package.json b/kez-chat/web/package.json
index a3deb09..083455b 100644
--- a/kez-chat/web/package.json
+++ b/kez-chat/web/package.json
@@ -13,6 +13,7 @@
"@bokuweb/zstd-wasm": "^0.0.22",
"@noble/curves": "^1.6.0",
"@noble/hashes": "^1.5.0",
+ "@noble/post-quantum": "^0.7.1",
"@scure/base": "^1.1.9",
"@scure/bip39": "^2.2.0",
"canonicalize": "^2.0.0",
diff --git a/kez-chat/web/src/lib/Avatar.svelte b/kez-chat/web/src/lib/Avatar.svelte
index 53e3afb..edfc082 100644
--- a/kez-chat/web/src/lib/Avatar.svelte
+++ b/kez-chat/web/src/lib/Avatar.svelte
@@ -67,7 +67,7 @@
src={picture}
width={size}
height={size}
- alt="profile picture"
+ alt=""
class="shrink-0 object-cover"
style="border-radius: {Math.max(4, size * 0.2)}px; {ring
? 'box-shadow: 0 0 0 2px var(--color-accent);'
diff --git a/kez-chat/web/src/lib/VerifiedBadge.svelte b/kez-chat/web/src/lib/VerifiedBadge.svelte
index feac3ad..0120201 100644
--- a/kez-chat/web/src/lib/VerifiedBadge.svelte
+++ b/kez-chat/web/src/lib/VerifiedBadge.svelte
@@ -19,8 +19,8 @@
fill="currentColor"
role="img"
aria-label="verified"
- {title}
>
+ {title}
diff --git a/kez-chat/web/src/lib/conversations-store.ts b/kez-chat/web/src/lib/conversations-store.ts
index c97be11..14bc6cf 100644
--- a/kez-chat/web/src/lib/conversations-store.ts
+++ b/kez-chat/web/src/lib/conversations-store.ts
@@ -10,6 +10,7 @@ import { ed25519 } from "@noble/curves/ed25519";
import { hexToBytes } from "@noble/hashes/utils";
import { get, set } from "idb-keyval";
import type { Identity } from "./kez.js";
+import { deleteAttachment, deleteChunkBuffer } from "./attachment-store.js";
// :v2 — schema reshaped to key by peer_primary (canonical identity) instead
// of an ad-hoc string. Old data under :conversations (no version) is
@@ -250,6 +251,64 @@ export async function markConversationRead(
}
}
+/** Delete the stored bytes (and any half-assembled chunk buffer)
+ * behind every attachment bubble in `messages`. A cleared chat must
+ * not leave its images sitting in IndexedDB. */
+async function deleteMessageAttachments(
+ peer_primary: Identity,
+ messages: ConversationMessage[],
+): Promise {
+ await Promise.all(
+ messages
+ .filter((m) => m.attachment)
+ .flatMap((m) => [
+ deleteAttachment(peer_primary, m.seq),
+ ...(m.attachment!.file_id ? [deleteChunkBuffer(m.attachment!.file_id)] : []),
+ ]),
+ );
+}
+
+/**
+ * Wipe every message from one conversation — including attachment
+ * bytes — while keeping the peer row itself (handle, primary, nostr
+ * pubkey, via_relay) so future messages from them land in the same
+ * thread. Returns the number of messages removed (diagnostic).
+ */
+export async function clearConversationMessages(
+ peer_primary: Identity,
+): Promise {
+ const s = await read();
+ const conv = s.by_peer[peer_primary];
+ if (!conv) return 0;
+ const n = conv.messages.length;
+ const removed = conv.messages;
+ conv.messages = [];
+ conv.unread_count = 0;
+ // last_seq stays so the inbox-service notification watermark
+ // doesn't badge-storm us with already-seen messages if they get
+ // re-broadcast by a relay.
+ await write(s);
+ await deleteMessageAttachments(peer_primary, removed);
+ return n;
+}
+
+/**
+ * Wipe the entire conversation row (messages + peer metadata).
+ * Use when the user wants the contact gone, not just the messages.
+ * Future messages from this peer will rebuild a fresh row.
+ */
+export async function deleteConversation(
+ peer_primary: Identity,
+): Promise {
+ const s = await read();
+ const conv = s.by_peer[peer_primary];
+ if (conv) {
+ delete s.by_peer[peer_primary];
+ await write(s);
+ await deleteMessageAttachments(peer_primary, conv.messages);
+ }
+}
+
/**
* Append an outbound message and return its synthetic `seq` so the
* caller can update the status later (sending → sent → delivered).
diff --git a/kez-chat/web/src/lib/crypto.ts b/kez-chat/web/src/lib/crypto.ts
index 883d302..c762b78 100644
--- a/kez-chat/web/src/lib/crypto.ts
+++ b/kez-chat/web/src/lib/crypto.ts
@@ -23,10 +23,23 @@ import { ed25519, x25519 } from "@noble/curves/ed25519";
import { sha256 } from "@noble/hashes/sha2";
import { hkdf } from "@noble/hashes/hkdf";
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
+import { ml_kem768 } from "@noble/post-quantum/ml-kem.js";
import { canonicalBytes, type Identity } from "./kez.js";
const HKDF_INFO_V1 = new TextEncoder().encode("kez-chat-msg-v1");
const HKDF_INFO_V2 = new TextEncoder().encode("kez-chat-msg-v2");
+const HKDF_INFO_V3 = new TextEncoder().encode("kez-chat-msg-v3");
+const HKDF_INFO_V4 = new TextEncoder().encode("kez-chat-msg-v4");
+
+/** Heartbeat private keys the receiver resolves for an incoming v3/v4
+ * envelope. `mlkem` is absent for heartbeats minted before v4. */
+export interface HeartbeatPrivs {
+ x25519: Uint8Array;
+ mlkem?: Uint8Array;
+}
+export type ResolveHeartbeatPrivs = (
+ x25519PubHex: string,
+) => Promise;
/** What the sender stores in the encrypted blob. */
export interface MessagePlaintext {
@@ -40,24 +53,49 @@ export interface MessagePlaintext {
/** What goes on the wire to POST /v1/messages. */
/**
- * Two envelope shapes coexist during the v1 → v2 migration window.
+ * Three envelope shapes coexist during the v1/v2 → v3 migration.
*
* v1: legacy — `from` (KEZ identity) and `to` (handle) in cleartext
* NEXT TO the ciphertext. Relays could JSON-parse `event.content`
* and build a perfect social graph. Decrypt-only support; we
* never emit new v1 envelopes.
*
- * v2: fixed — only an EPHEMERAL x25519 public key (per-message,
- * discarded right after sealing) and the ciphertext are visible
- * outside the encrypted blob. `from` (KEZ identity) lives INSIDE
- * the plaintext, verified post-decrypt via ed25519 sig. Forward
- * secrecy: a compromised long-term ed25519 seed can no longer
- * decrypt past captured ciphertexts (the ephemeral private key
- * was destroyed at send time).
+ * v2: fixed metadata leak — only an EPHEMERAL x25519 public key
+ * (per-message, discarded right after sealing) and the
+ * ciphertext are visible outside the encrypted blob. `from`
+ * (KEZ identity) lives INSIDE the plaintext, verified post-
+ * decrypt via ed25519 sig. Per-message forward secrecy from
+ * the SENDER side: a compromised long-term ed25519 seed cannot
+ * decrypt past captured ciphertexts because the sender
+ * ephemeral private was destroyed at send. RECEIVER side still
+ * uses their long-term x25519 identity — so receiver-seed
+ * compromise DOES decrypt history.
*
- * See kez-chat/TODO.md Day 1 #1.
+ * v3: heartbeat ratchet — RECEIVER side also forward-secure.
+ * Sender encrypts to a daily-rotating "heartbeat" public key
+ * the recipient publishes to nostr, instead of to their long-
+ * term x25519 identity. Receiver retains the matching private
+ * for a configurable window (default 16 days) then deletes
+ * it. After deletion, no one — not even the receiver — can
+ * decrypt those messages. Coarser-grained FS than Double
+ * Ratchet but vastly simpler: no per-peer session state, no
+ * prekey-distribution server, no out-of-order bookkeeping.
+ * See heartbeat-store.ts.
+ *
+ * v4: v3 + post-quantum hybrid. The recipient's heartbeat also
+ * carries an ML-KEM-768 key; the sender encapsulates to it and
+ * the AES key comes from HKDF(x25519_ss ‖ mlkem_ss). Breaking a
+ * message now needs BOTH X25519 and ML-KEM broken — so
+ * ciphertexts harvested off public relays today stay safe
+ * against a future quantum computer (same construction as
+ * Signal PQXDH). Emitted whenever the recipient's heartbeat has
+ * an ML-KEM key; otherwise v3.
*/
-export type SealedEnvelope = SealedEnvelopeV1 | SealedEnvelopeV2;
+export type SealedEnvelope =
+ | SealedEnvelopeV1
+ | SealedEnvelopeV2
+ | SealedEnvelopeV3
+ | SealedEnvelopeV4;
/** @deprecated v1 — leaks sender + recipient identity to relays. */
export interface SealedEnvelopeV1 {
@@ -88,6 +126,51 @@ export interface SealedEnvelopeV2 {
sender_sig: string;
}
+export interface SealedEnvelopeV3 {
+ v: 3;
+ /** Sender's ephemeral x25519 pub for this single message. Same
+ * semantics as v2. */
+ eph_pub: string;
+ /** Recipient's HEARTBEAT x25519 pub the sender ECDH'd against.
+ * Recipient looks up the matching private from their local
+ * heartbeat store (heartbeat-store.ts). If they don't have it
+ * (too old, never theirs), decrypt fails. This is the field that
+ * makes v3 receiver-side forward-secure. */
+ recipient_heartbeat_pub: string;
+ /** Indicates the sender fell back to the recipient's long-term
+ * identity key instead of a heartbeat — happens for the first
+ * message to a brand-new contact who hasn't published one yet.
+ * When true, `recipient_heartbeat_pub` actually holds the
+ * recipient's identity-derived x25519 pub (still derived from
+ * their KEZ primary). No FS for this single message. */
+ fallback?: boolean;
+ /** 12-byte AES-GCM nonce, hex. */
+ nonce: string;
+ /** AES-256-GCM(plaintext_json), hex. AAD binds
+ * {v, eph_pub, recipient_heartbeat_pub, nonce, fallback?} so a
+ * relay-in-the-middle can't swap routing without breaking auth. */
+ ciphertext: string;
+ /** ed25519 sig over canonical(envelope minus sig). */
+ sender_sig: string;
+}
+
+export interface SealedEnvelopeV4 {
+ v: 4;
+ /** Sender's ephemeral x25519 pub (as v3). */
+ eph_pub: string;
+ /** Recipient heartbeat x25519 pub — also the lookup id for the
+ * matching heartbeat's ML-KEM private. */
+ recipient_heartbeat_pub: string;
+ /** ML-KEM-768 ciphertext (1088 bytes), hex. */
+ kem_ct: string;
+ nonce: string;
+ /** AES-256-GCM(plaintext_json), hex. AAD binds
+ * {v, eph_pub, recipient_heartbeat_pub, kem_ct, nonce}. */
+ ciphertext: string;
+ /** ed25519 sig over canonical(envelope minus sig). */
+ sender_sig: string;
+}
+
// ─────────────────────────────────────────────────────────────────────────────
// Key derivation
// ─────────────────────────────────────────────────────────────────────────────
@@ -238,12 +321,31 @@ export async function openMessage(opts: {
envelope: SealedEnvelope;
myHandle: string;
mySeed: Uint8Array;
+ /** v3 only — supplied by the caller (inbox-service) so this
+ * module doesn't have to depend on heartbeat-store directly.
+ * Returns the matching heartbeat private if we still have it
+ * in the retention window; undefined otherwise. */
+ resolveHeartbeatPriv?: ResolveHeartbeatPrivs;
}): Promise {
const env = opts.envelope;
let plaintext: MessagePlaintext;
if (env.v === 1) plaintext = await openMessageV1(env, opts.myHandle, opts.mySeed);
else if (env.v === 2) plaintext = await openMessageV2(env, opts.mySeed);
- else {
+ else if (env.v === 3) {
+ if (!opts.resolveHeartbeatPriv) {
+ throw new Error(
+ "v3 envelope received but no heartbeat resolver supplied — caller bug",
+ );
+ }
+ plaintext = await openMessageV3(env, opts.mySeed, opts.resolveHeartbeatPriv);
+ } else if (env.v === 4) {
+ if (!opts.resolveHeartbeatPriv) {
+ throw new Error(
+ "v4 envelope received but no heartbeat resolver supplied — caller bug",
+ );
+ }
+ plaintext = await openMessageV4(env, opts.resolveHeartbeatPriv);
+ } else {
throw new Error(
`unsupported envelope version: ${(env as { v: number }).v}`,
);
@@ -372,3 +474,367 @@ async function openMessageV2(
return plaintext;
}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// v3 — heartbeat-ratcheted, receiver-side forward secure
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Build an envelope for `recipient_heartbeat_pub` — typically the
+ * recipient's latest published heartbeat from nostr. Pass
+ * `fallback: true` when the recipient has no heartbeat available
+ * and we're falling back to their identity-derived x25519 pub for
+ * just this one message (no FS for it).
+ *
+ * The caller (nostr-transport) is responsible for fetching the
+ * recipient's heartbeat and passing the right `recipient_heartbeat_pub`.
+ * This function only does the crypto.
+ */
+export async function sealMessageV3(opts: {
+ senderSeed: Uint8Array;
+ senderPrimary: Identity;
+ /** x25519 pub the recipient is currently advertising via nostr.
+ * When `fallback` is true, this is allowed to be derived from
+ * the recipient's identity instead. */
+ recipientHeartbeatPub: Uint8Array;
+ fallback?: boolean;
+ body: string;
+}): Promise {
+ // Fresh ephemeral on the sender side — identical FS posture to v2
+ // on the SENDER half.
+ const ephPriv = x25519.utils.randomSecretKey();
+ const ephPub = x25519.getPublicKey(ephPriv);
+
+ const nonce = crypto.getRandomValues(new Uint8Array(12));
+ const aesKey = await deriveAesKey(
+ ephPriv,
+ opts.recipientHeartbeatPub,
+ nonce,
+ HKDF_INFO_V3,
+ );
+
+ const plaintext: MessagePlaintext = {
+ from: opts.senderPrimary,
+ body: opts.body,
+ sent_at: new Date().toISOString(),
+ };
+ const ptBytes = new TextEncoder().encode(JSON.stringify(plaintext));
+
+ // AAD binds the full routing context. A relay tampering with
+ // recipient_heartbeat_pub or fallback would fail the auth tag.
+ const aad = canonicalBytes({
+ v: 3,
+ eph_pub: bytesToHex(ephPub),
+ recipient_heartbeat_pub: bytesToHex(opts.recipientHeartbeatPub),
+ nonce: bytesToHex(nonce),
+ fallback: !!opts.fallback,
+ });
+ const ctBytes = new Uint8Array(
+ await crypto.subtle.encrypt(
+ {
+ name: "AES-GCM",
+ iv: asBuffer(nonce),
+ additionalData: asBuffer(aad),
+ },
+ aesKey,
+ asBuffer(ptBytes),
+ ),
+ );
+
+ const partial = {
+ v: 3 as const,
+ eph_pub: bytesToHex(ephPub),
+ recipient_heartbeat_pub: bytesToHex(opts.recipientHeartbeatPub),
+ nonce: bytesToHex(nonce),
+ ciphertext: bytesToHex(ctBytes),
+ ...(opts.fallback ? { fallback: true } : {}),
+ };
+ const sig = ed25519.sign(canonicalBytes(partial), opts.senderSeed);
+
+ return { ...partial, sender_sig: bytesToHex(sig) };
+}
+
+/**
+ * Decrypt a v3 envelope. The recipient private is the identity-derived
+ * x25519 key for `fallback` envelopes (sender had no heartbeat for us),
+ * otherwise the heartbeat private looked up via the caller-supplied
+ * resolver — keeps this module free of IDB (heartbeat-store) imports.
+ * Throws if we no longer hold that heartbeat (expired or never ours).
+ */
+async function openMessageV3(
+ env: SealedEnvelopeV3,
+ mySeed: Uint8Array,
+ resolveHeartbeatPriv: ResolveHeartbeatPrivs,
+): Promise {
+ const recipientPriv = env.fallback
+ ? x25519PrivFromEd25519Seed(mySeed)
+ : (await resolveHeartbeatPriv(env.recipient_heartbeat_pub))?.x25519;
+ if (!recipientPriv) {
+ throw new Error(
+ "envelope addressed to an expired/unknown heartbeat (no matching private key on this device)",
+ );
+ }
+ const ephPub = hexToBytes(env.eph_pub);
+ const nonce = hexToBytes(env.nonce);
+ const aesKey = await deriveAesKey(
+ recipientPriv,
+ ephPub,
+ nonce,
+ HKDF_INFO_V3,
+ );
+
+ const aad = canonicalBytes({
+ v: 3,
+ eph_pub: env.eph_pub,
+ recipient_heartbeat_pub: env.recipient_heartbeat_pub,
+ nonce: env.nonce,
+ fallback: !!env.fallback,
+ });
+ const ptBytes = new Uint8Array(
+ await crypto.subtle.decrypt(
+ {
+ name: "AES-GCM",
+ iv: asBuffer(nonce),
+ additionalData: asBuffer(aad),
+ },
+ aesKey,
+ asBuffer(hexToBytes(env.ciphertext)),
+ ),
+ );
+ const plaintext = JSON.parse(
+ new TextDecoder().decode(ptBytes),
+ ) as MessagePlaintext;
+
+ if (!plaintext.from?.startsWith("ed25519:")) {
+ throw new Error(`unsupported sender primary scheme: ${plaintext.from}`);
+ }
+ const senderPubKey = hexToBytes(plaintext.from.slice("ed25519:".length));
+ const partial = {
+ v: env.v,
+ eph_pub: env.eph_pub,
+ recipient_heartbeat_pub: env.recipient_heartbeat_pub,
+ nonce: env.nonce,
+ ciphertext: env.ciphertext,
+ ...(env.fallback ? { fallback: true } : {}),
+ };
+ const sigOk = ed25519.verify(
+ hexToBytes(env.sender_sig),
+ canonicalBytes(partial),
+ senderPubKey,
+ );
+ if (!sigOk) throw new Error("envelope signature did not verify");
+
+ return plaintext;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// v4 — hybrid X25519 + ML-KEM-768 (post-quantum)
+// ─────────────────────────────────────────────────────────────────────────────
+
+/** HKDF over BOTH shared secrets, X25519 first (PQXDH order). Nonce as
+ * salt, as v1–v3. An attacker needs both secrets to get the key. */
+async function deriveHybridAesKey(
+ x25519Shared: Uint8Array,
+ mlkemShared: Uint8Array,
+ nonce: Uint8Array,
+): Promise {
+ const ikm = new Uint8Array(x25519Shared.length + mlkemShared.length);
+ ikm.set(x25519Shared, 0);
+ ikm.set(mlkemShared, x25519Shared.length);
+ const keyBytes = hkdf(sha256, ikm, nonce, HKDF_INFO_V4, 32);
+ return crypto.subtle.importKey("raw", asBuffer(keyBytes), "AES-GCM", false, [
+ "encrypt",
+ "decrypt",
+ ]);
+}
+
+function v4Aad(env: Omit): Uint8Array {
+ return canonicalBytes({
+ v: 4,
+ eph_pub: env.eph_pub,
+ recipient_heartbeat_pub: env.recipient_heartbeat_pub,
+ kem_ct: env.kem_ct,
+ nonce: env.nonce,
+ });
+}
+
+/** Seal to a recipient heartbeat that carries an ML-KEM-768 key. */
+export async function sealMessageV4(opts: {
+ senderSeed: Uint8Array;
+ senderPrimary: Identity;
+ recipientHeartbeatPub: Uint8Array;
+ recipientMlkemPub: Uint8Array;
+ body: string;
+}): Promise {
+ const ephPriv = x25519.utils.randomSecretKey();
+ const ephPub = x25519.getPublicKey(ephPriv);
+ const xShared = x25519.getSharedSecret(ephPriv, opts.recipientHeartbeatPub);
+ const { cipherText, sharedSecret } = ml_kem768.encapsulate(opts.recipientMlkemPub);
+ const nonce = crypto.getRandomValues(new Uint8Array(12));
+ const aesKey = await deriveHybridAesKey(xShared, sharedSecret, nonce);
+
+ const plaintext: MessagePlaintext = {
+ from: opts.senderPrimary,
+ body: opts.body,
+ sent_at: new Date().toISOString(),
+ };
+ const head = {
+ v: 4 as const,
+ eph_pub: bytesToHex(ephPub),
+ recipient_heartbeat_pub: bytesToHex(opts.recipientHeartbeatPub),
+ kem_ct: bytesToHex(cipherText),
+ nonce: bytesToHex(nonce),
+ };
+ const ctBytes = new Uint8Array(
+ await crypto.subtle.encrypt(
+ { name: "AES-GCM", iv: asBuffer(nonce), additionalData: asBuffer(v4Aad(head)) },
+ aesKey,
+ asBuffer(new TextEncoder().encode(JSON.stringify(plaintext))),
+ ),
+ );
+ const partial = { ...head, ciphertext: bytesToHex(ctBytes) };
+ const sig = ed25519.sign(canonicalBytes(partial), opts.senderSeed);
+ return { ...partial, sender_sig: bytesToHex(sig) };
+}
+
+async function openMessageV4(
+ env: SealedEnvelopeV4,
+ resolveHeartbeatPriv: ResolveHeartbeatPrivs,
+): Promise {
+ const privs = await resolveHeartbeatPriv(env.recipient_heartbeat_pub);
+ if (!privs?.mlkem) {
+ throw new Error(
+ "envelope addressed to an expired/unknown heartbeat (no matching private key on this device)",
+ );
+ }
+ const nonce = hexToBytes(env.nonce);
+ const xShared = x25519.getSharedSecret(privs.x25519, hexToBytes(env.eph_pub));
+ const kemShared = ml_kem768.decapsulate(hexToBytes(env.kem_ct), privs.mlkem);
+ const aesKey = await deriveHybridAesKey(xShared, kemShared, nonce);
+ const ptBytes = new Uint8Array(
+ await crypto.subtle.decrypt(
+ { name: "AES-GCM", iv: asBuffer(nonce), additionalData: asBuffer(v4Aad(env)) },
+ aesKey,
+ asBuffer(hexToBytes(env.ciphertext)),
+ ),
+ );
+ const plaintext = JSON.parse(new TextDecoder().decode(ptBytes)) as MessagePlaintext;
+
+ if (!plaintext.from?.startsWith("ed25519:")) {
+ throw new Error(`unsupported sender primary scheme: ${plaintext.from}`);
+ }
+ const partial = {
+ v: env.v,
+ eph_pub: env.eph_pub,
+ recipient_heartbeat_pub: env.recipient_heartbeat_pub,
+ kem_ct: env.kem_ct,
+ nonce: env.nonce,
+ ciphertext: env.ciphertext,
+ };
+ const sigOk = ed25519.verify(
+ hexToBytes(env.sender_sig),
+ canonicalBytes(partial),
+ hexToBytes(plaintext.from.slice("ed25519:".length)),
+ );
+ if (!sigOk) throw new Error("envelope signature did not verify");
+ return plaintext;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Heartbeat binding — ties a published heartbeat pub to a KEZ identity
+// ─────────────────────────────────────────────────────────────────────────────
+//
+// The nostr event carrying a heartbeat is signed by a nostr key, but
+// senders learn a peer's nostr pubkey from the OUTER event of an
+// inbound DM — which anyone can re-wrap around a replayed envelope.
+// So the nostr signature alone proves nothing about who the heartbeat
+// belongs to. The KEZ ed25519 signature below does: senders verify it
+// against the recipient's KEZ primary before ECDH'ing to the pub.
+
+/** Signed heartbeat payload — the JSON content of the nostr event. */
+export interface SignedHeartbeat {
+ pub: string; // x25519 pub, hex
+ /** ML-KEM-768 encapsulation key, hex (1184 bytes). Absent on
+ * pre-v4 heartbeats → senders use v3. Covered by `sig`. */
+ mlkem_pub?: string;
+ created_at: string; // ISO
+ sig: string; // ed25519 over canonical({t, pub, created_at}), hex
+}
+
+const HEARTBEAT_SIG_TAG = "kez-heartbeat-v1";
+
+/** Reject heartbeats older than this even if validly signed — bounds
+ * how far back an attacker can replay a superseded (possibly already
+ * deleted) heartbeat to make our messages undecryptable. */
+const MAX_HEARTBEAT_AGE_MS = 7 * 24 * 60 * 60 * 1000;
+const MAX_HEARTBEAT_SKEW_MS = 60 * 60 * 1000;
+
+/** Signed fields. `mlkem_pub` is only included when present so
+ * pre-v4 heartbeats keep verifying. */
+function heartbeatSigBytes(h: Omit): Uint8Array {
+ return canonicalBytes({
+ t: HEARTBEAT_SIG_TAG,
+ pub: h.pub,
+ created_at: h.created_at,
+ ...(h.mlkem_pub ? { mlkem_pub: h.mlkem_pub } : {}),
+ });
+}
+
+export function signHeartbeat(
+ seed: Uint8Array,
+ pubHex: string,
+ createdAt: string,
+ mlkemPubHex?: string,
+): SignedHeartbeat {
+ const body = {
+ pub: pubHex,
+ created_at: createdAt,
+ ...(mlkemPubHex ? { mlkem_pub: mlkemPubHex } : {}),
+ };
+ return { ...body, sig: bytesToHex(ed25519.sign(heartbeatSigBytes(body), seed)) };
+}
+
+/** A verified peer heartbeat's public keys. */
+export interface HeartbeatPubs {
+ x25519: Uint8Array;
+ mlkem?: Uint8Array;
+}
+
+/** Returns the heartbeat's public keys if `hb` is a fresh heartbeat
+ * signed by `primary`, else null. Never throws. */
+export function verifyHeartbeat(
+ hb: unknown,
+ primary: Identity,
+ now = Date.now(),
+): HeartbeatPubs | null {
+ try {
+ const h = hb as SignedHeartbeat;
+ if (
+ !primary.startsWith("ed25519:") ||
+ typeof h?.pub !== "string" ||
+ typeof h.created_at !== "string" ||
+ typeof h.sig !== "string" ||
+ !/^[0-9a-f]{64}$/i.test(h.pub) ||
+ (h.mlkem_pub !== undefined &&
+ !(typeof h.mlkem_pub === "string" && /^[0-9a-f]{2368}$/i.test(h.mlkem_pub)))
+ ) {
+ return null;
+ }
+ const age = now - Date.parse(h.created_at);
+ if (!(age <= MAX_HEARTBEAT_AGE_MS && age >= -MAX_HEARTBEAT_SKEW_MS)) {
+ return null;
+ }
+ const ok = ed25519.verify(
+ hexToBytes(h.sig),
+ heartbeatSigBytes(h),
+ hexToBytes(primary.slice("ed25519:".length)),
+ );
+ if (!ok) return null;
+ return {
+ x25519: hexToBytes(h.pub),
+ ...(h.mlkem_pub ? { mlkem: hexToBytes(h.mlkem_pub) } : {}),
+ };
+ } catch {
+ return null;
+ }
+}
diff --git a/kez-chat/web/src/lib/heartbeat-store.ts b/kez-chat/web/src/lib/heartbeat-store.ts
new file mode 100644
index 0000000..6056274
--- /dev/null
+++ b/kez-chat/web/src/lib/heartbeat-store.ts
@@ -0,0 +1,261 @@
+// Heartbeat ratchet — receiver-side store.
+//
+// Design summary (full spec in TODO.md "Heartbeat Ratchet"):
+//
+// Each user publishes a fresh random x25519 keypair to nostr once
+// per active day. The PUBLIC half goes out as a replaceable event
+// (kind 10142 — NIP-01 range 10000–19999, one live event per author;
+// the d-tag "heartbeat-v1" is informational only), signed by the
+// KEZ identity inside the content (see crypto.ts signHeartbeat); the PRIVATE
+// half is kept in this device's IndexedDB. Senders fetch the
+// recipient's latest published heartbeat and do ECDH against it
+// (instead of against the recipient's long-term identity key).
+//
+// Forward secrecy: heartbeat privates are RANDOM (not derived from
+// the seed). The seed only SIGNS the heartbeat to prove it's ours.
+// So an attacker who later compromises the seed cannot reconstruct
+// past heartbeat privates — they were random bytes the legitimate
+// device generated and (eventually) discarded.
+//
+// Retention window (user-configurable, default 16 days): how long
+// we keep an expired heartbeat private around before deleting. The
+// window has to be long enough that messages sent against slightly-
+// stale cached heartbeats still decrypt. Longer window = more
+// forgiving for offline senders, but more IDB material an attacker
+// gets in a one-shot device compromise.
+//
+// Post-compromise security: if the seed is briefly compromised and
+// the attacker doesn't maintain persistence, after the retention
+// window expires:
+// - Past messages: undecryptable (privates were random + deleted)
+// - Future messages: safe (new heartbeat privates are random and
+// live only on the legitimate device; attacker can sign fake
+// heartbeats with the seed but can't generate the same
+// randomness the real device produces)
+//
+// Single-device scope: this v0.1 assumes one device per identity.
+// Multi-device with shared heartbeat state is a future project;
+// for now, signing in on a second device generates a fresh set of
+// heartbeats and the first device is effectively orphaned.
+
+import { x25519 } from "@noble/curves/ed25519";
+import { ml_kem768 } from "@noble/post-quantum/ml-kem.js";
+import type { HeartbeatPrivs } from "./crypto.js";
+import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
+import { get, set, del, update } from "idb-keyval";
+
+const IDB_KEY = "kez-chat:heartbeats:v1";
+const SETTINGS_KEY = "kez-chat:heartbeat-settings:v1";
+
+/** Default retention window for expired heartbeat privates. Sliding
+ * cap; user can override in Settings within MIN/MAX below. */
+export const DEFAULT_RETENTION_DAYS = 16;
+export const MIN_RETENTION_DAYS = 3;
+export const MAX_RETENTION_DAYS = 60;
+
+/** How often we rotate (publish a new heartbeat). Lazy: rotation
+ * only happens when the user is active AND >24h has passed since
+ * the last rotation. Offline users let their heartbeat get stale,
+ * but other heartbeats from the past `retention_days` remain
+ * usable. */
+const ROTATION_INTERVAL_MS = 24 * 60 * 60 * 1000;
+
+// ─── on-disk shape ─────────────────────────────────────────────────────────
+
+interface StoredHeartbeat {
+ /** Public key, hex (32 bytes). Matches what was published to nostr. */
+ pub: string;
+ /** Private key, hex (32 bytes). NEVER leaves this device. */
+ priv: string;
+ /** ML-KEM-768 keypair, hex — the post-quantum half of v4 envelopes.
+ * Absent on heartbeats minted before v4. Private NEVER leaves
+ * this device; deleted together with `priv`. */
+ mlkem_pub?: string;
+ mlkem_priv?: string;
+ /** Wall-clock when this heartbeat was generated. Used by the
+ * retention sweep to decide when to delete. */
+ created_at: string; // ISO
+}
+
+interface StoredHeartbeatBundle {
+ /** Append-only list, sorted by created_at ascending. We never
+ * modify entries — we add new ones and delete old ones from the
+ * start. */
+ entries: StoredHeartbeat[];
+}
+
+interface HeartbeatSettings {
+ /** Retention window in days. Default DEFAULT_RETENTION_DAYS. */
+ retention_days: number;
+}
+
+// ─── settings ──────────────────────────────────────────────────────────────
+
+export async function loadHeartbeatSettings(): Promise {
+ const s = await get(SETTINGS_KEY);
+ return {
+ retention_days: s?.retention_days ?? DEFAULT_RETENTION_DAYS,
+ };
+}
+
+export async function saveHeartbeatSettings(
+ s: HeartbeatSettings,
+): Promise {
+ // Clamp to the same range the Settings slider offers.
+ const clamped: HeartbeatSettings = {
+ retention_days: Math.max(
+ MIN_RETENTION_DAYS,
+ Math.min(MAX_RETENTION_DAYS, Math.round(s.retention_days)),
+ ),
+ };
+ await set(SETTINGS_KEY, clamped);
+}
+
+// ─── private store helpers ─────────────────────────────────────────────────
+
+async function readBundle(): Promise {
+ return (await get(IDB_KEY)) ?? { entries: [] };
+}
+
+// Writes go through idb-keyval `update()` — one readwrite transaction —
+// so two open tabs can't lose each other's entries (a lost private
+// whose pub made it to relays = undecryptable messages).
+
+// ─── lifecycle ─────────────────────────────────────────────────────────────
+
+/**
+ * Return the device's current (newest) heartbeat. If there is none,
+ * or the latest is older than the rotation interval, generate a
+ * fresh one, persist it, and return that.
+ *
+ * Callers should ALSO publish the new heartbeat's `pub` to nostr if
+ * a fresh one was generated — see `needs_publish` on the return.
+ */
+export async function getOrRotateCurrentHeartbeat(): Promise<{
+ heartbeat: StoredHeartbeat;
+ needs_publish: boolean;
+}> {
+ const now = Date.now();
+ let result!: { heartbeat: StoredHeartbeat; needs_publish: boolean };
+ await update(IDB_KEY, (b) => {
+ const entries = b?.entries ?? [];
+ const newest = entries[entries.length - 1];
+ // A pre-v4 heartbeat (no ML-KEM key) is rotated immediately so
+ // peers can start sending post-quantum envelopes right away.
+ if (
+ newest?.mlkem_pub &&
+ now - new Date(newest.created_at).getTime() < ROTATION_INTERVAL_MS
+ ) {
+ result = { heartbeat: newest, needs_publish: false };
+ return { entries };
+ }
+ // Rotate: fresh random keypair appended; GC runs separately.
+ const priv = x25519.utils.randomSecretKey();
+ const kem = ml_kem768.keygen();
+ const fresh: StoredHeartbeat = {
+ pub: bytesToHex(x25519.getPublicKey(priv)),
+ priv: bytesToHex(priv),
+ mlkem_pub: bytesToHex(kem.publicKey),
+ mlkem_priv: bytesToHex(kem.secretKey),
+ created_at: new Date(now).toISOString(),
+ };
+ result = { heartbeat: fresh, needs_publish: true };
+ return { entries: [...entries, fresh] };
+ });
+ return result;
+}
+
+/**
+ * Find the heartbeat PRIVATE that matches a given public key (hex).
+ * Used by the decrypt path: an incoming message specifies the
+ * recipient's heartbeat pub it was encrypted to, and we look it up
+ * here. Returns undefined if we don't have it (either too old, or
+ * never ours).
+ */
+export async function findHeartbeatPrivateByPub(
+ pub_hex: string,
+): Promise {
+ const bundle = await readBundle();
+ const found = bundle.entries.find((e) => e.pub === pub_hex);
+ if (!found) return undefined;
+ return {
+ x25519: hexToBytes(found.priv),
+ ...(found.mlkem_priv ? { mlkem: hexToBytes(found.mlkem_priv) } : {}),
+ };
+}
+
+/**
+ * Which entries survive a GC pass. A heartbeat's retention clock
+ * starts when it was SUPERSEDED (= the next entry's created_at), not
+ * when it was created: it stays the published target until then, so
+ * senders may still be encrypting to it. The newest entry is never
+ * expired — it's what's on relays right now. Pure; exported for the
+ * self-check.
+ */
+export function selectRetainedHeartbeats(
+ entries: T[],
+ retentionDays: number,
+ now = Date.now(),
+): T[] {
+ const cutoff = now - retentionDays * 24 * 60 * 60 * 1000;
+ return entries.filter(
+ (_, i) =>
+ i === entries.length - 1 ||
+ new Date(entries[i + 1].created_at).getTime() >= cutoff,
+ );
+}
+
+/**
+ * Delete heartbeat privates superseded more than `retention_days`
+ * ago. Idempotent. Run AFTER rotation on session start. Returns the
+ * number of entries deleted (diagnostic).
+ */
+export async function gcExpiredHeartbeats(): Promise {
+ const settings = await loadHeartbeatSettings();
+ let deleted = 0;
+ await update(IDB_KEY, (b) => {
+ const entries = b?.entries ?? [];
+ const kept = selectRetainedHeartbeats(entries, settings.retention_days);
+ deleted = entries.length - kept.length;
+ return { entries: kept };
+ });
+ return deleted;
+}
+
+/**
+ * Wipe ALL heartbeat material. Called from "Forget this account on
+ * this device" — the privates decrypt retained messages without the
+ * seed, so they must not outlive the account. Deliberately NOT called
+ * on Lock: that would make every message still in flight to the
+ * current heartbeat undecryptable.
+ */
+export async function wipeAllHeartbeats(): Promise {
+ await del(IDB_KEY);
+}
+
+/**
+ * Diagnostic snapshot: count of heartbeats in store + age of newest
+ * + age of oldest. Surfaced in Settings so the user can see what's
+ * going on. Never returns the private bytes.
+ */
+export async function describeHeartbeats(): Promise<{
+ count: number;
+ newest_age_hours?: number;
+ oldest_age_hours?: number;
+ current_pub?: string;
+}> {
+ const bundle = await readBundle();
+ const n = bundle.entries.length;
+ if (n === 0) return { count: 0 };
+ const newest = bundle.entries[n - 1];
+ const oldest = bundle.entries[0];
+ const now = Date.now();
+ return {
+ count: n,
+ current_pub: newest.pub,
+ newest_age_hours:
+ (now - new Date(newest.created_at).getTime()) / (1000 * 60 * 60),
+ oldest_age_hours:
+ (now - new Date(oldest.created_at).getTime()) / (1000 * 60 * 60),
+ };
+}
diff --git a/kez-chat/web/src/lib/heartbeat.selfcheck.ts b/kez-chat/web/src/lib/heartbeat.selfcheck.ts
new file mode 100644
index 0000000..08a2235
--- /dev/null
+++ b/kez-chat/web/src/lib/heartbeat.selfcheck.ts
@@ -0,0 +1,94 @@
+// Self-check for heartbeat binding, retention, and v4 (post-quantum
+// hybrid) envelopes. Not bundled into the app.
+// Run: npx esbuild src/lib/heartbeat.selfcheck.ts --bundle --platform=node --format=esm | node --input-type=module
+
+import { ed25519, x25519 } from "@noble/curves/ed25519";
+import { bytesToHex } from "@noble/hashes/utils";
+import { ml_kem768 } from "@noble/post-quantum/ml-kem.js";
+import { openMessage, sealMessageV4, signHeartbeat, verifyHeartbeat } from "./crypto.js";
+import { selectRetainedHeartbeats } from "./heartbeat-store.js";
+import type { Identity } from "./kez.js";
+
+function assert(cond: unknown, msg: string): void {
+ if (!cond) throw new Error(`FAIL: ${msg}`);
+}
+
+const DAY = 24 * 60 * 60 * 1000;
+const now = Date.parse("2026-09-27T12:00:00Z");
+const iso = (t: number) => new Date(t).toISOString();
+
+// ─── bug 1: heartbeat must be signed by the recipient's KEZ key ───
+const aliceSeed = ed25519.utils.randomSecretKey();
+const alice = `ed25519:${bytesToHex(ed25519.getPublicKey(aliceSeed))}` as Identity;
+const mallorySeed = ed25519.utils.randomSecretKey();
+const hbPub = bytesToHex(x25519.getPublicKey(x25519.utils.randomSecretKey()));
+
+const good = signHeartbeat(aliceSeed, hbPub, iso(now - DAY));
+assert(verifyHeartbeat(good, alice, now), "alice's own heartbeat verifies");
+assert(
+ !verifyHeartbeat(signHeartbeat(mallorySeed, hbPub, iso(now)), alice, now),
+ "heartbeat signed by someone else is rejected",
+);
+assert(
+ !verifyHeartbeat({ ...good, pub: bytesToHex(x25519.getPublicKey(x25519.utils.randomSecretKey())) }, alice, now),
+ "swapped pub is rejected",
+);
+assert(
+ !verifyHeartbeat(signHeartbeat(aliceSeed, hbPub, iso(now - 8 * DAY)), alice, now),
+ "stale (replayed) heartbeat is rejected",
+);
+assert(!verifyHeartbeat(hbPub, alice, now), "legacy bare-hex content is rejected");
+
+// ─── bug 2: retention counts from supersession, newest never expires ───
+const e = (daysAgo: number) => ({ created_at: iso(now - daysAgo * DAY) });
+// Only heartbeat is 20 days old (user was offline) — must survive.
+assert(selectRetainedHeartbeats([e(20)], 16, now).length === 1, "lone old heartbeat kept");
+// Created 20d ago, superseded 1d ago → kept; created 30d, superseded 20d ago → dropped.
+const kept = selectRetainedHeartbeats([e(30), e(20), e(1)], 16, now);
+assert(kept.length === 2 && kept[0].created_at === e(20).created_at, "supersession-based expiry");
+
+// ─── v4: ML-KEM key is signed, so it can't be stripped or swapped ───
+const kem = ml_kem768.keygen();
+const kemHex = bytesToHex(kem.publicKey);
+const pq = signHeartbeat(aliceSeed, hbPub, iso(now - DAY), kemHex);
+assert(verifyHeartbeat(pq, alice, now)?.mlkem, "v4 heartbeat verifies with ML-KEM key");
+const { mlkem_pub: _dropped, ...stripped } = pq;
+assert(!verifyHeartbeat(stripped, alice, now), "stripping ML-KEM key (downgrade) is rejected");
+assert(
+ !verifyHeartbeat({ ...pq, mlkem_pub: bytesToHex(ml_kem768.keygen().publicKey) }, alice, now),
+ "swapped ML-KEM key is rejected",
+);
+
+// ─── v4: hybrid seal/open round-trip; needs BOTH private keys ───
+const bobSeed = ed25519.utils.randomSecretKey();
+const bobHbPriv = x25519.utils.randomSecretKey();
+const bobHbPub = x25519.getPublicKey(bobHbPriv);
+const bobKem = ml_kem768.keygen();
+const env = await sealMessageV4({
+ senderSeed: aliceSeed,
+ senderPrimary: alice,
+ recipientHeartbeatPub: bobHbPub,
+ recipientMlkemPub: bobKem.publicKey,
+ body: "hi bob",
+});
+const open = (e: typeof env, mlkem: Uint8Array) =>
+ openMessage({
+ envelope: e,
+ myHandle: "bob",
+ mySeed: bobSeed,
+ resolveHeartbeatPriv: async () => ({ x25519: bobHbPriv, mlkem }),
+ });
+const pt = await open(env, bobKem.secretKey);
+assert(pt.body === "hi bob" && pt.from === alice, "v4 round-trip");
+const fails = (p: Promise) => p.then(() => false, () => true);
+assert(await fails(open(env, ml_kem768.keygen().secretKey)), "wrong ML-KEM key fails");
+const flipped = env.kem_ct.slice(0, -2) + (env.kem_ct.endsWith("00") ? "01" : "00");
+assert(await fails(open({ ...env, kem_ct: flipped }, bobKem.secretKey)), "tampered kem_ct fails");
+assert(
+ await fails(
+ openMessage({ envelope: env, myHandle: "bob", mySeed: bobSeed, resolveHeartbeatPriv: async () => ({ x25519: bobHbPriv }) }),
+ ),
+ "v4 without ML-KEM private fails",
+);
+
+console.log("heartbeat self-check: ok");
diff --git a/kez-chat/web/src/lib/image-utils.ts b/kez-chat/web/src/lib/image-utils.ts
index 0594348..d78b813 100644
--- a/kez-chat/web/src/lib/image-utils.ts
+++ b/kez-chat/web/src/lib/image-utils.ts
@@ -73,6 +73,70 @@ function loadImage(src: string): Promise {
});
}
+/** Long-edge cap for photos sent in chat. Also keeps us under iOS
+ * Safari's 16.7M-pixel canvas limit — a 24MP iPhone 15/16 Pro HEIC
+ * (5712×4284) would otherwise make canvas.toBlob return null. */
+const MAX_SEND_EDGE = 2048;
+
+/**
+ * Normalise a photo before it's sent as a chat attachment:
+ * • HEIC/HEIF (iPhone default) → JPEG, since Chrome/Firefox/Android
+ * recipients can't decode HEIC even though Safari (the sender) can.
+ * • HEIC or JPEG with a long edge over MAX_SEND_EDGE → downscaled.
+ * Re-encoding also drops EXIF (GPS location etc.).
+ * Everything else — small JPEGs, PNG (transparency), GIF (animation),
+ * non-images — passes through unchanged.
+ *
+ * Throws with user-facing guidance if this browser can't decode HEIC
+ * (e.g. desktop Chrome).
+ */
+export async function prepareImageForSend(
+ file: File,
+ quality = 0.85,
+): Promise {
+ const isHeic =
+ file.type === "image/heic" ||
+ file.type === "image/heif" ||
+ /\.(heic|heif)$/i.test(file.name);
+ const isJpeg = file.type === "image/jpeg";
+ if (!isHeic && !isJpeg) return file;
+
+ const url = URL.createObjectURL(file);
+ let img: HTMLImageElement;
+ try {
+ img = await loadImage(url);
+ } catch {
+ if (!isHeic) return file; // odd JPEG the browser can't decode — send as-is
+ throw new Error(
+ "This browser can't decode HEIC images. " +
+ "On iPhone: Settings → Camera → Formats → 'Most Compatible' to save as JPEG, " +
+ "or share a JPEG version of the photo.",
+ );
+ } finally {
+ URL.revokeObjectURL(url);
+ }
+
+ const w = img.naturalWidth;
+ const h = img.naturalHeight;
+ const scale = Math.min(1, MAX_SEND_EDGE / Math.max(w, h));
+ if (!isHeic && scale === 1) return file;
+
+ const canvas = document.createElement("canvas");
+ canvas.width = Math.round(w * scale);
+ canvas.height = Math.round(h * scale);
+ const ctx = canvas.getContext("2d");
+ if (!ctx) throw new Error("canvas 2d context unavailable");
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
+
+ const blob = await new Promise((resolve) =>
+ canvas.toBlob(resolve, "image/jpeg", quality),
+ );
+ if (!blob) throw new Error("Image conversion failed (canvas.toBlob)");
+ // .heic/.heif → .jpg so recipients see a sensible filename.
+ const newName = file.name.replace(/\.(heic|heif)$/i, ".jpg");
+ return new File([blob], newName, { type: "image/jpeg" });
+}
+
/** Rough byte size of a data URL. Cheap — we just look at the
* base64 payload length. Used by Settings to surface "your picture
* is X KB" so the user knows what they're publishing. */
diff --git a/kez-chat/web/src/lib/inbox-service.svelte.ts b/kez-chat/web/src/lib/inbox-service.svelte.ts
index 8d868ca..e17deb3 100644
--- a/kez-chat/web/src/lib/inbox-service.svelte.ts
+++ b/kez-chat/web/src/lib/inbox-service.svelte.ts
@@ -43,12 +43,20 @@ import {
} from "./conversations-store.js";
import type { Identity } from "./kez.js";
import { peerProfiles } from "./peer-profile-cell.svelte.js";
+import { hexToBytes } from "@noble/hashes/utils";
+import { get as idbGet, update as idbUpdate } from "idb-keyval";
import {
parseFileBody,
base64ToBytes,
assembleChunks,
bytesToDataUrl,
} from "./file-transfer.js";
+import {
+ findHeartbeatPrivateByPub,
+ gcExpiredHeartbeats,
+ getOrRotateCurrentHeartbeat,
+} from "./heartbeat-store.js";
+import { publishHeartbeat } from "./nostr-transport.js";
import {
chunkBufferIsComplete,
deleteChunkBuffer,
@@ -60,6 +68,15 @@ import {
const POLL_INTERVAL_MS = 30_000;
+/** Envelopes we've already ingested, keyed by (a prefix of) their KEZ
+ * `sender_sig`. ed25519 sigs are deterministic, so the same envelope
+ * always carries the same sig — even when someone re-wraps it in a
+ * new nostr event (new event id → new seq, which seq-dedupe misses),
+ * or a relay re-broadcasts it after the user cleared the chat.
+ * FIFO-capped; survives clearConversationMessages on purpose. */
+const SEEN_ENVELOPES_KEY = "kez-chat:seen-envelopes:v1";
+const SEEN_ENVELOPES_MAX = 5000;
+
export type StreamStatus = "off" | "connecting" | "live" | "reconnecting";
class InboxService {
@@ -80,6 +97,20 @@ class InboxService {
* was either already-known on session start (so the user has seen it
* on this or another device) or already notified about. */
#notifiedThroughSeq = -1;
+ #seenEnvelopes: Set | null = null;
+
+ async #seenSet(): Promise> {
+ return (this.#seenEnvelopes ??= new Set(
+ (await idbGet(SEEN_ENVELOPES_KEY)) ?? [],
+ ));
+ }
+
+ async #markEnvelopeSeen(key: string): Promise {
+ (await this.#seenSet()).add(key);
+ await idbUpdate(SEEN_ENVELOPES_KEY, (a = []) =>
+ [...a.filter((k) => k !== key), key].slice(-SEEN_ENVELOPES_MAX),
+ );
+ }
/** Start streaming for this session. Called from App.svelte on unlock. */
async start(handle: string, seed: Uint8Array) {
@@ -107,6 +138,13 @@ class InboxService {
onAck: (eventId, sigHex) => void this.#ingestAck(eventId, sigHex),
onStatus: (s) => (this.status = s),
});
+ // Heartbeat ratchet. Always publish on session start (not only
+ // when freshly rotated): the event is replaceable so it's cheap,
+ // and it self-heals a publish that failed last time or a relay
+ // list the user changed since. Must run before the first poll,
+ // whose non-forcing ratchet call would otherwise win the
+ // single-flight and skip the publish.
+ void this.#heartbeatRatchet(true);
this.#pollTimer = setInterval(() => void this.#heartbeat(), POLL_INTERVAL_MS);
// Eager first poll so we catch up anything queued before this session.
void this.#heartbeat();
@@ -116,6 +154,48 @@ class InboxService {
void flushPendingAcks(seed);
}
+ #ratchetInFlight: Promise | null = null;
+
+ /**
+ * Rotate the heartbeat if it's past the rotation interval, publish
+ * it (always when `forcePublish`, otherwise only if freshly minted),
+ * then sweep entries superseded longer ago than the retention
+ * window. Order matters: rotate first, so the previously-current
+ * heartbeat (which the backlog we're about to ingest was encrypted
+ * to) counts as superseded NOW and stays for the full window.
+ *
+ * Runs on start and on every poll tick, so a PWA left open for days
+ * still rotates. Single-flight so overlapping ticks can't double-
+ * rotate.
+ */
+ #heartbeatRatchet(forcePublish: boolean): Promise {
+ const seed = this.#seed;
+ if (!seed) return Promise.resolve();
+ if (this.#ratchetInFlight) return this.#ratchetInFlight;
+ this.#ratchetInFlight = (async () => {
+ try {
+ const { heartbeat, needs_publish } =
+ await getOrRotateCurrentHeartbeat();
+ if (forcePublish || needs_publish) {
+ await publishHeartbeat({
+ seed,
+ heartbeat_pub: hexToBytes(heartbeat.pub),
+ mlkem_pub: heartbeat.mlkem_pub,
+ created_at: heartbeat.created_at,
+ }).catch((e) => {
+ console.warn("heartbeat publish failed:", e);
+ });
+ }
+ await gcExpiredHeartbeats();
+ } catch (e) {
+ console.warn("heartbeat lifecycle failed:", e);
+ } finally {
+ this.#ratchetInFlight = null;
+ }
+ })();
+ return this.#ratchetInFlight;
+ }
+
/** Stop everything. Called on lock + on tab close. */
stop() {
this.#stream?.close();
@@ -143,6 +223,7 @@ class InboxService {
async #heartbeat() {
if (!this.#handle || !this.#seed) return;
+ void this.#heartbeatRatchet(false);
try {
const since = await getGlobalCursor();
const { messages } = await pollInbox({
@@ -160,8 +241,17 @@ class InboxService {
async #ingest(m: InboxMessage) {
if (!this.#handle || !this.#seed) return;
+ // Only marked seen AFTER a verified decrypt + persist below, so a
+ // forged envelope that copies a real sig can't pre-poison this.
+ const envKey = m.envelope.sender_sig?.slice(0, 32);
+ if (envKey && (await this.#seenSet()).has(envKey)) return;
try {
- const pt = await decrypt(m.envelope, this.#handle, this.#seed);
+ const pt = await decrypt(
+ m.envelope,
+ this.#handle,
+ this.#seed,
+ findHeartbeatPrivateByPub,
+ );
// Resolve sender's display handle (cache on conversation row).
let displayName = "";
const existing = await getConversation(pt.from);
@@ -196,6 +286,7 @@ class InboxService {
// peer-profile fetch, badge bump) to run for inline files
// and pointers, but NOT for chunks (which are user-invisible
// plumbing). Bail early on chunks.
+ if (envKey) await this.#markEnvelopeSeen(envKey);
if (fileBody.type === "kez-file-chunk-v1") return;
} else {
await appendInbound({
@@ -207,6 +298,7 @@ class InboxService {
peer_nostr_pubkey: m.sender_nostr_pubkey,
via_relay: m.via_relay,
});
+ if (envKey) await this.#markEnvelopeSeen(envKey);
}
// First time we've seen this peer's nostr pubkey? Kick off a
diff --git a/kez-chat/web/src/lib/messages.ts b/kez-chat/web/src/lib/messages.ts
index 258d803..0a98892 100644
--- a/kez-chat/web/src/lib/messages.ts
+++ b/kez-chat/web/src/lib/messages.ts
@@ -6,7 +6,13 @@
import { ed25519 } from "@noble/curves/ed25519";
import { bytesToHex } from "@noble/hashes/utils";
import { lookup } from "./api.js";
-import { sealMessage, openMessage, type SealedEnvelope, type MessagePlaintext } from "./crypto.js";
+import {
+ sealMessage,
+ openMessage,
+ type SealedEnvelope,
+ type MessagePlaintext,
+ type ResolveHeartbeatPrivs,
+} from "./crypto.js";
import type { Identity } from "./kez.js";
/** Server base URL (always same-origin for the SPA). */
@@ -94,6 +100,9 @@ export async function sendMessage(opts: {
* POST /v1/messages — so it can't help when the server is down —
* but skipping the extra round-trip is faster. */
recipientPrimary?: Identity;
+ /** v3 heartbeat parity — ignored on server transport (no
+ * heartbeats in play; the chat-server is itself the rendezvous). */
+ recipientNostrPubkey?: string;
}): Promise<{ seq: number; event_id: string; accepted_by?: string }> {
const recipientHandle = opts.recipient.split("@")[0];
let resolvedPrimary: Identity;
@@ -164,13 +173,22 @@ export async function pollInbox(opts: {
return (await resp.json()) as { messages: InboxMessage[]; cursor: number };
}
-/** Decrypt one inbox envelope. Wrap in try/catch — bad sigs throw. */
+/** Decrypt one inbox envelope. Wrap in try/catch — bad sigs throw.
+ * `resolveHeartbeatPriv` is only consulted for v3 envelopes; pass
+ * it through from the inbox-service so this module stays free of
+ * IDB imports. v1/v2 envelopes ignore it. */
export async function decrypt(
envelope: SealedEnvelope,
myHandle: string,
mySeed: Uint8Array,
+ resolveHeartbeatPriv?: ResolveHeartbeatPrivs,
): Promise {
- return openMessage({ envelope, myHandle, mySeed });
+ return openMessage({
+ envelope,
+ myHandle,
+ mySeed,
+ resolveHeartbeatPriv,
+ });
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -268,6 +286,7 @@ export async function sendAck(_opts: {
ackedEventId: string;
originalSenderNostrPubkey?: string;
preferRelay?: string;
+ extraRelays?: string[];
}): Promise {
/* server transport: receipts not implemented in v0.1 */
}
@@ -292,6 +311,8 @@ export async function sendFile(_opts: {
mime: string;
raw: Uint8Array;
preferRelay?: string;
+ extraRelays?: string[];
+ recipientNostrPubkey?: string;
progress?: (done: number, total: number) => void;
}): Promise<{
pointer_event_id: string;
diff --git a/kez-chat/web/src/lib/nostr-transport.ts b/kez-chat/web/src/lib/nostr-transport.ts
index 9eca5ec..380db1a 100644
--- a/kez-chat/web/src/lib/nostr-transport.ts
+++ b/kez-chat/web/src/lib/nostr-transport.ts
@@ -18,7 +18,17 @@
import { ed25519 } from "@noble/curves/ed25519";
import { bytesToHex } from "@noble/hashes/utils";
import { SimplePool, finalizeEvent, type Event, type EventTemplate } from "nostr-tools";
-import { sealMessage, type SealedEnvelope } from "./crypto.js";
+import {
+ sealMessageV3,
+ sealMessageV4,
+ signHeartbeat,
+ verifyHeartbeat,
+ x25519PubFromPrimary,
+ type SealedEnvelope,
+ type SignedHeartbeat,
+ type HeartbeatPubs,
+} from "./crypto.js";
+import { get as idbGet, set as idbSet } from "idb-keyval";
import {
bytesToBase64,
chunkifyBytes,
@@ -94,18 +104,15 @@ export function getRelayStatuses(): RelayStatus[] {
});
}
-/** Relays to publish to / read from. Override with VITE_NOSTR_RELAYS (csv).
- * Default set: 3 long-running general-purpose relays (damus, nos, primal)
- * + 2 popular extras (snort.social, nostr.wine) — chosen for redundancy
- * and geographic diversity. If any single relay is slow/down our publish
- * still succeeds as long as one accepts. */
-const RELAYS: string[] = (
- import.meta.env.VITE_NOSTR_RELAYS ??
- "wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr.wine"
-)
- .split(",")
- .map((r: string) => r.trim())
- .filter(Boolean);
+/** Relays to publish to / read from. Loaded synchronously from
+ * relay-store at module init: user override from localStorage,
+ * falling back to env override (VITE_NOSTR_RELAYS), falling back
+ * to a hardcoded 5-relay default. Order matters — earlier entries
+ * win the publish race more often, so users can express
+ * preference in Settings. Changes from Settings require a page
+ * reload to apply (we do that with a clear UI prompt). */
+import { loadCurrentRelays } from "./relay-store.js";
+const RELAYS: string[] = loadCurrentRelays();
/** One pool for the whole session — relay connections are reused.
* `attachSigner(seed)` recreates the pool with `automaticallyAuth`
@@ -316,17 +323,234 @@ function toInboxMessage(ev: Event): InboxMessage | null {
/**
* Order relays for this send: any preferred relay (typically: the
* relay that delivered the most recent inbound message from this
- * peer) goes first, then the rest of the configured set, deduped.
- * Putting a preferred relay first means the WebSocket open + publish
- * race usually completes against it — same network path the inbound
- * message came in on, so geographically/network-wise the fastest.
+ * peer) goes first, then the peer's NIP-65 read-relays (so their
+ * client subscribed to those receives the event), then the rest of
+ * the configured set. Deduped + capped so a peer that lists 20
+ * relays doesn't make every send a relay flood.
+ *
+ * Putting the preferred relay first means the WebSocket open +
+ * publish race usually completes against it — same network path
+ * the inbound came in on.
*/
-function orderedRelaysForSend(preferRelay?: string): string[] {
- if (!preferRelay) return RELAYS;
- const norm = preferRelay.replace(/\/$/, "");
- const matches = RELAYS.filter((r) => r.replace(/\/$/, "") === norm);
- const others = RELAYS.filter((r) => r.replace(/\/$/, "") !== norm);
- return [...matches, ...others];
+const MAX_RELAYS_PER_SEND = 10;
+function orderedRelaysForSend(
+ preferRelay?: string,
+ extraRelays?: string[],
+): string[] {
+ const norm = (u: string) => u.replace(/\/$/, "").toLowerCase();
+ const seen = new Set();
+ const out: string[] = [];
+ const push = (url: string) => {
+ const n = norm(url);
+ if (seen.has(n)) return;
+ if (out.length >= MAX_RELAYS_PER_SEND) return;
+ seen.add(n);
+ out.push(url);
+ };
+ if (preferRelay) push(preferRelay);
+ if (extraRelays) for (const r of extraRelays) push(r);
+ for (const r of RELAYS) push(r);
+ return out;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Heartbeat ratchet — publish + fetch
+// ─────────────────────────────────────────────────────────────────────────────
+//
+// Each user publishes a fresh ephemeral x25519 pub once per active
+// day via a replaceable event (kind 10142 — NIP-01 range 10000–19999,
+// one live event per author; the d-tag is informational). The
+// PRIVATE half lives only on the
+// publishing device (heartbeat-store.ts). Senders fetch the most
+// recent heartbeat from relays and use it as the ECDH target for
+// v3 envelopes instead of the recipient's long-term identity.
+//
+// Properties (full reasoning in TODO.md "Heartbeat Ratchet"):
+// * Per-message forward secrecy on the SENDER side via ephemeral pub
+// * Window-grained forward secrecy on the RECEIVER side via
+// heartbeat rotation + retention deletion
+// * Authentication: the content is a SignedHeartbeat carrying an
+// ed25519 signature from the user's KEZ identity, verified by
+// senders against the recipient's primary (the outer nostr sig
+// alone proves nothing — see fetchPeerHeartbeat)
+// * No prekey server, no per-peer session state, no out-of-order
+// bookkeeping; nostr's replaceable-event semantics carry it.
+
+export const HEARTBEAT_KIND = 10142;
+const HEARTBEAT_D_TAG = "heartbeat-v1";
+
+/** Publish a heartbeat public key to relays. Caller (heartbeat-
+ * service) generates the keypair, persists the private to IDB,
+ * and hands the public bytes here. Content is a SignedHeartbeat —
+ * the pub signed by our KEZ identity, so senders can verify it
+ * belongs to us regardless of which nostr key they think we have.
+ * Throws if no relay accepts. */
+export async function publishHeartbeat(opts: {
+ seed: Uint8Array;
+ heartbeat_pub: Uint8Array;
+ /** ML-KEM-768 encapsulation key (hex) — lets peers send v4
+ * (post-quantum hybrid) envelopes. */
+ mlkem_pub?: string;
+ created_at: string;
+}): Promise<{ event_id: string; accepted_by?: string }> {
+ const sk = nostrSecretFromSeed(opts.seed);
+ const pubHex = bytesToHex(opts.heartbeat_pub);
+ const tmpl: EventTemplate = {
+ kind: HEARTBEAT_KIND,
+ created_at: Math.floor(Date.now() / 1000),
+ tags: [
+ ["d", HEARTBEAT_D_TAG],
+ // Pub in a tag for filterability; the authoritative, KEZ-signed
+ // copy is the content.
+ ["x25519", pubHex],
+ ],
+ content: JSON.stringify(
+ signHeartbeat(opts.seed, pubHex, opts.created_at, opts.mlkem_pub),
+ ),
+ };
+ const signed = finalizeEvent(tmpl, sk);
+ const publishPromises = pool().publish(RELAYS, signed);
+ let acceptedBy: string | undefined;
+ try {
+ acceptedBy = await Promise.any(
+ publishPromises.map((p, i) => p.then(() => RELAYS[i])),
+ );
+ } catch {
+ /* fall through */
+ }
+ const results = await Promise.allSettled(publishPromises);
+ if (!results.some((r) => r.status === "fulfilled")) {
+ throw new Error("no relay accepted the heartbeat");
+ }
+ return { event_id: signed.id, accepted_by: acceptedBy };
+}
+
+/**
+ * Fetch the most-recent heartbeat published by a given nostr
+ * pubkey that carries a valid, fresh signature from `peerPrimary`'s
+ * KEZ key. Returns the 32-byte x25519 public key, or null (→ caller
+ * falls back to identity ECDH).
+ *
+ * The KEZ signature check is what makes this safe: `peerNostrPubkey`
+ * comes from the outer event of an inbound DM and can be spoofed by
+ * anyone re-wrapping a replayed envelope. An attacker's heartbeat
+ * won't carry the peer's KEZ signature, so it's rejected here.
+ *
+ * Raw relay fetch — senders go through `resolvePeerHeartbeat`, which
+ * adds caching + downgrade protection on top.
+ */
+export async function fetchPeerHeartbeat(
+ peerNostrPubkey: string,
+ peerPrimary: Identity,
+): Promise {
+ try {
+ const events = await pool().querySync(RELAYS, {
+ kinds: [HEARTBEAT_KIND],
+ authors: [peerNostrPubkey],
+ "#d": [HEARTBEAT_D_TAG],
+ limit: 1,
+ });
+ // Replaceable events: should be one, but if multiple snuck in
+ // (race between relays), take the most recent that verifies.
+ events.sort((a, b) => b.created_at - a.created_at);
+ for (const ev of events) {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(ev.content);
+ } catch {
+ continue;
+ }
+ if (verifyHeartbeat(parsed, peerPrimary)) return parsed as SignedHeartbeat;
+ }
+ return null;
+ } catch (e) {
+ console.warn(`fetchPeerHeartbeat failed for ${peerNostrPubkey}:`, e);
+ return null;
+ }
+}
+
+/**
+ * Last verified heartbeat per peer primary. Two jobs:
+ * • Skip the relay round-trip for HEARTBEAT_CACHE_TTL_MS — a chunked
+ * file is ~125 sends, and querySync waits on every relay.
+ * • Anti-downgrade: if a later fetch comes back empty or invalid
+ * (relay withholding, spoofed nostr pubkey, network blip), keep
+ * sealing to the last known-good heartbeat while verifyHeartbeat
+ * still considers it fresh, instead of silently dropping to
+ * identity ECDH. An older heartbeat never replaces a newer one.
+ * Persisted to IDB so the anti-downgrade survives reloads.
+ */
+const PEER_HEARTBEATS_KEY = "kez-chat:peer-heartbeats:v1";
+const HEARTBEAT_CACHE_TTL_MS = 5 * 60 * 1000;
+const heartbeatCache = new Map();
+
+async function resolvePeerHeartbeat(
+ peerNostrPubkey: string | undefined,
+ peerPrimary: Identity,
+): Promise {
+ let cached = heartbeatCache.get(peerPrimary);
+ if (!cached) {
+ const stored = (await idbGet>(PEER_HEARTBEATS_KEY))?.[peerPrimary];
+ if (stored) cached = { hb: stored, fetched_at: 0 };
+ }
+ if (cached && Date.now() - cached.fetched_at < HEARTBEAT_CACHE_TTL_MS) {
+ const pub = verifyHeartbeat(cached.hb, peerPrimary);
+ if (pub) return pub;
+ }
+
+ const fresh = peerNostrPubkey
+ ? await fetchPeerHeartbeat(peerNostrPubkey, peerPrimary)
+ : null;
+ let best = cached?.hb;
+ if (fresh && (!best || Date.parse(fresh.created_at) >= Date.parse(best.created_at))) {
+ best = fresh;
+ }
+ if (fresh && best) {
+ heartbeatCache.set(peerPrimary, { hb: best, fetched_at: Date.now() });
+ const all = (await idbGet>(PEER_HEARTBEATS_KEY)) ?? {};
+ all[peerPrimary] = best;
+ await idbSet(PEER_HEARTBEATS_KEY, all);
+ }
+ const pub = best ? verifyHeartbeat(best, peerPrimary) : null;
+ if (!pub && cached) {
+ console.warn(
+ `heartbeat for ${peerPrimary} unavailable or stale — sending without receiver-side forward secrecy`,
+ );
+ }
+ return pub;
+}
+
+/** Envelope for a recipient, strongest first:
+ * v4 — heartbeat has an ML-KEM key → X25519 + ML-KEM-768 hybrid
+ * v3 — heartbeat without ML-KEM (pre-v4 client) → X25519 only
+ * v3 fallback — no heartbeat at all (brand-new contact) → identity
+ * key, no receiver-side FS, no PQ. */
+async function sealForRecipient(opts: {
+ senderSeed: Uint8Array;
+ senderPrimary: Identity;
+ recipientPrimary: Identity;
+ recipientNostrPubkey?: string;
+ body: string;
+}): Promise {
+ const hb = await resolvePeerHeartbeat(opts.recipientNostrPubkey, opts.recipientPrimary);
+ // Post-quantum hybrid whenever the recipient's heartbeat offers an
+ // ML-KEM key (every heartbeat minted by a v4-capable client).
+ if (hb?.mlkem) {
+ return sealMessageV4({
+ senderSeed: opts.senderSeed,
+ senderPrimary: opts.senderPrimary,
+ recipientHeartbeatPub: hb.x25519,
+ recipientMlkemPub: hb.mlkem,
+ body: opts.body,
+ });
+ }
+ return sealMessageV3({
+ senderSeed: opts.senderSeed,
+ senderPrimary: opts.senderPrimary,
+ recipientHeartbeatPub: hb?.x25519 ?? x25519PubFromPrimary(opts.recipientPrimary),
+ fallback: !hb,
+ body: opts.body,
+ });
}
export async function sendMessage(opts: {
@@ -339,6 +563,12 @@ export async function sendMessage(opts: {
* recipient's last message arrived on. Falls back to the full
* configured relay set if unset or unknown. */
preferRelay?: string;
+ /** Additional relays to include in the publish target set (in
+ * addition to our configured default RELAYS) — typically the
+ * peer's NIP-65 read-relays so their client sees the event even
+ * when they're not subscribed to our defaults. Deduped + capped
+ * inside orderedRelaysForSend. */
+ extraRelays?: string[];
/**
* Recipient's KEZ primary, if the caller already has it (e.g. from
* the conversation row). Lets us skip the `/v1/u/:handle` lookup
@@ -347,6 +577,17 @@ export async function sendMessage(opts: {
* brand-new conversations (no cached primary) still need lookup.
*/
recipientPrimary?: Identity;
+ /**
+ * Recipient's nostr pubkey (hex), if the caller has learned it
+ * from a previous inbound DM. Used to fetch the recipient's
+ * current heartbeat from relays for v3 envelope sealing. When
+ * absent we fall back to identity-derived ECDH for this message
+ * (the `fallback: true` v3 branch) — first messages to a new
+ * contact end up here. Once the contact replies once, the
+ * conversation row caches their pubkey and all future sends are
+ * full v3.
+ */
+ recipientNostrPubkey?: string;
}): Promise<{ seq: number; event_id: string; accepted_by?: string }> {
const recipientHandle = opts.recipient.split("@")[0];
// Skip the server lookup if the caller already has the primary
@@ -360,12 +601,19 @@ export async function sendMessage(opts: {
recipientPrimary = record.primary as Identity;
}
- // Our own encryption layer — identical to the server transport.
- const envelope = await sealMessage({
+ // ─── envelope selection: v3 (heartbeat) by default, fallback ─
+ // We always emit v3 envelopes. The receiver's HEARTBEAT pub is
+ // what we ECDH against — except for the first-ever message to a
+ // brand-new contact who hasn't published a heartbeat yet, where
+ // we fall back to their identity-derived x25519 pub and mark the
+ // envelope `fallback: true`. That one message has no receiver-
+ // side forward secrecy; everything after does. See TODO.md
+ // "Heartbeat Ratchet".
+ const envelope = await sealForRecipient({
senderSeed: opts.senderSeed,
senderPrimary: opts.senderPrimary,
- recipientHandle,
recipientPrimary,
+ recipientNostrPubkey: opts.recipientNostrPubkey,
body: opts.body,
});
@@ -382,7 +630,7 @@ export async function sendMessage(opts: {
// record which relay accepted first so the UI can show "sent via
// X" and so the next message in this thread can prefer the same
// relay.
- const ordered = orderedRelaysForSend(opts.preferRelay);
+ const ordered = orderedRelaysForSend(opts.preferRelay, opts.extraRelays);
const publishPromises = pool().publish(ordered, signed);
// Promise.any returns the first-fulfilled value; if every relay
// rejects, it throws AggregateError. We still want best-effort
@@ -442,14 +690,18 @@ async function publishRawBody(opts: {
recipientPrimary: Identity;
body: string;
preferRelay?: string;
+ /** Additional relays to include in the publish target set (in
+ * addition to our configured default RELAYS) — typically the
+ * peer's NIP-65 read-relays so their client sees the event even
+ * when they're not subscribed to our defaults. Deduped + capped
+ * inside orderedRelaysForSend. */
+ extraRelays?: string[];
+ recipientNostrPubkey?: string;
}): Promise<{ event_id: string; accepted_by?: string }> {
- const envelope = await sealMessage({
- senderSeed: opts.senderSeed,
- senderPrimary: opts.senderPrimary,
- recipientHandle: opts.recipientHandle,
- recipientPrimary: opts.recipientPrimary,
- body: opts.body,
- });
+ // Same v3 / fallback decision as sendMessage. File-transfer chunks
+ // and pointers go through here, so they get heartbeat-ratcheted
+ // automatically too.
+ const envelope = await sealForRecipient(opts);
const sk = nostrSecretFromSeed(opts.senderSeed);
const tmpl: EventTemplate = {
kind: KEZ_DM_KIND,
@@ -458,7 +710,7 @@ async function publishRawBody(opts: {
content: JSON.stringify(envelope),
};
const signed = finalizeEvent(tmpl, sk);
- const ordered = orderedRelaysForSend(opts.preferRelay);
+ const ordered = orderedRelaysForSend(opts.preferRelay, opts.extraRelays);
const publishPromises = pool().publish(ordered, signed);
let acceptedBy: string | undefined;
try {
@@ -505,6 +757,15 @@ export async function sendFile(opts: {
mime: string;
raw: Uint8Array;
preferRelay?: string;
+ /** Additional relays to include in the publish target set (in
+ * addition to our configured default RELAYS) — typically the
+ * peer's NIP-65 read-relays so their client sees the event even
+ * when they're not subscribed to our defaults. Deduped + capped
+ * inside orderedRelaysForSend. */
+ extraRelays?: string[];
+ /** Threaded into v3 envelope sealing — same semantics as
+ * sendMessage. */
+ recipientNostrPubkey?: string;
progress?: (done: number, total: number) => void;
}): Promise {
const { raw } = opts;
@@ -683,6 +944,12 @@ export async function sendAck(opts: {
* the same relay, since that path was demonstrably working for
* this peer. Falls back to the full set if unset. */
preferRelay?: string;
+ /** Additional relays to include in the publish target set (in
+ * addition to our configured default RELAYS) — typically the
+ * peer's NIP-65 read-relays so their client sees the event even
+ * when they're not subscribed to our defaults. Deduped + capped
+ * inside orderedRelaysForSend. */
+ extraRelays?: string[];
}): Promise {
const sk = nostrSecretFromSeed(opts.ackingSeed);
const targetAddr = addrFromPrimary(opts.originalSenderPrimary);
@@ -704,7 +971,7 @@ export async function sendAck(opts: {
content: "",
};
const signed = finalizeEvent(tmpl, sk);
- const ordered = orderedRelaysForSend(opts.preferRelay);
+ const ordered = orderedRelaysForSend(opts.preferRelay, opts.extraRelays);
const results = await Promise.allSettled(pool().publish(ordered, signed));
const acceptedSomewhere = results.some((r) => r.status === "fulfilled");
if (!acceptedSomewhere) {
diff --git a/kez-chat/web/src/lib/peer-profile-store.ts b/kez-chat/web/src/lib/peer-profile-store.ts
index 4440c6e..f280bd8 100644
--- a/kez-chat/web/src/lib/peer-profile-store.ts
+++ b/kez-chat/web/src/lib/peer-profile-store.ts
@@ -26,6 +26,7 @@
// on access (peers may have updated their picture).
import { get, set } from "idb-keyval";
+import { isAllowedRelayUrl, loadCurrentRelays } from "./relay-store.js";
import { hexToBytes } from "@noble/hashes/utils";
import { SimplePool } from "nostr-tools";
@@ -43,14 +44,8 @@ const CACHE_KEY = "kez-chat:peer-profiles:v1";
const STALE_AFTER_MS = 6 * 60 * 60 * 1000;
const FETCH_TIMEOUT_MS = 8000;
-/** Same default-relay list as nostr-transport.ts. */
-const RELAYS: string[] = (
- (import.meta.env.VITE_NOSTR_RELAYS as string | undefined) ??
- "wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr.wine"
-)
- .split(",")
- .map((r) => r.trim())
- .filter(Boolean);
+/** Same relay list as nostr-transport.ts (user override → env → default). */
+const RELAYS: string[] = loadCurrentRelays();
export interface CachedPeerProfile {
/** The KEZ primary key — canonical identifier; never changes. */
@@ -73,6 +68,15 @@ export interface CachedPeerProfile {
fetched_at: string;
/** Event id of the kind:0 we descrambled — debug breadcrumb. */
source_event_id?: string;
+ /**
+ * NIP-65 (kind:10002) preferred relays the peer published. Senders
+ * should union the peer's READ relays with our own write set when
+ * publishing a DM addressed to this peer. Absent if the peer hasn't
+ * published a kind:10002 yet (most clients today). Each entry is a
+ * normalized wss:// URL.
+ */
+ read_relays?: string[];
+ write_relays?: string[];
}
type Cache = Record;
@@ -133,7 +137,9 @@ export async function fetchPeerProfile(opts: {
try {
const events = await Promise.race([
pool.querySync(RELAYS, {
- kinds: [0],
+ // Pull kind:0 (profile) AND kind:10002 (NIP-65 relay list)
+ // in one query so we only pay one round-trip per peer.
+ kinds: [0, 10002],
authors: [opts.peer_nostr_pubkey],
limit: 5, // grab a few in case relays disagree on `latest`
}),
@@ -142,14 +148,53 @@ export async function fetchPeerProfile(opts: {
),
]);
if (!events.length) return cached; // nothing to update
- const latest = events.sort((a, b) => b.created_at - a.created_at)[0];
- let metadata: Record;
- try {
- metadata = JSON.parse(latest.content);
- } catch (e) {
- console.warn(`peer-profile: bad kind:0 content for ${opts.peer_primary}`, e);
- return cached;
+ // Split the response: kind:0 carries display metadata + the
+ // visual-encryption wraps; kind:10002 carries the NIP-65 relay
+ // list. We take the latest of each kind independently.
+ const kind0Events = events.filter((e) => e.kind === 0);
+ const kind10002Events = events.filter((e) => e.kind === 10002);
+ const latestKind0 = kind0Events.sort((a, b) => b.created_at - a.created_at)[0];
+ const latestKind10002 = kind10002Events.sort((a, b) => b.created_at - a.created_at)[0];
+
+ let metadata: Record = {};
+ if (latestKind0) {
+ try {
+ metadata = JSON.parse(latestKind0.content);
+ } catch (e) {
+ console.warn(`peer-profile: bad kind:0 content for ${opts.peer_primary}`, e);
+ // Don't bail — we can still pick up kind:10002 even if kind:0 is malformed.
+ }
+ }
+
+ // ─── NIP-65: parse the peer's preferred relay list ─────────
+ // kind:10002 tags are ["r", , <"read"|"write"|"">]. The
+ // optional marker tells us whether it's a read-relay, a write-
+ // relay, or both (when missing). Senders to this peer should
+ // include their READ relays in the publish set so the peer's
+ // client (which is subscribed to their own read-relays) sees
+ // the event.
+ let read_relays: string[] | undefined;
+ let write_relays: string[] | undefined;
+ if (latestKind10002) {
+ const reads: string[] = [];
+ const writes: string[] = [];
+ for (const tag of latestKind10002.tags) {
+ if (tag[0] !== "r" || typeof tag[1] !== "string") continue;
+ const url = tag[1].trim();
+ // Skip ws:// etc. — we'd leak routing metadata in cleartext.
+ if (!isAllowedRelayUrl(url)) continue;
+ const marker = tag[2]?.toLowerCase();
+ if (marker === "read") reads.push(url);
+ else if (marker === "write") writes.push(url);
+ else {
+ // No marker = both.
+ reads.push(url);
+ writes.push(url);
+ }
+ }
+ if (reads.length) read_relays = [...new Set(reads)];
+ if (writes.length) write_relays = [...new Set(writes)];
}
const profile: CachedPeerProfile = {
@@ -157,7 +202,9 @@ export async function fetchPeerProfile(opts: {
name: typeof metadata.name === "string" ? metadata.name : undefined,
about: typeof metadata.about === "string" ? metadata.about : undefined,
fetched_at: new Date().toISOString(),
- source_event_id: latest.id,
+ source_event_id: latestKind0?.id,
+ read_relays,
+ write_relays,
};
// ─── descramble path ────────────────────────────────────────
diff --git a/kez-chat/web/src/lib/profile-store.ts b/kez-chat/web/src/lib/profile-store.ts
index 1051dc9..705447f 100644
--- a/kez-chat/web/src/lib/profile-store.ts
+++ b/kez-chat/web/src/lib/profile-store.ts
@@ -27,6 +27,7 @@
// helper which keeps a $state cell in sync.
import { get, set, del } from "idb-keyval";
+import { loadCurrentRelays } from "./relay-store.js";
import { hexToBytes } from "@noble/hashes/utils";
import {
finalizeEvent,
@@ -42,15 +43,8 @@ import type { Identity } from "./kez.js";
const PROFILE_KEY = "kez-chat:my-profile:v1";
-/** Same default-relay list as nostr-transport.ts. Profile publish is
- * best-effort; if relays change, we just re-publish on next save. */
-const RELAYS: string[] = (
- (import.meta.env.VITE_NOSTR_RELAYS as string | undefined) ??
- "wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr.wine"
-)
- .split(",")
- .map((r) => r.trim())
- .filter(Boolean);
+/** Same relay list as nostr-transport.ts (user override → env → default). */
+const RELAYS: string[] = loadCurrentRelays();
export interface StoredProfile {
/** The picture WE see locally — always cleartext. When `encrypted`
@@ -104,16 +98,26 @@ export async function clearMyProfile(): Promise {
* reach us. TODO.md Day 3 Option B #10.
*/
const BASELINE_PUBLISHED_KEY = "kez-chat:nostr-baseline-published:v1";
+/** Relay set our last kind:10002 advertised. Re-published whenever the
+ * user's relay list changes, so peers stop sending to relays we've
+ * dropped. Tracked separately from the kind:0 baseline, which must
+ * NOT be re-sent (it would clobber a real profile). */
+const RELAYLIST_PUBLISHED_KEY = "kez-chat:nip65-published:v1";
export async function publishKind0BaselineIfNeeded(
seed: Uint8Array,
handle: string,
): Promise {
+ const relaySig = RELAYS.join(",");
+ let baselineDone = false;
+ let relayListDone = false;
try {
- if (localStorage.getItem(BASELINE_PUBLISHED_KEY) === "1") return;
+ baselineDone = localStorage.getItem(BASELINE_PUBLISHED_KEY) === "1";
+ relayListDone = localStorage.getItem(RELAYLIST_PUBLISHED_KEY) === relaySig;
} catch {
/* private mode — proceed anyway, worst case we publish once per tab */
}
+ if (baselineDone && relayListDone) return;
try {
const sk = nostrSecretFromSeed(seed);
const now = Math.floor(Date.now() / 1000);
@@ -142,13 +146,18 @@ export async function publishKind0BaselineIfNeeded(
const pool = new SimplePool();
try {
- await Promise.allSettled(pool.publish(RELAYS, signedKind0));
- await Promise.allSettled(pool.publish(RELAYS, signedKind10002));
+ if (!baselineDone) {
+ await Promise.allSettled(pool.publish(RELAYS, signedKind0));
+ }
+ if (!relayListDone) {
+ await Promise.allSettled(pool.publish(RELAYS, signedKind10002));
+ }
} finally {
pool.close(RELAYS);
}
try {
localStorage.setItem(BASELINE_PUBLISHED_KEY, "1");
+ localStorage.setItem(RELAYLIST_PUBLISHED_KEY, relaySig);
} catch {
/* private mode */
}
diff --git a/kez-chat/web/src/lib/relay-store.ts b/kez-chat/web/src/lib/relay-store.ts
new file mode 100644
index 0000000..764a219
--- /dev/null
+++ b/kez-chat/web/src/lib/relay-store.ts
@@ -0,0 +1,118 @@
+// User-configurable nostr relay list.
+//
+// The shipped build hard-codes a set of default relays (mirrored
+// across nostr-transport.ts, profile-store.ts, peer-profile-store.ts,
+// and the rust server's nostr_listener). Power users can override
+// the set from Settings; their list is persisted in localStorage and
+// loaded at module-init time before the transport opens any
+// subscriptions.
+//
+// Why localStorage instead of IndexedDB: this needs to be readable
+// SYNCHRONOUSLY before the rest of the app starts, so we can pre-
+// populate the transport's `RELAYS` array. localStorage is sync;
+// IDB is not.
+//
+// Trade-off: localStorage is per-origin, not per-device, so signing
+// in on the same browser profile picks up your custom list. Fresh
+// devices revert to defaults until the user opens Settings again.
+
+const STORAGE_KEY = "kez-chat:relays:v1";
+
+/** wss:// only — plain ws:// would expose who-talks-to-whom metadata
+ * to anyone on the network path. ws:// is allowed for a local dev
+ * relay only. Also used to vet peers' NIP-65 relay lists. */
+export function isAllowedRelayUrl(url: string): boolean {
+ return (
+ /^wss:\/\/[^\s]+$/i.test(url) ||
+ /^ws:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/[^\s]*)?$/i.test(url)
+ );
+}
+
+/** Hard-coded fallback when no env override AND no user override.
+ * This MUST match the source-of-truth used elsewhere in the app
+ * (search for "wss://relay.damus.io" if you ever change it). */
+const HARDCODED_DEFAULT =
+ "wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr.wine";
+
+/** The build-time default: env override → hardcoded fallback. This
+ * is what "Restore defaults" resets to in the UI. Computed once at
+ * module load. */
+export const DEFAULT_RELAYS: string[] =
+ ((import.meta.env.VITE_NOSTR_RELAYS as string | undefined) ??
+ HARDCODED_DEFAULT)
+ .split(",")
+ .map((r) => r.trim())
+ .filter(Boolean);
+
+/** Read the current effective relay list: user override if present,
+ * otherwise the build default. Synchronous. */
+export function loadCurrentRelays(): string[] {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (raw) {
+ const parsed = JSON.parse(raw);
+ if (Array.isArray(parsed)) {
+ const cleaned = parsed
+ .filter((r) => typeof r === "string")
+ .map((r) => r.trim())
+ .filter(isAllowedRelayUrl);
+ if (cleaned.length > 0) return cleaned;
+ }
+ }
+ } catch {
+ /* corrupted JSON — fall through to defaults */
+ }
+ return [...DEFAULT_RELAYS];
+}
+
+/** Persist a user-configured relay list. Validates each entry as a
+ * wss:// or ws:// URL; throws on bad input so the UI can surface
+ * the error rather than silently dropping entries. Empty list →
+ * reset to defaults (clears the override). */
+export function saveCustomRelays(list: string[]): void {
+ if (list.length === 0) {
+ clearCustomRelays();
+ return;
+ }
+ const cleaned: string[] = [];
+ for (const r of list) {
+ const t = r.trim();
+ if (!t) continue;
+ if (!isAllowedRelayUrl(t)) {
+ throw new Error(`"${t}" is not a valid relay URL (expected wss://…)`);
+ }
+ cleaned.push(t);
+ }
+ // De-dupe case-insensitively while preserving original order.
+ const seen = new Set();
+ const deduped: string[] = [];
+ for (const r of cleaned) {
+ const k = r.toLowerCase().replace(/\/+$/, "");
+ if (seen.has(k)) continue;
+ seen.add(k);
+ deduped.push(r);
+ }
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(deduped));
+}
+
+/** Wipe the user override → next load reverts to DEFAULT_RELAYS. */
+export function clearCustomRelays(): void {
+ try {
+ localStorage.removeItem(STORAGE_KEY);
+ } catch {
+ /* private mode — fine */
+ }
+}
+
+/** True when the effective list equals the build defaults, in the
+ * same order — order is meaningful (earlier relays are preferred),
+ * so a reordered default set counts as custom. Used by the UI to
+ * show "Restore defaults". */
+export function isUsingDefaultRelays(): boolean {
+ const current = loadCurrentRelays();
+ const norm = (u: string) => u.toLowerCase().replace(/\/+$/, "");
+ return (
+ current.length === DEFAULT_RELAYS.length &&
+ current.every((r, i) => norm(r) === norm(DEFAULT_RELAYS[i]))
+ );
+}
diff --git a/kez-chat/web/src/routes/Messages.svelte b/kez-chat/web/src/routes/Messages.svelte
index 12006ee..70c8086 100644
--- a/kez-chat/web/src/routes/Messages.svelte
+++ b/kez-chat/web/src/routes/Messages.svelte
@@ -10,12 +10,12 @@
fetchAcksForEventIds,
type RelayStatus,
} from "../lib/transport.js";
- import { MAX_FILE_BYTES, INLINE_LIMIT } from "../lib/file-transfer.js";
+ import { MAX_FILE_BYTES } from "../lib/file-transfer.js";
import {
loadAttachment,
saveAttachment,
- type StoredAttachment,
} from "../lib/attachment-store.js";
+ import { prepareImageForSend } from "../lib/image-utils.js";
import { lookup, lookupByPrimary, ApiError } from "../lib/api.js";
import { inboxService } from "../lib/inbox-service.svelte.js";
import { verifySubject } from "../lib/verify.js";
@@ -34,6 +34,7 @@
import {
appendOutbound,
appendOutboundAttachment,
+ clearConversationMessages,
ensureConversation,
listConversations,
markConversationRead,
@@ -42,6 +43,7 @@
setVerified,
type Conversation,
} from "../lib/conversations-store.js";
+ import { parseFileBody } from "../lib/file-transfer.js";
import type { Identity } from "../lib/kez.js";
let conversations = $state([]);
@@ -98,19 +100,37 @@
* and render an optimistic local echo of the attachment so the
* sender sees their image immediately.
*/
- async function onFilePicked(file: File) {
+ async function onFilePicked(rawFile: File) {
if (!session.unlocked || !activeConv) return;
+ // Snapshot the thread NOW — the user can switch conversations
+ // during the awaits below, and every field must come from the
+ // thread the file was picked in.
+ const conv = activeConv;
fileSendError = null;
+ composing = true;
+
+ // HEIC → JPEG (most recipients can't render HEIC) and big photos
+ // downscaled; see prepareImageForSend. Throws with user-friendly
+ // guidance if this browser can't decode HEIC (e.g. desktop Chrome).
+ let file: File;
+ try {
+ file = await prepareImageForSend(rawFile);
+ } catch (e) {
+ composing = false;
+ fileSendError = (e as Error).message;
+ return;
+ }
+
if (file.size > MAX_FILE_BYTES) {
+ composing = false;
fileSendError = `Files larger than ${MAX_FILE_BYTES / 1024 / 1024} MB aren't supported yet.`;
return;
}
- composing = true;
const raw = new Uint8Array(await file.arrayBuffer());
const filename = file.name || "file";
const mime = file.type || "application/octet-stream";
- const peer_primary = activeConv.peer_primary;
- const peer_handle = activeConv.peer_handle;
+ const peer_primary = conv.peer_primary;
+ const peer_handle = conv.peer_handle;
// ─── optimistic local echo ─────────────────────────────────
// Save the raw bytes locally so the bubble can render
@@ -139,6 +159,7 @@
data_url: dataUrl,
});
await refresh();
+ scrollChatToBottom();
composing = false;
@@ -153,7 +174,9 @@
filename,
mime,
raw,
- preferRelay: activeConv?.peer_via_relay,
+ preferRelay: conv.peer_via_relay,
+ recipientNostrPubkey: conv.peer_nostr_pubkey,
+ extraRelays: peerProfiles.byPrimary[peer_primary]?.read_relays,
});
await markOutboundStatus(peer_primary, localSeq, "sent", {
event_id: result.pointer_event_id,
@@ -231,6 +254,98 @@
/** Scroll container for the thread view — bound for auto-scroll. */
let scrollEl: HTMLDivElement | null = $state(null);
+
+ /**
+ * Scroll the active thread to the bottom. Compound problem we're
+ * solving:
+ *
+ * 1. Single rAF isn't enough — Svelte commits DOM nodes but
+ * browser layout hasn't run; scrollHeight reads stale.
+ * 2. Image previews inside the new bubble decode async and grow
+ * the bubble AFTER our first scroll lands.
+ * 3. THE BIG ONE: on mobile, submitting a form blurs the input,
+ * keyboard hides, visualViewport HEIGHT GROWS. Our scroll
+ * already fired against the pre-resize bottom — now there's
+ * space below the last message and the compose form sits
+ * where the user expected the message.
+ *
+ * Two-pronged fix:
+ * • Keep the keyboard UP after send (refocus the compose input
+ * in the user-gesture handler — same as WhatsApp / iMessage).
+ * If the keyboard never hides, there's no resize.
+ * • A visualViewport resize listener (separate effect, runs
+ * anytime in a thread) that re-pins to bottom whenever the
+ * user was already at the bottom and the layout shifts.
+ * Belt + suspenders.
+ */
+ function scrollChatToBottom() {
+ // Where our last pin landed. A retry only re-pins if the user
+ // hasn't scrolled UP since — content growing below (image decode)
+ // raises scrollHeight but leaves scrollTop where we put it.
+ let pinnedAt = -1;
+ const doScroll = () => {
+ const el = scrollEl;
+ if (!el) return;
+ el.scrollTop = el.scrollHeight;
+ pinnedAt = el.scrollTop;
+ };
+ const retry = () => {
+ if (scrollEl && scrollEl.scrollTop >= pinnedAt - 2) doScroll();
+ };
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ doScroll();
+ // Retry windows catch image decode + (on iOS) any straggling
+ // visualViewport adjustments even if our listener missed.
+ setTimeout(retry, 100);
+ setTimeout(retry, 350);
+ setTimeout(retry, 750);
+ });
+ });
+ }
+
+ /** Are we within 80px of the bottom of the thread? Used as the
+ * "did the user want to stay pinned to bottom" heuristic for the
+ * visualViewport resize listener. 80px tolerates a few lines of
+ * drift while still recognising "user scrolled up to read older
+ * messages". */
+ function isNearBottom(): boolean {
+ const el = scrollEl;
+ if (!el) return true;
+ return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
+ }
+
+ /** Mobile keyboard show/hide reshapes the visualViewport. If the
+ * user was at the bottom of the thread when that happens, re-pin
+ * them. Otherwise leave their scroll position alone — if they
+ * were reading older messages, an auto-jump down would be rude. */
+ $effect(() => {
+ if (!activePrimary) return;
+ const vp = (
+ window as unknown as { visualViewport?: VisualViewport }
+ ).visualViewport;
+ if (!vp) return;
+ let wasNearBottom = true;
+ const onResize = () => {
+ if (wasNearBottom) {
+ // Two-frame wait so the layout settles before we measure.
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ if (scrollEl) scrollEl.scrollTop = scrollEl.scrollHeight;
+ });
+ });
+ }
+ };
+ const onScroll = () => {
+ wasNearBottom = isNearBottom();
+ };
+ vp.addEventListener("resize", onResize);
+ scrollEl?.addEventListener("scroll", onScroll, { passive: true });
+ return () => {
+ vp.removeEventListener("resize", onResize);
+ scrollEl?.removeEventListener("scroll", onScroll);
+ };
+ });
/** Tracks the previously-rendered conversation so we know when the
* user just OPENED a different thread (always scroll to bottom) vs.
* when a new message landed in the current one (scroll only if they
@@ -288,8 +403,50 @@
function closeZoomedAvatar() {
zoomedAvatarOpen = false;
}
- function onZoomKeydown(e: KeyboardEvent) {
- if (e.key === "Escape") closeZoomedAvatar();
+
+ // Clear-chat confirmation modal.
+ let clearChatOpen = $state(false);
+ let clearChatBusy = $state(false);
+
+ function openClearChat() {
+ if (!activeConv) return;
+ clearChatOpen = true;
+ }
+ function closeClearChat() {
+ clearChatOpen = false;
+ }
+ async function confirmClearChat() {
+ if (!activeConv || clearChatBusy) return;
+ clearChatBusy = true;
+ try {
+ await clearConversationMessages(activeConv.peer_primary);
+ await refresh();
+ clearChatOpen = false;
+ } finally {
+ clearChatBusy = false;
+ }
+ }
+
+ // Tap-to-fullscreen for attached images. Click an image bubble →
+ // overlay with the picture upscaled. ESC + click-backdrop dismiss.
+ let zoomedAttachment = $state<{
+ src: string;
+ filename: string;
+ } | null>(null);
+ function openZoomedAttachment(src: string, filename: string) {
+ zoomedAttachment = { src, filename };
+ }
+ function closeZoomedAttachment() {
+ zoomedAttachment = null;
+ }
+
+ /** Escape closes whichever overlay is open. Window-level so it
+ * works without the overlay holding focus. */
+ function onWindowKeydown(e: KeyboardEvent) {
+ if (e.key !== "Escape") return;
+ if (clearChatOpen && !clearChatBusy) closeClearChat();
+ closeZoomedAttachment();
+ closeZoomedAvatar();
}
// ─── Relay state ──────────────────────────────────────────────────────
@@ -565,6 +722,8 @@
async function send() {
if (!session.unlocked || !activeConv || !composeText.trim()) return;
+ // Snapshot the thread — see onFilePicked.
+ const conv = activeConv;
composing = true;
const body = composeText;
composeText = "";
@@ -574,8 +733,8 @@
// the user sees their own message immediately. Status icon
// flips to "sent" once at least one relay accepts, then to
// "delivered" when the recipient's client publishes an ack.
- const peer_primary = activeConv.peer_primary;
- const peer_handle = activeConv.peer_handle;
+ const peer_primary = conv.peer_primary;
+ const peer_handle = conv.peer_handle;
let localSeq: number;
try {
localSeq = await appendOutbound({
@@ -586,6 +745,17 @@
status: "sending",
});
await refresh();
+ // Keep the keyboard up by re-focusing the compose input — same
+ // UX as iMessage / WhatsApp. If we DON'T refocus, the form
+ // submit blurs the input, the soft keyboard slides away, the
+ // visualViewport grows, and the freshly-sent bubble ends up
+ // way above where the user expected. The
+ // visualViewport-resize effect above also catches this case;
+ // this is the primary defence.
+ composeEl?.focus();
+ // Scroll to the bottom AFTER the new message is in the DOM —
+ // requestAnimationFrame ensures Svelte's render commit ran.
+ scrollChatToBottom();
} catch (e) {
alert(`Local append failed: ${(e as Error).message}`);
composing = false;
@@ -608,11 +778,21 @@
// depend on the chat-server; if the server's down, we still
// publish to relays and the recipient still gets the message.
recipientPrimary: peer_primary,
+ // Their nostr pubkey is needed to fetch their current
+ // heartbeat for v3 envelope sealing. Cached on the conv row
+ // since the first inbound DM; undefined for never-replied
+ // contacts (first send falls back to identity ECDH for that
+ // one message).
+ recipientNostrPubkey: conv.peer_nostr_pubkey,
// Reply over the same relay that delivered the recipient's
// most recent message to us — usually the lowest-latency
// path for the round-trip. Falls back to our default set if
// unset.
- preferRelay: activeConv?.peer_via_relay,
+ preferRelay: conv.peer_via_relay,
+ // NIP-65: union the peer's preferred read-relays into our
+ // publish set, so their client (subscribed to those) sees
+ // our event even if it's on a different relay pool than us.
+ extraRelays: peerProfiles.byPrimary[peer_primary]?.read_relays,
});
await markOutboundStatus(peer_primary, localSeq, "sent", {
event_id: result.event_id,
@@ -891,7 +1071,7 @@
class="min-w-[18px] h-[18px] px-1.5 inline-flex items-center justify-center rounded-full bg-accent text-accent-contrast text-[10px] font-semibold leading-none"
aria-label="{c.unread_count} unread message{c.unread_count === 1 ? '' : 's'}"
>
- {c.unread_count > 99 ? "99+" : c.unread_count}
+ {(c.unread_count ?? 0) > 99 ? "99+" : c.unread_count}
{/if}
@@ -949,13 +1129,31 @@
{:else}
{/if}
-
+{/if}
+
diff --git a/kez-chat/web/src/routes/Settings.svelte b/kez-chat/web/src/routes/Settings.svelte
index 150adc1..14d41e3 100644
--- a/kez-chat/web/src/routes/Settings.svelte
+++ b/kez-chat/web/src/routes/Settings.svelte
@@ -11,6 +11,23 @@
import { scrambleImage } from "../lib/visual-crypto.js";
import { hexToBytes } from "@noble/hashes/utils";
import Avatar from "../lib/Avatar.svelte";
+ import {
+ loadHeartbeatSettings,
+ saveHeartbeatSettings,
+ describeHeartbeats,
+ gcExpiredHeartbeats,
+ DEFAULT_RETENTION_DAYS,
+ MIN_RETENTION_DAYS,
+ MAX_RETENTION_DAYS,
+ } from "../lib/heartbeat-store.js";
+ import {
+ loadCurrentRelays,
+ saveCustomRelays,
+ clearCustomRelays,
+ isUsingDefaultRelays,
+ isAllowedRelayUrl,
+ DEFAULT_RELAYS,
+ } from "../lib/relay-store.js";
import { hasStoredPhrase, unlockIdentity } from "../lib/identity-store.js";
import {
hasStoredBiometric,
@@ -55,6 +72,104 @@
let biometricError = $state(null);
let notifSupported = $state(false);
+
+ // ─── Nostr relays ────────────────────────────────────────────────
+ let relayList = $state([]);
+ let relayDirty = $state(false);
+ let relayUsingDefaults = $state(true);
+ let relayNewUrl = $state("");
+ let relayError = $state(null);
+ let relayShowReload = $state(false);
+
+ function refreshRelayUi() {
+ relayList = loadCurrentRelays();
+ relayUsingDefaults = isUsingDefaultRelays();
+ relayDirty = false;
+ }
+
+ function moveRelay(i: number, dir: -1 | 1) {
+ const j = i + dir;
+ if (j < 0 || j >= relayList.length) return;
+ const next = [...relayList];
+ [next[i], next[j]] = [next[j], next[i]];
+ relayList = next;
+ relayDirty = true;
+ }
+
+ function removeRelay(i: number) {
+ relayList = relayList.filter((_, idx) => idx !== i);
+ relayDirty = true;
+ }
+
+ function addRelay() {
+ const t = relayNewUrl.trim();
+ if (!t) return;
+ if (!isAllowedRelayUrl(t)) {
+ relayError = "Relay URL must start with wss:// (ws:// only for localhost).";
+ return;
+ }
+ if (relayList.some((r) => r.toLowerCase().replace(/\/+$/, "") === t.toLowerCase().replace(/\/+$/, ""))) {
+ relayError = "That relay is already in the list.";
+ return;
+ }
+ relayList = [...relayList, t];
+ relayNewUrl = "";
+ relayDirty = true;
+ relayError = null;
+ }
+
+ async function saveRelays() {
+ relayError = null;
+ try {
+ saveCustomRelays(relayList);
+ relayUsingDefaults = isUsingDefaultRelays();
+ relayDirty = false;
+ relayShowReload = true;
+ } catch (e) {
+ relayError = (e as Error).message;
+ }
+ }
+
+ function restoreRelayDefaults() {
+ clearCustomRelays();
+ relayList = [...DEFAULT_RELAYS];
+ relayUsingDefaults = true;
+ relayDirty = false;
+ relayShowReload = true;
+ relayError = null;
+ }
+
+ function doReload() {
+ window.location.reload();
+ }
+
+ // ─── Heartbeat ratchet ───────────────────────────────────────────
+ let heartbeatRetention = $state(DEFAULT_RETENTION_DAYS);
+ /** Last saved value — lets us warn when the user is SHORTENING it. */
+ let heartbeatRetentionSaved = $state(DEFAULT_RETENTION_DAYS);
+ let heartbeatRetentionDirty = $state(false);
+ let heartbeatDescribe = $state<{
+ count: number;
+ newest_age_hours?: number;
+ oldest_age_hours?: number;
+ current_pub?: string;
+ } | null>(null);
+
+ async function refreshHeartbeatStatus() {
+ const s = await loadHeartbeatSettings();
+ heartbeatRetention = s.retention_days;
+ heartbeatRetentionSaved = s.retention_days;
+ heartbeatRetentionDirty = false;
+ heartbeatDescribe = await describeHeartbeats();
+ }
+
+ async function saveHeartbeatRetention() {
+ await saveHeartbeatSettings({ retention_days: heartbeatRetention });
+ // Apply now rather than at next session start — a user shortening
+ // retention expects old keys gone when they hit Save.
+ await gcExpiredHeartbeats();
+ await refreshHeartbeatStatus();
+ }
let notifPerm = $state("default");
let testNotifResult = $state<{ ok: boolean; reason?: string } | null>(null);
@@ -145,6 +260,8 @@
}
await refreshBiometricStatus();
notifSupported = notificationsSupported();
+ void refreshHeartbeatStatus();
+ refreshRelayUi();
notifPerm = notificationsPermission();
webPushOk = pushSupported();
pwaInstalled = isStandalonePwa();
@@ -609,6 +726,196 @@
{/if}
+
+
+
+ Nostr relays
+
+
+ The publish/subscribe servers your messages route through.
+ Earlier entries are tried first when sending — use the arrows
+ to move a relay up to prefer it. Changes require a page reload
+ to take effect.
+
+
+
+ {#each relayList as r, i (r)}
+
+
+ {i + 1}.
+
+
+ {r}
+
+
+
+
+
+ {/each}
+ {#if relayList.length === 0}
+
+ No relays — add at least one or restore defaults.
+
+ Each day, your device generates a fresh decryption key and
+ publishes its public half to nostr (called a "heartbeat"). Old
+ keys are deleted on a schedule — once a key is gone, NOBODY
+ can decrypt messages addressed to it, not even you. Longer
+ retention forgives long offline periods; shorter retention
+ leaks less if your device is ever stolen.
+
+ Saving deletes keys retired more than {heartbeatRetention} days
+ ago right away. Messages sent to those keys that haven't
+ arrived yet can never be decrypted.
+
+ {/if}
+
+ {#if heartbeatDescribe}
+
+ {heartbeatDescribe.count} key{heartbeatDescribe.count === 1 ? "" : "s"} in storage
+ {#if heartbeatDescribe.newest_age_hours !== undefined}
+ · current age {Math.round(heartbeatDescribe.newest_age_hours)} h
+ {/if}
+
diff --git a/kez-chat/web/src/routes/Unlock.svelte b/kez-chat/web/src/routes/Unlock.svelte
index ca77808..0d12a6c 100644
--- a/kez-chat/web/src/routes/Unlock.svelte
+++ b/kez-chat/web/src/routes/Unlock.svelte
@@ -13,6 +13,7 @@
getStoredBiometricMeta,
} from "../lib/webauthn.js";
import { session } from "../lib/store.svelte.js";
+ import { wipeAllHeartbeats } from "../lib/heartbeat-store.js";
let meta = $state<{ handle: string; server: string; primary: string; created_at: string } | null>(null);
let passphrase = $state("");
@@ -76,6 +77,9 @@
async function forget() {
if (!confirm("Delete the local copy of this account? You'll need your seed to restore.")) return;
await deleteStoredIdentity();
+ // Heartbeat privates can decrypt retained messages without the
+ // seed — they must not outlive the account on this device.
+ await wipeAllHeartbeats();
session.lock();
push("/");
}