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>
This commit is contained in:
Jason Tudisco 2026-09-27 09:32:08 -06:00
parent 89cb9f11e0
commit 79e7e9536b
25 changed files with 3147 additions and 358 deletions

3
.gitignore vendored
View File

@ -57,3 +57,6 @@ kez-chat/deploy/prebuilt/
# the path below is just a historical fence-post in case anyone has the # the path below is just a historical fence-post in case anyone has the
# old in-repo cache lying around from before that move. # old in-repo cache lying around from before that move.
kez-chat/deploy/.buildx-cache/ kez-chat/deploy/.buildx-cache/
# Syncthing per-folder ignore config — machine-local, not project source
.stignore

View File

@ -31,10 +31,11 @@ rand = "0.8"
nostr-sdk = { version = "0.36", default-features = false, features = ["all-nips"] } nostr-sdk = { version = "0.36", default-features = false, features = ["all-nips"] }
hkdf = "0.12" hkdf = "0.12"
sha2 = "0.10" sha2 = "0.10"
# Proxies /v1/sigchains/* to the internal sig-server (api.rs).
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
tower-http = { version = "0.6", features = ["trace", "cors", "fs"] } tower-http = { version = "0.6", features = ["trace", "cors", "fs"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies] [dev-dependencies]
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
tempfile = "3" tempfile = "3"

View File

@ -1,168 +1,355 @@
# kez-chat-server # kez-chat
Home server for the kez-chat application. One Rust binary that hosts: End-to-end encrypted chat where **your account is a key, not a phone
number or an email address.** Messages travel over public
[nostr](https://nostr.com) relays; nobody in the middle can read them,
and the server that hosts the app never sees them at all.
- Handle registry (`POST /v1/register`, `GET /v1/u/:handle`) Live at **<https://kez.lat>** — installable as a PWA on iOS, Android, and
- WebFinger discovery (`GET /.well-known/webfinger`) desktop.
- NATS auth callout endpoint (`POST /internal/nats/auth`) — stub in v0.1
- Static SPA serving (`GET /`) — placeholder until the Svelte build lands
- Healthz (`GET /v1/healthz`)
Designed in [`document.md`](document.md). Spec for the underlying KEZ kez-chat is the flagship app for [KEZ](../README.md), a portable,
identity layer in [`../SPEC.md`](../SPEC.md). decentralized identity graph. Your chat identity is a KEZ identity, so
you can *prove* who you are by linking accounts you already own (GitHub,
Bluesky, DNS, your website, nostr) — and your contacts
can verify those links themselves, without trusting anyone.
## What's in v0.1 (this is the scaffold) ---
✅ HTTP API end-to-end ## What you get
✅ Ed25519-signed handle registration with replay protection
✅ SQLite-backed registry with uniqueness on both handle and primary key
✅ WebFinger endpoint
✅ Placeholder SPA at `/`
✅ docker-compose for full stack (chat + nats + sig-server)
✅ Multi-stage Dockerfiles
✅ 13 integration tests against a live router
⚠️ NATS auth callout returns 501 — wired up in v0.2 - **An account in seconds, no personal data.** Pick a handle
⚠️ Svelte SPA build pipeline not yet in place — placeholder HTML for now (`you@kez.lat`); the app generates a key on your device. Your backup is
⚠️ TLS terminated upstream (no cert handling in this binary) a 12-word recovery phrase.
- **End-to-end encrypted messages and files.** Text, emoji, photos, and
arbitrary files up to 10 MB. iPhone HEIC photos are converted to JPEG
and large photos are downscaled (which also strips GPS/EXIF data).
- **Forward secrecy.** Decryption keys rotate daily and are deleted after
a window you choose (3–60 days, default 16). Once gone, those messages
can't be decrypted by anyone — including you, or someone who later
steals your device.
- **Post-quantum protection.** Messages use a hybrid of X25519 and
ML-KEM-768 (the NIST post-quantum standard), so ciphertext harvested
from public relays today stays safe against a future quantum computer.
- **Delivery receipts.** Sending → sent (a relay accepted it) →
delivered (the recipient's device decrypted it and sent back a
receipt signed with their KEZ key).
- **Verified identity.** Link your GitHub, Bluesky, domain, website,
or nostr account with a signed claim (fediverse coming). Contacts see a
verified badge they checked themselves.
- **Private profile pictures.** Your avatar is published *visually
encrypted*: strangers see colored noise, contacts see your face.
- **Push notifications** that tell the push provider nothing — the
payload is empty.
- **Unlock with Face ID / Touch ID / Windows Hello / a security key**
(WebAuthn passkeys), or a passphrase.
- **Your relays, your choice.** Reorder, add, or remove nostr relays in
Settings.
## Quick start (local development) ---
## How it works
```
┌──────────── your device (browser / PWA) ────────────┐
│ seed (12-word phrase) ─► Ed25519 identity │
│ encrypt + sign locally ─► sealed envelope │
└───────────────┬──────────────────────────────────────┘
│ publish / subscribe (encrypted only)
▼
public nostr relays (damus, nos.lol, primal, …)
▲
│ subscribe by routing tag
┌───────────────┴──────────── their device ────────────┐
│ verify signature ─► decrypt ─► ack back │
└──────────────────────────────────────────────────────┘
kez.lat chat-server (this crate): handle directory, serves the app,
Web Push fan-out, proxy to the sigchain store. Never sees plaintext
and never carries nostr-transport messages.
```
### Identity
Your account is a 32-byte **seed**, derived from your 12-word recovery
phrase (BIP-39 wordlist, KEZ derivation — compatible with the Rust,
Node, and Python KEZ CLIs, which also accept 24-word phrases). From the
seed come:
| Key | Used for |
|---|---|
| **Ed25519** | Your KEZ identity (`ed25519:<hex>`). Signs every message, heartbeat, ack, and claim. |
| **X25519** | Identity-level key exchange (first contact only — see below). |
| **secp256k1** (HKDF-derived) | Signs the nostr events that carry your messages. A transport credential only — never your "real" nostr account. |
On the device, the seed is stored in IndexedDB encrypted under your
passphrase (PBKDF2 → AES-GCM), and optionally under a passkey via the
WebAuthn PRF extension. A 30-day session, wrapped under a non-extractable
WebCrypto key, keeps you signed in while the PWA is backgrounded, so push
notifications stay useful. **Lock** ends it; **Forget this account**
wipes the device.
Your **handle** (`you@kez.lat`) is registered with the chat-server by a
request signed with your identity key. It's a public directory: handle
→ public key.
### Messages
A message is sealed on your device into an **envelope**, then published
as a nostr event (kind `4242`) tagged with a routing tag derived from the
recipient's public key. The recipient's app subscribes to its own tag on
the same relays. Relays can't read the message, and the sender's KEZ
identity only appears *inside* the ciphertext. They do see the event's
signing key, though — your app-specific nostr key, which is stable and
whose public profile carries your handle — plus the recipient's routing
tag and the time. See the metadata note under Security.
Envelope versions (all still decryptable; the app sends the strongest the
recipient supports):
| v | Key agreement | Protects against |
|---|---|---|
| **4** | X25519 **+ ML-KEM-768** to the recipient's current heartbeat, per-message ephemeral | relays, stolen keys (forward secrecy), future quantum computers |
| 3 | X25519 to the recipient's current heartbeat | relays, stolen keys (forward secrecy) |
| 3 (`fallback`) | X25519 to the recipient's identity key | relays — used only for the very first message to someone who has never opened the app |
| 1–2 | legacy | receive-only |
In every version the body is AES-256-GCM, the routing fields are bound
as authenticated data, and the whole envelope is signed with the
sender's Ed25519 key.
### Heartbeats — forward secrecy and post-quantum keys
Instead of Signal's per-message Double Ratchet (which needs session state
and a prekey server), kez-chat uses a **heartbeat ratchet** that fits
nostr's publish-and-forget model:
1. Once a day while you're active, your device generates a fresh random
X25519 key **and** an ML-KEM-768 key, and publishes the public halves
to nostr (kind `10142`) — **signed by your KEZ identity**, so a
sender can check the heartbeat really belongs to you.
2. Senders encrypt to your latest heartbeat rather than your long-term
identity key.
3. Your device keeps each retired heartbeat's private keys for the
retention window (counted from when it was *replaced*), then deletes
them. After that, those messages are undecryptable by anyone.
Because heartbeat keys are random — not derived from your seed — someone
who later steals your seed or recovery phrase still can't decrypt old
messages. Senders cache your last verified heartbeat, so a relay that
hides it can't quietly downgrade them to weaker encryption.
### Files
Files up to 80 KB go inline in one envelope. Larger files (up to 10 MB)
are split into encrypted chunks plus a pointer message; the recipient
reassembles them locally. Attachments live in IndexedDB on each device;
**Clear chat** removes them too.
### Delivery receipts and duplicates
When a message decrypts, the recipient's app publishes an ack
(kind `4244`) carrying their KEZ signature over the message id, so a
third party can't fake "delivered". Receipts are
retried if you were offline. Replayed or re-broadcast envelopes are
recognized by their signature and ignored.
### Verified claims (KEZ)
On **Identity → Add claim** you sign a statement like "`github:you` is
me" with your KEZ key and publish the proof where only that account's
owner could put it: a gist, a Bluesky post, a DNS TXT record,
`<your-site>/.well-known/kez.json`, or a nostr note (a NIP-07 extension
like Alby can sign and publish it for you). Anyone can re-verify it.
ActivityPub verification is planned — most instances block the browser
from fetching WebFinger.
Each claim is also appended to your **sigchain** — a signed, append-only
log of identity events — stored by the KEZ sig-server behind
`https://kez.lat/v1/sigchains/...`. If a sync fails, retry it from the
Claims page. The sigchain is a convenience mirror; the published proofs
are the source of truth.
### Profiles
Your display name and avatar go out as a standard nostr profile
(kind `0`). With visual encryption on (the default), the picture is
pixel-scrambled under a key that's individually wrapped for each
contact, so it stays private even though the profile event is public.
A NIP-65 relay list (kind `10002`) tells other clients which relays to
reach you on; it's republished when you change your relays.
### Notifications
The chat-server watches the relays for events addressed to registered
handles and sends a Web Push (VAPID) with an **empty payload**. Your
device wakes up, fetches from the relays, and decrypts locally. The push
provider (Apple, Google, Mozilla) learns only that kez-chat woke a
device.
---
## Security
The full, plain-language threat model — what's protected and, honestly,
what isn't — is in **[SECURITY.md](SECURITY.md)**. The short version of
the limits:
- **Metadata:** relays can see which app key sent an event to which
routing tag, and when — enough to map who talks to whom. There's no
onion routing or padding.
- **The first message** to someone who has never opened kez-chat has no
forward secrecy and no post-quantum protection.
- **Signatures are still classical** (Ed25519, secp256k1). A future
quantum attacker could impersonate someone in real time, but not
decrypt past messages.
- **One device per account** for now — signing in on a second device
starts a fresh set of heartbeats.
- A device compromised *during* the retention window exposes what's
stored on it.
---
## Repository layout
```
kez-chat/
├── src/ chat-server (Rust, axum)
│ ├── api.rs routes, handle directory, WebFinger, sigchain proxy
│ ├── messages.rs server-transport mailbox (POST /v1/messages, inbox + SSE)
│ ├── nostr_listener.rs watches relays → Web Push fan-out
│ ├── push.rs Web Push / VAPID
│ ├── rate_limit.rs per-IP token bucket
│ └── store.rs SQLite handle registry
├── web/ the app (Svelte 5 + Vite + Tailwind, PWA)
│ └── src/lib/
│ ├── crypto.ts envelopes v1–v4, heartbeat signing
│ ├── nostr-transport.ts relays, heartbeats, receipts, files
│ ├── heartbeat-store.ts rotation + retention of heartbeat keys
│ ├── inbox-service.svelte.ts receive pipeline
│ ├── identity-store.ts / webauthn.ts / persistent-session.ts
│ ├── visual-crypto.ts scrambled profile pictures
│ ├── verifiers/ github, bluesky, dns, web, nostr (ap: planned)
│ └── heartbeat.selfcheck.ts runnable crypto self-check
├── deploy/ Dockerfiles + docker-compose
├── SECURITY.md threat model
├── TODO.md roadmap + review notes
├── document.md original design doc (partly historical)
└── web/NOSTR-CHAT.md nostr transport deep-dive
```
---
## Running it locally
**Chat-server** (Rust, from `kez-chat/`):
```sh ```sh
# Run from source
cargo run -- --bind 127.0.0.1:6969 --db ./kez-chat.db --server kez.lat cargo run -- --bind 127.0.0.1:6969 --db ./kez-chat.db --server kez.lat
# Or install once
cargo install --path .
kez-chat-server --bind 127.0.0.1:6969 --server kez.lat
``` ```
Configuration via flags or env vars: **Web app** (from `kez-chat/web/`) — Vite proxies `/v1`, `/internal`, and
`/.well-known` to the server on `:6969`:
| Flag | Env | Default |
|---|---|---|
| `--bind` | `KEZ_CHAT_BIND` | `0.0.0.0:6969` |
| `--db` | `KEZ_CHAT_DB` | `kez-chat.db` |
| `--server` | `KEZ_CHAT_SERVER` | `kez.lat` |
| `--sig-server-url` | `KEZ_CHAT_SIG_SERVER_URL` | `http://localhost:7878` |
| `--web-dir` | `KEZ_CHAT_WEB_DIR` | _(unset → placeholder page)_ |
Logging: `RUST_LOG=debug,hyper=info` etc.
## Quick start (Docker compose, full stack)
```sh ```sh
cd deploy npm ci
docker compose up -d --build npm run dev
``` ```
Brings up three services: For sigchain sync you also need the sig-server
([`../rust-sig-server`](../rust-sig-server/)) running on `:7878`.
| Service | Port(s) | What it does | ### Server configuration
| Flag | Env | Default | |
|---|---|---|---|
| `--bind` | `KEZ_CHAT_BIND` | `0.0.0.0:6969` | HTTP listen address |
| `--db` | `KEZ_CHAT_DB` | `kez-chat.db` | SQLite file |
| `--server` | `KEZ_CHAT_SERVER` | `kez.lat` | Handle domain; also the public host in `sigchain_url` |
| `--sig-server-url` | `KEZ_CHAT_SIG_SERVER_URL` | `http://localhost:7878` | *Internal* sig-server address the proxy forwards to — never shown to clients |
| `--web-dir` | `KEZ_CHAT_WEB_DIR` | unset | Built SPA to serve at `/` |
| `--vapid-key-path` | `KEZ_CHAT_VAPID_KEY` | `vapid-key.txt` | Web Push key (auto-generated) |
| `--vapid-subject` | `KEZ_CHAT_VAPID_SUBJECT` | `mailto:admin@kez.lat` | VAPID contact |
| `--nostr-relays` | `KEZ_CHAT_NOSTR_RELAYS` | damus, nos.lol, primal, snort, nostr.wine | Relays watched for push; empty disables |
Logging: `RUST_LOG=info` (or `debug`).
### Web configuration (`web/.env`)
| Variable | Default | |
|---|---|---| |---|---|---|
| `chat-server` | 6969 | HTTP API + SPA | | `VITE_TRANSPORT` | `server` (`.env` sets `nostr`) | `nostr` = relays (production); `server` = HTTP mailbox on the chat-server |
| `nats` | 4222 (native), 8443 (WebSocket), 8222 (monitoring) | Dumb broker, JetStream enabled | | `VITE_NOSTR_RELAYS` | 5 default relays | Build-time default relay list; users can override in Settings |
| `sig-server` | 7878 | Sigchain storage (the existing `rust-sig-server`) |
Then point a reverse proxy / Cloudflare tunnel at `localhost:6969`. ---
## Testing ## HTTP API
| Method | Path | |
|---|---|---|
| GET | `/v1/healthz` | Liveness |
| GET | `/v1/u/:handle` | Handle → primary key, `sigchain_url`, proofs |
| GET | `/v1/by-primary/:primary` | Reverse lookup |
| POST | `/v1/register` | Claim a handle (Ed25519-signed, ±5 min freshness) |
| PUT | `/v1/profile/:handle/proofs` | Publish verified-proof summary (signed) |
| GET/POST | `/v1/sigchains/:scheme/:id[/head\|/events]` | Proxied to the sig-server |
| POST | `/v1/push/subscribe/:handle`, `/unsubscribe/:handle` | Web Push subscriptions (signed) |
| GET | `/v1/push/vapid-public-key` | VAPID public key |
| POST | `/v1/messages` · GET `/v1/inbox/:handle[/stream]` | Server-transport mailbox (not used when `VITE_TRANSPORT=nostr`) |
| GET | `/.well-known/webfinger` | Fediverse-style discovery |
### Nostr events
| Kind | What |
|---|---|
| `4242` | Encrypted message envelope, routed by `q` tag |
| `4244` | Signed delivery receipt |
| `10142` | Heartbeat (X25519 + ML-KEM-768 public keys, KEZ-signed) |
| `0` | Profile (optionally visually-encrypted avatar) |
| `10002` | NIP-65 relay list |
---
## Deploying
`deploy/docker-compose.yml` runs the stack:
| Service | Port | |
|---|---|---|
| `chat-server` | 6969 | API + app. Put TLS (Caddy, nginx, Cloudflare tunnel) in front |
| `sig-server` | 7878 | Sigchain store; reached publicly through the chat-server proxy |
| `nats` | 4222 / 8443 / 8222 | Provisioned for future multi-instance fan-out; not used yet |
```sh ```sh
cargo test # 13 integration tests (real server, real HTTP) cd deploy && docker compose up -d --build
``` ```
The tests stand up the router on a random local port and exercise it Images build from the **repo root** as context (the server depends on
via `reqwest`. No mocks. They cover: healthz, lookup, registration `rust/crates/kez-core`). For production, kez.lat uses a local
(success + duplicate + wrong-server + reserved-name + tampered-sig + cross-build + rsync script (`deploy/deploy-fast.local.sh`, gitignored
stale-timestamp), WebFinger, the placeholder SPA, and the NATS auth because it holds host credentials) that builds the binary and app on a
callout stub. fast machine and restarts only the runtime container.
## Endpoints in detail ## Checks
### `GET /v1/healthz` ```sh
cd web
```json npm run check # svelte-check + types
{ "status": "ok", "server": "kez.lat", "version": "0.1.0" } npx esbuild src/lib/heartbeat.selfcheck.ts --bundle --platform=node --format=esm \
| node --input-type=module # crypto self-check (heartbeats, retention, v4)
``` ```
### `GET /v1/u/:handle` `cargo test` (server integration tests in `tests/http.rs`) currently
needs updating for newer config fields before it compiles.
Returns: ---
```json ## More reading
{
"handle": "tudisco",
"fqhn": "tudisco@kez.lat",
"primary": "ed25519:2152f8d19b...",
"sigchain_url": "https://sig.kez.lat/v1/sigchains/ed25519/2152f8d19b...",
"registered_at": "2026-05-25T03:00:00Z"
}
```
Returns 404 if the handle isn't registered. - [SECURITY.md](SECURITY.md) — threat model and crypto, in plain language
- [web/NOSTR-CHAT.md](web/NOSTR-CHAT.md) — how messages ride on nostr
### `POST /v1/register` - [TODO.md](TODO.md) — roadmap and design notes (heartbeat ratchet, v4)
- [../SPEC.md](../SPEC.md) — the KEZ identity protocol
Request body — a signed registration envelope:
```json
{
"kez": "handle_registration",
"payload": {
"type": "kez.chat.handle_registration",
"version": 1,
"handle": "tudisco",
"primary": "ed25519:2152f8d19b...",
"server": "kez.lat",
"created_at": "2026-05-25T03:00:00Z"
},
"signature": {
"alg": "ed25519-sha512-jcs",
"key": "ed25519:2152f8d19b...",
"sig": "<128-char-hex>"
}
}
```
Server validates:
1. Envelope tag is `"handle_registration"`
2. Payload type is `"kez.chat.handle_registration"`, version 1
3. `signature.key` equals `payload.primary`
4. Signature verifies against the primary key (Ed25519 only for chat)
5. `payload.server` matches this server's configured domain
6. `payload.handle` passes validation (length 3-32, `a-z0-9_-`,
starts with letter/digit, not in reserved list)
7. `payload.created_at` is within 5 minutes of server time
On success: `201 Created` with the same body as `GET /v1/u/:handle`.
### `GET /.well-known/webfinger?resource=acct:user@server`
Standard fediverse-style discovery. Returns the user's KEZ identity
info as a WebFinger JRD. Used by other servers (federated lookup,
future) and by tools like fediverse browsers.
### `POST /internal/nats/auth`
NATS auth callout endpoint. **Stub in v0.1** — returns 501. The real
implementation (v0.2) will: parse the NATS auth request JWT, extract
the connecting client's nkey, look up the corresponding handle, sign
a response permitting `kez.inbox.<pubkey>.>` subjects.
## Deployment notes
- The `chat-server` Docker image is built from the **repo root** as
context (so it can copy `rust/crates/kez-core` for the path dep).
`docker-compose.yml` sets this correctly.
- The `sig-server` is the existing [`../rust-sig-server`](../rust-sig-server/)
binary, built into a separate image via `Dockerfile.sig-server`.
- NATS config (`nats.conf`) has WebSocket enabled on port 8443 so the
browser SPA can connect via `nats.ws`. The `issuer` field in
`auth_callout` is a placeholder — generate a real nkey and replace
before going to production.
- TLS is **not** handled by this binary. Put a reverse proxy (Caddy,
nginx, Cloudflare tunnel) in front for HTTPS.
## License ## License

406
kez-chat/SECURITY.md Normal file
View File

@ -0,0 +1,406 @@
# 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`.

View File

@ -20,7 +20,7 @@ review reports are at the bottom.
## Day 1 — ~half a day of work, biggest wins ## Day 1 — ~half a day of work, biggest wins
### #1. Strip envelope metadata (ephemeral x25519 per message) [TODO] ### #1. Strip envelope metadata (ephemeral x25519 per message) [DONE]
**Why it matters:** the `SealedEnvelope.from` (KEZ identity) and `to` (handle) **Why it matters:** the `SealedEnvelope.from` (KEZ identity) and `to` (handle)
fields sit in cleartext alongside the ciphertext in `event.content`. Any fields sit in cleartext alongside the ciphertext in `event.content`. Any
@ -46,7 +46,7 @@ message.
**Migration:** envelope `v: 1 → v: 2`. Recipient accepts both during a **Migration:** envelope `v: 1 → v: 2`. Recipient accepts both during a
1-week window, then drops v1 support. 1-week window, then drops v1 support.
### #3. Empty Web Push payload [TODO] ### #3. Empty Web Push payload [DONE]
**Why it matters:** we send `{type, to: <handle>, seq}` to FCM/APNs/Mozilla **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 on every fanout. RFC 8291 encrypts the payload so the push provider can't
@ -64,7 +64,7 @@ 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 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). right thread is the price of *not* exporting metadata to FCM).
### #5. Rename the routing tag from `h` to something less claimed [TODO] ### #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 **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 group id. Today's three relays don't enforce NIP-29 semantics, but the
@ -81,7 +81,7 @@ 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 from new ones during the migration window. Server-side listener subscribes
to BOTH `#h` and `#q` for one week. to BOTH `#h` and `#q` for one week.
### #17. Demote handle-revealing logs to `debug!` [TODO] ### #17. Demote handle-revealing logs to `debug!` [DONE]
**Why it matters:** every fanout currently logs `push: fanout triggered **Why it matters:** every fanout currently logs `push: fanout triggered
handle=<plain handle> sub_count=N` at INFO level. Operator-side log handle=<plain handle> sub_count=N` at INFO level. Operator-side log
@ -155,7 +155,48 @@ server vouches.
## Roadmap — multi-day, needs design pass ## Roadmap — multi-day, needs design pass
### #6. Forward secrecy (Double Ratchet) [ROADMAP] ### #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 **Why it matters:** today's static-static x25519 means whoever compromises
a seed once decrypts ALL retained history that any relay still has — and a seed once decrypts ALL retained history that any relay still has — and

View File

@ -4,6 +4,8 @@
//! GET /v1/healthz liveness //! GET /v1/healthz liveness
//! GET /v1/u/:handle handle → primary + sigchain pointer + endpoints //! GET /v1/u/:handle handle → primary + sigchain pointer + endpoints
//! POST /v1/register claim a handle (signed body) //! POST /v1/register claim a handle (signed body)
//! GET /v1/sigchains/:scheme/:id[/head] } proxied to the internal
//! POST /v1/sigchains/:scheme/:id/events } sig-server
//! GET /.well-known/webfinger?resource=... fediverse-style discovery //! GET /.well-known/webfinger?resource=... fediverse-style discovery
//! POST /internal/nats/auth NATS auth callout (stub in v0.1) //! POST /internal/nats/auth NATS auth callout (stub in v0.1)
@ -53,6 +55,9 @@ pub fn router(state: AppState) -> axum::Router {
.route("/v1/push/subscribe/:handle", post(push_subscribe)) .route("/v1/push/subscribe/:handle", post(push_subscribe))
.route("/v1/push/unsubscribe/:handle", post(push_unsubscribe)) .route("/v1/push/unsubscribe/:handle", post(push_unsubscribe))
.route("/v1/push/subscriptions/:handle", get(push_list_subscriptions)) .route("/v1/push/subscriptions/:handle", get(push_list_subscriptions))
.route("/v1/sigchains/:scheme/:id", get(sigchain_proxy))
.route("/v1/sigchains/:scheme/:id/head", get(sigchain_proxy))
.route("/v1/sigchains/:scheme/:id/events", post(sigchain_proxy))
.route("/.well-known/webfinger", get(webfinger)) .route("/.well-known/webfinger", get(webfinger))
.route("/internal/nats/auth", post(nats_auth_callout)); .route("/internal/nats/auth", post(nats_auth_callout));
@ -149,12 +154,10 @@ fn handle_response(config: &Config, record: &HandleRecord) -> HandleResponse {
handle: record.handle.clone(), handle: record.handle.clone(),
fqhn: format!("{}@{}", record.handle, config.server), fqhn: format!("{}@{}", record.handle, config.server),
primary: record.primary.to_string(), primary: record.primary.to_string(),
sigchain_url: format!( // Public address, served by `sigchain_proxy` below. The
"{}/v1/sigchains/{}/{}", // sig-server itself (`sig_server_url`) is only reachable inside
config.sig_server_url.trim_end_matches('/'), // the deploy network, so it must never be handed to clients.
scheme, sigchain_url: format!("https://{}/v1/sigchains/{}/{}", config.server, scheme, id),
id
),
registered_at: record.registered_at.to_rfc3339(), registered_at: record.registered_at.to_rfc3339(),
proofs: record proofs: record
.proofs .proofs
@ -649,3 +652,59 @@ for the design doc and progress.</p>
)) ))
} }
/// Forward a sigchain request to the internal sig-server, same path and
/// body. The sig-server sits on the private deploy network (compose
/// service `sig-server`), so this is how browsers and external
/// verifiers reach it via the one public origin. It re-validates every
/// event itself (tag, seq, prev, signature), so no checks here.
async fn sigchain_proxy(
State(state): State<AppState>,
method: axum::http::Method,
uri: axum::http::Uri,
headers: axum::http::HeaderMap,
body: axum::body::Bytes,
) -> axum::response::Response {
static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
let client = CLIENT.get_or_init(|| {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.expect("reqwest client")
});
let url = format!(
"{}{}",
state.config.sig_server_url.trim_end_matches('/'),
uri.path()
);
let mut req = client.request(method, &url).body(body);
if let Some(ct) = headers.get(header::CONTENT_TYPE) {
req = req.header(header::CONTENT_TYPE, ct);
}
match req.send().await {
Ok(resp) => {
let status = resp.status();
let ct = resp.headers().get(header::CONTENT_TYPE).cloned();
match resp.bytes().await {
Ok(bytes) => {
let mut out = (status, bytes).into_response();
if let Some(ct) = ct {
out.headers_mut().insert(header::CONTENT_TYPE, ct);
}
out
}
Err(e) => sig_server_unavailable(e),
}
}
Err(e) => sig_server_unavailable(e),
}
}
fn sig_server_unavailable(e: reqwest::Error) -> axum::response::Response {
tracing::warn!(error = %e, "sig-server proxy failed");
(
StatusCode::BAD_GATEWAY,
Json(json!({"error": {"code": "sig_server_unavailable", "message": "sigchain store unreachable"}})),
)
.into_response()
}

View File

@ -5,5 +5,8 @@
VITE_TRANSPORT=nostr VITE_TRANSPORT=nostr
# Relays to publish to / read from (comma-separated). Optional — the # Relays to publish to / read from (comma-separated). Optional — the
# transport falls back to these same defaults if unset. # transport falls back to these same defaults if unset. The set
VITE_NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net # below mirrors the source-of-truth default in nostr-transport.ts,
# profile-store.ts, peer-profile-store.ts, and the rust
# nostr_listener config. Update all four when changing the default.
VITE_NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr.wine

View File

@ -11,6 +11,7 @@
"@bokuweb/zstd-wasm": "^0.0.22", "@bokuweb/zstd-wasm": "^0.0.22",
"@noble/curves": "^1.6.0", "@noble/curves": "^1.6.0",
"@noble/hashes": "^1.5.0", "@noble/hashes": "^1.5.0",
"@noble/post-quantum": "^0.7.1",
"@scure/base": "^1.1.9", "@scure/base": "^1.1.9",
"@scure/bip39": "^2.2.0", "@scure/bip39": "^2.2.0",
"canonicalize": "^2.0.0", "canonicalize": "^2.0.0",
@ -2148,9 +2149,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -2168,9 +2166,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -2188,9 +2183,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -2208,9 +2200,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -2228,9 +2217,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -2248,9 +2234,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@ -2268,9 +2251,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2294,9 +2274,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2320,9 +2297,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2346,9 +2320,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2372,9 +2343,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2398,9 +2366,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -2586,6 +2551,62 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/@noble/post-quantum": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.7.1.tgz",
"integrity": "sha512-+P9981IiAnVh+rmcubozzVwrEy3XsN/tMhTnvsjV9VDaYpOnNCqWqKo2FLWxbu92YHfjGIlE5XnW175UK+ln+Q==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "2.4.0",
"@noble/curves": "2.4.0",
"@noble/hashes": "2.4.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/post-quantum/node_modules/@noble/ciphers": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
"integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/post-quantum/node_modules/@noble/curves": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz",
"integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.4.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/post-quantum/node_modules/@noble/hashes": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@quansync/fs": { "node_modules/@quansync/fs": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz",
@ -2811,9 +2832,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2828,9 +2846,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2845,9 +2860,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2862,9 +2874,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2879,9 +2888,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2896,9 +2902,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2913,9 +2916,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2930,9 +2930,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2947,9 +2944,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2964,9 +2958,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2981,9 +2972,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2998,9 +2986,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3015,9 +3000,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3384,9 +3366,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3404,9 +3383,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3424,9 +3400,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3444,9 +3417,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5743,9 +5713,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -5767,9 +5734,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -5791,9 +5755,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -5815,9 +5776,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [

View File

@ -13,6 +13,7 @@
"@bokuweb/zstd-wasm": "^0.0.22", "@bokuweb/zstd-wasm": "^0.0.22",
"@noble/curves": "^1.6.0", "@noble/curves": "^1.6.0",
"@noble/hashes": "^1.5.0", "@noble/hashes": "^1.5.0",
"@noble/post-quantum": "^0.7.1",
"@scure/base": "^1.1.9", "@scure/base": "^1.1.9",
"@scure/bip39": "^2.2.0", "@scure/bip39": "^2.2.0",
"canonicalize": "^2.0.0", "canonicalize": "^2.0.0",

View File

@ -67,7 +67,7 @@
src={picture} src={picture}
width={size} width={size}
height={size} height={size}
alt="profile picture" alt=""
class="shrink-0 object-cover" class="shrink-0 object-cover"
style="border-radius: {Math.max(4, size * 0.2)}px; {ring style="border-radius: {Math.max(4, size * 0.2)}px; {ring
? 'box-shadow: 0 0 0 2px var(--color-accent);' ? 'box-shadow: 0 0 0 2px var(--color-accent);'

View File

@ -19,8 +19,8 @@
fill="currentColor" fill="currentColor"
role="img" role="img"
aria-label="verified" aria-label="verified"
{title}
> >
<title>{title}</title>
<!-- Scalloped seal + check, the familiar verified glyph. --> <!-- Scalloped seal + check, the familiar verified glyph. -->
<path d="M12 1.5l2.3 1.7 2.85-.2 1 2.67 2.45 1.46-.83 2.74.83 2.74-2.45 1.46-1 2.67-2.85-.2L12 22.5l-2.3-1.7-2.85.2-1-2.67-2.45-1.46.83-2.74-.83-2.74 2.45-1.46 1-2.67 2.85.2z"/> <path d="M12 1.5l2.3 1.7 2.85-.2 1 2.67 2.45 1.46-.83 2.74.83 2.74-2.45 1.46-1 2.67-2.85-.2L12 22.5l-2.3-1.7-2.85.2-1-2.67-2.45-1.46.83-2.74-.83-2.74 2.45-1.46 1-2.67 2.85.2z"/>
<path d="M10.6 14.6l-2.2-2.2-1.4 1.4 3.6 3.6 6-6-1.4-1.4z" fill="var(--color-bg)"/> <path d="M10.6 14.6l-2.2-2.2-1.4 1.4 3.6 3.6 6-6-1.4-1.4z" fill="var(--color-bg)"/>

View File

@ -10,6 +10,7 @@ import { ed25519 } from "@noble/curves/ed25519";
import { hexToBytes } from "@noble/hashes/utils"; import { hexToBytes } from "@noble/hashes/utils";
import { get, set } from "idb-keyval"; import { get, set } from "idb-keyval";
import type { Identity } from "./kez.js"; import type { Identity } from "./kez.js";
import { deleteAttachment, deleteChunkBuffer } from "./attachment-store.js";
// :v2 — schema reshaped to key by peer_primary (canonical identity) instead // :v2 — schema reshaped to key by peer_primary (canonical identity) instead
// of an ad-hoc string. Old data under :conversations (no version) is // of an ad-hoc string. Old data under :conversations (no version) is
@ -250,6 +251,64 @@ export async function markConversationRead(
} }
} }
/** Delete the stored bytes (and any half-assembled chunk buffer)
* behind every attachment bubble in `messages`. A cleared chat must
* not leave its images sitting in IndexedDB. */
async function deleteMessageAttachments(
peer_primary: Identity,
messages: ConversationMessage[],
): Promise<void> {
await Promise.all(
messages
.filter((m) => m.attachment)
.flatMap((m) => [
deleteAttachment(peer_primary, m.seq),
...(m.attachment!.file_id ? [deleteChunkBuffer(m.attachment!.file_id)] : []),
]),
);
}
/**
* Wipe every message from one conversation — including attachment
* bytes — while keeping the peer row itself (handle, primary, nostr
* pubkey, via_relay) so future messages from them land in the same
* thread. Returns the number of messages removed (diagnostic).
*/
export async function clearConversationMessages(
peer_primary: Identity,
): Promise<number> {
const s = await read();
const conv = s.by_peer[peer_primary];
if (!conv) return 0;
const n = conv.messages.length;
const removed = conv.messages;
conv.messages = [];
conv.unread_count = 0;
// last_seq stays so the inbox-service notification watermark
// doesn't badge-storm us with already-seen messages if they get
// re-broadcast by a relay.
await write(s);
await deleteMessageAttachments(peer_primary, removed);
return n;
}
/**
* Wipe the entire conversation row (messages + peer metadata).
* Use when the user wants the contact gone, not just the messages.
* Future messages from this peer will rebuild a fresh row.
*/
export async function deleteConversation(
peer_primary: Identity,
): Promise<void> {
const s = await read();
const conv = s.by_peer[peer_primary];
if (conv) {
delete s.by_peer[peer_primary];
await write(s);
await deleteMessageAttachments(peer_primary, conv.messages);
}
}
/** /**
* Append an outbound message and return its synthetic `seq` so the * Append an outbound message and return its synthetic `seq` so the
* caller can update the status later (sending → sent → delivered). * caller can update the status later (sending → sent → delivered).

View File

@ -23,10 +23,23 @@ import { ed25519, x25519 } from "@noble/curves/ed25519";
import { sha256 } from "@noble/hashes/sha2"; import { sha256 } from "@noble/hashes/sha2";
import { hkdf } from "@noble/hashes/hkdf"; import { hkdf } from "@noble/hashes/hkdf";
import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
import { ml_kem768 } from "@noble/post-quantum/ml-kem.js";
import { canonicalBytes, type Identity } from "./kez.js"; import { canonicalBytes, type Identity } from "./kez.js";
const HKDF_INFO_V1 = new TextEncoder().encode("kez-chat-msg-v1"); const HKDF_INFO_V1 = new TextEncoder().encode("kez-chat-msg-v1");
const HKDF_INFO_V2 = new TextEncoder().encode("kez-chat-msg-v2"); const HKDF_INFO_V2 = new TextEncoder().encode("kez-chat-msg-v2");
const HKDF_INFO_V3 = new TextEncoder().encode("kez-chat-msg-v3");
const HKDF_INFO_V4 = new TextEncoder().encode("kez-chat-msg-v4");
/** Heartbeat private keys the receiver resolves for an incoming v3/v4
* envelope. `mlkem` is absent for heartbeats minted before v4. */
export interface HeartbeatPrivs {
x25519: Uint8Array;
mlkem?: Uint8Array;
}
export type ResolveHeartbeatPrivs = (
x25519PubHex: string,
) => Promise<HeartbeatPrivs | undefined>;
/** What the sender stores in the encrypted blob. */ /** What the sender stores in the encrypted blob. */
export interface MessagePlaintext { export interface MessagePlaintext {
@ -40,24 +53,49 @@ export interface MessagePlaintext {
/** What goes on the wire to POST /v1/messages. */ /** What goes on the wire to POST /v1/messages. */
/** /**
* Two envelope shapes coexist during the v1 → v2 migration window. * Three envelope shapes coexist during the v1/v2 → v3 migration.
* *
* v1: legacy — `from` (KEZ identity) and `to` (handle) in cleartext * v1: legacy — `from` (KEZ identity) and `to` (handle) in cleartext
* NEXT TO the ciphertext. Relays could JSON-parse `event.content` * NEXT TO the ciphertext. Relays could JSON-parse `event.content`
* and build a perfect social graph. Decrypt-only support; we * and build a perfect social graph. Decrypt-only support; we
* never emit new v1 envelopes. * never emit new v1 envelopes.
* *
* v2: fixed — only an EPHEMERAL x25519 public key (per-message, * v2: fixed metadata leak — only an EPHEMERAL x25519 public key
* discarded right after sealing) and the ciphertext are visible * (per-message, discarded right after sealing) and the
* outside the encrypted blob. `from` (KEZ identity) lives INSIDE * ciphertext are visible outside the encrypted blob. `from`
* the plaintext, verified post-decrypt via ed25519 sig. Forward * (KEZ identity) lives INSIDE the plaintext, verified post-
* secrecy: a compromised long-term ed25519 seed can no longer * decrypt via ed25519 sig. Per-message forward secrecy from
* decrypt past captured ciphertexts (the ephemeral private key * the SENDER side: a compromised long-term ed25519 seed cannot
* was destroyed at send time). * decrypt past captured ciphertexts because the sender
* ephemeral private was destroyed at send. RECEIVER side still
* uses their long-term x25519 identity — so receiver-seed
* compromise DOES decrypt history.
* *
* See kez-chat/TODO.md Day 1 #1. * v3: heartbeat ratchet — RECEIVER side also forward-secure.
* Sender encrypts to a daily-rotating "heartbeat" public key
* the recipient publishes to nostr, instead of to their long-
* term x25519 identity. Receiver retains the matching private
* for a configurable window (default 16 days) then deletes
* it. After deletion, no one — not even the receiver — can
* decrypt those messages. Coarser-grained FS than Double
* Ratchet but vastly simpler: no per-peer session state, no
* prekey-distribution server, no out-of-order bookkeeping.
* See heartbeat-store.ts.
*
* v4: v3 + post-quantum hybrid. The recipient's heartbeat also
* carries an ML-KEM-768 key; the sender encapsulates to it and
* the AES key comes from HKDF(x25519_ss ‖ mlkem_ss). Breaking a
* message now needs BOTH X25519 and ML-KEM broken — so
* ciphertexts harvested off public relays today stay safe
* against a future quantum computer (same construction as
* Signal PQXDH). Emitted whenever the recipient's heartbeat has
* an ML-KEM key; otherwise v3.
*/ */
export type SealedEnvelope = SealedEnvelopeV1 | SealedEnvelopeV2; export type SealedEnvelope =
| SealedEnvelopeV1
| SealedEnvelopeV2
| SealedEnvelopeV3
| SealedEnvelopeV4;
/** @deprecated v1 — leaks sender + recipient identity to relays. */ /** @deprecated v1 — leaks sender + recipient identity to relays. */
export interface SealedEnvelopeV1 { export interface SealedEnvelopeV1 {
@ -88,6 +126,51 @@ export interface SealedEnvelopeV2 {
sender_sig: string; sender_sig: string;
} }
export interface SealedEnvelopeV3 {
v: 3;
/** Sender's ephemeral x25519 pub for this single message. Same
* semantics as v2. */
eph_pub: string;
/** Recipient's HEARTBEAT x25519 pub the sender ECDH'd against.
* Recipient looks up the matching private from their local
* heartbeat store (heartbeat-store.ts). If they don't have it
* (too old, never theirs), decrypt fails. This is the field that
* makes v3 receiver-side forward-secure. */
recipient_heartbeat_pub: string;
/** Indicates the sender fell back to the recipient's long-term
* identity key instead of a heartbeat — happens for the first
* message to a brand-new contact who hasn't published one yet.
* When true, `recipient_heartbeat_pub` actually holds the
* recipient's identity-derived x25519 pub (still derived from
* their KEZ primary). No FS for this single message. */
fallback?: boolean;
/** 12-byte AES-GCM nonce, hex. */
nonce: string;
/** AES-256-GCM(plaintext_json), hex. AAD binds
* {v, eph_pub, recipient_heartbeat_pub, nonce, fallback?} so a
* relay-in-the-middle can't swap routing without breaking auth. */
ciphertext: string;
/** ed25519 sig over canonical(envelope minus sig). */
sender_sig: string;
}
export interface SealedEnvelopeV4 {
v: 4;
/** Sender's ephemeral x25519 pub (as v3). */
eph_pub: string;
/** Recipient heartbeat x25519 pub — also the lookup id for the
* matching heartbeat's ML-KEM private. */
recipient_heartbeat_pub: string;
/** ML-KEM-768 ciphertext (1088 bytes), hex. */
kem_ct: string;
nonce: string;
/** AES-256-GCM(plaintext_json), hex. AAD binds
* {v, eph_pub, recipient_heartbeat_pub, kem_ct, nonce}. */
ciphertext: string;
/** ed25519 sig over canonical(envelope minus sig). */
sender_sig: string;
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Key derivation // Key derivation
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@ -238,12 +321,31 @@ export async function openMessage(opts: {
envelope: SealedEnvelope; envelope: SealedEnvelope;
myHandle: string; myHandle: string;
mySeed: Uint8Array; mySeed: Uint8Array;
/** v3 only — supplied by the caller (inbox-service) so this
* module doesn't have to depend on heartbeat-store directly.
* Returns the matching heartbeat private if we still have it
* in the retention window; undefined otherwise. */
resolveHeartbeatPriv?: ResolveHeartbeatPrivs;
}): Promise<MessagePlaintext> { }): Promise<MessagePlaintext> {
const env = opts.envelope; const env = opts.envelope;
let plaintext: MessagePlaintext; let plaintext: MessagePlaintext;
if (env.v === 1) plaintext = await openMessageV1(env, opts.myHandle, opts.mySeed); if (env.v === 1) plaintext = await openMessageV1(env, opts.myHandle, opts.mySeed);
else if (env.v === 2) plaintext = await openMessageV2(env, opts.mySeed); else if (env.v === 2) plaintext = await openMessageV2(env, opts.mySeed);
else { else if (env.v === 3) {
if (!opts.resolveHeartbeatPriv) {
throw new Error(
"v3 envelope received but no heartbeat resolver supplied — caller bug",
);
}
plaintext = await openMessageV3(env, opts.mySeed, opts.resolveHeartbeatPriv);
} else if (env.v === 4) {
if (!opts.resolveHeartbeatPriv) {
throw new Error(
"v4 envelope received but no heartbeat resolver supplied — caller bug",
);
}
plaintext = await openMessageV4(env, opts.resolveHeartbeatPriv);
} else {
throw new Error( throw new Error(
`unsupported envelope version: ${(env as { v: number }).v}`, `unsupported envelope version: ${(env as { v: number }).v}`,
); );
@ -372,3 +474,367 @@ async function openMessageV2(
return plaintext; return plaintext;
} }
// ─────────────────────────────────────────────────────────────────────────────
// v3 — heartbeat-ratcheted, receiver-side forward secure
// ─────────────────────────────────────────────────────────────────────────────
/**
* Build an envelope for `recipient_heartbeat_pub` — typically the
* recipient's latest published heartbeat from nostr. Pass
* `fallback: true` when the recipient has no heartbeat available
* and we're falling back to their identity-derived x25519 pub for
* just this one message (no FS for it).
*
* The caller (nostr-transport) is responsible for fetching the
* recipient's heartbeat and passing the right `recipient_heartbeat_pub`.
* This function only does the crypto.
*/
export async function sealMessageV3(opts: {
senderSeed: Uint8Array;
senderPrimary: Identity;
/** x25519 pub the recipient is currently advertising via nostr.
* When `fallback` is true, this is allowed to be derived from
* the recipient's identity instead. */
recipientHeartbeatPub: Uint8Array;
fallback?: boolean;
body: string;
}): Promise<SealedEnvelopeV3> {
// Fresh ephemeral on the sender side — identical FS posture to v2
// on the SENDER half.
const ephPriv = x25519.utils.randomSecretKey();
const ephPub = x25519.getPublicKey(ephPriv);
const nonce = crypto.getRandomValues(new Uint8Array(12));
const aesKey = await deriveAesKey(
ephPriv,
opts.recipientHeartbeatPub,
nonce,
HKDF_INFO_V3,
);
const plaintext: MessagePlaintext = {
from: opts.senderPrimary,
body: opts.body,
sent_at: new Date().toISOString(),
};
const ptBytes = new TextEncoder().encode(JSON.stringify(plaintext));
// AAD binds the full routing context. A relay tampering with
// recipient_heartbeat_pub or fallback would fail the auth tag.
const aad = canonicalBytes({
v: 3,
eph_pub: bytesToHex(ephPub),
recipient_heartbeat_pub: bytesToHex(opts.recipientHeartbeatPub),
nonce: bytesToHex(nonce),
fallback: !!opts.fallback,
});
const ctBytes = new Uint8Array(
await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: asBuffer(nonce),
additionalData: asBuffer(aad),
},
aesKey,
asBuffer(ptBytes),
),
);
const partial = {
v: 3 as const,
eph_pub: bytesToHex(ephPub),
recipient_heartbeat_pub: bytesToHex(opts.recipientHeartbeatPub),
nonce: bytesToHex(nonce),
ciphertext: bytesToHex(ctBytes),
...(opts.fallback ? { fallback: true } : {}),
};
const sig = ed25519.sign(canonicalBytes(partial), opts.senderSeed);
return { ...partial, sender_sig: bytesToHex(sig) };
}
/**
* Decrypt a v3 envelope. The recipient private is the identity-derived
* x25519 key for `fallback` envelopes (sender had no heartbeat for us),
* otherwise the heartbeat private looked up via the caller-supplied
* resolver — keeps this module free of IDB (heartbeat-store) imports.
* Throws if we no longer hold that heartbeat (expired or never ours).
*/
async function openMessageV3(
env: SealedEnvelopeV3,
mySeed: Uint8Array,
resolveHeartbeatPriv: ResolveHeartbeatPrivs,
): Promise<MessagePlaintext> {
const recipientPriv = env.fallback
? x25519PrivFromEd25519Seed(mySeed)
: (await resolveHeartbeatPriv(env.recipient_heartbeat_pub))?.x25519;
if (!recipientPriv) {
throw new Error(
"envelope addressed to an expired/unknown heartbeat (no matching private key on this device)",
);
}
const ephPub = hexToBytes(env.eph_pub);
const nonce = hexToBytes(env.nonce);
const aesKey = await deriveAesKey(
recipientPriv,
ephPub,
nonce,
HKDF_INFO_V3,
);
const aad = canonicalBytes({
v: 3,
eph_pub: env.eph_pub,
recipient_heartbeat_pub: env.recipient_heartbeat_pub,
nonce: env.nonce,
fallback: !!env.fallback,
});
const ptBytes = new Uint8Array(
await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: asBuffer(nonce),
additionalData: asBuffer(aad),
},
aesKey,
asBuffer(hexToBytes(env.ciphertext)),
),
);
const plaintext = JSON.parse(
new TextDecoder().decode(ptBytes),
) as MessagePlaintext;
if (!plaintext.from?.startsWith("ed25519:")) {
throw new Error(`unsupported sender primary scheme: ${plaintext.from}`);
}
const senderPubKey = hexToBytes(plaintext.from.slice("ed25519:".length));
const partial = {
v: env.v,
eph_pub: env.eph_pub,
recipient_heartbeat_pub: env.recipient_heartbeat_pub,
nonce: env.nonce,
ciphertext: env.ciphertext,
...(env.fallback ? { fallback: true } : {}),
};
const sigOk = ed25519.verify(
hexToBytes(env.sender_sig),
canonicalBytes(partial),
senderPubKey,
);
if (!sigOk) throw new Error("envelope signature did not verify");
return plaintext;
}
// ─────────────────────────────────────────────────────────────────────────────
// v4 — hybrid X25519 + ML-KEM-768 (post-quantum)
// ─────────────────────────────────────────────────────────────────────────────
/** HKDF over BOTH shared secrets, X25519 first (PQXDH order). Nonce as
* salt, as v1–v3. An attacker needs both secrets to get the key. */
async function deriveHybridAesKey(
x25519Shared: Uint8Array,
mlkemShared: Uint8Array,
nonce: Uint8Array,
): Promise<CryptoKey> {
const ikm = new Uint8Array(x25519Shared.length + mlkemShared.length);
ikm.set(x25519Shared, 0);
ikm.set(mlkemShared, x25519Shared.length);
const keyBytes = hkdf(sha256, ikm, nonce, HKDF_INFO_V4, 32);
return crypto.subtle.importKey("raw", asBuffer(keyBytes), "AES-GCM", false, [
"encrypt",
"decrypt",
]);
}
function v4Aad(env: Omit<SealedEnvelopeV4, "ciphertext" | "sender_sig">): Uint8Array {
return canonicalBytes({
v: 4,
eph_pub: env.eph_pub,
recipient_heartbeat_pub: env.recipient_heartbeat_pub,
kem_ct: env.kem_ct,
nonce: env.nonce,
});
}
/** Seal to a recipient heartbeat that carries an ML-KEM-768 key. */
export async function sealMessageV4(opts: {
senderSeed: Uint8Array;
senderPrimary: Identity;
recipientHeartbeatPub: Uint8Array;
recipientMlkemPub: Uint8Array;
body: string;
}): Promise<SealedEnvelopeV4> {
const ephPriv = x25519.utils.randomSecretKey();
const ephPub = x25519.getPublicKey(ephPriv);
const xShared = x25519.getSharedSecret(ephPriv, opts.recipientHeartbeatPub);
const { cipherText, sharedSecret } = ml_kem768.encapsulate(opts.recipientMlkemPub);
const nonce = crypto.getRandomValues(new Uint8Array(12));
const aesKey = await deriveHybridAesKey(xShared, sharedSecret, nonce);
const plaintext: MessagePlaintext = {
from: opts.senderPrimary,
body: opts.body,
sent_at: new Date().toISOString(),
};
const head = {
v: 4 as const,
eph_pub: bytesToHex(ephPub),
recipient_heartbeat_pub: bytesToHex(opts.recipientHeartbeatPub),
kem_ct: bytesToHex(cipherText),
nonce: bytesToHex(nonce),
};
const ctBytes = new Uint8Array(
await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: asBuffer(nonce), additionalData: asBuffer(v4Aad(head)) },
aesKey,
asBuffer(new TextEncoder().encode(JSON.stringify(plaintext))),
),
);
const partial = { ...head, ciphertext: bytesToHex(ctBytes) };
const sig = ed25519.sign(canonicalBytes(partial), opts.senderSeed);
return { ...partial, sender_sig: bytesToHex(sig) };
}
async function openMessageV4(
env: SealedEnvelopeV4,
resolveHeartbeatPriv: ResolveHeartbeatPrivs,
): Promise<MessagePlaintext> {
const privs = await resolveHeartbeatPriv(env.recipient_heartbeat_pub);
if (!privs?.mlkem) {
throw new Error(
"envelope addressed to an expired/unknown heartbeat (no matching private key on this device)",
);
}
const nonce = hexToBytes(env.nonce);
const xShared = x25519.getSharedSecret(privs.x25519, hexToBytes(env.eph_pub));
const kemShared = ml_kem768.decapsulate(hexToBytes(env.kem_ct), privs.mlkem);
const aesKey = await deriveHybridAesKey(xShared, kemShared, nonce);
const ptBytes = new Uint8Array(
await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: asBuffer(nonce), additionalData: asBuffer(v4Aad(env)) },
aesKey,
asBuffer(hexToBytes(env.ciphertext)),
),
);
const plaintext = JSON.parse(new TextDecoder().decode(ptBytes)) as MessagePlaintext;
if (!plaintext.from?.startsWith("ed25519:")) {
throw new Error(`unsupported sender primary scheme: ${plaintext.from}`);
}
const partial = {
v: env.v,
eph_pub: env.eph_pub,
recipient_heartbeat_pub: env.recipient_heartbeat_pub,
kem_ct: env.kem_ct,
nonce: env.nonce,
ciphertext: env.ciphertext,
};
const sigOk = ed25519.verify(
hexToBytes(env.sender_sig),
canonicalBytes(partial),
hexToBytes(plaintext.from.slice("ed25519:".length)),
);
if (!sigOk) throw new Error("envelope signature did not verify");
return plaintext;
}
// ─────────────────────────────────────────────────────────────────────────────
// Heartbeat binding — ties a published heartbeat pub to a KEZ identity
// ─────────────────────────────────────────────────────────────────────────────
//
// The nostr event carrying a heartbeat is signed by a nostr key, but
// senders learn a peer's nostr pubkey from the OUTER event of an
// inbound DM — which anyone can re-wrap around a replayed envelope.
// So the nostr signature alone proves nothing about who the heartbeat
// belongs to. The KEZ ed25519 signature below does: senders verify it
// against the recipient's KEZ primary before ECDH'ing to the pub.
/** Signed heartbeat payload — the JSON content of the nostr event. */
export interface SignedHeartbeat {
pub: string; // x25519 pub, hex
/** ML-KEM-768 encapsulation key, hex (1184 bytes). Absent on
* pre-v4 heartbeats → senders use v3. Covered by `sig`. */
mlkem_pub?: string;
created_at: string; // ISO
sig: string; // ed25519 over canonical({t, pub, created_at}), hex
}
const HEARTBEAT_SIG_TAG = "kez-heartbeat-v1";
/** Reject heartbeats older than this even if validly signed — bounds
* how far back an attacker can replay a superseded (possibly already
* deleted) heartbeat to make our messages undecryptable. */
const MAX_HEARTBEAT_AGE_MS = 7 * 24 * 60 * 60 * 1000;
const MAX_HEARTBEAT_SKEW_MS = 60 * 60 * 1000;
/** Signed fields. `mlkem_pub` is only included when present so
* pre-v4 heartbeats keep verifying. */
function heartbeatSigBytes(h: Omit<SignedHeartbeat, "sig">): Uint8Array {
return canonicalBytes({
t: HEARTBEAT_SIG_TAG,
pub: h.pub,
created_at: h.created_at,
...(h.mlkem_pub ? { mlkem_pub: h.mlkem_pub } : {}),
});
}
export function signHeartbeat(
seed: Uint8Array,
pubHex: string,
createdAt: string,
mlkemPubHex?: string,
): SignedHeartbeat {
const body = {
pub: pubHex,
created_at: createdAt,
...(mlkemPubHex ? { mlkem_pub: mlkemPubHex } : {}),
};
return { ...body, sig: bytesToHex(ed25519.sign(heartbeatSigBytes(body), seed)) };
}
/** A verified peer heartbeat's public keys. */
export interface HeartbeatPubs {
x25519: Uint8Array;
mlkem?: Uint8Array;
}
/** Returns the heartbeat's public keys if `hb` is a fresh heartbeat
* signed by `primary`, else null. Never throws. */
export function verifyHeartbeat(
hb: unknown,
primary: Identity,
now = Date.now(),
): HeartbeatPubs | null {
try {
const h = hb as SignedHeartbeat;
if (
!primary.startsWith("ed25519:") ||
typeof h?.pub !== "string" ||
typeof h.created_at !== "string" ||
typeof h.sig !== "string" ||
!/^[0-9a-f]{64}$/i.test(h.pub) ||
(h.mlkem_pub !== undefined &&
!(typeof h.mlkem_pub === "string" && /^[0-9a-f]{2368}$/i.test(h.mlkem_pub)))
) {
return null;
}
const age = now - Date.parse(h.created_at);
if (!(age <= MAX_HEARTBEAT_AGE_MS && age >= -MAX_HEARTBEAT_SKEW_MS)) {
return null;
}
const ok = ed25519.verify(
hexToBytes(h.sig),
heartbeatSigBytes(h),
hexToBytes(primary.slice("ed25519:".length)),
);
if (!ok) return null;
return {
x25519: hexToBytes(h.pub),
...(h.mlkem_pub ? { mlkem: hexToBytes(h.mlkem_pub) } : {}),
};
} catch {
return null;
}
}

View File

@ -0,0 +1,261 @@
// Heartbeat ratchet — receiver-side store.
//
// Design summary (full spec in TODO.md "Heartbeat Ratchet"):
//
// Each user publishes a fresh random x25519 keypair to nostr once
// per active day. The PUBLIC half goes out as a replaceable event
// (kind 10142 — NIP-01 range 10000–19999, one live event per author;
// the d-tag "heartbeat-v1" is informational only), signed by the
// KEZ identity inside the content (see crypto.ts signHeartbeat); the PRIVATE
// half is kept in this device's IndexedDB. Senders fetch the
// recipient's latest published heartbeat and do ECDH against it
// (instead of against the recipient's long-term identity key).
//
// Forward secrecy: heartbeat privates are RANDOM (not derived from
// the seed). The seed only SIGNS the heartbeat to prove it's ours.
// So an attacker who later compromises the seed cannot reconstruct
// past heartbeat privates — they were random bytes the legitimate
// device generated and (eventually) discarded.
//
// Retention window (user-configurable, default 16 days): how long
// we keep an expired heartbeat private around before deleting. The
// window has to be long enough that messages sent against slightly-
// stale cached heartbeats still decrypt. Longer window = more
// forgiving for offline senders, but more IDB material an attacker
// gets in a one-shot device compromise.
//
// Post-compromise security: if the seed is briefly compromised and
// the attacker doesn't maintain persistence, after the retention
// window expires:
// - Past messages: undecryptable (privates were random + deleted)
// - Future messages: safe (new heartbeat privates are random and
// live only on the legitimate device; attacker can sign fake
// heartbeats with the seed but can't generate the same
// randomness the real device produces)
//
// Single-device scope: this v0.1 assumes one device per identity.
// Multi-device with shared heartbeat state is a future project;
// for now, signing in on a second device generates a fresh set of
// heartbeats and the first device is effectively orphaned.
import { x25519 } from "@noble/curves/ed25519";
import { ml_kem768 } from "@noble/post-quantum/ml-kem.js";
import type { HeartbeatPrivs } from "./crypto.js";
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
import { get, set, del, update } from "idb-keyval";
const IDB_KEY = "kez-chat:heartbeats:v1";
const SETTINGS_KEY = "kez-chat:heartbeat-settings:v1";
/** Default retention window for expired heartbeat privates. Sliding
* cap; user can override in Settings within MIN/MAX below. */
export const DEFAULT_RETENTION_DAYS = 16;
export const MIN_RETENTION_DAYS = 3;
export const MAX_RETENTION_DAYS = 60;
/** How often we rotate (publish a new heartbeat). Lazy: rotation
* only happens when the user is active AND >24h has passed since
* the last rotation. Offline users let their heartbeat get stale,
* but other heartbeats from the past `retention_days` remain
* usable. */
const ROTATION_INTERVAL_MS = 24 * 60 * 60 * 1000;
// ─── on-disk shape ─────────────────────────────────────────────────────────
interface StoredHeartbeat {
/** Public key, hex (32 bytes). Matches what was published to nostr. */
pub: string;
/** Private key, hex (32 bytes). NEVER leaves this device. */
priv: string;
/** ML-KEM-768 keypair, hex — the post-quantum half of v4 envelopes.
* Absent on heartbeats minted before v4. Private NEVER leaves
* this device; deleted together with `priv`. */
mlkem_pub?: string;
mlkem_priv?: string;
/** Wall-clock when this heartbeat was generated. Used by the
* retention sweep to decide when to delete. */
created_at: string; // ISO
}
interface StoredHeartbeatBundle {
/** Append-only list, sorted by created_at ascending. We never
* modify entries — we add new ones and delete old ones from the
* start. */
entries: StoredHeartbeat[];
}
interface HeartbeatSettings {
/** Retention window in days. Default DEFAULT_RETENTION_DAYS. */
retention_days: number;
}
// ─── settings ──────────────────────────────────────────────────────────────
export async function loadHeartbeatSettings(): Promise<HeartbeatSettings> {
const s = await get<HeartbeatSettings>(SETTINGS_KEY);
return {
retention_days: s?.retention_days ?? DEFAULT_RETENTION_DAYS,
};
}
export async function saveHeartbeatSettings(
s: HeartbeatSettings,
): Promise<void> {
// Clamp to the same range the Settings slider offers.
const clamped: HeartbeatSettings = {
retention_days: Math.max(
MIN_RETENTION_DAYS,
Math.min(MAX_RETENTION_DAYS, Math.round(s.retention_days)),
),
};
await set(SETTINGS_KEY, clamped);
}
// ─── private store helpers ─────────────────────────────────────────────────
async function readBundle(): Promise<StoredHeartbeatBundle> {
return (await get<StoredHeartbeatBundle>(IDB_KEY)) ?? { entries: [] };
}
// Writes go through idb-keyval `update()` — one readwrite transaction —
// so two open tabs can't lose each other's entries (a lost private
// whose pub made it to relays = undecryptable messages).
// ─── lifecycle ─────────────────────────────────────────────────────────────
/**
* Return the device's current (newest) heartbeat. If there is none,
* or the latest is older than the rotation interval, generate a
* fresh one, persist it, and return that.
*
* Callers should ALSO publish the new heartbeat's `pub` to nostr if
* a fresh one was generated — see `needs_publish` on the return.
*/
export async function getOrRotateCurrentHeartbeat(): Promise<{
heartbeat: StoredHeartbeat;
needs_publish: boolean;
}> {
const now = Date.now();
let result!: { heartbeat: StoredHeartbeat; needs_publish: boolean };
await update<StoredHeartbeatBundle>(IDB_KEY, (b) => {
const entries = b?.entries ?? [];
const newest = entries[entries.length - 1];
// A pre-v4 heartbeat (no ML-KEM key) is rotated immediately so
// peers can start sending post-quantum envelopes right away.
if (
newest?.mlkem_pub &&
now - new Date(newest.created_at).getTime() < ROTATION_INTERVAL_MS
) {
result = { heartbeat: newest, needs_publish: false };
return { entries };
}
// Rotate: fresh random keypair appended; GC runs separately.
const priv = x25519.utils.randomSecretKey();
const kem = ml_kem768.keygen();
const fresh: StoredHeartbeat = {
pub: bytesToHex(x25519.getPublicKey(priv)),
priv: bytesToHex(priv),
mlkem_pub: bytesToHex(kem.publicKey),
mlkem_priv: bytesToHex(kem.secretKey),
created_at: new Date(now).toISOString(),
};
result = { heartbeat: fresh, needs_publish: true };
return { entries: [...entries, fresh] };
});
return result;
}
/**
* Find the heartbeat PRIVATE that matches a given public key (hex).
* Used by the decrypt path: an incoming message specifies the
* recipient's heartbeat pub it was encrypted to, and we look it up
* here. Returns undefined if we don't have it (either too old, or
* never ours).
*/
export async function findHeartbeatPrivateByPub(
pub_hex: string,
): Promise<HeartbeatPrivs | undefined> {
const bundle = await readBundle();
const found = bundle.entries.find((e) => e.pub === pub_hex);
if (!found) return undefined;
return {
x25519: hexToBytes(found.priv),
...(found.mlkem_priv ? { mlkem: hexToBytes(found.mlkem_priv) } : {}),
};
}
/**
* Which entries survive a GC pass. A heartbeat's retention clock
* starts when it was SUPERSEDED (= the next entry's created_at), not
* when it was created: it stays the published target until then, so
* senders may still be encrypting to it. The newest entry is never
* expired — it's what's on relays right now. Pure; exported for the
* self-check.
*/
export function selectRetainedHeartbeats<T extends { created_at: string }>(
entries: T[],
retentionDays: number,
now = Date.now(),
): T[] {
const cutoff = now - retentionDays * 24 * 60 * 60 * 1000;
return entries.filter(
(_, i) =>
i === entries.length - 1 ||
new Date(entries[i + 1].created_at).getTime() >= cutoff,
);
}
/**
* Delete heartbeat privates superseded more than `retention_days`
* ago. Idempotent. Run AFTER rotation on session start. Returns the
* number of entries deleted (diagnostic).
*/
export async function gcExpiredHeartbeats(): Promise<number> {
const settings = await loadHeartbeatSettings();
let deleted = 0;
await update<StoredHeartbeatBundle>(IDB_KEY, (b) => {
const entries = b?.entries ?? [];
const kept = selectRetainedHeartbeats(entries, settings.retention_days);
deleted = entries.length - kept.length;
return { entries: kept };
});
return deleted;
}
/**
* Wipe ALL heartbeat material. Called from "Forget this account on
* this device" — the privates decrypt retained messages without the
* seed, so they must not outlive the account. Deliberately NOT called
* on Lock: that would make every message still in flight to the
* current heartbeat undecryptable.
*/
export async function wipeAllHeartbeats(): Promise<void> {
await del(IDB_KEY);
}
/**
* Diagnostic snapshot: count of heartbeats in store + age of newest
* + age of oldest. Surfaced in Settings so the user can see what's
* going on. Never returns the private bytes.
*/
export async function describeHeartbeats(): Promise<{
count: number;
newest_age_hours?: number;
oldest_age_hours?: number;
current_pub?: string;
}> {
const bundle = await readBundle();
const n = bundle.entries.length;
if (n === 0) return { count: 0 };
const newest = bundle.entries[n - 1];
const oldest = bundle.entries[0];
const now = Date.now();
return {
count: n,
current_pub: newest.pub,
newest_age_hours:
(now - new Date(newest.created_at).getTime()) / (1000 * 60 * 60),
oldest_age_hours:
(now - new Date(oldest.created_at).getTime()) / (1000 * 60 * 60),
};
}

View File

@ -0,0 +1,94 @@
// Self-check for heartbeat binding, retention, and v4 (post-quantum
// hybrid) envelopes. Not bundled into the app.
// Run: npx esbuild src/lib/heartbeat.selfcheck.ts --bundle --platform=node --format=esm | node --input-type=module
import { ed25519, x25519 } from "@noble/curves/ed25519";
import { bytesToHex } from "@noble/hashes/utils";
import { ml_kem768 } from "@noble/post-quantum/ml-kem.js";
import { openMessage, sealMessageV4, signHeartbeat, verifyHeartbeat } from "./crypto.js";
import { selectRetainedHeartbeats } from "./heartbeat-store.js";
import type { Identity } from "./kez.js";
function assert(cond: unknown, msg: string): void {
if (!cond) throw new Error(`FAIL: ${msg}`);
}
const DAY = 24 * 60 * 60 * 1000;
const now = Date.parse("2026-09-27T12:00:00Z");
const iso = (t: number) => new Date(t).toISOString();
// ─── bug 1: heartbeat must be signed by the recipient's KEZ key ───
const aliceSeed = ed25519.utils.randomSecretKey();
const alice = `ed25519:${bytesToHex(ed25519.getPublicKey(aliceSeed))}` as Identity;
const mallorySeed = ed25519.utils.randomSecretKey();
const hbPub = bytesToHex(x25519.getPublicKey(x25519.utils.randomSecretKey()));
const good = signHeartbeat(aliceSeed, hbPub, iso(now - DAY));
assert(verifyHeartbeat(good, alice, now), "alice's own heartbeat verifies");
assert(
!verifyHeartbeat(signHeartbeat(mallorySeed, hbPub, iso(now)), alice, now),
"heartbeat signed by someone else is rejected",
);
assert(
!verifyHeartbeat({ ...good, pub: bytesToHex(x25519.getPublicKey(x25519.utils.randomSecretKey())) }, alice, now),
"swapped pub is rejected",
);
assert(
!verifyHeartbeat(signHeartbeat(aliceSeed, hbPub, iso(now - 8 * DAY)), alice, now),
"stale (replayed) heartbeat is rejected",
);
assert(!verifyHeartbeat(hbPub, alice, now), "legacy bare-hex content is rejected");
// ─── bug 2: retention counts from supersession, newest never expires ───
const e = (daysAgo: number) => ({ created_at: iso(now - daysAgo * DAY) });
// Only heartbeat is 20 days old (user was offline) — must survive.
assert(selectRetainedHeartbeats([e(20)], 16, now).length === 1, "lone old heartbeat kept");
// Created 20d ago, superseded 1d ago → kept; created 30d, superseded 20d ago → dropped.
const kept = selectRetainedHeartbeats([e(30), e(20), e(1)], 16, now);
assert(kept.length === 2 && kept[0].created_at === e(20).created_at, "supersession-based expiry");
// ─── v4: ML-KEM key is signed, so it can't be stripped or swapped ───
const kem = ml_kem768.keygen();
const kemHex = bytesToHex(kem.publicKey);
const pq = signHeartbeat(aliceSeed, hbPub, iso(now - DAY), kemHex);
assert(verifyHeartbeat(pq, alice, now)?.mlkem, "v4 heartbeat verifies with ML-KEM key");
const { mlkem_pub: _dropped, ...stripped } = pq;
assert(!verifyHeartbeat(stripped, alice, now), "stripping ML-KEM key (downgrade) is rejected");
assert(
!verifyHeartbeat({ ...pq, mlkem_pub: bytesToHex(ml_kem768.keygen().publicKey) }, alice, now),
"swapped ML-KEM key is rejected",
);
// ─── v4: hybrid seal/open round-trip; needs BOTH private keys ───
const bobSeed = ed25519.utils.randomSecretKey();
const bobHbPriv = x25519.utils.randomSecretKey();
const bobHbPub = x25519.getPublicKey(bobHbPriv);
const bobKem = ml_kem768.keygen();
const env = await sealMessageV4({
senderSeed: aliceSeed,
senderPrimary: alice,
recipientHeartbeatPub: bobHbPub,
recipientMlkemPub: bobKem.publicKey,
body: "hi bob",
});
const open = (e: typeof env, mlkem: Uint8Array) =>
openMessage({
envelope: e,
myHandle: "bob",
mySeed: bobSeed,
resolveHeartbeatPriv: async () => ({ x25519: bobHbPriv, mlkem }),
});
const pt = await open(env, bobKem.secretKey);
assert(pt.body === "hi bob" && pt.from === alice, "v4 round-trip");
const fails = (p: Promise<unknown>) => p.then(() => false, () => true);
assert(await fails(open(env, ml_kem768.keygen().secretKey)), "wrong ML-KEM key fails");
const flipped = env.kem_ct.slice(0, -2) + (env.kem_ct.endsWith("00") ? "01" : "00");
assert(await fails(open({ ...env, kem_ct: flipped }, bobKem.secretKey)), "tampered kem_ct fails");
assert(
await fails(
openMessage({ envelope: env, myHandle: "bob", mySeed: bobSeed, resolveHeartbeatPriv: async () => ({ x25519: bobHbPriv }) }),
),
"v4 without ML-KEM private fails",
);
console.log("heartbeat self-check: ok");

View File

@ -73,6 +73,70 @@ function loadImage(src: string): Promise<HTMLImageElement> {
}); });
} }
/** Long-edge cap for photos sent in chat. Also keeps us under iOS
* Safari's 16.7M-pixel canvas limit — a 24MP iPhone 15/16 Pro HEIC
* (5712×4284) would otherwise make canvas.toBlob return null. */
const MAX_SEND_EDGE = 2048;
/**
* Normalise a photo before it's sent as a chat attachment:
* • HEIC/HEIF (iPhone default) → JPEG, since Chrome/Firefox/Android
* recipients can't decode HEIC even though Safari (the sender) can.
* • HEIC or JPEG with a long edge over MAX_SEND_EDGE → downscaled.
* Re-encoding also drops EXIF (GPS location etc.).
* Everything else — small JPEGs, PNG (transparency), GIF (animation),
* non-images — passes through unchanged.
*
* Throws with user-facing guidance if this browser can't decode HEIC
* (e.g. desktop Chrome).
*/
export async function prepareImageForSend(
file: File,
quality = 0.85,
): Promise<File> {
const isHeic =
file.type === "image/heic" ||
file.type === "image/heif" ||
/\.(heic|heif)$/i.test(file.name);
const isJpeg = file.type === "image/jpeg";
if (!isHeic && !isJpeg) return file;
const url = URL.createObjectURL(file);
let img: HTMLImageElement;
try {
img = await loadImage(url);
} catch {
if (!isHeic) return file; // odd JPEG the browser can't decode — send as-is
throw new Error(
"This browser can't decode HEIC images. " +
"On iPhone: Settings → Camera → Formats → 'Most Compatible' to save as JPEG, " +
"or share a JPEG version of the photo.",
);
} finally {
URL.revokeObjectURL(url);
}
const w = img.naturalWidth;
const h = img.naturalHeight;
const scale = Math.min(1, MAX_SEND_EDGE / Math.max(w, h));
if (!isHeic && scale === 1) return file;
const canvas = document.createElement("canvas");
canvas.width = Math.round(w * scale);
canvas.height = Math.round(h * scale);
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("canvas 2d context unavailable");
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/jpeg", quality),
);
if (!blob) throw new Error("Image conversion failed (canvas.toBlob)");
// .heic/.heif → .jpg so recipients see a sensible filename.
const newName = file.name.replace(/\.(heic|heif)$/i, ".jpg");
return new File([blob], newName, { type: "image/jpeg" });
}
/** Rough byte size of a data URL. Cheap — we just look at the /** Rough byte size of a data URL. Cheap — we just look at the
* base64 payload length. Used by Settings to surface "your picture * base64 payload length. Used by Settings to surface "your picture
* is X KB" so the user knows what they're publishing. */ * is X KB" so the user knows what they're publishing. */

View File

@ -43,12 +43,20 @@ import {
} from "./conversations-store.js"; } from "./conversations-store.js";
import type { Identity } from "./kez.js"; import type { Identity } from "./kez.js";
import { peerProfiles } from "./peer-profile-cell.svelte.js"; import { peerProfiles } from "./peer-profile-cell.svelte.js";
import { hexToBytes } from "@noble/hashes/utils";
import { get as idbGet, update as idbUpdate } from "idb-keyval";
import { import {
parseFileBody, parseFileBody,
base64ToBytes, base64ToBytes,
assembleChunks, assembleChunks,
bytesToDataUrl, bytesToDataUrl,
} from "./file-transfer.js"; } from "./file-transfer.js";
import {
findHeartbeatPrivateByPub,
gcExpiredHeartbeats,
getOrRotateCurrentHeartbeat,
} from "./heartbeat-store.js";
import { publishHeartbeat } from "./nostr-transport.js";
import { import {
chunkBufferIsComplete, chunkBufferIsComplete,
deleteChunkBuffer, deleteChunkBuffer,
@ -60,6 +68,15 @@ import {
const POLL_INTERVAL_MS = 30_000; const POLL_INTERVAL_MS = 30_000;
/** Envelopes we've already ingested, keyed by (a prefix of) their KEZ
* `sender_sig`. ed25519 sigs are deterministic, so the same envelope
* always carries the same sig — even when someone re-wraps it in a
* new nostr event (new event id → new seq, which seq-dedupe misses),
* or a relay re-broadcasts it after the user cleared the chat.
* FIFO-capped; survives clearConversationMessages on purpose. */
const SEEN_ENVELOPES_KEY = "kez-chat:seen-envelopes:v1";
const SEEN_ENVELOPES_MAX = 5000;
export type StreamStatus = "off" | "connecting" | "live" | "reconnecting"; export type StreamStatus = "off" | "connecting" | "live" | "reconnecting";
class InboxService { class InboxService {
@ -80,6 +97,20 @@ class InboxService {
* was either already-known on session start (so the user has seen it * was either already-known on session start (so the user has seen it
* on this or another device) or already notified about. */ * on this or another device) or already notified about. */
#notifiedThroughSeq = -1; #notifiedThroughSeq = -1;
#seenEnvelopes: Set<string> | null = null;
async #seenSet(): Promise<Set<string>> {
return (this.#seenEnvelopes ??= new Set(
(await idbGet<string[]>(SEEN_ENVELOPES_KEY)) ?? [],
));
}
async #markEnvelopeSeen(key: string): Promise<void> {
(await this.#seenSet()).add(key);
await idbUpdate<string[]>(SEEN_ENVELOPES_KEY, (a = []) =>
[...a.filter((k) => k !== key), key].slice(-SEEN_ENVELOPES_MAX),
);
}
/** Start streaming for this session. Called from App.svelte on unlock. */ /** Start streaming for this session. Called from App.svelte on unlock. */
async start(handle: string, seed: Uint8Array) { async start(handle: string, seed: Uint8Array) {
@ -107,6 +138,13 @@ class InboxService {
onAck: (eventId, sigHex) => void this.#ingestAck(eventId, sigHex), onAck: (eventId, sigHex) => void this.#ingestAck(eventId, sigHex),
onStatus: (s) => (this.status = s), onStatus: (s) => (this.status = s),
}); });
// Heartbeat ratchet. Always publish on session start (not only
// when freshly rotated): the event is replaceable so it's cheap,
// and it self-heals a publish that failed last time or a relay
// list the user changed since. Must run before the first poll,
// whose non-forcing ratchet call would otherwise win the
// single-flight and skip the publish.
void this.#heartbeatRatchet(true);
this.#pollTimer = setInterval(() => void this.#heartbeat(), POLL_INTERVAL_MS); this.#pollTimer = setInterval(() => void this.#heartbeat(), POLL_INTERVAL_MS);
// Eager first poll so we catch up anything queued before this session. // Eager first poll so we catch up anything queued before this session.
void this.#heartbeat(); void this.#heartbeat();
@ -116,6 +154,48 @@ class InboxService {
void flushPendingAcks(seed); void flushPendingAcks(seed);
} }
#ratchetInFlight: Promise<void> | null = null;
/**
* Rotate the heartbeat if it's past the rotation interval, publish
* it (always when `forcePublish`, otherwise only if freshly minted),
* then sweep entries superseded longer ago than the retention
* window. Order matters: rotate first, so the previously-current
* heartbeat (which the backlog we're about to ingest was encrypted
* to) counts as superseded NOW and stays for the full window.
*
* Runs on start and on every poll tick, so a PWA left open for days
* still rotates. Single-flight so overlapping ticks can't double-
* rotate.
*/
#heartbeatRatchet(forcePublish: boolean): Promise<void> {
const seed = this.#seed;
if (!seed) return Promise.resolve();
if (this.#ratchetInFlight) return this.#ratchetInFlight;
this.#ratchetInFlight = (async () => {
try {
const { heartbeat, needs_publish } =
await getOrRotateCurrentHeartbeat();
if (forcePublish || needs_publish) {
await publishHeartbeat({
seed,
heartbeat_pub: hexToBytes(heartbeat.pub),
mlkem_pub: heartbeat.mlkem_pub,
created_at: heartbeat.created_at,
}).catch((e) => {
console.warn("heartbeat publish failed:", e);
});
}
await gcExpiredHeartbeats();
} catch (e) {
console.warn("heartbeat lifecycle failed:", e);
} finally {
this.#ratchetInFlight = null;
}
})();
return this.#ratchetInFlight;
}
/** Stop everything. Called on lock + on tab close. */ /** Stop everything. Called on lock + on tab close. */
stop() { stop() {
this.#stream?.close(); this.#stream?.close();
@ -143,6 +223,7 @@ class InboxService {
async #heartbeat() { async #heartbeat() {
if (!this.#handle || !this.#seed) return; if (!this.#handle || !this.#seed) return;
void this.#heartbeatRatchet(false);
try { try {
const since = await getGlobalCursor(); const since = await getGlobalCursor();
const { messages } = await pollInbox({ const { messages } = await pollInbox({
@ -160,8 +241,17 @@ class InboxService {
async #ingest(m: InboxMessage) { async #ingest(m: InboxMessage) {
if (!this.#handle || !this.#seed) return; if (!this.#handle || !this.#seed) return;
// Only marked seen AFTER a verified decrypt + persist below, so a
// forged envelope that copies a real sig can't pre-poison this.
const envKey = m.envelope.sender_sig?.slice(0, 32);
if (envKey && (await this.#seenSet()).has(envKey)) return;
try { try {
const pt = await decrypt(m.envelope, this.#handle, this.#seed); const pt = await decrypt(
m.envelope,
this.#handle,
this.#seed,
findHeartbeatPrivateByPub,
);
// Resolve sender's display handle (cache on conversation row). // Resolve sender's display handle (cache on conversation row).
let displayName = ""; let displayName = "";
const existing = await getConversation(pt.from); const existing = await getConversation(pt.from);
@ -196,6 +286,7 @@ class InboxService {
// peer-profile fetch, badge bump) to run for inline files // peer-profile fetch, badge bump) to run for inline files
// and pointers, but NOT for chunks (which are user-invisible // and pointers, but NOT for chunks (which are user-invisible
// plumbing). Bail early on chunks. // plumbing). Bail early on chunks.
if (envKey) await this.#markEnvelopeSeen(envKey);
if (fileBody.type === "kez-file-chunk-v1") return; if (fileBody.type === "kez-file-chunk-v1") return;
} else { } else {
await appendInbound({ await appendInbound({
@ -207,6 +298,7 @@ class InboxService {
peer_nostr_pubkey: m.sender_nostr_pubkey, peer_nostr_pubkey: m.sender_nostr_pubkey,
via_relay: m.via_relay, via_relay: m.via_relay,
}); });
if (envKey) await this.#markEnvelopeSeen(envKey);
} }
// First time we've seen this peer's nostr pubkey? Kick off a // First time we've seen this peer's nostr pubkey? Kick off a

View File

@ -6,7 +6,13 @@
import { ed25519 } from "@noble/curves/ed25519"; import { ed25519 } from "@noble/curves/ed25519";
import { bytesToHex } from "@noble/hashes/utils"; import { bytesToHex } from "@noble/hashes/utils";
import { lookup } from "./api.js"; import { lookup } from "./api.js";
import { sealMessage, openMessage, type SealedEnvelope, type MessagePlaintext } from "./crypto.js"; import {
sealMessage,
openMessage,
type SealedEnvelope,
type MessagePlaintext,
type ResolveHeartbeatPrivs,
} from "./crypto.js";
import type { Identity } from "./kez.js"; import type { Identity } from "./kez.js";
/** Server base URL (always same-origin for the SPA). */ /** Server base URL (always same-origin for the SPA). */
@ -94,6 +100,9 @@ export async function sendMessage(opts: {
* POST /v1/messages — so it can't help when the server is down — * POST /v1/messages — so it can't help when the server is down —
* but skipping the extra round-trip is faster. */ * but skipping the extra round-trip is faster. */
recipientPrimary?: Identity; recipientPrimary?: Identity;
/** v3 heartbeat parity — ignored on server transport (no
* heartbeats in play; the chat-server is itself the rendezvous). */
recipientNostrPubkey?: string;
}): Promise<{ seq: number; event_id: string; accepted_by?: string }> { }): Promise<{ seq: number; event_id: string; accepted_by?: string }> {
const recipientHandle = opts.recipient.split("@")[0]; const recipientHandle = opts.recipient.split("@")[0];
let resolvedPrimary: Identity; let resolvedPrimary: Identity;
@ -164,13 +173,22 @@ export async function pollInbox(opts: {
return (await resp.json()) as { messages: InboxMessage[]; cursor: number }; return (await resp.json()) as { messages: InboxMessage[]; cursor: number };
} }
/** Decrypt one inbox envelope. Wrap in try/catch — bad sigs throw. */ /** Decrypt one inbox envelope. Wrap in try/catch — bad sigs throw.
* `resolveHeartbeatPriv` is only consulted for v3 envelopes; pass
* it through from the inbox-service so this module stays free of
* IDB imports. v1/v2 envelopes ignore it. */
export async function decrypt( export async function decrypt(
envelope: SealedEnvelope, envelope: SealedEnvelope,
myHandle: string, myHandle: string,
mySeed: Uint8Array, mySeed: Uint8Array,
resolveHeartbeatPriv?: ResolveHeartbeatPrivs,
): Promise<MessagePlaintext> { ): Promise<MessagePlaintext> {
return openMessage({ envelope, myHandle, mySeed }); return openMessage({
envelope,
myHandle,
mySeed,
resolveHeartbeatPriv,
});
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@ -268,6 +286,7 @@ export async function sendAck(_opts: {
ackedEventId: string; ackedEventId: string;
originalSenderNostrPubkey?: string; originalSenderNostrPubkey?: string;
preferRelay?: string; preferRelay?: string;
extraRelays?: string[];
}): Promise<void> { }): Promise<void> {
/* server transport: receipts not implemented in v0.1 */ /* server transport: receipts not implemented in v0.1 */
} }
@ -292,6 +311,8 @@ export async function sendFile(_opts: {
mime: string; mime: string;
raw: Uint8Array; raw: Uint8Array;
preferRelay?: string; preferRelay?: string;
extraRelays?: string[];
recipientNostrPubkey?: string;
progress?: (done: number, total: number) => void; progress?: (done: number, total: number) => void;
}): Promise<{ }): Promise<{
pointer_event_id: string; pointer_event_id: string;

View File

@ -18,7 +18,17 @@
import { ed25519 } from "@noble/curves/ed25519"; import { ed25519 } from "@noble/curves/ed25519";
import { bytesToHex } from "@noble/hashes/utils"; import { bytesToHex } from "@noble/hashes/utils";
import { SimplePool, finalizeEvent, type Event, type EventTemplate } from "nostr-tools"; import { SimplePool, finalizeEvent, type Event, type EventTemplate } from "nostr-tools";
import { sealMessage, type SealedEnvelope } from "./crypto.js"; import {
sealMessageV3,
sealMessageV4,
signHeartbeat,
verifyHeartbeat,
x25519PubFromPrimary,
type SealedEnvelope,
type SignedHeartbeat,
type HeartbeatPubs,
} from "./crypto.js";
import { get as idbGet, set as idbSet } from "idb-keyval";
import { import {
bytesToBase64, bytesToBase64,
chunkifyBytes, chunkifyBytes,
@ -94,18 +104,15 @@ export function getRelayStatuses(): RelayStatus[] {
}); });
} }
/** Relays to publish to / read from. Override with VITE_NOSTR_RELAYS (csv). /** Relays to publish to / read from. Loaded synchronously from
* Default set: 3 long-running general-purpose relays (damus, nos, primal) * relay-store at module init: user override from localStorage,
* + 2 popular extras (snort.social, nostr.wine) — chosen for redundancy * falling back to env override (VITE_NOSTR_RELAYS), falling back
* and geographic diversity. If any single relay is slow/down our publish * to a hardcoded 5-relay default. Order matters — earlier entries
* still succeeds as long as one accepts. */ * win the publish race more often, so users can express
const RELAYS: string[] = ( * preference in Settings. Changes from Settings require a page
import.meta.env.VITE_NOSTR_RELAYS ?? * reload to apply (we do that with a clear UI prompt). */
"wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr.wine" import { loadCurrentRelays } from "./relay-store.js";
) const RELAYS: string[] = loadCurrentRelays();
.split(",")
.map((r: string) => r.trim())
.filter(Boolean);
/** One pool for the whole session — relay connections are reused. /** One pool for the whole session — relay connections are reused.
* `attachSigner(seed)` recreates the pool with `automaticallyAuth` * `attachSigner(seed)` recreates the pool with `automaticallyAuth`
@ -316,17 +323,234 @@ function toInboxMessage(ev: Event): InboxMessage | null {
/** /**
* Order relays for this send: any preferred relay (typically: the * Order relays for this send: any preferred relay (typically: the
* relay that delivered the most recent inbound message from this * relay that delivered the most recent inbound message from this
* peer) goes first, then the rest of the configured set, deduped. * peer) goes first, then the peer's NIP-65 read-relays (so their
* Putting a preferred relay first means the WebSocket open + publish * client subscribed to those receives the event), then the rest of
* race usually completes against it — same network path the inbound * the configured set. Deduped + capped so a peer that lists 20
* message came in on, so geographically/network-wise the fastest. * relays doesn't make every send a relay flood.
*
* Putting the preferred relay first means the WebSocket open +
* publish race usually completes against it — same network path
* the inbound came in on.
*/ */
function orderedRelaysForSend(preferRelay?: string): string[] { const MAX_RELAYS_PER_SEND = 10;
if (!preferRelay) return RELAYS; function orderedRelaysForSend(
const norm = preferRelay.replace(/\/$/, ""); preferRelay?: string,
const matches = RELAYS.filter((r) => r.replace(/\/$/, "") === norm); extraRelays?: string[],
const others = RELAYS.filter((r) => r.replace(/\/$/, "") !== norm); ): string[] {
return [...matches, ...others]; const norm = (u: string) => u.replace(/\/$/, "").toLowerCase();
const seen = new Set<string>();
const out: string[] = [];
const push = (url: string) => {
const n = norm(url);
if (seen.has(n)) return;
if (out.length >= MAX_RELAYS_PER_SEND) return;
seen.add(n);
out.push(url);
};
if (preferRelay) push(preferRelay);
if (extraRelays) for (const r of extraRelays) push(r);
for (const r of RELAYS) push(r);
return out;
}
// ─────────────────────────────────────────────────────────────────────────────
// Heartbeat ratchet — publish + fetch
// ─────────────────────────────────────────────────────────────────────────────
//
// Each user publishes a fresh ephemeral x25519 pub once per active
// day via a replaceable event (kind 10142 — NIP-01 range 10000–19999,
// one live event per author; the d-tag is informational). The
// PRIVATE half lives only on the
// publishing device (heartbeat-store.ts). Senders fetch the most
// recent heartbeat from relays and use it as the ECDH target for
// v3 envelopes instead of the recipient's long-term identity.
//
// Properties (full reasoning in TODO.md "Heartbeat Ratchet"):
// * Per-message forward secrecy on the SENDER side via ephemeral pub
// * Window-grained forward secrecy on the RECEIVER side via
// heartbeat rotation + retention deletion
// * Authentication: the content is a SignedHeartbeat carrying an
// ed25519 signature from the user's KEZ identity, verified by
// senders against the recipient's primary (the outer nostr sig
// alone proves nothing — see fetchPeerHeartbeat)
// * No prekey server, no per-peer session state, no out-of-order
// bookkeeping; nostr's replaceable-event semantics carry it.
export const HEARTBEAT_KIND = 10142;
const HEARTBEAT_D_TAG = "heartbeat-v1";
/** Publish a heartbeat public key to relays. Caller (heartbeat-
* service) generates the keypair, persists the private to IDB,
* and hands the public bytes here. Content is a SignedHeartbeat —
* the pub signed by our KEZ identity, so senders can verify it
* belongs to us regardless of which nostr key they think we have.
* Throws if no relay accepts. */
export async function publishHeartbeat(opts: {
seed: Uint8Array;
heartbeat_pub: Uint8Array;
/** ML-KEM-768 encapsulation key (hex) — lets peers send v4
* (post-quantum hybrid) envelopes. */
mlkem_pub?: string;
created_at: string;
}): Promise<{ event_id: string; accepted_by?: string }> {
const sk = nostrSecretFromSeed(opts.seed);
const pubHex = bytesToHex(opts.heartbeat_pub);
const tmpl: EventTemplate = {
kind: HEARTBEAT_KIND,
created_at: Math.floor(Date.now() / 1000),
tags: [
["d", HEARTBEAT_D_TAG],
// Pub in a tag for filterability; the authoritative, KEZ-signed
// copy is the content.
["x25519", pubHex],
],
content: JSON.stringify(
signHeartbeat(opts.seed, pubHex, opts.created_at, opts.mlkem_pub),
),
};
const signed = finalizeEvent(tmpl, sk);
const publishPromises = pool().publish(RELAYS, signed);
let acceptedBy: string | undefined;
try {
acceptedBy = await Promise.any(
publishPromises.map((p, i) => p.then(() => RELAYS[i])),
);
} catch {
/* fall through */
}
const results = await Promise.allSettled(publishPromises);
if (!results.some((r) => r.status === "fulfilled")) {
throw new Error("no relay accepted the heartbeat");
}
return { event_id: signed.id, accepted_by: acceptedBy };
}
/**
* Fetch the most-recent heartbeat published by a given nostr
* pubkey that carries a valid, fresh signature from `peerPrimary`'s
* KEZ key. Returns the 32-byte x25519 public key, or null (→ caller
* falls back to identity ECDH).
*
* The KEZ signature check is what makes this safe: `peerNostrPubkey`
* comes from the outer event of an inbound DM and can be spoofed by
* anyone re-wrapping a replayed envelope. An attacker's heartbeat
* won't carry the peer's KEZ signature, so it's rejected here.
*
* Raw relay fetch — senders go through `resolvePeerHeartbeat`, which
* adds caching + downgrade protection on top.
*/
export async function fetchPeerHeartbeat(
peerNostrPubkey: string,
peerPrimary: Identity,
): Promise<SignedHeartbeat | null> {
try {
const events = await pool().querySync(RELAYS, {
kinds: [HEARTBEAT_KIND],
authors: [peerNostrPubkey],
"#d": [HEARTBEAT_D_TAG],
limit: 1,
});
// Replaceable events: should be one, but if multiple snuck in
// (race between relays), take the most recent that verifies.
events.sort((a, b) => b.created_at - a.created_at);
for (const ev of events) {
let parsed: unknown;
try {
parsed = JSON.parse(ev.content);
} catch {
continue;
}
if (verifyHeartbeat(parsed, peerPrimary)) return parsed as SignedHeartbeat;
}
return null;
} catch (e) {
console.warn(`fetchPeerHeartbeat failed for ${peerNostrPubkey}:`, e);
return null;
}
}
/**
* Last verified heartbeat per peer primary. Two jobs:
* • Skip the relay round-trip for HEARTBEAT_CACHE_TTL_MS — a chunked
* file is ~125 sends, and querySync waits on every relay.
* • Anti-downgrade: if a later fetch comes back empty or invalid
* (relay withholding, spoofed nostr pubkey, network blip), keep
* sealing to the last known-good heartbeat while verifyHeartbeat
* still considers it fresh, instead of silently dropping to
* identity ECDH. An older heartbeat never replaces a newer one.
* Persisted to IDB so the anti-downgrade survives reloads.
*/
const PEER_HEARTBEATS_KEY = "kez-chat:peer-heartbeats:v1";
const HEARTBEAT_CACHE_TTL_MS = 5 * 60 * 1000;
const heartbeatCache = new Map<string, { hb: SignedHeartbeat; fetched_at: number }>();
async function resolvePeerHeartbeat(
peerNostrPubkey: string | undefined,
peerPrimary: Identity,
): Promise<HeartbeatPubs | null> {
let cached = heartbeatCache.get(peerPrimary);
if (!cached) {
const stored = (await idbGet<Record<string, SignedHeartbeat>>(PEER_HEARTBEATS_KEY))?.[peerPrimary];
if (stored) cached = { hb: stored, fetched_at: 0 };
}
if (cached && Date.now() - cached.fetched_at < HEARTBEAT_CACHE_TTL_MS) {
const pub = verifyHeartbeat(cached.hb, peerPrimary);
if (pub) return pub;
}
const fresh = peerNostrPubkey
? await fetchPeerHeartbeat(peerNostrPubkey, peerPrimary)
: null;
let best = cached?.hb;
if (fresh && (!best || Date.parse(fresh.created_at) >= Date.parse(best.created_at))) {
best = fresh;
}
if (fresh && best) {
heartbeatCache.set(peerPrimary, { hb: best, fetched_at: Date.now() });
const all = (await idbGet<Record<string, SignedHeartbeat>>(PEER_HEARTBEATS_KEY)) ?? {};
all[peerPrimary] = best;
await idbSet(PEER_HEARTBEATS_KEY, all);
}
const pub = best ? verifyHeartbeat(best, peerPrimary) : null;
if (!pub && cached) {
console.warn(
`heartbeat for ${peerPrimary} unavailable or stale — sending without receiver-side forward secrecy`,
);
}
return pub;
}
/** Envelope for a recipient, strongest first:
* v4 — heartbeat has an ML-KEM key → X25519 + ML-KEM-768 hybrid
* v3 — heartbeat without ML-KEM (pre-v4 client) → X25519 only
* v3 fallback — no heartbeat at all (brand-new contact) → identity
* key, no receiver-side FS, no PQ. */
async function sealForRecipient(opts: {
senderSeed: Uint8Array;
senderPrimary: Identity;
recipientPrimary: Identity;
recipientNostrPubkey?: string;
body: string;
}): Promise<SealedEnvelope> {
const hb = await resolvePeerHeartbeat(opts.recipientNostrPubkey, opts.recipientPrimary);
// Post-quantum hybrid whenever the recipient's heartbeat offers an
// ML-KEM key (every heartbeat minted by a v4-capable client).
if (hb?.mlkem) {
return sealMessageV4({
senderSeed: opts.senderSeed,
senderPrimary: opts.senderPrimary,
recipientHeartbeatPub: hb.x25519,
recipientMlkemPub: hb.mlkem,
body: opts.body,
});
}
return sealMessageV3({
senderSeed: opts.senderSeed,
senderPrimary: opts.senderPrimary,
recipientHeartbeatPub: hb?.x25519 ?? x25519PubFromPrimary(opts.recipientPrimary),
fallback: !hb,
body: opts.body,
});
} }
export async function sendMessage(opts: { export async function sendMessage(opts: {
@ -339,6 +563,12 @@ export async function sendMessage(opts: {
* recipient's last message arrived on. Falls back to the full * recipient's last message arrived on. Falls back to the full
* configured relay set if unset or unknown. */ * configured relay set if unset or unknown. */
preferRelay?: string; preferRelay?: string;
/** Additional relays to include in the publish target set (in
* addition to our configured default RELAYS) — typically the
* peer's NIP-65 read-relays so their client sees the event even
* when they're not subscribed to our defaults. Deduped + capped
* inside orderedRelaysForSend. */
extraRelays?: string[];
/** /**
* Recipient's KEZ primary, if the caller already has it (e.g. from * Recipient's KEZ primary, if the caller already has it (e.g. from
* the conversation row). Lets us skip the `/v1/u/:handle` lookup * the conversation row). Lets us skip the `/v1/u/:handle` lookup
@ -347,6 +577,17 @@ export async function sendMessage(opts: {
* brand-new conversations (no cached primary) still need lookup. * brand-new conversations (no cached primary) still need lookup.
*/ */
recipientPrimary?: Identity; recipientPrimary?: Identity;
/**
* Recipient's nostr pubkey (hex), if the caller has learned it
* from a previous inbound DM. Used to fetch the recipient's
* current heartbeat from relays for v3 envelope sealing. When
* absent we fall back to identity-derived ECDH for this message
* (the `fallback: true` v3 branch) — first messages to a new
* contact end up here. Once the contact replies once, the
* conversation row caches their pubkey and all future sends are
* full v3.
*/
recipientNostrPubkey?: string;
}): Promise<{ seq: number; event_id: string; accepted_by?: string }> { }): Promise<{ seq: number; event_id: string; accepted_by?: string }> {
const recipientHandle = opts.recipient.split("@")[0]; const recipientHandle = opts.recipient.split("@")[0];
// Skip the server lookup if the caller already has the primary // Skip the server lookup if the caller already has the primary
@ -360,12 +601,19 @@ export async function sendMessage(opts: {
recipientPrimary = record.primary as Identity; recipientPrimary = record.primary as Identity;
} }
// Our own encryption layer — identical to the server transport. // ─── envelope selection: v3 (heartbeat) by default, fallback ─
const envelope = await sealMessage({ // We always emit v3 envelopes. The receiver's HEARTBEAT pub is
// what we ECDH against — except for the first-ever message to a
// brand-new contact who hasn't published a heartbeat yet, where
// we fall back to their identity-derived x25519 pub and mark the
// envelope `fallback: true`. That one message has no receiver-
// side forward secrecy; everything after does. See TODO.md
// "Heartbeat Ratchet".
const envelope = await sealForRecipient({
senderSeed: opts.senderSeed, senderSeed: opts.senderSeed,
senderPrimary: opts.senderPrimary, senderPrimary: opts.senderPrimary,
recipientHandle,
recipientPrimary, recipientPrimary,
recipientNostrPubkey: opts.recipientNostrPubkey,
body: opts.body, body: opts.body,
}); });
@ -382,7 +630,7 @@ export async function sendMessage(opts: {
// record which relay accepted first so the UI can show "sent via // record which relay accepted first so the UI can show "sent via
// X" and so the next message in this thread can prefer the same // X" and so the next message in this thread can prefer the same
// relay. // relay.
const ordered = orderedRelaysForSend(opts.preferRelay); const ordered = orderedRelaysForSend(opts.preferRelay, opts.extraRelays);
const publishPromises = pool().publish(ordered, signed); const publishPromises = pool().publish(ordered, signed);
// Promise.any returns the first-fulfilled value; if every relay // Promise.any returns the first-fulfilled value; if every relay
// rejects, it throws AggregateError. We still want best-effort // rejects, it throws AggregateError. We still want best-effort
@ -442,14 +690,18 @@ async function publishRawBody(opts: {
recipientPrimary: Identity; recipientPrimary: Identity;
body: string; body: string;
preferRelay?: string; preferRelay?: string;
/** Additional relays to include in the publish target set (in
* addition to our configured default RELAYS) — typically the
* peer's NIP-65 read-relays so their client sees the event even
* when they're not subscribed to our defaults. Deduped + capped
* inside orderedRelaysForSend. */
extraRelays?: string[];
recipientNostrPubkey?: string;
}): Promise<{ event_id: string; accepted_by?: string }> { }): Promise<{ event_id: string; accepted_by?: string }> {
const envelope = await sealMessage({ // Same v3 / fallback decision as sendMessage. File-transfer chunks
senderSeed: opts.senderSeed, // and pointers go through here, so they get heartbeat-ratcheted
senderPrimary: opts.senderPrimary, // automatically too.
recipientHandle: opts.recipientHandle, const envelope = await sealForRecipient(opts);
recipientPrimary: opts.recipientPrimary,
body: opts.body,
});
const sk = nostrSecretFromSeed(opts.senderSeed); const sk = nostrSecretFromSeed(opts.senderSeed);
const tmpl: EventTemplate = { const tmpl: EventTemplate = {
kind: KEZ_DM_KIND, kind: KEZ_DM_KIND,
@ -458,7 +710,7 @@ async function publishRawBody(opts: {
content: JSON.stringify(envelope), content: JSON.stringify(envelope),
}; };
const signed = finalizeEvent(tmpl, sk); const signed = finalizeEvent(tmpl, sk);
const ordered = orderedRelaysForSend(opts.preferRelay); const ordered = orderedRelaysForSend(opts.preferRelay, opts.extraRelays);
const publishPromises = pool().publish(ordered, signed); const publishPromises = pool().publish(ordered, signed);
let acceptedBy: string | undefined; let acceptedBy: string | undefined;
try { try {
@ -505,6 +757,15 @@ export async function sendFile(opts: {
mime: string; mime: string;
raw: Uint8Array; raw: Uint8Array;
preferRelay?: string; preferRelay?: string;
/** Additional relays to include in the publish target set (in
* addition to our configured default RELAYS) — typically the
* peer's NIP-65 read-relays so their client sees the event even
* when they're not subscribed to our defaults. Deduped + capped
* inside orderedRelaysForSend. */
extraRelays?: string[];
/** Threaded into v3 envelope sealing — same semantics as
* sendMessage. */
recipientNostrPubkey?: string;
progress?: (done: number, total: number) => void; progress?: (done: number, total: number) => void;
}): Promise<SendFileResult> { }): Promise<SendFileResult> {
const { raw } = opts; const { raw } = opts;
@ -683,6 +944,12 @@ export async function sendAck(opts: {
* the same relay, since that path was demonstrably working for * the same relay, since that path was demonstrably working for
* this peer. Falls back to the full set if unset. */ * this peer. Falls back to the full set if unset. */
preferRelay?: string; preferRelay?: string;
/** Additional relays to include in the publish target set (in
* addition to our configured default RELAYS) — typically the
* peer's NIP-65 read-relays so their client sees the event even
* when they're not subscribed to our defaults. Deduped + capped
* inside orderedRelaysForSend. */
extraRelays?: string[];
}): Promise<void> { }): Promise<void> {
const sk = nostrSecretFromSeed(opts.ackingSeed); const sk = nostrSecretFromSeed(opts.ackingSeed);
const targetAddr = addrFromPrimary(opts.originalSenderPrimary); const targetAddr = addrFromPrimary(opts.originalSenderPrimary);
@ -704,7 +971,7 @@ export async function sendAck(opts: {
content: "", content: "",
}; };
const signed = finalizeEvent(tmpl, sk); const signed = finalizeEvent(tmpl, sk);
const ordered = orderedRelaysForSend(opts.preferRelay); const ordered = orderedRelaysForSend(opts.preferRelay, opts.extraRelays);
const results = await Promise.allSettled(pool().publish(ordered, signed)); const results = await Promise.allSettled(pool().publish(ordered, signed));
const acceptedSomewhere = results.some((r) => r.status === "fulfilled"); const acceptedSomewhere = results.some((r) => r.status === "fulfilled");
if (!acceptedSomewhere) { if (!acceptedSomewhere) {

View File

@ -26,6 +26,7 @@
// on access (peers may have updated their picture). // on access (peers may have updated their picture).
import { get, set } from "idb-keyval"; import { get, set } from "idb-keyval";
import { isAllowedRelayUrl, loadCurrentRelays } from "./relay-store.js";
import { hexToBytes } from "@noble/hashes/utils"; import { hexToBytes } from "@noble/hashes/utils";
import { SimplePool } from "nostr-tools"; import { SimplePool } from "nostr-tools";
@ -43,14 +44,8 @@ const CACHE_KEY = "kez-chat:peer-profiles:v1";
const STALE_AFTER_MS = 6 * 60 * 60 * 1000; const STALE_AFTER_MS = 6 * 60 * 60 * 1000;
const FETCH_TIMEOUT_MS = 8000; const FETCH_TIMEOUT_MS = 8000;
/** Same default-relay list as nostr-transport.ts. */ /** Same relay list as nostr-transport.ts (user override → env → default). */
const RELAYS: string[] = ( const RELAYS: string[] = loadCurrentRelays();
(import.meta.env.VITE_NOSTR_RELAYS as string | undefined) ??
"wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr.wine"
)
.split(",")
.map((r) => r.trim())
.filter(Boolean);
export interface CachedPeerProfile { export interface CachedPeerProfile {
/** The KEZ primary key — canonical identifier; never changes. */ /** The KEZ primary key — canonical identifier; never changes. */
@ -73,6 +68,15 @@ export interface CachedPeerProfile {
fetched_at: string; fetched_at: string;
/** Event id of the kind:0 we descrambled — debug breadcrumb. */ /** Event id of the kind:0 we descrambled — debug breadcrumb. */
source_event_id?: string; source_event_id?: string;
/**
* NIP-65 (kind:10002) preferred relays the peer published. Senders
* should union the peer's READ relays with our own write set when
* publishing a DM addressed to this peer. Absent if the peer hasn't
* published a kind:10002 yet (most clients today). Each entry is a
* normalized wss:// URL.
*/
read_relays?: string[];
write_relays?: string[];
} }
type Cache = Record<string, CachedPeerProfile>; type Cache = Record<string, CachedPeerProfile>;
@ -133,7 +137,9 @@ export async function fetchPeerProfile(opts: {
try { try {
const events = await Promise.race([ const events = await Promise.race([
pool.querySync(RELAYS, { pool.querySync(RELAYS, {
kinds: [0], // Pull kind:0 (profile) AND kind:10002 (NIP-65 relay list)
// in one query so we only pay one round-trip per peer.
kinds: [0, 10002],
authors: [opts.peer_nostr_pubkey], authors: [opts.peer_nostr_pubkey],
limit: 5, // grab a few in case relays disagree on `latest` limit: 5, // grab a few in case relays disagree on `latest`
}), }),
@ -142,14 +148,53 @@ export async function fetchPeerProfile(opts: {
), ),
]); ]);
if (!events.length) return cached; // nothing to update if (!events.length) return cached; // nothing to update
const latest = events.sort((a, b) => b.created_at - a.created_at)[0];
let metadata: Record<string, unknown>; // Split the response: kind:0 carries display metadata + the
// visual-encryption wraps; kind:10002 carries the NIP-65 relay
// list. We take the latest of each kind independently.
const kind0Events = events.filter((e) => e.kind === 0);
const kind10002Events = events.filter((e) => e.kind === 10002);
const latestKind0 = kind0Events.sort((a, b) => b.created_at - a.created_at)[0];
const latestKind10002 = kind10002Events.sort((a, b) => b.created_at - a.created_at)[0];
let metadata: Record<string, unknown> = {};
if (latestKind0) {
try { try {
metadata = JSON.parse(latest.content); metadata = JSON.parse(latestKind0.content);
} catch (e) { } catch (e) {
console.warn(`peer-profile: bad kind:0 content for ${opts.peer_primary}`, e); console.warn(`peer-profile: bad kind:0 content for ${opts.peer_primary}`, e);
return cached; // Don't bail — we can still pick up kind:10002 even if kind:0 is malformed.
}
}
// ─── NIP-65: parse the peer's preferred relay list ─────────
// kind:10002 tags are ["r", <wss url>, <"read"|"write"|"">]. The
// optional marker tells us whether it's a read-relay, a write-
// relay, or both (when missing). Senders to this peer should
// include their READ relays in the publish set so the peer's
// client (which is subscribed to their own read-relays) sees
// the event.
let read_relays: string[] | undefined;
let write_relays: string[] | undefined;
if (latestKind10002) {
const reads: string[] = [];
const writes: string[] = [];
for (const tag of latestKind10002.tags) {
if (tag[0] !== "r" || typeof tag[1] !== "string") continue;
const url = tag[1].trim();
// Skip ws:// etc. — we'd leak routing metadata in cleartext.
if (!isAllowedRelayUrl(url)) continue;
const marker = tag[2]?.toLowerCase();
if (marker === "read") reads.push(url);
else if (marker === "write") writes.push(url);
else {
// No marker = both.
reads.push(url);
writes.push(url);
}
}
if (reads.length) read_relays = [...new Set(reads)];
if (writes.length) write_relays = [...new Set(writes)];
} }
const profile: CachedPeerProfile = { const profile: CachedPeerProfile = {
@ -157,7 +202,9 @@ export async function fetchPeerProfile(opts: {
name: typeof metadata.name === "string" ? metadata.name : undefined, name: typeof metadata.name === "string" ? metadata.name : undefined,
about: typeof metadata.about === "string" ? metadata.about : undefined, about: typeof metadata.about === "string" ? metadata.about : undefined,
fetched_at: new Date().toISOString(), fetched_at: new Date().toISOString(),
source_event_id: latest.id, source_event_id: latestKind0?.id,
read_relays,
write_relays,
}; };
// ─── descramble path ──────────────────────────────────────── // ─── descramble path ────────────────────────────────────────

View File

@ -27,6 +27,7 @@
// helper which keeps a $state cell in sync. // helper which keeps a $state cell in sync.
import { get, set, del } from "idb-keyval"; import { get, set, del } from "idb-keyval";
import { loadCurrentRelays } from "./relay-store.js";
import { hexToBytes } from "@noble/hashes/utils"; import { hexToBytes } from "@noble/hashes/utils";
import { import {
finalizeEvent, finalizeEvent,
@ -42,15 +43,8 @@ import type { Identity } from "./kez.js";
const PROFILE_KEY = "kez-chat:my-profile:v1"; const PROFILE_KEY = "kez-chat:my-profile:v1";
/** Same default-relay list as nostr-transport.ts. Profile publish is /** Same relay list as nostr-transport.ts (user override → env → default). */
* best-effort; if relays change, we just re-publish on next save. */ const RELAYS: string[] = loadCurrentRelays();
const RELAYS: string[] = (
(import.meta.env.VITE_NOSTR_RELAYS as string | undefined) ??
"wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr.wine"
)
.split(",")
.map((r) => r.trim())
.filter(Boolean);
export interface StoredProfile { export interface StoredProfile {
/** The picture WE see locally — always cleartext. When `encrypted` /** The picture WE see locally — always cleartext. When `encrypted`
@ -104,16 +98,26 @@ export async function clearMyProfile(): Promise<void> {
* reach us. TODO.md Day 3 Option B #10. * reach us. TODO.md Day 3 Option B #10.
*/ */
const BASELINE_PUBLISHED_KEY = "kez-chat:nostr-baseline-published:v1"; const BASELINE_PUBLISHED_KEY = "kez-chat:nostr-baseline-published:v1";
/** Relay set our last kind:10002 advertised. Re-published whenever the
* user's relay list changes, so peers stop sending to relays we've
* dropped. Tracked separately from the kind:0 baseline, which must
* NOT be re-sent (it would clobber a real profile). */
const RELAYLIST_PUBLISHED_KEY = "kez-chat:nip65-published:v1";
export async function publishKind0BaselineIfNeeded( export async function publishKind0BaselineIfNeeded(
seed: Uint8Array, seed: Uint8Array,
handle: string, handle: string,
): Promise<void> { ): Promise<void> {
const relaySig = RELAYS.join(",");
let baselineDone = false;
let relayListDone = false;
try { try {
if (localStorage.getItem(BASELINE_PUBLISHED_KEY) === "1") return; baselineDone = localStorage.getItem(BASELINE_PUBLISHED_KEY) === "1";
relayListDone = localStorage.getItem(RELAYLIST_PUBLISHED_KEY) === relaySig;
} catch { } catch {
/* private mode — proceed anyway, worst case we publish once per tab */ /* private mode — proceed anyway, worst case we publish once per tab */
} }
if (baselineDone && relayListDone) return;
try { try {
const sk = nostrSecretFromSeed(seed); const sk = nostrSecretFromSeed(seed);
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
@ -142,13 +146,18 @@ export async function publishKind0BaselineIfNeeded(
const pool = new SimplePool(); const pool = new SimplePool();
try { try {
if (!baselineDone) {
await Promise.allSettled(pool.publish(RELAYS, signedKind0)); await Promise.allSettled(pool.publish(RELAYS, signedKind0));
}
if (!relayListDone) {
await Promise.allSettled(pool.publish(RELAYS, signedKind10002)); await Promise.allSettled(pool.publish(RELAYS, signedKind10002));
}
} finally { } finally {
pool.close(RELAYS); pool.close(RELAYS);
} }
try { try {
localStorage.setItem(BASELINE_PUBLISHED_KEY, "1"); localStorage.setItem(BASELINE_PUBLISHED_KEY, "1");
localStorage.setItem(RELAYLIST_PUBLISHED_KEY, relaySig);
} catch { } catch {
/* private mode */ /* private mode */
} }

View File

@ -0,0 +1,118 @@
// User-configurable nostr relay list.
//
// The shipped build hard-codes a set of default relays (mirrored
// across nostr-transport.ts, profile-store.ts, peer-profile-store.ts,
// and the rust server's nostr_listener). Power users can override
// the set from Settings; their list is persisted in localStorage and
// loaded at module-init time before the transport opens any
// subscriptions.
//
// Why localStorage instead of IndexedDB: this needs to be readable
// SYNCHRONOUSLY before the rest of the app starts, so we can pre-
// populate the transport's `RELAYS` array. localStorage is sync;
// IDB is not.
//
// Trade-off: localStorage is per-origin, not per-device, so signing
// in on the same browser profile picks up your custom list. Fresh
// devices revert to defaults until the user opens Settings again.
const STORAGE_KEY = "kez-chat:relays:v1";
/** wss:// only — plain ws:// would expose who-talks-to-whom metadata
* to anyone on the network path. ws:// is allowed for a local dev
* relay only. Also used to vet peers' NIP-65 relay lists. */
export function isAllowedRelayUrl(url: string): boolean {
return (
/^wss:\/\/[^\s]+$/i.test(url) ||
/^ws:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/[^\s]*)?$/i.test(url)
);
}
/** Hard-coded fallback when no env override AND no user override.
* This MUST match the source-of-truth used elsewhere in the app
* (search for "wss://relay.damus.io" if you ever change it). */
const HARDCODED_DEFAULT =
"wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr.wine";
/** The build-time default: env override → hardcoded fallback. This
* is what "Restore defaults" resets to in the UI. Computed once at
* module load. */
export const DEFAULT_RELAYS: string[] =
((import.meta.env.VITE_NOSTR_RELAYS as string | undefined) ??
HARDCODED_DEFAULT)
.split(",")
.map((r) => r.trim())
.filter(Boolean);
/** Read the current effective relay list: user override if present,
* otherwise the build default. Synchronous. */
export function loadCurrentRelays(): string[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
const cleaned = parsed
.filter((r) => typeof r === "string")
.map((r) => r.trim())
.filter(isAllowedRelayUrl);
if (cleaned.length > 0) return cleaned;
}
}
} catch {
/* corrupted JSON — fall through to defaults */
}
return [...DEFAULT_RELAYS];
}
/** Persist a user-configured relay list. Validates each entry as a
* wss:// or ws:// URL; throws on bad input so the UI can surface
* the error rather than silently dropping entries. Empty list →
* reset to defaults (clears the override). */
export function saveCustomRelays(list: string[]): void {
if (list.length === 0) {
clearCustomRelays();
return;
}
const cleaned: string[] = [];
for (const r of list) {
const t = r.trim();
if (!t) continue;
if (!isAllowedRelayUrl(t)) {
throw new Error(`"${t}" is not a valid relay URL (expected wss://…)`);
}
cleaned.push(t);
}
// De-dupe case-insensitively while preserving original order.
const seen = new Set<string>();
const deduped: string[] = [];
for (const r of cleaned) {
const k = r.toLowerCase().replace(/\/+$/, "");
if (seen.has(k)) continue;
seen.add(k);
deduped.push(r);
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(deduped));
}
/** Wipe the user override → next load reverts to DEFAULT_RELAYS. */
export function clearCustomRelays(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* private mode — fine */
}
}
/** True when the effective list equals the build defaults, in the
* same order — order is meaningful (earlier relays are preferred),
* so a reordered default set counts as custom. Used by the UI to
* show "Restore defaults". */
export function isUsingDefaultRelays(): boolean {
const current = loadCurrentRelays();
const norm = (u: string) => u.toLowerCase().replace(/\/+$/, "");
return (
current.length === DEFAULT_RELAYS.length &&
current.every((r, i) => norm(r) === norm(DEFAULT_RELAYS[i]))
);
}

View File

@ -10,12 +10,12 @@
fetchAcksForEventIds, fetchAcksForEventIds,
type RelayStatus, type RelayStatus,
} from "../lib/transport.js"; } from "../lib/transport.js";
import { MAX_FILE_BYTES, INLINE_LIMIT } from "../lib/file-transfer.js"; import { MAX_FILE_BYTES } from "../lib/file-transfer.js";
import { import {
loadAttachment, loadAttachment,
saveAttachment, saveAttachment,
type StoredAttachment,
} from "../lib/attachment-store.js"; } from "../lib/attachment-store.js";
import { prepareImageForSend } from "../lib/image-utils.js";
import { lookup, lookupByPrimary, ApiError } from "../lib/api.js"; import { lookup, lookupByPrimary, ApiError } from "../lib/api.js";
import { inboxService } from "../lib/inbox-service.svelte.js"; import { inboxService } from "../lib/inbox-service.svelte.js";
import { verifySubject } from "../lib/verify.js"; import { verifySubject } from "../lib/verify.js";
@ -34,6 +34,7 @@
import { import {
appendOutbound, appendOutbound,
appendOutboundAttachment, appendOutboundAttachment,
clearConversationMessages,
ensureConversation, ensureConversation,
listConversations, listConversations,
markConversationRead, markConversationRead,
@ -42,6 +43,7 @@
setVerified, setVerified,
type Conversation, type Conversation,
} from "../lib/conversations-store.js"; } from "../lib/conversations-store.js";
import { parseFileBody } from "../lib/file-transfer.js";
import type { Identity } from "../lib/kez.js"; import type { Identity } from "../lib/kez.js";
let conversations = $state<Conversation[]>([]); let conversations = $state<Conversation[]>([]);
@ -98,19 +100,37 @@
* and render an optimistic local echo of the attachment so the * and render an optimistic local echo of the attachment so the
* sender sees their image immediately. * sender sees their image immediately.
*/ */
async function onFilePicked(file: File) { async function onFilePicked(rawFile: File) {
if (!session.unlocked || !activeConv) return; if (!session.unlocked || !activeConv) return;
// Snapshot the thread NOW — the user can switch conversations
// during the awaits below, and every field must come from the
// thread the file was picked in.
const conv = activeConv;
fileSendError = null; fileSendError = null;
composing = true;
// HEIC → JPEG (most recipients can't render HEIC) and big photos
// downscaled; see prepareImageForSend. Throws with user-friendly
// guidance if this browser can't decode HEIC (e.g. desktop Chrome).
let file: File;
try {
file = await prepareImageForSend(rawFile);
} catch (e) {
composing = false;
fileSendError = (e as Error).message;
return;
}
if (file.size > MAX_FILE_BYTES) { if (file.size > MAX_FILE_BYTES) {
composing = false;
fileSendError = `Files larger than ${MAX_FILE_BYTES / 1024 / 1024} MB aren't supported yet.`; fileSendError = `Files larger than ${MAX_FILE_BYTES / 1024 / 1024} MB aren't supported yet.`;
return; return;
} }
composing = true;
const raw = new Uint8Array(await file.arrayBuffer()); const raw = new Uint8Array(await file.arrayBuffer());
const filename = file.name || "file"; const filename = file.name || "file";
const mime = file.type || "application/octet-stream"; const mime = file.type || "application/octet-stream";
const peer_primary = activeConv.peer_primary; const peer_primary = conv.peer_primary;
const peer_handle = activeConv.peer_handle; const peer_handle = conv.peer_handle;
// ─── optimistic local echo ───────────────────────────────── // ─── optimistic local echo ─────────────────────────────────
// Save the raw bytes locally so the bubble can render // Save the raw bytes locally so the bubble can render
@ -139,6 +159,7 @@
data_url: dataUrl, data_url: dataUrl,
}); });
await refresh(); await refresh();
scrollChatToBottom();
composing = false; composing = false;
@ -153,7 +174,9 @@
filename, filename,
mime, mime,
raw, raw,
preferRelay: activeConv?.peer_via_relay, preferRelay: conv.peer_via_relay,
recipientNostrPubkey: conv.peer_nostr_pubkey,
extraRelays: peerProfiles.byPrimary[peer_primary]?.read_relays,
}); });
await markOutboundStatus(peer_primary, localSeq, "sent", { await markOutboundStatus(peer_primary, localSeq, "sent", {
event_id: result.pointer_event_id, event_id: result.pointer_event_id,
@ -231,6 +254,98 @@
/** Scroll container for the thread view — bound for auto-scroll. */ /** Scroll container for the thread view — bound for auto-scroll. */
let scrollEl: HTMLDivElement | null = $state(null); let scrollEl: HTMLDivElement | null = $state(null);
/**
* Scroll the active thread to the bottom. Compound problem we're
* solving:
*
* 1. Single rAF isn't enough — Svelte commits DOM nodes but
* browser layout hasn't run; scrollHeight reads stale.
* 2. Image previews inside the new bubble decode async and grow
* the bubble AFTER our first scroll lands.
* 3. THE BIG ONE: on mobile, submitting a form blurs the input,
* keyboard hides, visualViewport HEIGHT GROWS. Our scroll
* already fired against the pre-resize bottom — now there's
* space below the last message and the compose form sits
* where the user expected the message.
*
* Two-pronged fix:
* • Keep the keyboard UP after send (refocus the compose input
* in the user-gesture handler — same as WhatsApp / iMessage).
* If the keyboard never hides, there's no resize.
* • A visualViewport resize listener (separate effect, runs
* anytime in a thread) that re-pins to bottom whenever the
* user was already at the bottom and the layout shifts.
* Belt + suspenders.
*/
function scrollChatToBottom() {
// Where our last pin landed. A retry only re-pins if the user
// hasn't scrolled UP since — content growing below (image decode)
// raises scrollHeight but leaves scrollTop where we put it.
let pinnedAt = -1;
const doScroll = () => {
const el = scrollEl;
if (!el) return;
el.scrollTop = el.scrollHeight;
pinnedAt = el.scrollTop;
};
const retry = () => {
if (scrollEl && scrollEl.scrollTop >= pinnedAt - 2) doScroll();
};
requestAnimationFrame(() => {
requestAnimationFrame(() => {
doScroll();
// Retry windows catch image decode + (on iOS) any straggling
// visualViewport adjustments even if our listener missed.
setTimeout(retry, 100);
setTimeout(retry, 350);
setTimeout(retry, 750);
});
});
}
/** Are we within 80px of the bottom of the thread? Used as the
* "did the user want to stay pinned to bottom" heuristic for the
* visualViewport resize listener. 80px tolerates a few lines of
* drift while still recognising "user scrolled up to read older
* messages". */
function isNearBottom(): boolean {
const el = scrollEl;
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
}
/** Mobile keyboard show/hide reshapes the visualViewport. If the
* user was at the bottom of the thread when that happens, re-pin
* them. Otherwise leave their scroll position alone — if they
* were reading older messages, an auto-jump down would be rude. */
$effect(() => {
if (!activePrimary) return;
const vp = (
window as unknown as { visualViewport?: VisualViewport }
).visualViewport;
if (!vp) return;
let wasNearBottom = true;
const onResize = () => {
if (wasNearBottom) {
// Two-frame wait so the layout settles before we measure.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (scrollEl) scrollEl.scrollTop = scrollEl.scrollHeight;
});
});
}
};
const onScroll = () => {
wasNearBottom = isNearBottom();
};
vp.addEventListener("resize", onResize);
scrollEl?.addEventListener("scroll", onScroll, { passive: true });
return () => {
vp.removeEventListener("resize", onResize);
scrollEl?.removeEventListener("scroll", onScroll);
};
});
/** Tracks the previously-rendered conversation so we know when the /** Tracks the previously-rendered conversation so we know when the
* user just OPENED a different thread (always scroll to bottom) vs. * user just OPENED a different thread (always scroll to bottom) vs.
* when a new message landed in the current one (scroll only if they * when a new message landed in the current one (scroll only if they
@ -288,8 +403,50 @@
function closeZoomedAvatar() { function closeZoomedAvatar() {
zoomedAvatarOpen = false; zoomedAvatarOpen = false;
} }
function onZoomKeydown(e: KeyboardEvent) {
if (e.key === "Escape") closeZoomedAvatar(); // Clear-chat confirmation modal.
let clearChatOpen = $state(false);
let clearChatBusy = $state(false);
function openClearChat() {
if (!activeConv) return;
clearChatOpen = true;
}
function closeClearChat() {
clearChatOpen = false;
}
async function confirmClearChat() {
if (!activeConv || clearChatBusy) return;
clearChatBusy = true;
try {
await clearConversationMessages(activeConv.peer_primary);
await refresh();
clearChatOpen = false;
} finally {
clearChatBusy = false;
}
}
// Tap-to-fullscreen for attached images. Click an image bubble →
// overlay with the picture upscaled. ESC + click-backdrop dismiss.
let zoomedAttachment = $state<{
src: string;
filename: string;
} | null>(null);
function openZoomedAttachment(src: string, filename: string) {
zoomedAttachment = { src, filename };
}
function closeZoomedAttachment() {
zoomedAttachment = null;
}
/** Escape closes whichever overlay is open. Window-level so it
* works without the overlay holding focus. */
function onWindowKeydown(e: KeyboardEvent) {
if (e.key !== "Escape") return;
if (clearChatOpen && !clearChatBusy) closeClearChat();
closeZoomedAttachment();
closeZoomedAvatar();
} }
// ─── Relay state ────────────────────────────────────────────────────── // ─── Relay state ──────────────────────────────────────────────────────
@ -565,6 +722,8 @@
async function send() { async function send() {
if (!session.unlocked || !activeConv || !composeText.trim()) return; if (!session.unlocked || !activeConv || !composeText.trim()) return;
// Snapshot the thread — see onFilePicked.
const conv = activeConv;
composing = true; composing = true;
const body = composeText; const body = composeText;
composeText = ""; composeText = "";
@ -574,8 +733,8 @@
// the user sees their own message immediately. Status icon // the user sees their own message immediately. Status icon
// flips to "sent" once at least one relay accepts, then to // flips to "sent" once at least one relay accepts, then to
// "delivered" when the recipient's client publishes an ack. // "delivered" when the recipient's client publishes an ack.
const peer_primary = activeConv.peer_primary; const peer_primary = conv.peer_primary;
const peer_handle = activeConv.peer_handle; const peer_handle = conv.peer_handle;
let localSeq: number; let localSeq: number;
try { try {
localSeq = await appendOutbound({ localSeq = await appendOutbound({
@ -586,6 +745,17 @@
status: "sending", status: "sending",
}); });
await refresh(); await refresh();
// Keep the keyboard up by re-focusing the compose input — same
// UX as iMessage / WhatsApp. If we DON'T refocus, the form
// submit blurs the input, the soft keyboard slides away, the
// visualViewport grows, and the freshly-sent bubble ends up
// way above where the user expected. The
// visualViewport-resize effect above also catches this case;
// this is the primary defence.
composeEl?.focus();
// Scroll to the bottom AFTER the new message is in the DOM —
// requestAnimationFrame ensures Svelte's render commit ran.
scrollChatToBottom();
} catch (e) { } catch (e) {
alert(`Local append failed: ${(e as Error).message}`); alert(`Local append failed: ${(e as Error).message}`);
composing = false; composing = false;
@ -608,11 +778,21 @@
// depend on the chat-server; if the server's down, we still // depend on the chat-server; if the server's down, we still
// publish to relays and the recipient still gets the message. // publish to relays and the recipient still gets the message.
recipientPrimary: peer_primary, recipientPrimary: peer_primary,
// Their nostr pubkey is needed to fetch their current
// heartbeat for v3 envelope sealing. Cached on the conv row
// since the first inbound DM; undefined for never-replied
// contacts (first send falls back to identity ECDH for that
// one message).
recipientNostrPubkey: conv.peer_nostr_pubkey,
// Reply over the same relay that delivered the recipient's // Reply over the same relay that delivered the recipient's
// most recent message to us — usually the lowest-latency // most recent message to us — usually the lowest-latency
// path for the round-trip. Falls back to our default set if // path for the round-trip. Falls back to our default set if
// unset. // unset.
preferRelay: activeConv?.peer_via_relay, preferRelay: conv.peer_via_relay,
// NIP-65: union the peer's preferred read-relays into our
// publish set, so their client (subscribed to those) sees
// our event even if it's on a different relay pool than us.
extraRelays: peerProfiles.byPrimary[peer_primary]?.read_relays,
}); });
await markOutboundStatus(peer_primary, localSeq, "sent", { await markOutboundStatus(peer_primary, localSeq, "sent", {
event_id: result.event_id, event_id: result.event_id,
@ -891,7 +1071,7 @@
class="min-w-[18px] h-[18px] px-1.5 inline-flex items-center justify-center rounded-full bg-accent text-accent-contrast text-[10px] font-semibold leading-none" class="min-w-[18px] h-[18px] px-1.5 inline-flex items-center justify-center rounded-full bg-accent text-accent-contrast text-[10px] font-semibold leading-none"
aria-label="{c.unread_count} unread message{c.unread_count === 1 ? '' : 's'}" aria-label="{c.unread_count} unread message{c.unread_count === 1 ? '' : 's'}"
> >
{c.unread_count > 99 ? "99+" : c.unread_count} {(c.unread_count ?? 0) > 99 ? "99+" : c.unread_count}
</span> </span>
{/if} {/if}
</div> </div>
@ -949,13 +1129,31 @@
{:else} {:else}
<Avatar seed={activeConv.peer_primary} size={36} /> <Avatar seed={activeConv.peer_primary} size={36} />
{/if} {/if}
<div class="min-w-0"> <div class="min-w-0 flex-1">
<p class="font-mono text-sm font-semibold text-text truncate flex items-center gap-1"> <p class="font-mono text-sm font-semibold text-text truncate flex items-center gap-1">
<span class="truncate">{displayName(activeConv)}</span> <span class="truncate">{displayName(activeConv)}</span>
{#if activeConv.verified}<VerifiedBadge size={15} />{/if} {#if activeConv.verified}<VerifiedBadge size={15} />{/if}
</p> </p>
<p class="text-[10px] text-text-muted truncate font-mono">{activeConv.peer_primary}</p> <p class="text-[10px] text-text-muted truncate font-mono">{activeConv.peer_primary}</p>
</div> </div>
<!-- Clear chat — wipes local message history for this thread
only. Future messages still land here (the row itself
persists). Confirmation modal because this is destructive. -->
<button
type="button"
class="shrink-0 text-text-secondary hover:text-danger -mr-1 p-1"
onclick={openClearChat}
aria-label="Clear chat history"
title="Clear chat history (local only)"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/>
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
<line x1="10" y1="11" x2="10" y2="17"/>
<line x1="14" y1="11" x2="14" y2="17"/>
</svg>
</button>
</div> </div>
<!-- Messages --> <!-- Messages -->
@ -1006,14 +1204,25 @@
<span class="text-xs">⚠ {m.attachment.filename}: assembly failed</span> <span class="text-xs">⚠ {m.attachment.filename}: assembly failed</span>
</div> </div>
{:else if att?.data_url && m.attachment.mime?.startsWith("image/")} {:else if att?.data_url && m.attachment.mime?.startsWith("image/")}
<!-- Image preview. Capped at 320px in the chat; <!-- Image preview. Tap to open fullscreen
tap could open a full-screen viewer (TODO). --> viewer. Capped at 320px tall in chat. -->
<button
type="button"
class="block w-full p-0 border-0 bg-transparent cursor-zoom-in"
style="margin: -2px -8px 4px -8px;"
onclick={() =>
openZoomedAttachment(
att.data_url,
m.attachment!.filename,
)}
aria-label="View {m.attachment.filename} fullscreen"
>
<img <img
src={att.data_url} src={att.data_url}
alt={m.attachment.filename} alt={m.attachment.filename}
class="max-w-full max-h-80 rounded-lg block" class="max-w-full max-h-80 rounded-lg block"
style="margin: -2px -8px 4px -8px;"
/> />
</button>
{:else if att?.data_url} {:else if att?.data_url}
<!-- Non-image: filename + size + download link. --> <!-- Non-image: filename + size + download link. -->
<a <a
@ -1033,9 +1242,36 @@
</span> </span>
{/if} {/if}
{/await} {/await}
{:else}
<!-- Defensive: if the message body looks like a
file payload but the attachment field is
missing (e.g. ingested by an older build
before the file-router existed, or recovered
from an envelope edge case), render an
attachment chip rather than dumping the raw
JSON. The fileBodyFallback derives just
enough to show "📎 filename" + a kind hint. -->
{@const fb = parseFileBody(m.body)}
{#if fb && fb.type === "kez-file-v1" && fb.mode === "inline"}
<div class="flex items-center gap-2 py-1 text-xs opacity-90">
<span class="text-base">📎</span>
<span class="break-all">{fb.filename}</span>
<span class="opacity-70">({(fb.size / 1024).toFixed(0)} KB)</span>
</div>
{:else if fb && fb.type === "kez-file-v1" && fb.mode === "chunked"}
<div class="flex items-center gap-2 py-1 text-xs opacity-90">
<span class="text-base">📎</span>
<span class="break-all">{fb.filename}</span>
<span class="opacity-70">(chunked, {fb.n} parts)</span>
</div>
{:else if fb && fb.type === "kez-file-chunk-v1"}
<!-- A loose chunk that escaped routing. Don't
dump it in the chat — it's plumbing. -->
<span class="text-[10px] opacity-50 italic">(file chunk, hidden)</span>
{:else} {:else}
<span class="whitespace-pre-wrap break-words align-middle">{m.body}</span> <span class="whitespace-pre-wrap break-words align-middle">{m.body}</span>
{/if} {/if}
{/if}
<!-- <!--
Inline timestamp + delivery status. `float-right` + Inline timestamp + delivery status. `float-right` +
a leading non-breaking space pulls the cluster onto a leading non-breaking space pulls the cluster onto
@ -1103,14 +1339,14 @@
{/if} {/if}
<form class="p-3 flex gap-2 items-center" onsubmit={(e) => { e.preventDefault(); send(); }}> <form class="p-3 flex gap-2 items-center" onsubmit={(e) => { e.preventDefault(); send(); }}>
<EmojiButton onpick={insertEmoji} /> <EmojiButton onpick={insertEmoji} />
<!-- Paperclip — image picker. Hidden file input driven <!-- Paperclip — generic file picker. No `accept` filter so
by a styled button. accept=image/* keeps the OS picker the OS picker offers everything (photos, PDFs, docs,
showing photos only on mobile; if you want arbitrary whatever). The transport decides inline vs chunked by
files later, drop the accept. --> size; the bubble decides image-preview vs file-chip
render by mime. -->
<input <input
bind:this={fileInput} bind:this={fileInput}
type="file" type="file"
accept="image/*"
class="hidden" class="hidden"
onchange={(e) => { onchange={(e) => {
const f = (e.currentTarget as HTMLInputElement).files?.[0]; const f = (e.currentTarget as HTMLInputElement).files?.[0];
@ -1153,6 +1389,92 @@
</div> </div>
</div> </div>
<svelte:window onkeydown={onWindowKeydown} />
<!--
Clear-chat confirmation. Wipes only the local message history for
the active thread; the contact row + their nostr pubkey + their
via_relay are preserved so future inbound messages still route to
this same thread instead of creating a duplicate.
-->
{#if clearChatOpen && activeConv}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
aria-label="Clear chat history"
>
<div class="w-full max-w-sm bg-surface border border-border rounded-xl p-5 space-y-4 shadow-2xl">
<div class="space-y-1">
<h3 class="text-base font-semibold text-text">Clear this chat?</h3>
<p class="text-xs text-text-secondary">
Removes all local messages and attachments with
<span class="font-mono text-text">{displayName(activeConv)}</span>
from THIS device. Their copy on their device is untouched.
Anything you've sent that's still in flight on relays may
arrive again as new messages.
</p>
</div>
<div class="flex items-center justify-end gap-2 pt-1">
<button
type="button"
class="px-3 py-2 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text disabled:opacity-50"
disabled={clearChatBusy}
onclick={closeClearChat}
>
Cancel
</button>
<button
type="button"
class="px-3 py-2 text-sm bg-danger text-white font-semibold rounded-md hover:opacity-90 disabled:opacity-50"
disabled={clearChatBusy}
onclick={confirmClearChat}
>
{clearChatBusy ? "Clearing…" : "Clear"}
</button>
</div>
</div>
</div>
{/if}
<!--
Attached-image zoom overlay. A full-bleed close button sits BEHIND
the picture (the img ignores pointer events, so tapping it closes
too); the Download link sits above it as a sibling — links can't
live inside a <button>.
-->
{#if zoomedAttachment}
<div
class="fixed inset-0 z-50 flex flex-col items-center justify-center gap-3 bg-black/85 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
aria-label={zoomedAttachment.filename}
>
<button
type="button"
class="absolute inset-0 cursor-zoom-out"
onclick={closeZoomedAttachment}
aria-label="Close image"
></button>
<img
src={zoomedAttachment.src}
alt={zoomedAttachment.filename}
class="relative object-contain rounded-lg shadow-2xl pointer-events-none"
style="max-width: 92vw; max-height: 80vh;"
/>
<div class="relative flex items-center gap-3 text-xs text-white/80">
<span class="truncate max-w-[60vw] font-mono">{zoomedAttachment.filename}</span>
<a
href={zoomedAttachment.src}
download={zoomedAttachment.filename}
class="px-2 py-1 rounded border border-white/30 hover:border-white/60 hover:bg-white/10"
>
Download
</a>
</div>
</div>
{/if}
<!-- <!--
Avatar zoom overlay. Only renders when the user tapped the thread Avatar zoom overlay. Only renders when the user tapped the thread
header avatar. Black backdrop fills the viewport; the picture header avatar. Black backdrop fills the viewport; the picture
@ -1166,7 +1488,6 @@
type="button" type="button"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm cursor-zoom-out" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm cursor-zoom-out"
onclick={closeZoomedAvatar} onclick={closeZoomedAvatar}
onkeydown={onZoomKeydown}
aria-label="Close profile picture" aria-label="Close profile picture"
> >
<div class="relative flex flex-col items-center gap-3 max-w-[90vw]"> <div class="relative flex flex-col items-center gap-3 max-w-[90vw]">
@ -1178,7 +1499,7 @@
orientations. --> orientations. -->
<img <img
src={peerPic} src={peerPic}
alt="profile picture" alt="{displayName(activeConv)}"
class="object-cover rounded-2xl shadow-2xl" class="object-cover rounded-2xl shadow-2xl"
style="width: min(70vw, 70vh); height: min(70vw, 70vh);" style="width: min(70vw, 70vh); height: min(70vw, 70vh);"
/> />

View File

@ -11,6 +11,23 @@
import { scrambleImage } from "../lib/visual-crypto.js"; import { scrambleImage } from "../lib/visual-crypto.js";
import { hexToBytes } from "@noble/hashes/utils"; import { hexToBytes } from "@noble/hashes/utils";
import Avatar from "../lib/Avatar.svelte"; import Avatar from "../lib/Avatar.svelte";
import {
loadHeartbeatSettings,
saveHeartbeatSettings,
describeHeartbeats,
gcExpiredHeartbeats,
DEFAULT_RETENTION_DAYS,
MIN_RETENTION_DAYS,
MAX_RETENTION_DAYS,
} from "../lib/heartbeat-store.js";
import {
loadCurrentRelays,
saveCustomRelays,
clearCustomRelays,
isUsingDefaultRelays,
isAllowedRelayUrl,
DEFAULT_RELAYS,
} from "../lib/relay-store.js";
import { hasStoredPhrase, unlockIdentity } from "../lib/identity-store.js"; import { hasStoredPhrase, unlockIdentity } from "../lib/identity-store.js";
import { import {
hasStoredBiometric, hasStoredBiometric,
@ -55,6 +72,104 @@
let biometricError = $state<string | null>(null); let biometricError = $state<string | null>(null);
let notifSupported = $state(false); let notifSupported = $state(false);
// ─── Nostr relays ────────────────────────────────────────────────
let relayList = $state<string[]>([]);
let relayDirty = $state(false);
let relayUsingDefaults = $state(true);
let relayNewUrl = $state("");
let relayError = $state<string | null>(null);
let relayShowReload = $state(false);
function refreshRelayUi() {
relayList = loadCurrentRelays();
relayUsingDefaults = isUsingDefaultRelays();
relayDirty = false;
}
function moveRelay(i: number, dir: -1 | 1) {
const j = i + dir;
if (j < 0 || j >= relayList.length) return;
const next = [...relayList];
[next[i], next[j]] = [next[j], next[i]];
relayList = next;
relayDirty = true;
}
function removeRelay(i: number) {
relayList = relayList.filter((_, idx) => idx !== i);
relayDirty = true;
}
function addRelay() {
const t = relayNewUrl.trim();
if (!t) return;
if (!isAllowedRelayUrl(t)) {
relayError = "Relay URL must start with wss:// (ws:// only for localhost).";
return;
}
if (relayList.some((r) => r.toLowerCase().replace(/\/+$/, "") === t.toLowerCase().replace(/\/+$/, ""))) {
relayError = "That relay is already in the list.";
return;
}
relayList = [...relayList, t];
relayNewUrl = "";
relayDirty = true;
relayError = null;
}
async function saveRelays() {
relayError = null;
try {
saveCustomRelays(relayList);
relayUsingDefaults = isUsingDefaultRelays();
relayDirty = false;
relayShowReload = true;
} catch (e) {
relayError = (e as Error).message;
}
}
function restoreRelayDefaults() {
clearCustomRelays();
relayList = [...DEFAULT_RELAYS];
relayUsingDefaults = true;
relayDirty = false;
relayShowReload = true;
relayError = null;
}
function doReload() {
window.location.reload();
}
// ─── Heartbeat ratchet ───────────────────────────────────────────
let heartbeatRetention = $state(DEFAULT_RETENTION_DAYS);
/** Last saved value — lets us warn when the user is SHORTENING it. */
let heartbeatRetentionSaved = $state(DEFAULT_RETENTION_DAYS);
let heartbeatRetentionDirty = $state(false);
let heartbeatDescribe = $state<{
count: number;
newest_age_hours?: number;
oldest_age_hours?: number;
current_pub?: string;
} | null>(null);
async function refreshHeartbeatStatus() {
const s = await loadHeartbeatSettings();
heartbeatRetention = s.retention_days;
heartbeatRetentionSaved = s.retention_days;
heartbeatRetentionDirty = false;
heartbeatDescribe = await describeHeartbeats();
}
async function saveHeartbeatRetention() {
await saveHeartbeatSettings({ retention_days: heartbeatRetention });
// Apply now rather than at next session start — a user shortening
// retention expects old keys gone when they hit Save.
await gcExpiredHeartbeats();
await refreshHeartbeatStatus();
}
let notifPerm = $state<NotificationPermission | "unsupported">("default"); let notifPerm = $state<NotificationPermission | "unsupported">("default");
let testNotifResult = $state<{ ok: boolean; reason?: string } | null>(null); let testNotifResult = $state<{ ok: boolean; reason?: string } | null>(null);
@ -145,6 +260,8 @@
} }
await refreshBiometricStatus(); await refreshBiometricStatus();
notifSupported = notificationsSupported(); notifSupported = notificationsSupported();
void refreshHeartbeatStatus();
refreshRelayUi();
notifPerm = notificationsPermission(); notifPerm = notificationsPermission();
webPushOk = pushSupported(); webPushOk = pushSupported();
pwaInstalled = isStandalonePwa(); pwaInstalled = isStandalonePwa();
@ -609,6 +726,196 @@
{/if} {/if}
</section> </section>
<!-- Nostr relays — user-editable list with reorder + restore -->
<section class="bg-surface border border-border rounded-xl p-6">
<h2 class="text-sm font-semibold text-text uppercase tracking-wider mb-1">
Nostr relays
</h2>
<p class="text-xs text-text-secondary">
The publish/subscribe servers your messages route through.
Earlier entries are tried first when sending — use the arrows
to move a relay up to prefer it. Changes require a page reload
to take effect.
</p>
<ul class="mt-4 space-y-1">
{#each relayList as r, i (r)}
<li class="flex items-center gap-2 bg-elevated border border-border rounded-md px-2 py-1.5">
<span class="font-mono text-xs text-text-secondary w-6 text-right">
{i + 1}.
</span>
<code class="font-mono text-xs text-text flex-1 truncate" title={r}>
{r}
</code>
<button
type="button"
class="px-1.5 py-0.5 text-text-muted hover:text-text disabled:opacity-30"
disabled={i === 0}
onclick={() => moveRelay(i, -1)}
aria-label="Move up"
title="Move up (prefer this relay)"
>
↑
</button>
<button
type="button"
class="px-1.5 py-0.5 text-text-muted hover:text-text disabled:opacity-30"
disabled={i === relayList.length - 1}
onclick={() => moveRelay(i, 1)}
aria-label="Move down"
title="Move down"
>
↓
</button>
<button
type="button"
class="px-1.5 py-0.5 text-text-muted hover:text-danger"
onclick={() => removeRelay(i)}
aria-label="Remove relay"
title="Remove this relay"
>
✕
</button>
</li>
{/each}
{#if relayList.length === 0}
<li class="text-xs text-text-muted italic px-2 py-1">
No relays — add at least one or restore defaults.
</li>
{/if}
</ul>
<form
class="mt-3 flex gap-2"
onsubmit={(e) => {
e.preventDefault();
addRelay();
}}
>
<input
type="text"
bind:value={relayNewUrl}
placeholder="wss://relay.example.com"
class="flex-1 px-3 py-1.5 text-xs bg-elevated border border-border rounded-md text-text placeholder:text-text-muted focus:border-accent focus:outline-none font-mono"
autocomplete="off"
/>
<button
type="submit"
class="px-3 py-1.5 text-xs border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text disabled:opacity-50"
disabled={!relayNewUrl.trim()}
>
Add
</button>
</form>
{#if relayError}
<p class="mt-2 text-xs text-danger">{relayError}</p>
{/if}
<div class="mt-4 flex items-center justify-between gap-2">
{#if !relayUsingDefaults}
<button
type="button"
class="px-3 py-1.5 text-xs border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text"
onclick={restoreRelayDefaults}
>
Restore defaults ({DEFAULT_RELAYS.length})
</button>
{:else}
<span class="text-[10px] text-text-muted italic">Using built-in defaults</span>
{/if}
{#if relayDirty}
<button
type="button"
class="px-3 py-1.5 text-xs bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim disabled:opacity-50"
disabled={relayList.length === 0}
onclick={saveRelays}
>
Save
</button>
{/if}
</div>
{#if relayShowReload}
<div class="mt-3 px-3 py-2 bg-accent/10 border border-accent/40 rounded-md flex items-center justify-between gap-2">
<p class="text-xs text-text">
Relay list saved. Reload kez-chat to apply.
</p>
<button
type="button"
class="px-3 py-1 text-xs bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim"
onclick={doReload}
>
Reload
</button>
</div>
{/if}
</section>
<!-- Heartbeat ratchet (forward secrecy retention) -->
<section class="bg-surface border border-border rounded-xl p-6">
<h2 class="text-sm font-semibold text-text uppercase tracking-wider mb-1">
Forward secrecy
</h2>
<p class="text-xs text-text-secondary">
Each day, your device generates a fresh decryption key and
publishes its public half to nostr (called a "heartbeat"). Old
keys are deleted on a schedule — once a key is gone, NOBODY
can decrypt messages addressed to it, not even you. Longer
retention forgives long offline periods; shorter retention
leaks less if your device is ever stolen.
</p>
<div class="mt-4 flex items-center gap-3">
<label
for="hb-retention"
class="text-sm text-text-secondary shrink-0"
>
Retention:
</label>
<input
id="hb-retention"
type="range"
min={MIN_RETENTION_DAYS}
max={MAX_RETENTION_DAYS}
step="1"
bind:value={heartbeatRetention}
oninput={() => (heartbeatRetentionDirty = true)}
class="flex-1"
/>
<span class="font-mono text-sm text-text shrink-0 w-16 text-right">
{heartbeatRetention} days
</span>
</div>
{#if heartbeatRetentionDirty && heartbeatRetention < heartbeatRetentionSaved}
<p class="mt-2 text-xs text-danger">
Saving deletes keys retired more than {heartbeatRetention} days
ago right away. Messages sent to those keys that haven't
arrived yet can never be decrypted.
</p>
{/if}
<div class="mt-3 flex items-center justify-between">
{#if heartbeatDescribe}
<p class="text-[10px] text-text-muted">
{heartbeatDescribe.count} key{heartbeatDescribe.count === 1 ? "" : "s"} in storage
{#if heartbeatDescribe.newest_age_hours !== undefined}
· current age {Math.round(heartbeatDescribe.newest_age_hours)} h
{/if}
</p>
{:else}
<span></span>
{/if}
{#if heartbeatRetentionDirty}
<button
type="button"
class="px-3 py-1.5 text-xs bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim"
onclick={saveHeartbeatRetention}
>
Save
</button>
{/if}
</div>
</section>
<!-- Account / About --> <!-- Account / About -->
<section class="bg-surface border border-border rounded-xl p-6 space-y-4"> <section class="bg-surface border border-border rounded-xl p-6 space-y-4">
<h2 class="text-sm font-semibold text-text uppercase tracking-wider">Account</h2> <h2 class="text-sm font-semibold text-text uppercase tracking-wider">Account</h2>

View File

@ -13,6 +13,7 @@
getStoredBiometricMeta, getStoredBiometricMeta,
} from "../lib/webauthn.js"; } from "../lib/webauthn.js";
import { session } from "../lib/store.svelte.js"; import { session } from "../lib/store.svelte.js";
import { wipeAllHeartbeats } from "../lib/heartbeat-store.js";
let meta = $state<{ handle: string; server: string; primary: string; created_at: string } | null>(null); let meta = $state<{ handle: string; server: string; primary: string; created_at: string } | null>(null);
let passphrase = $state(""); let passphrase = $state("");
@ -76,6 +77,9 @@
async function forget() { async function forget() {
if (!confirm("Delete the local copy of this account? You'll need your seed to restore.")) return; if (!confirm("Delete the local copy of this account? You'll need your seed to restore.")) return;
await deleteStoredIdentity(); await deleteStoredIdentity();
// Heartbeat privates can decrypt retained messages without the
// seed — they must not outlive the account on this device.
await wipeAllHeartbeats();
session.lock(); session.lock();
push("/"); push("/");
} }