# kez-chat 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. Live at **** — installable as a PWA on iOS, Android, and desktop. 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 you get - **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. --- ## 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 cargo run -- --bind 127.0.0.1:6969 --db ./kez-chat.db --server kez.lat ``` **Web app** (from `kez-chat/web/`) — Vite proxies `/v1`, `/internal`, and `/.well-known` to the server on `:6969`: ```sh npm ci npm run dev ``` For sigchain sync you also need the sig-server ([`../rust-sig-server`](../rust-sig-server/)) running on `:7878`. ### 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 | | |---|---|---| | `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 | --- ## 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 cd deploy && docker compose up -d --build ``` 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. ## Checks ```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) ``` `cargo test` (server integration tests in `tests/http.rs`) currently needs updating for newer config fields before it compiles. --- ## More reading - [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 Dual-licensed under MIT or Apache-2.0.