SPEC.md → v0.4 - §3.1 BIP-39 recovery phrases (12-word hashed with "kez-bip39-12-v1", 24-word = seed), parsing rules, test vectors. - §7 nostr: kind 30078 canonical (MUST); verifiers SHOULD also check kind-1 notes and the kind-0 `about`. - §9.1 sigchain HTTP store API; §11 BIP-39 primitive; §15 Python and kez-chat; §16 applications (kez-chat tags); changelog. nodejs kez-channels - NostrChannel now requests kinds 0, 1, 30078 and extracts the proof body per kind (kind-0 `about` JSON-decoded), mirroring Rust's extract_proof_body. Proofs posted as kind-1 notes (e.g. via NIP-07 from kez-chat) previously failed to verify in Node. - Tests for kind-1 Markdown notes, kind-0 about, and extractProofBody. Docs - kez-chat/web/NOSTR-CHAT.md rewritten for the current protocol: `q` routing tag, kinds 4242/4244/10142/0/10002, KEZ-signed heartbeats, v1–v4 envelopes, receipts, relay ordering, honest metadata model. - Stale `#h` comments in nostr-id.ts / nostr-transport.ts. - rust/README.md nostr channel rows list all three kinds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 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, 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:
- 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. - Senders encrypt to your latest heartbeat rather than your long-term identity key.
- 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. 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/):
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:
npm ci
npm run dev
For sigchain sync you also need the 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 |
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
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 — threat model and crypto, in plain language
- web/NOSTR-CHAT.md — how messages ride on nostr
- TODO.md — roadmap and design notes (heartbeat ratchet, v4)
- ../SPEC.md — the KEZ identity protocol
License
Dual-licensed under MIT or Apache-2.0.