Kez/kez-chat/SECURITY.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

407 lines
17 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 Security Model
A plain-language description of how kez-chat protects your messages.
Written for the curious — read top to bottom, or skip to whichever
section you care about.
## TL;DR
kez-chat is end-to-end encrypted. Only the people you're talking to
can read what you write — not the nostr relays the messages flow
through, not the push providers that wake your phone, nobody.
Messages have **forward secrecy** (an attacker who steals your
identity key TODAY can't decrypt messages from more than ~2 weeks ago)
and **post-compromise security** (after a 2-week window elapses, any
new messages are safe again, even if the attacker briefly held your
key in the past).
Message encryption is **post-quantum hybrid** (X25519 + ML-KEM-768),
so traffic recorded today can't be decrypted by a future quantum
computer.
Profile pictures are optionally **visually encrypted** so people who
aren't your contacts see colored noise instead of your face.
This document explains how. Honestly — including where the design
isn't quite as strong as Signal's, and why we think the trade is
worth it for a chat built on nostr.
## What kez-chat protects against
- **Nostr relays reading your messages.** They see encrypted bytes
and routing tags. They cannot read content, even if every relay you
use colludes.
- **Push providers (FCM / APNs / Mozilla) reading your messages.**
Push payloads are empty by design. The provider knows "kez-chat
woke a device," nothing about who messaged whom.
- **A passive observer who logs everything for years.** If they later
compromise your KEZ identity key, they STILL cannot decrypt
messages older than your forward-secrecy window (default 16 days).
Those keys are gone.
- **Brief device compromise** (an attacker had your phone for 30
seconds at a bar). Anything within the current retention window is
exposed, but new messages sent AFTER the compromise are safe again
once the window rolls over.
- **Identity impersonation.** Every message is signed by the sender's
long-term Ed25519 key. Forgeries don't verify.
- **Replay attacks.** Each event has a unique id; envelopes carry a
timestamp; the receiving client rejects anything more than ±5
minutes off the wall clock or older than the freshness window.
- **Metadata leak in the message body.** Sender identity, recipient
identity, and message content all live INSIDE the encrypted blob.
Relays see only an opaque "to addr X" routing tag.
- **Stranger profile-picture scraping.** If you enable visual
encryption (default), strangers see scrambled noise. Only people
who've messaged you can see your actual face.
## What kez-chat does NOT protect against (be honest)
- **A determined attacker on your device DURING the retention
window.** Anything in your IndexedDB at the moment of compromise is
exposed. Mitigation: shorter retention window (Settings → Forward
Secrecy slider) trades offline tolerance for tighter exposure.
- **Per-message forward secrecy at Signal's granularity.** Our
window is days, not "until you've decrypted the next message."
More on this below.
- **The chat-server operator learning who's registered with them.**
Handle registration is on a public directory. If you want your
handle private, don't register it.
- **Traffic-analysis metadata.** Nostr relays see "Alice posted an
event tagged to Bob's routing addr at 14:35." That's enough to
build a social graph. We don't onion-route or pad messages. (If
you need that, use a chat that does — there are real trade-offs.)
- **First-message-ever-to-a-brand-new-contact fallback.** When you
message someone who has never published a heartbeat, that ONE
message uses a fallback path with no receiver-side forward
secrecy. Every subsequent message in the conversation gets the
full protection.
- **Multi-device.** Today, signing in on a second device generates a
fresh set of heartbeats and effectively orphans the first device's
decryption history. This is documented as a v0.1 limitation; the
fix is on the roadmap.
- **Quantum computers — partially.** Message *encryption* is
post-quantum: v4 envelopes combine X25519 with ML-KEM-768 (the NIST
standard, same idea as Signal PQXDH and Apple PQ3), so messages
harvested from relays today stay safe. Two gaps remain: the very
first message to a contact who has never published a heartbeat is
classical-only, and *signatures* (Ed25519, nostr secp256k1) are still
classical — a future quantum attacker could impersonate someone in
real time, though not decrypt past messages.
## How identity works
Each kez-chat account is a single 32-byte random number (the "seed"),
generated on first install. From the seed we derive:
- **Ed25519 signing key** — your KEZ identity. The public half
(`ed25519:<hex>`) IS your long-term identity. You sign every
message envelope with it so recipients know it's you.
- **X25519 key** — used for ECDH (key exchange) with peers. Derived
from the same seed via standard Montgomery conversion.
- **Secp256k1 / Schnorr key** — your nostr signing key. Derived via
HKDF with a domain separator (`kez-chat:nostr-signkey/v1`). Used
only to sign nostr events so relays accept them. Never surfaced to
the user.
The seed itself is encrypted at rest under your passphrase
(PBKDF2 → AES-GCM) and stored in browser IndexedDB. Unlock decrypts
it into memory; lock zeroes it.
Optionally: a 30-day "persistent session" wraps the seed under a
non-extractable WebCrypto AES key for fast re-unlocks. This is
designed as the same trust boundary as the biometric-unlock path:
an attacker with code execution in your browser origin can decrypt
the seed, but cannot exfiltrate the key off the device.
## How messages are encrypted (the envelope)
When you send a message, kez-chat builds a "v3 envelope" that looks
roughly like this:
```json
{
"v": 3,
"eph_pub": "<32 bytes hex>",
"recipient_heartbeat_pub":"<32 bytes hex>",
"nonce": "<12 bytes hex>",
"ciphertext": "<AEAD output, hex>",
"sender_sig": "<64 bytes hex>"
}
```
The full envelope is JSON-stringified, then carried as the `content`
field of a nostr DM event (kind 4242). The nostr event is signed
with your nostr key and broadcast to ~5 relays.
Encryption process:
1. Generate a fresh ephemeral X25519 keypair just for this one
message. Discard the private the moment you're done.
2. Look up the recipient's most recent **heartbeat** public key from
nostr (more on this below). Fall back to their identity X25519
pub if they've never published one.
3. Perform X25519 ECDH using the ephemeral private + recipient's
heartbeat public. This produces a shared secret known only to
you and the recipient.
4. Run the shared secret through HKDF-SHA256 (with the nonce as
salt and a domain-separated info string) to get a single-use
AES-256 key.
5. Encrypt your message text with AES-256-GCM, using a 12-byte
random nonce and **Additional Authenticated Data** that binds
the envelope's `{v, eph_pub, recipient_heartbeat_pub, nonce,
fallback}` fields into the auth tag. A relay tampering with any
routing field would break decryption.
6. Sign the envelope (minus signature) with your Ed25519 key. This
proves YOU sent it.
The plaintext that gets encrypted carries `{from, body, sent_at}`.
Your identity is INSIDE the ciphertext; the relay never sees it.
## The Heartbeat Ratchet — kez-chat's forward secrecy
This is the interesting part.
Traditional end-to-end systems either (a) accept that compromise of
your long-term key compromises ALL history (no forward secrecy) or
(b) implement Signal's Double Ratchet (excellent forward secrecy but
significant complexity and dependence on a central prekey server).
kez-chat takes a third path that's specific to chat over nostr:
### How it works
- Each day, on first activity, your device generates a fresh random
X25519 keypair — the "heartbeat."
- The PUBLIC half is published to nostr as a parameterized
replaceable event (kind 10142, d-tag `heartbeat-v1`), signed with
your nostr key. Anyone can fetch your latest heartbeat.
- The PRIVATE half lives only on your device, in IndexedDB.
- When someone messages you, their client fetches your latest
heartbeat and uses ITS public key as the ECDH target — not your
long-term identity key.
- You decrypt by looking up the matching private key from your
local IDB store.
- After your configurable retention window (default 16 days; 3–60
in Settings), old heartbeat privates are **deleted**.
### Why this gives you forward secrecy
The heartbeat privates are random bytes, NOT derived from your
seed. The seed only signs the heartbeat publish to prove it's
yours. So:
- A passive observer who has been logging every encrypted message
for years, then later compromises your seed, **cannot decrypt
messages older than ~16 days**. The private keys for those
messages were random and have been deleted. The seed alone is not
enough.
### Why this gives you post-compromise security
If your device is briefly compromised (one-shot attacker, no
persistence):
- They can decrypt anything in your IDB at that moment — up to 16
days of stored heartbeat privates.
- After 16 days, those privates have been swept. The captured data
is now permanently undecryptable, even from your seed.
- Meanwhile, fresh heartbeats your device generates AFTER the
compromise are random bytes the attacker never saw. They can sign
fake heartbeats AS you (impersonation), but they cannot predict
the randomness your real device produces. Future messages to your
real device are safe.
### Why this is simpler than Double Ratchet
- **No per-peer session state.** One heartbeat keyset serves all
contacts. Signal has to maintain a separate ratchet state per
conversation.
- **No prekey distribution server.** Signal users publish prekey
bundles to Signal's server; new senders fetch them. We publish
heartbeats to nostr — the same publish/subscribe infrastructure we
use for messages.
- **No X3DH initial handshake.** First contact uses a documented
fallback (no FS on that one message) and the very next reply
bootstraps full v3.
- **No out-of-order message bookkeeping.** Each message is
independently sealable to whichever heartbeat the sender saw at
send time.
### The honest trade — granularity
Signal's forward secrecy is per-message. The moment a message is
decrypted, that key is gone. Our forward secrecy is per-window
(default 16 days). Anything in IDB at the moment of compromise is
readable.
For chat-style threat models — "someone steals my phone, sees what's
there" — both approaches give the attacker WHAT'S ON THE DEVICE NOW.
Signal's edge is the brief gap between decrypting a message and the
next one arriving. Real-world significance: small but not zero.
The relevant question: is the simpler protocol worth a coarser FS
window? We think yes, because:
1. **Implementation correctness matters.** A correctly-implemented
simple ratchet beats a subtly-broken Double Ratchet every time.
Signal-style ratchet implementations are notoriously bug-prone.
2. **It naturally fits nostr.** Signal was designed for a central
server; our design uses nostr's actual semantics (replaceable
events for keys, broadcast for messages). No fighting the
substrate.
3. **The chat threat model is "phone theft" not "subtle ratchet
misuse."** Most real attacks fall into the category our window
handles correctly.
4. **You can tune the window.** Slide it to 3 days for tighter FS,
or 60 days if you travel offline a lot. Signal's per-message
granularity isn't tunable.
## Profile picture privacy (visual encryption)
Your nostr `kind:0` profile event is fundamentally public — every
nostr client can read it. Putting your face directly in your
profile means anyone scraping nostr sees you.
kez-chat's solution: by default, the profile picture you publish
is **visually scrambled**.
### How it works
- You pick a picture in Settings → Profile.
- Your device generates a random 256-bit symmetric key K and a
per-image nonce.
- The image's pixels are deterministically shuffled by a PRNG
seeded from K — Fisher-Yates shuffle. The output is a valid PNG
with the same dimensions and color palette, but the content is
garbled.
- The scrambled PNG goes in your kind:0 event under `picture`.
- For each of your **contacts** (people you've ever DM'd), your
client wraps K under a SealedEnvelope (the same v3 envelope crypto
used for DMs) addressed to that contact's KEZ primary. The wrapped
keys go in a `kez_visual_keys` field of your kind:0.
- A "self-wrap" is also included so any other device of yours can
rebuild K from your own seed.
### What strangers see
A scrambled PNG. Same colors, same dimensions, no recognizable
content. They cannot decrypt — the per-recipient wraps are
encrypted to specific contacts.
### What contacts see
Their client fetches your kind:0, finds the wrap addressed to them,
opens it via your existing DM crypto, recovers K, and uses K to
unscramble the picture. They see your real face.
### What this gives you
Your face is private by default and visible only to people you've
actually talked to. No additional infrastructure, no separate image
host, no permission requests. Same nostr publish/subscribe path.
You can turn it off in Settings if you want a public profile
picture.
## Comparison with Signal Protocol
| Property | Signal | kez-chat |
|---|---|---|
| End-to-end encryption | ✅ | ✅ |
| Authentication / sender forgery resistance | ✅ | ✅ |
| Replay protection | ✅ | ✅ |
| Sender-side per-message forward secrecy | ✅ | ✅ |
| Receiver-side forward secrecy | per-message | per-window (default 16d) |
| Post-compromise security after seed leak | per-DH-step (~per-reply) | per-window |
| Multi-device | first-class | single-device in v0.1 |
| Group chat | ✅ | ❌ (future) |
| Prekey distribution | central Signal server | nostr (decentralized) |
| Identity / contact discovery | Signal's server | KEZ + nostr (decentralized) |
| Per-peer session state | yes (real bookkeeping) | none |
| Code complexity | high | moderate |
| Post-quantum encryption | PQXDH (X25519 + ML-KEM) | v4 hybrid (X25519 + ML-KEM-768) |
Where Signal wins: per-message FS granularity, multi-device today,
groups today, longer track record of stress-testing.
Where kez-chat wins: no central server, no Signal-account
registration, no Signal-prekey-store dependence, identities
portable across the KEZ graph, the entire system fits in a PWA you
can run from any browser.
## Trust assumptions
To use kez-chat, you're trusting:
- **Your browser's WebCrypto implementation.** Same as Signal Web,
same as iMessage Web, same as any browser-delivered crypto.
- **Standard cryptographic primitives.** Ed25519 (signatures), X25519
(key exchange), AES-256-GCM (encryption), SHA-256 (hashing), HKDF
(key derivation). All well-studied and widely deployed.
- **`@noble/curves` and `@noble/hashes`** — pure JavaScript crypto
libraries with formal audits, used by many production wallets and
chat tools.
- **The `nostr-tools` library** — for relay communication. Doesn't
touch your private keys (we sign locally before handing off).
- **At least ONE of the configured nostr relays staying honest
enough to forward your events.** Of the default 5
(`relay.damus.io`, `nos.lol`, `relay.primal.net`,
`relay.snort.social`, `nostr.wine`), only one needs to work for
delivery to succeed.
You are NOT trusting:
- The kez-chat home server (kez.lat) for message delivery. Chat
flows directly through nostr; the home server is only used for
handle directory lookups and Web Push fanout.
- Any single nostr relay. All 5 would have to refuse independently.
- Any push provider. Push payloads are empty.
## Glossary
**AAD (Additional Authenticated Data)** — extra metadata that an
AEAD cipher includes in the authentication tag. We bind envelope
routing fields so a relay can't tamper without breaking the auth
check.
**AEAD (Authenticated Encryption with Associated Data)** —
encryption + integrity + tampering detection in one primitive. We
use AES-256-GCM.
**ECDH (Elliptic Curve Diffie-Hellman)** — two parties compute the
same shared secret from their respective private keys + each other's
public keys. The foundation of all our message encryption.
**Forward Secrecy** — past messages stay safe even if your key
leaks later.
**Heartbeat** — a fresh random X25519 keypair your client publishes
daily to nostr. Senders use the public half for ECDH instead of your
long-term identity.
**HKDF (HMAC-based Key Derivation Function)** — turns a high-entropy
input (like an ECDH output) into one or more cryptographic keys
suitable for use in AEAD.
**KEZ identity** — your long-term Ed25519 keypair, the canonical
"this is who I am" in the kez-chat / KEZ system.
**Nostr** — a simple publish/subscribe protocol over WebSockets we
use as the message-delivery substrate. Decentralized; you publish
events to relays, others subscribe.
**Post-Compromise Security (PCS)** — even if your key was
compromised in the past, future messages eventually become safe
again as keys evolve.
**v3 envelope** — kez-chat's current message format. Carries a
sender ephemeral pub, a recipient heartbeat pub, a nonce, the
ciphertext, and the sender's signature. See `crypto.ts`.
---
Last updated: 2026-06-12. Source: `kez-chat/web/src/lib/crypto.ts`,
`heartbeat-store.ts`, `nostr-transport.ts`, `visual-crypto.ts`,
`profile-store.ts`.