Kez/kez-chat/README.md
Jason Tudisco 79e7e9536b feat(kez-chat): post-quantum v4 envelopes, heartbeat ratchet hardening, sig-server proxy
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>
2026-09-27 09:32:08 -06:00

15 KiB
Raw Blame History

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

License

Dual-licensed under MIT or Apache-2.0.