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} -
+

{displayName(activeConv)} {#if activeConv.verified}{/if}

{activeConv.peer_primary}

+ +
@@ -1006,14 +1204,25 @@ ⚠ {m.attachment.filename}: assembly failed {:else if att?.data_url && m.attachment.mime?.startsWith("image/")} - - {m.attachment.filename} + {:else if att?.data_url} {m.body} + + {@const fb = parseFileBody(m.body)} + {#if fb && fb.type === "kez-file-v1" && fb.mode === "inline"} +
+ 📎 + {fb.filename} + ({(fb.size / 1024).toFixed(0)} KB) +
+ {:else if fb && fb.type === "kez-file-v1" && fb.mode === "chunked"} +
+ 📎 + {fb.filename} + (chunked, {fb.n} parts) +
+ {:else if fb && fb.type === "kez-file-chunk-v1"} + + (file chunk, hidden) + {:else} + {m.body} + {/if} {/if} + { const f = (e.currentTarget as HTMLInputElement).files?.[0]; @@ -1153,6 +1389,92 @@ + + + +{#if clearChatOpen && activeConv} + +{/if} + + +{#if zoomedAttachment} +
+{/if} + profile picture 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. +
  • + {/if} +
+ +
{ + e.preventDefault(); + addRelay(); + }} + > + + +
+ + {#if relayError} +

{relayError}

+ {/if} + +
+ {#if !relayUsingDefaults} + + {:else} + Using built-in defaults + {/if} + {#if relayDirty} + + {/if} +
+ + {#if relayShowReload} +
+

+ Relay list saved. Reload kez-chat to apply. +

+ +
+ {/if} +
+ + +
+

+ Forward secrecy +

+

+ 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. +

+
+ + (heartbeatRetentionDirty = true)} + class="flex-1" + /> + + {heartbeatRetention} days + +
+ {#if heartbeatRetentionDirty && heartbeatRetention < heartbeatRetentionSaved} +

+ 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} +

+ {:else} + + {/if} + {#if heartbeatRetentionDirty} + + {/if} +
+
+

Account

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("/"); }