Crypto / protocol
- v4 envelope: X25519 + ML-KEM-768 hybrid (PQXDH-style HKDF over both
secrets). Heartbeats now carry an ML-KEM key; senders emit v4 whenever
the peer's heartbeat has one, v3 otherwise. Adds @noble/post-quantum.
- Heartbeats are signed by the KEZ identity and verified against the
recipient's primary before use. Previously a spoofed nostr pubkey
(from a re-wrapped replayed envelope) let an attacker substitute their
own heartbeat and read messages.
- Heartbeat retention counts from supersession, never deletes the
newest key, and GC runs after rotation — returning after a long
offline stretch no longer destroys the key the backlog needs.
- Verified-heartbeat cache per peer: 5-min TTL (one lookup per file
instead of per chunk) and anti-downgrade to identity ECDH.
- Always republish heartbeat on session start; rotate on the poll timer;
atomic IDB updates; wipe heartbeats on "Forget this account".
- Dedupe inbound envelopes by sender_sig (replays / re-broadcasts).
Server
- Proxy /v1/sigchains/* to the internal sig-server and hand out the
public https://<server>/v1/sigchains/... URL. Sigchain sync from the
app was failing ("Load failed") against http://sig-server:7878.
App
- Relay settings apply to profile / NIP-65 publishing (republished on
change); wss-only relays (ws:// for localhost); order-aware defaults.
- Clear chat also deletes attachments; retention changes apply
immediately with a warning when shortening.
- Photos: HEIC -> JPEG and downscale to 2048px (iOS canvas limit, strips
EXIF); snapshot the conversation at send time.
- Overlay a11y/Escape fixes, scroll-pin fix, svelte-check to 0 errors.
- Clear chat, image zoom, file-body fallback rendering, NIP-65 read
relays in publish set, relay settings UI, forward-secrecy settings.
Docs
- README rewritten to explain the whole app; SECURITY.md updated for v4;
heartbeat self-check (src/lib/heartbeat.selfcheck.ts).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
357 lines
15 KiB
Markdown
357 lines
15 KiB
Markdown
# 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.
|
||
- **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.
|
||
|
||
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.
|