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

17 KiB
Raw Blame History

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:

{
  "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.