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

378 lines
16 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 + protocol TODO
Consolidated from a nostr-protocol expert review and an independent security
audit (both run 2026-06-08). Ordered by impact-per-hour-of-work, not by
review severity — a CRIT that's a half-week of design discussion isn't a
ship-stopper when there are real CRITs that take 30 minutes.
Update the status column as we land things. Cross-links to the original
review reports are at the bottom.
## Status legend
- `TODO` — not started
- `WIP` — actively being worked
- `DONE` — landed in main
- `ROADMAP` — committed but multi-day; will need its own design doc
- `WONTFIX` — accepted trade-off, documented
---
## Day 1 — ~half a day of work, biggest wins
### #1. Strip envelope metadata (ephemeral x25519 per message) [DONE]
**Why it matters:** the `SealedEnvelope.from` (KEZ identity) and `to` (handle)
fields sit in cleartext alongside the ciphertext in `event.content`. Any
nostr relay can JSON-parse the content and build a perfect social graph:
who-messages-whom, when, how often. The actual message body stays encrypted;
all the metadata is wide open.
**Files:**
- `kez-chat/web/src/lib/crypto.ts:42-54` (the `SealedEnvelope` shape)
- `kez-chat/web/src/lib/crypto.ts:115-153` (`sealMessage` / `openMessage`)
**Fix:** replace `envelope.from` (the KEZ identity) with `envelope.eph_pub`
(a 32-byte x25519 public key sender generated for this message only).
Recipient does ECDH against `eph_pub` instead of deriving it from the KEZ
identity. The decrypted plaintext already carries `from` for identity
verification.
Bonus: a fresh ephemeral key per message gives partial forward secrecy —
compromise of the recipient's long-term seed still decrypts retained
ciphertexts, but a captured single-message ECDH key reveals only that one
message.
**Migration:** envelope `v: 1 → v: 2`. Recipient accepts both during a
1-week window, then drops v1 support.
### #3. Empty Web Push payload [DONE]
**Why it matters:** we send `{type, to: <handle>, seq}` to FCM/APNs/Mozilla
on every fanout. RFC 8291 encrypts the payload so the push provider can't
read the bytes, but the provider already knows the endpoint's owner — the
`to` field adds no information for the recipient and *does* give Google a
clear "message arrived for alice at T" timeline.
**Files:**
- `kez-chat/src/messages.rs:123-127` (the payload we hand to `push.fanout`)
- `kez-chat/web/src/sw.ts:78-99` (the `push` handler)
**Fix:** send `{}` as the payload. The service worker shows a generic
"New kez-chat message" notification — it can still focus an existing tab,
which navigates to the conversation list. Deep-linking to a specific peer
goes away on cold-open (acceptable trade-off — one extra tap to open the
right thread is the price of *not* exporting metadata to FCM).
### #5. Rename the routing tag from `h` to something less claimed [DONE]
**Why it matters:** `h` is informally used by NIP-29 (Simple Groups) as the
group id. Today's three relays don't enforce NIP-29 semantics, but the
moment a NIP-29-aware relay enters our pool it will try to route our `#h`
filter as a group join, and we'll get cryptic failures.
**Files:**
- `kez-chat/web/src/lib/nostr-id.ts:28` (`ADDR_TAG = "h"`)
- `kez-chat/web/src/lib/nostr-transport.ts:201, 269` (publish + subscribe)
- `kez-chat/src/nostr_listener.rs:113` (server-side mirror)
**Fix:** switch to `q` (less-claimed single letter, still indexable per
NIP-01). Bump envelope/event `v` so the listener can tell old-tag events
from new ones during the migration window. Server-side listener subscribes
to BOTH `#h` and `#q` for one week.
### #17. Demote handle-revealing logs to `debug!` [DONE]
**Why it matters:** every fanout currently logs `push: fanout triggered
handle=<plain handle> sub_count=N` at INFO level. Operator-side log
retention turns this into a permanent "who's chatting" ledger. Even if
we trust the operator (it's us), forensics on a stolen log file leaks the
social graph in plaintext.
**Files:**
- `kez-chat/src/push.rs:259-262, 275-281` (fanout + send logging)
- `kez-chat/src/api.rs:387-393` (subscribe registration)
**Fix:** demote the handle-bearing INFO lines to DEBUG. Replace the visible
field with a short HMAC of the handle under a server-instance secret so we
can still group "all sends for X" in logs without exposing X. Set log level
in production to INFO, so DEBUG lines are off by default.
---
## Day 2 — another half-day
### #2. Replay protection — bound + timestamp freshness [DONE]
**Why it matters:** `SEEN_CAP=500` evicts oldest event ids once we've seen
500 messages. An active user rolls past that in days, then a malicious
relay can re-broadcast any old event and we accept it as a fresh message —
the decrypted `sent_at` is never compared to wall-clock.
**Files:**
- `kez-chat/web/src/lib/nostr-transport.ts:107` (`SEEN_CAP = 500`)
- `kez-chat/web/src/lib/nostr-transport.ts:142` (`slice(-SEEN_CAP)`)
- `kez-chat/web/src/lib/crypto.ts:161-205` (`openMessage` — no freshness check)
**Fix:**
1. Bump `SEEN_CAP` to 10_000 and move from localStorage to IndexedDB so the
set isn't capped by the 5MB localStorage quota.
2. In `openMessage`, reject envelopes where `|now − sent_at| > 7 days`.
3. Also clamp `ev.created_at` to `[now − 7d, now + 5min]` before using it
as a seq generator — otherwise a relay can backdate or future-date
events and either replay or skip-ahead `bumpSince`.
### #4. Reveal-recovery-phrase requires fresh auth [DONE]
**Why it matters:** 30 seconds of access to an unlocked phone = full
identity exfil. The Settings → Reveal Phrase button decrypts straight from
the persistent-session blob with no re-prompt.
**Files:**
- `kez-chat/web/src/routes/Settings.svelte` (the Reveal flow)
**Fix:** gate Reveal Phrase + Lock + biometric setup behind a fresh
passphrase prompt OR a WebAuthn assertion. Same model Apple/1Password use:
"this action requires your password again".
### #15. Rate-limit `POST /v1/messages` [DONE]
**Why it matters:** the endpoint currently accepts anonymous posts (no
auth on send) capped at 256KB per envelope. A bot can fill any mailbox
until disk fills. Acknowledged in `messages.rs:18-20` ("Spam: v0.1 doesn't
gate POST").
**Files:**
- `kez-chat/src/messages.rs:70-133`
**Fix (v0.1):** per-IP token bucket — 60 messages/min per source IP. Drop
overflow with 429.
**Fix (v0.2):** require the sender to sign with their KEZ primary; chat-
server verifies. Becomes useless for cross-server v0.2 unless the sender's
server vouches.
---
## Roadmap — multi-day, needs design pass
### #6. Forward secrecy (Heartbeat Ratchet) [DONE — alternative design to Double Ratchet]
Implemented a custom forward-secrecy protocol that fits nostr's
shape better than Signal's Double Ratchet would. Full reasoning
in `heartbeat-store.ts` and `crypto.ts` (search for "v3").
Summary:
- Each user publishes a fresh random x25519 pub once per active
day as a replaceable event (kind 10142, NIP-01 10000–19999
range; d-tag "heartbeat-v1" informational). Content is signed
by the KEZ identity; senders verify it against the recipient's
primary before use.
- Private half kept on the publishing device only; deleted after
the user-configurable retention window (default 16 days,
settable 3–60 in Settings).
- Senders fetch the recipient's latest heartbeat and ECDH against
it instead of the recipient's long-term identity key (v3
envelope).
- First-ever message to a brand-new contact (no heartbeat
fetchable yet) falls back to identity ECDH with `fallback: true`
on the envelope. Single message, no FS. Subsequent messages get
full v3.
- Receiver-side forward secrecy at retention-window granularity:
after the window elapses, even seed compromise can't decrypt
historical messages.
- Sender-side FS preserved from v2 (per-message ephemeral).
- Post-compromise security: one-shot seed compromise (no
persistence) becomes invisible after retention window, because
fresh heartbeats are random and live only on the legitimate
device.
- Migration: dual-decrypt. v1/v2/v3 all accepted; only v3 emitted.
- Single-device scope for v0.1. Multi-device with shared heartbeat
state is future work.
- v4 (post-quantum hybrid): each heartbeat also carries an
ML-KEM-768 key (signed with the rest of the heartbeat). Senders
encapsulate to it and derive the AES key from
HKDF(x25519_ss ‖ mlkem_ss) — PQXDH-style, so harvested ciphertexts
need BOTH X25519 and ML-KEM broken. Emitted whenever the peer's
heartbeat has an ML-KEM key; v3 otherwise. Identity-key fallback
(first message to a new contact) stays classical-only. Signatures
(Ed25519, nostr secp256k1) are still classical — ML-DSA is future
work; forgery needs a live quantum attacker, unlike decryption.
**Why it matters:** today's static-static x25519 means whoever compromises
a seed once decrypts ALL retained history that any relay still has — and
relays retain indefinitely. The ephemeral-x25519 fix in #1 is partial
forward secrecy (per-message) but not post-compromise security.
**What's needed:** Signal-style X3DH + Double Ratchet. Significant
refactor of crypto.ts; needs careful API design so existing conversations
migrate cleanly. Owner: TBD. ETA: separate sprint.
### #7. WebAuthn-gated session rehydrate [ROADMAP]
**Why it matters:** the persistent-session blob's non-extractable AES key
blocks `exportKey` but NOT `decrypt`-then-read-plaintext. Any malicious
extension with `<all_urls>`, any XSS, any compromised npm dep can call
`restoreSession()` and lift the seed. My comment in
`persistent-session.ts:18-23` overstates the actual protection.
**Fix:** gate `restoreSession()` on a user-gesture WebAuthn assertion
(touchID / passkey). Background scripts can't fake a user gesture, so the
seed never gets decrypted unattended. Falls back to passphrase on devices
without WebAuthn.
### #8. Rotate addr daily (`info = "v1|YYYYMMDD"`) [ROADMAP]
**Why it matters:** a relay scrapes `#h` filter values + the public KEZ
directory + builds a rainbow table mapping `addr → primary → handle`. The
hash buys little when the input space is enumerable. Per-day addr
rotation forces the rainbow table to be rebuilt daily and stops long-term
correlation.
**Trade-off:** receivers need to subscribe to multiple addrs during the
boundary day (yesterday's + today's). Listener server-side needs the same.
Migration logic isn't hard but isn't free.
### #9. Unforgeable delivery acks [DONE — Day 3 Option A]
**Why it matters:** anyone who saw an event id can publish a fake kind-4244
ack. Sender's UI shows false "delivered". Cosmetic-only today; will be a
real problem when someone builds a tracker bot.
**Fix:** ack payload = recipient's ed25519 signature over the acked event
id. Sender verifies against the recipient's known KEZ primary. Free —
already have ed25519 plumbing.
### #10. NIP-65 outbox model [PARTIAL — Day 3 Option B]
Publish-side only. We now emit a `kind:10002` event on first session
alongside the kind:0 baseline, listing our 3 default relays as
read+write. NIP-65-aware clients can discover where to reach us.
What's still missing: when SENDING to a peer, we should fetch their
`kind:10002` and union their read-relays with ours. v0.2 — needs a
deeper transport refactor (per-message relay set).
We hardcode 3 relays for every user. Real nostr clients publish
`kind:10002` listing their preferred read+write relays; senders publish to
each recipient's published read-relays. Without this, isolated networks
of users on different relay sets can't reach each other.
### #11. NIP-42 AUTH support [DONE — Day 3 Option B]
damus.io regularly requires NIP-42 AUTH for DM-kind reads. Without it our
subscriptions get rejected silently. Add the client AUTH handshake +
support being prompted by the relay.
### #12. Publish a minimal kind-0 profile on first use [DONE — Day 3 Option B]
Some relays silently drop writes from "unknown" pubkeys (no kind-0). A
single minimal `kind:0` per derived nostr pubkey (just `{"name":"kez-chat
user"}`) unblocks this without revealing anything.
### #13. NIP-25 ack shape with `["p", senderNostrPubkey]` [DONE — Day 3 Option B]
Our kind-4244 ack is custom. Adopting the NIP-25 shape gets free interop
with nostr clients that already render reactions — handy if we ever expose
the underlying events.
### #14. Shorten `since=` default cursor [DONE — Day 3 Option A]
Default 7-day cursor exceeds most relay retention windows (often 1–3
days). Fresh devices on quiet conversations silently miss messages.
Shorten to 48h + augment with explicit "fetch full history" UI for the
rare resurrect case.
### #16. Bounded concurrency on push fanout [DONE — Day 3 Option A]
**Why it matters:** every send spawns an unbounded `tokio::spawn` to fan
out push. Under flood, OOM.
**Files:**
- `kez-chat/src/messages.rs:128`
**Fix:** semaphore-bound to ~32 concurrent fanouts. Excess queues; under
extreme flood we drop with a warn-log rather than swap-thrash.
---
## Visually-encrypted profile pictures (new feature, in progress)
### Phase 1A — local scramble + per-contact key wrap [DONE this commit]
- `kez-chat/web/src/lib/visual-crypto.ts` — keyed Fisher-Yates pixel
permutation + xoshiro256** PRNG. Output is a valid PNG with same
dimensions, scrambled content. Salt embedded as `#kez-visual-v1:<hex>`
URL fragment so descramble doesn't need out-of-band metadata.
- `profile-store.ts` — profile gains `encrypted: boolean` (default
true) + `picture_key` (local-only). On publish: scramble the picture,
wrap the visual key for each contact via the existing
`sealMessage()` envelope, embed as `kez_visual_keys` map in the
kind:0 content.
- Settings — "Visually encrypt picture (recommended)" toggle, default ON.
### Phase 1B — peer descramble [DONE this commit]
- `peer-profile-store.ts` (new): IDB cache + one-shot `pool.querySync`
fetch of the peer's kind:0 metadata event. On hit, looks up our
primary in `kez_visual_keys`, opens the SealedEnvelope wrap to
recover the visual key, descrambles `metadata.picture`, caches the
rendered data URL.
- `peer-profile-cell.svelte.ts` (new): reactive Svelte 5 mirror over
the IDB cache so component re-renders are automatic on fetch.
- `nostr-transport.ts`: surfaces `sender_nostr_pubkey` on every
inbound DM. `conversations-store.ts` persists it on the conversation
row so we can locate the peer's kind:0 later.
- `inbox-service.svelte.ts`: on every fresh DM, fires off a profile
fetch for the sender — first DM lights up their avatar.
- `Messages.svelte`: hydrates the cache on mount, kicks off refreshes
for every visible conversation, threads cached pictures through
both Avatar usages (conversation list + thread header).
- Conversation list re-renders on cache update; staleness window 24h.
Edges noted for later: peers we've only *sent* to (never received from)
have no `peer_nostr_pubkey` until they reply, so they don't get a
picture lookup yet. Easy follow-up: backfill pubkey from a NIP-05 or
WebFinger lookup, or proactively probe relays for `kind:0` events whose
content tags match a known primary.
### Phase 1C — UX polish [TODO]
- "X contacts can see your real picture" hint in Settings.
- Re-publish kind:0 automatically when a new conversation is created
(so the new contact gets key-wrapped without the user re-saving).
- Optional: per-image AES-CTR mode for uniform-noise output (stronger,
less "visually meaningful").
---
## Acknowledged trade-offs (won't fix in v0.1)
### Persistent-session is no stronger than the biometric path
The `non-extractable AES key` story stops `exportKey`, NOT
`decrypt`+`read`. Anyone with origin-execution access (XSS, malicious
extension) can lift the seed. Document this honestly in the README and the
file header. Real fix is #7 above.
### 30-day TTL is client-only
`expiresAt` in localStorage is editable by anyone with file-system access.
Server-side device binding (issue a signed nonce on unlock, expire at the
server) would help but adds round-trips. v0.2 candidate.
### Identity-key reuse is safe under current crypto
ed25519 seed → ed25519 (sigchain, envelope sig) + x25519 (ECDH) + HKDF →
secp256k1 (nostr signer). The auditor confirmed: no cross-curve
chosen-message attack path. Standard libsodium pattern.
---
## Tracking + cross-references
- Nostr-protocol review: see commit message of this commit; full report in
the audit-trail.
- Security audit: ditto.
- Owner: tudisco
- Last updated: 2026-06-08