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>
378 lines
16 KiB
Markdown
378 lines
16 KiB
Markdown
# 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
|