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>
407 lines
17 KiB
Markdown
407 lines
17 KiB
Markdown
# 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`.
|