Kez/kez-chat/README.md
Jason Tudisco 3628199a5b fix(kez-chat): show recovery phrase as words, never hex
The recovery reveal (Getting Started and Settings) fell back to the
64-char hex seed whenever the phrase wasn't in the session — which
happened after every Face ID / passkey unlock (PRF only recovers the
seed) and for accounts created before 12-word phrases.

- Phrase stored but not in session: Getting Started asks for the
  passphrase once and decrypts it.
- No stored phrase (legacy / hex-restored): show the seed's exact
  24-word encoding, which Restore accepts.
- Settings alert lists words numbered one per line.
- README: document passkey unlock as per-browser and how the recovery
  phrase is shown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:49:10 -06:00

373 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 **<https://kez.lat>** — 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. Passkey unlock is per-browser;
on a new or wiped browser you restore with your recovery phrase.
- **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:<hex>`). 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.
Passkey unlock is a local convenience, not a login: the passkey-encrypted
seed lives only in this browser's IndexedDB, and nothing is stored on the
server. If the browser forgets you (storage cleared, new device), sign
back in with **Restore** and your recovery phrase (or the 64-hex seed).
**Showing your recovery phrase** (Getting Started, or Settings → reveal)
displays it as a numbered word list:
- 12 words for accounts created with a phrase. After a passkey unlock
the phrase isn't in memory (the passkey only unlocks the seed), so the
app asks for your passphrase once to decrypt it.
- 24 words for older accounts created before 12-word phrases (or restored
from a hex seed). Their seed has no 12-word form, but its exact 24-word
encoding restores the same account.
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,
`<your-site>/.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.