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:
parent
89cb9f11e0
commit
79e7e9536b
3
.gitignore
vendored
3
.gitignore
vendored
@ -57,3 +57,6 @@ kez-chat/deploy/prebuilt/
|
||||
# the path below is just a historical fence-post in case anyone has the
|
||||
# old in-repo cache lying around from before that move.
|
||||
kez-chat/deploy/.buildx-cache/
|
||||
|
||||
# Syncthing per-folder ignore config — machine-local, not project source
|
||||
.stignore
|
||||
|
||||
@ -31,10 +31,11 @@ rand = "0.8"
|
||||
nostr-sdk = { version = "0.36", default-features = false, features = ["all-nips"] }
|
||||
hkdf = "0.12"
|
||||
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"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[dev-dependencies]
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||
tempfile = "3"
|
||||
|
||||
@ -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`)
|
||||
- WebFinger discovery (`GET /.well-known/webfinger`)
|
||||
- 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`)
|
||||
Live at **<https://kez.lat>** — installable as a PWA on iOS, Android, and
|
||||
desktop.
|
||||
|
||||
Designed in [`document.md`](document.md). Spec for the underlying KEZ
|
||||
identity layer in [`../SPEC.md`](../SPEC.md).
|
||||
kez-chat is the flagship app for [KEZ](../README.md), a portable,
|
||||
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
|
||||
✅ 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
|
||||
## What you get
|
||||
|
||||
⚠️ NATS auth callout returns 501 — wired up in v0.2
|
||||
⚠️ Svelte SPA build pipeline not yet in place — placeholder HTML for now
|
||||
⚠️ TLS terminated upstream (no cert handling in this binary)
|
||||
- **An account in seconds, no personal data.** Pick a handle
|
||||
(`you@kez.lat`); the app generates a key on your device. Your backup is
|
||||
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
|
||||
# Run from source
|
||||
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:
|
||||
|
||||
| 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)
|
||||
**Web app** (from `kez-chat/web/`) — Vite proxies `/v1`, `/internal`, and
|
||||
`/.well-known` to the server on `:6969`:
|
||||
|
||||
```sh
|
||||
cd deploy
|
||||
docker compose up -d --build
|
||||
npm ci
|
||||
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 |
|
||||
| `nats` | 4222 (native), 8443 (WebSocket), 8222 (monitoring) | Dumb broker, JetStream enabled |
|
||||
| `sig-server` | 7878 | Sigchain storage (the existing `rust-sig-server`) |
|
||||
| `VITE_TRANSPORT` | `server` (`.env` sets `nostr`) | `nostr` = relays (production); `server` = HTTP mailbox on the chat-server |
|
||||
| `VITE_NOSTR_RELAYS` | 5 default relays | Build-time default relay list; users can override in Settings |
|
||||
|
||||
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
|
||||
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
|
||||
via `reqwest`. No mocks. They cover: healthz, lookup, registration
|
||||
(success + duplicate + wrong-server + reserved-name + tampered-sig +
|
||||
stale-timestamp), WebFinger, the placeholder SPA, and the NATS auth
|
||||
callout stub.
|
||||
Images build from the **repo root** as context (the server depends on
|
||||
`rust/crates/kez-core`). For production, kez.lat uses a local
|
||||
cross-build + rsync script (`deploy/deploy-fast.local.sh`, gitignored
|
||||
because it holds host credentials) that builds the binary and app on a
|
||||
fast machine and restarts only the runtime container.
|
||||
|
||||
## Endpoints in detail
|
||||
## Checks
|
||||
|
||||
### `GET /v1/healthz`
|
||||
|
||||
```json
|
||||
{ "status": "ok", "server": "kez.lat", "version": "0.1.0" }
|
||||
```sh
|
||||
cd web
|
||||
npm run check # svelte-check + types
|
||||
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
|
||||
{
|
||||
"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"
|
||||
}
|
||||
```
|
||||
## More reading
|
||||
|
||||
Returns 404 if the handle isn't registered.
|
||||
|
||||
### `POST /v1/register`
|
||||
|
||||
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.
|
||||
- [SECURITY.md](SECURITY.md) — threat model and crypto, in plain language
|
||||
- [web/NOSTR-CHAT.md](web/NOSTR-CHAT.md) — how messages ride on nostr
|
||||
- [TODO.md](TODO.md) — roadmap and design notes (heartbeat ratchet, v4)
|
||||
- [../SPEC.md](../SPEC.md) — the KEZ identity protocol
|
||||
|
||||
## License
|
||||
|
||||
|
||||
406
kez-chat/SECURITY.md
Normal file
406
kez-chat/SECURITY.md
Normal 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`.
|
||||
@ -20,7 +20,7 @@ review reports are at the bottom.
|
||||
|
||||
## 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)
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
handle=<plain handle> sub_count=N` at INFO level. Operator-side log
|
||||
@ -155,7 +155,48 @@ server vouches.
|
||||
|
||||
## 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
|
||||
a seed once decrypts ALL retained history that any relay still has — and
|
||||
|
||||
@ -4,6 +4,8 @@
|
||||
//! GET /v1/healthz liveness
|
||||
//! GET /v1/u/:handle handle → primary + sigchain pointer + endpoints
|
||||
//! 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
|
||||
//! 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/unsubscribe/:handle", post(push_unsubscribe))
|
||||
.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("/internal/nats/auth", post(nats_auth_callout));
|
||||
|
||||
@ -149,12 +154,10 @@ fn handle_response(config: &Config, record: &HandleRecord) -> HandleResponse {
|
||||
handle: record.handle.clone(),
|
||||
fqhn: format!("{}@{}", record.handle, config.server),
|
||||
primary: record.primary.to_string(),
|
||||
sigchain_url: format!(
|
||||
"{}/v1/sigchains/{}/{}",
|
||||
config.sig_server_url.trim_end_matches('/'),
|
||||
scheme,
|
||||
id
|
||||
),
|
||||
// Public address, served by `sigchain_proxy` below. The
|
||||
// sig-server itself (`sig_server_url`) is only reachable inside
|
||||
// the deploy network, so it must never be handed to clients.
|
||||
sigchain_url: format!("https://{}/v1/sigchains/{}/{}", config.server, scheme, id),
|
||||
registered_at: record.registered_at.to_rfc3339(),
|
||||
proofs: record
|
||||
.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()
|
||||
}
|
||||
|
||||
@ -5,5 +5,8 @@
|
||||
VITE_TRANSPORT=nostr
|
||||
|
||||
# Relays to publish to / read from (comma-separated). Optional — the
|
||||
# transport falls back to these same defaults if unset.
|
||||
VITE_NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net
|
||||
# transport falls back to these same defaults if unset. The set
|
||||
# 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
|
||||
|
||||
156
kez-chat/web/package-lock.json
generated
156
kez-chat/web/package-lock.json
generated
@ -11,6 +11,7 @@
|
||||
"@bokuweb/zstd-wasm": "^0.0.22",
|
||||
"@noble/curves": "^1.6.0",
|
||||
"@noble/hashes": "^1.5.0",
|
||||
"@noble/post-quantum": "^0.7.1",
|
||||
"@scure/base": "^1.1.9",
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"canonicalize": "^2.0.0",
|
||||
@ -2148,9 +2149,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2168,9 +2166,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2188,9 +2183,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2208,9 +2200,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2228,9 +2217,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2248,9 +2234,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2268,9 +2251,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2294,9 +2274,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2320,9 +2297,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2346,9 +2320,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2372,9 +2343,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2398,9 +2366,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2586,6 +2551,62 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz",
|
||||
@ -2811,9 +2832,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2828,9 +2846,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2845,9 +2860,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2862,9 +2874,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2879,9 +2888,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2896,9 +2902,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2913,9 +2916,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2930,9 +2930,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2947,9 +2944,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2964,9 +2958,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2981,9 +2972,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -2998,9 +2986,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -3015,9 +3000,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -3384,9 +3366,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -3404,9 +3383,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -3424,9 +3400,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -3444,9 +3417,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -5743,9 +5713,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -5767,9 +5734,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -5791,9 +5755,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -5815,9 +5776,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
"@bokuweb/zstd-wasm": "^0.0.22",
|
||||
"@noble/curves": "^1.6.0",
|
||||
"@noble/hashes": "^1.5.0",
|
||||
"@noble/post-quantum": "^0.7.1",
|
||||
"@scure/base": "^1.1.9",
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"canonicalize": "^2.0.0",
|
||||
|
||||
@ -67,7 +67,7 @@
|
||||
src={picture}
|
||||
width={size}
|
||||
height={size}
|
||||
alt="profile picture"
|
||||
alt=""
|
||||
class="shrink-0 object-cover"
|
||||
style="border-radius: {Math.max(4, size * 0.2)}px; {ring
|
||||
? 'box-shadow: 0 0 0 2px var(--color-accent);'
|
||||
|
||||
@ -19,8 +19,8 @@
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
aria-label="verified"
|
||||
{title}
|
||||
>
|
||||
<title>{title}</title>
|
||||
<!-- 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="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)"/>
|
||||
|
||||
@ -10,6 +10,7 @@ import { ed25519 } from "@noble/curves/ed25519";
|
||||
import { hexToBytes } from "@noble/hashes/utils";
|
||||
import { get, set } from "idb-keyval";
|
||||
import type { Identity } from "./kez.js";
|
||||
import { deleteAttachment, deleteChunkBuffer } from "./attachment-store.js";
|
||||
|
||||
// :v2 — schema reshaped to key by peer_primary (canonical identity) instead
|
||||
// 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
|
||||
* caller can update the status later (sending → sent → delivered).
|
||||
|
||||
@ -23,10 +23,23 @@ import { ed25519, x25519 } from "@noble/curves/ed25519";
|
||||
import { sha256 } from "@noble/hashes/sha2";
|
||||
import { hkdf } from "@noble/hashes/hkdf";
|
||||
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
|
||||
import { ml_kem768 } from "@noble/post-quantum/ml-kem.js";
|
||||
import { canonicalBytes, type Identity } from "./kez.js";
|
||||
|
||||
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_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. */
|
||||
export interface MessagePlaintext {
|
||||
@ -40,24 +53,49 @@ export interface MessagePlaintext {
|
||||
|
||||
/** 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
|
||||
* NEXT TO the ciphertext. Relays could JSON-parse `event.content`
|
||||
* and build a perfect social graph. Decrypt-only support; we
|
||||
* never emit new v1 envelopes.
|
||||
*
|
||||
* v2: fixed — only an EPHEMERAL x25519 public key (per-message,
|
||||
* discarded right after sealing) and the ciphertext are visible
|
||||
* outside the encrypted blob. `from` (KEZ identity) lives INSIDE
|
||||
* the plaintext, verified post-decrypt via ed25519 sig. Forward
|
||||
* secrecy: a compromised long-term ed25519 seed can no longer
|
||||
* decrypt past captured ciphertexts (the ephemeral private key
|
||||
* was destroyed at send time).
|
||||
* v2: fixed metadata leak — only an EPHEMERAL x25519 public key
|
||||
* (per-message, discarded right after sealing) and the
|
||||
* ciphertext are visible outside the encrypted blob. `from`
|
||||
* (KEZ identity) lives INSIDE the plaintext, verified post-
|
||||
* decrypt via ed25519 sig. Per-message forward secrecy from
|
||||
* the SENDER side: a compromised long-term ed25519 seed cannot
|
||||
* 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. */
|
||||
export interface SealedEnvelopeV1 {
|
||||
@ -88,6 +126,51 @@ export interface SealedEnvelopeV2 {
|
||||
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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@ -238,12 +321,31 @@ export async function openMessage(opts: {
|
||||
envelope: SealedEnvelope;
|
||||
myHandle: string;
|
||||
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> {
|
||||
const env = opts.envelope;
|
||||
let plaintext: MessagePlaintext;
|
||||
if (env.v === 1) plaintext = await openMessageV1(env, opts.myHandle, 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(
|
||||
`unsupported envelope version: ${(env as { v: number }).v}`,
|
||||
);
|
||||
@ -372,3 +474,367 @@ async function openMessageV2(
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
261
kez-chat/web/src/lib/heartbeat-store.ts
Normal file
261
kez-chat/web/src/lib/heartbeat-store.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
94
kez-chat/web/src/lib/heartbeat.selfcheck.ts
Normal file
94
kez-chat/web/src/lib/heartbeat.selfcheck.ts
Normal 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");
|
||||
@ -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
|
||||
* base64 payload length. Used by Settings to surface "your picture
|
||||
* is X KB" so the user knows what they're publishing. */
|
||||
|
||||
@ -43,12 +43,20 @@ import {
|
||||
} from "./conversations-store.js";
|
||||
import type { Identity } from "./kez.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 {
|
||||
parseFileBody,
|
||||
base64ToBytes,
|
||||
assembleChunks,
|
||||
bytesToDataUrl,
|
||||
} from "./file-transfer.js";
|
||||
import {
|
||||
findHeartbeatPrivateByPub,
|
||||
gcExpiredHeartbeats,
|
||||
getOrRotateCurrentHeartbeat,
|
||||
} from "./heartbeat-store.js";
|
||||
import { publishHeartbeat } from "./nostr-transport.js";
|
||||
import {
|
||||
chunkBufferIsComplete,
|
||||
deleteChunkBuffer,
|
||||
@ -60,6 +68,15 @@ import {
|
||||
|
||||
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";
|
||||
|
||||
class InboxService {
|
||||
@ -80,6 +97,20 @@ class InboxService {
|
||||
* was either already-known on session start (so the user has seen it
|
||||
* on this or another device) or already notified about. */
|
||||
#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. */
|
||||
async start(handle: string, seed: Uint8Array) {
|
||||
@ -107,6 +138,13 @@ class InboxService {
|
||||
onAck: (eventId, sigHex) => void this.#ingestAck(eventId, sigHex),
|
||||
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);
|
||||
// Eager first poll so we catch up anything queued before this session.
|
||||
void this.#heartbeat();
|
||||
@ -116,6 +154,48 @@ class InboxService {
|
||||
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() {
|
||||
this.#stream?.close();
|
||||
@ -143,6 +223,7 @@ class InboxService {
|
||||
|
||||
async #heartbeat() {
|
||||
if (!this.#handle || !this.#seed) return;
|
||||
void this.#heartbeatRatchet(false);
|
||||
try {
|
||||
const since = await getGlobalCursor();
|
||||
const { messages } = await pollInbox({
|
||||
@ -160,8 +241,17 @@ class InboxService {
|
||||
|
||||
async #ingest(m: InboxMessage) {
|
||||
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 {
|
||||
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).
|
||||
let displayName = "";
|
||||
const existing = await getConversation(pt.from);
|
||||
@ -196,6 +286,7 @@ class InboxService {
|
||||
// peer-profile fetch, badge bump) to run for inline files
|
||||
// and pointers, but NOT for chunks (which are user-invisible
|
||||
// plumbing). Bail early on chunks.
|
||||
if (envKey) await this.#markEnvelopeSeen(envKey);
|
||||
if (fileBody.type === "kez-file-chunk-v1") return;
|
||||
} else {
|
||||
await appendInbound({
|
||||
@ -207,6 +298,7 @@ class InboxService {
|
||||
peer_nostr_pubkey: m.sender_nostr_pubkey,
|
||||
via_relay: m.via_relay,
|
||||
});
|
||||
if (envKey) await this.#markEnvelopeSeen(envKey);
|
||||
}
|
||||
|
||||
// First time we've seen this peer's nostr pubkey? Kick off a
|
||||
|
||||
@ -6,7 +6,13 @@
|
||||
import { ed25519 } from "@noble/curves/ed25519";
|
||||
import { bytesToHex } from "@noble/hashes/utils";
|
||||
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";
|
||||
|
||||
/** 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 —
|
||||
* but skipping the extra round-trip is faster. */
|
||||
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 }> {
|
||||
const recipientHandle = opts.recipient.split("@")[0];
|
||||
let resolvedPrimary: Identity;
|
||||
@ -164,13 +173,22 @@ export async function pollInbox(opts: {
|
||||
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(
|
||||
envelope: SealedEnvelope,
|
||||
myHandle: string,
|
||||
mySeed: Uint8Array,
|
||||
resolveHeartbeatPriv?: ResolveHeartbeatPrivs,
|
||||
): Promise<MessagePlaintext> {
|
||||
return openMessage({ envelope, myHandle, mySeed });
|
||||
return openMessage({
|
||||
envelope,
|
||||
myHandle,
|
||||
mySeed,
|
||||
resolveHeartbeatPriv,
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@ -268,6 +286,7 @@ export async function sendAck(_opts: {
|
||||
ackedEventId: string;
|
||||
originalSenderNostrPubkey?: string;
|
||||
preferRelay?: string;
|
||||
extraRelays?: string[];
|
||||
}): Promise<void> {
|
||||
/* server transport: receipts not implemented in v0.1 */
|
||||
}
|
||||
@ -292,6 +311,8 @@ export async function sendFile(_opts: {
|
||||
mime: string;
|
||||
raw: Uint8Array;
|
||||
preferRelay?: string;
|
||||
extraRelays?: string[];
|
||||
recipientNostrPubkey?: string;
|
||||
progress?: (done: number, total: number) => void;
|
||||
}): Promise<{
|
||||
pointer_event_id: string;
|
||||
|
||||
@ -18,7 +18,17 @@
|
||||
import { ed25519 } from "@noble/curves/ed25519";
|
||||
import { bytesToHex } from "@noble/hashes/utils";
|
||||
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 {
|
||||
bytesToBase64,
|
||||
chunkifyBytes,
|
||||
@ -94,18 +104,15 @@ export function getRelayStatuses(): RelayStatus[] {
|
||||
});
|
||||
}
|
||||
|
||||
/** Relays to publish to / read from. Override with VITE_NOSTR_RELAYS (csv).
|
||||
* Default set: 3 long-running general-purpose relays (damus, nos, primal)
|
||||
* + 2 popular extras (snort.social, nostr.wine) — chosen for redundancy
|
||||
* and geographic diversity. If any single relay is slow/down our publish
|
||||
* still succeeds as long as one accepts. */
|
||||
const RELAYS: string[] = (
|
||||
import.meta.env.VITE_NOSTR_RELAYS ??
|
||||
"wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr.wine"
|
||||
)
|
||||
.split(",")
|
||||
.map((r: string) => r.trim())
|
||||
.filter(Boolean);
|
||||
/** Relays to publish to / read from. Loaded synchronously from
|
||||
* relay-store at module init: user override from localStorage,
|
||||
* falling back to env override (VITE_NOSTR_RELAYS), falling back
|
||||
* to a hardcoded 5-relay default. Order matters — earlier entries
|
||||
* win the publish race more often, so users can express
|
||||
* preference in Settings. Changes from Settings require a page
|
||||
* reload to apply (we do that with a clear UI prompt). */
|
||||
import { loadCurrentRelays } from "./relay-store.js";
|
||||
const RELAYS: string[] = loadCurrentRelays();
|
||||
|
||||
/** One pool for the whole session — relay connections are reused.
|
||||
* `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
|
||||
* relay that delivered the most recent inbound message from this
|
||||
* peer) goes first, then the rest of the configured set, deduped.
|
||||
* Putting a preferred relay first means the WebSocket open + publish
|
||||
* race usually completes against it — same network path the inbound
|
||||
* message came in on, so geographically/network-wise the fastest.
|
||||
* peer) goes first, then the peer's NIP-65 read-relays (so their
|
||||
* client subscribed to those receives the event), then the rest of
|
||||
* the configured set. Deduped + capped so a peer that lists 20
|
||||
* 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[] {
|
||||
if (!preferRelay) return RELAYS;
|
||||
const norm = preferRelay.replace(/\/$/, "");
|
||||
const matches = RELAYS.filter((r) => r.replace(/\/$/, "") === norm);
|
||||
const others = RELAYS.filter((r) => r.replace(/\/$/, "") !== norm);
|
||||
return [...matches, ...others];
|
||||
const MAX_RELAYS_PER_SEND = 10;
|
||||
function orderedRelaysForSend(
|
||||
preferRelay?: string,
|
||||
extraRelays?: string[],
|
||||
): string[] {
|
||||
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: {
|
||||
@ -339,6 +563,12 @@ export async function sendMessage(opts: {
|
||||
* recipient's last message arrived on. Falls back to the full
|
||||
* configured relay set if unset or unknown. */
|
||||
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
|
||||
* 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.
|
||||
*/
|
||||
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 }> {
|
||||
const recipientHandle = opts.recipient.split("@")[0];
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Our own encryption layer — identical to the server transport.
|
||||
const envelope = await sealMessage({
|
||||
// ─── envelope selection: v3 (heartbeat) by default, fallback ─
|
||||
// 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,
|
||||
senderPrimary: opts.senderPrimary,
|
||||
recipientHandle,
|
||||
recipientPrimary,
|
||||
recipientNostrPubkey: opts.recipientNostrPubkey,
|
||||
body: opts.body,
|
||||
});
|
||||
|
||||
@ -382,7 +630,7 @@ export async function sendMessage(opts: {
|
||||
// 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
|
||||
// relay.
|
||||
const ordered = orderedRelaysForSend(opts.preferRelay);
|
||||
const ordered = orderedRelaysForSend(opts.preferRelay, opts.extraRelays);
|
||||
const publishPromises = pool().publish(ordered, signed);
|
||||
// Promise.any returns the first-fulfilled value; if every relay
|
||||
// rejects, it throws AggregateError. We still want best-effort
|
||||
@ -442,14 +690,18 @@ async function publishRawBody(opts: {
|
||||
recipientPrimary: Identity;
|
||||
body: 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 }> {
|
||||
const envelope = await sealMessage({
|
||||
senderSeed: opts.senderSeed,
|
||||
senderPrimary: opts.senderPrimary,
|
||||
recipientHandle: opts.recipientHandle,
|
||||
recipientPrimary: opts.recipientPrimary,
|
||||
body: opts.body,
|
||||
});
|
||||
// Same v3 / fallback decision as sendMessage. File-transfer chunks
|
||||
// and pointers go through here, so they get heartbeat-ratcheted
|
||||
// automatically too.
|
||||
const envelope = await sealForRecipient(opts);
|
||||
const sk = nostrSecretFromSeed(opts.senderSeed);
|
||||
const tmpl: EventTemplate = {
|
||||
kind: KEZ_DM_KIND,
|
||||
@ -458,7 +710,7 @@ async function publishRawBody(opts: {
|
||||
content: JSON.stringify(envelope),
|
||||
};
|
||||
const signed = finalizeEvent(tmpl, sk);
|
||||
const ordered = orderedRelaysForSend(opts.preferRelay);
|
||||
const ordered = orderedRelaysForSend(opts.preferRelay, opts.extraRelays);
|
||||
const publishPromises = pool().publish(ordered, signed);
|
||||
let acceptedBy: string | undefined;
|
||||
try {
|
||||
@ -505,6 +757,15 @@ export async function sendFile(opts: {
|
||||
mime: string;
|
||||
raw: Uint8Array;
|
||||
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;
|
||||
}): Promise<SendFileResult> {
|
||||
const { raw } = opts;
|
||||
@ -683,6 +944,12 @@ export async function sendAck(opts: {
|
||||
* the same relay, since that path was demonstrably working for
|
||||
* this peer. Falls back to the full set if unset. */
|
||||
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> {
|
||||
const sk = nostrSecretFromSeed(opts.ackingSeed);
|
||||
const targetAddr = addrFromPrimary(opts.originalSenderPrimary);
|
||||
@ -704,7 +971,7 @@ export async function sendAck(opts: {
|
||||
content: "",
|
||||
};
|
||||
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 acceptedSomewhere = results.some((r) => r.status === "fulfilled");
|
||||
if (!acceptedSomewhere) {
|
||||
|
||||
@ -26,6 +26,7 @@
|
||||
// on access (peers may have updated their picture).
|
||||
|
||||
import { get, set } from "idb-keyval";
|
||||
import { isAllowedRelayUrl, loadCurrentRelays } from "./relay-store.js";
|
||||
import { hexToBytes } from "@noble/hashes/utils";
|
||||
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 FETCH_TIMEOUT_MS = 8000;
|
||||
|
||||
/** Same default-relay list as nostr-transport.ts. */
|
||||
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);
|
||||
/** Same relay list as nostr-transport.ts (user override → env → default). */
|
||||
const RELAYS: string[] = loadCurrentRelays();
|
||||
|
||||
export interface CachedPeerProfile {
|
||||
/** The KEZ primary key — canonical identifier; never changes. */
|
||||
@ -73,6 +68,15 @@ export interface CachedPeerProfile {
|
||||
fetched_at: string;
|
||||
/** Event id of the kind:0 we descrambled — debug breadcrumb. */
|
||||
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>;
|
||||
@ -133,7 +137,9 @@ export async function fetchPeerProfile(opts: {
|
||||
try {
|
||||
const events = await Promise.race([
|
||||
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],
|
||||
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
|
||||
const latest = events.sort((a, b) => b.created_at - a.created_at)[0];
|
||||
|
||||
let metadata: Record<string, unknown>;
|
||||
try {
|
||||
metadata = JSON.parse(latest.content);
|
||||
} catch (e) {
|
||||
console.warn(`peer-profile: bad kind:0 content for ${opts.peer_primary}`, e);
|
||||
return cached;
|
||||
// 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 {
|
||||
metadata = JSON.parse(latestKind0.content);
|
||||
} catch (e) {
|
||||
console.warn(`peer-profile: bad kind:0 content for ${opts.peer_primary}`, e);
|
||||
// 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 = {
|
||||
@ -157,7 +202,9 @@ export async function fetchPeerProfile(opts: {
|
||||
name: typeof metadata.name === "string" ? metadata.name : undefined,
|
||||
about: typeof metadata.about === "string" ? metadata.about : undefined,
|
||||
fetched_at: new Date().toISOString(),
|
||||
source_event_id: latest.id,
|
||||
source_event_id: latestKind0?.id,
|
||||
read_relays,
|
||||
write_relays,
|
||||
};
|
||||
|
||||
// ─── descramble path ────────────────────────────────────────
|
||||
|
||||
@ -27,6 +27,7 @@
|
||||
// helper which keeps a $state cell in sync.
|
||||
|
||||
import { get, set, del } from "idb-keyval";
|
||||
import { loadCurrentRelays } from "./relay-store.js";
|
||||
import { hexToBytes } from "@noble/hashes/utils";
|
||||
import {
|
||||
finalizeEvent,
|
||||
@ -42,15 +43,8 @@ import type { Identity } from "./kez.js";
|
||||
|
||||
const PROFILE_KEY = "kez-chat:my-profile:v1";
|
||||
|
||||
/** Same default-relay list as nostr-transport.ts. Profile publish is
|
||||
* best-effort; if relays change, we just re-publish on next save. */
|
||||
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);
|
||||
/** Same relay list as nostr-transport.ts (user override → env → default). */
|
||||
const RELAYS: string[] = loadCurrentRelays();
|
||||
|
||||
export interface StoredProfile {
|
||||
/** 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.
|
||||
*/
|
||||
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(
|
||||
seed: Uint8Array,
|
||||
handle: string,
|
||||
): Promise<void> {
|
||||
const relaySig = RELAYS.join(",");
|
||||
let baselineDone = false;
|
||||
let relayListDone = false;
|
||||
try {
|
||||
if (localStorage.getItem(BASELINE_PUBLISHED_KEY) === "1") return;
|
||||
baselineDone = localStorage.getItem(BASELINE_PUBLISHED_KEY) === "1";
|
||||
relayListDone = localStorage.getItem(RELAYLIST_PUBLISHED_KEY) === relaySig;
|
||||
} catch {
|
||||
/* private mode — proceed anyway, worst case we publish once per tab */
|
||||
}
|
||||
if (baselineDone && relayListDone) return;
|
||||
try {
|
||||
const sk = nostrSecretFromSeed(seed);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
@ -142,13 +146,18 @@ export async function publishKind0BaselineIfNeeded(
|
||||
|
||||
const pool = new SimplePool();
|
||||
try {
|
||||
await Promise.allSettled(pool.publish(RELAYS, signedKind0));
|
||||
await Promise.allSettled(pool.publish(RELAYS, signedKind10002));
|
||||
if (!baselineDone) {
|
||||
await Promise.allSettled(pool.publish(RELAYS, signedKind0));
|
||||
}
|
||||
if (!relayListDone) {
|
||||
await Promise.allSettled(pool.publish(RELAYS, signedKind10002));
|
||||
}
|
||||
} finally {
|
||||
pool.close(RELAYS);
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(BASELINE_PUBLISHED_KEY, "1");
|
||||
localStorage.setItem(RELAYLIST_PUBLISHED_KEY, relaySig);
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
|
||||
118
kez-chat/web/src/lib/relay-store.ts
Normal file
118
kez-chat/web/src/lib/relay-store.ts
Normal 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]))
|
||||
);
|
||||
}
|
||||
@ -10,12 +10,12 @@
|
||||
fetchAcksForEventIds,
|
||||
type RelayStatus,
|
||||
} 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 {
|
||||
loadAttachment,
|
||||
saveAttachment,
|
||||
type StoredAttachment,
|
||||
} from "../lib/attachment-store.js";
|
||||
import { prepareImageForSend } from "../lib/image-utils.js";
|
||||
import { lookup, lookupByPrimary, ApiError } from "../lib/api.js";
|
||||
import { inboxService } from "../lib/inbox-service.svelte.js";
|
||||
import { verifySubject } from "../lib/verify.js";
|
||||
@ -34,6 +34,7 @@
|
||||
import {
|
||||
appendOutbound,
|
||||
appendOutboundAttachment,
|
||||
clearConversationMessages,
|
||||
ensureConversation,
|
||||
listConversations,
|
||||
markConversationRead,
|
||||
@ -42,6 +43,7 @@
|
||||
setVerified,
|
||||
type Conversation,
|
||||
} from "../lib/conversations-store.js";
|
||||
import { parseFileBody } from "../lib/file-transfer.js";
|
||||
import type { Identity } from "../lib/kez.js";
|
||||
|
||||
let conversations = $state<Conversation[]>([]);
|
||||
@ -98,19 +100,37 @@
|
||||
* and render an optimistic local echo of the attachment so the
|
||||
* sender sees their image immediately.
|
||||
*/
|
||||
async function onFilePicked(file: File) {
|
||||
async function onFilePicked(rawFile: File) {
|
||||
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;
|
||||
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) {
|
||||
composing = false;
|
||||
fileSendError = `Files larger than ${MAX_FILE_BYTES / 1024 / 1024} MB aren't supported yet.`;
|
||||
return;
|
||||
}
|
||||
composing = true;
|
||||
const raw = new Uint8Array(await file.arrayBuffer());
|
||||
const filename = file.name || "file";
|
||||
const mime = file.type || "application/octet-stream";
|
||||
const peer_primary = activeConv.peer_primary;
|
||||
const peer_handle = activeConv.peer_handle;
|
||||
const peer_primary = conv.peer_primary;
|
||||
const peer_handle = conv.peer_handle;
|
||||
|
||||
// ─── optimistic local echo ─────────────────────────────────
|
||||
// Save the raw bytes locally so the bubble can render
|
||||
@ -139,6 +159,7 @@
|
||||
data_url: dataUrl,
|
||||
});
|
||||
await refresh();
|
||||
scrollChatToBottom();
|
||||
|
||||
composing = false;
|
||||
|
||||
@ -153,7 +174,9 @@
|
||||
filename,
|
||||
mime,
|
||||
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", {
|
||||
event_id: result.pointer_event_id,
|
||||
@ -231,6 +254,98 @@
|
||||
|
||||
/** Scroll container for the thread view — bound for auto-scroll. */
|
||||
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
|
||||
* user just OPENED a different thread (always scroll to bottom) vs.
|
||||
* when a new message landed in the current one (scroll only if they
|
||||
@ -288,8 +403,50 @@
|
||||
function closeZoomedAvatar() {
|
||||
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 ──────────────────────────────────────────────────────
|
||||
@ -565,6 +722,8 @@
|
||||
|
||||
async function send() {
|
||||
if (!session.unlocked || !activeConv || !composeText.trim()) return;
|
||||
// Snapshot the thread — see onFilePicked.
|
||||
const conv = activeConv;
|
||||
composing = true;
|
||||
const body = composeText;
|
||||
composeText = "";
|
||||
@ -574,8 +733,8 @@
|
||||
// the user sees their own message immediately. Status icon
|
||||
// flips to "sent" once at least one relay accepts, then to
|
||||
// "delivered" when the recipient's client publishes an ack.
|
||||
const peer_primary = activeConv.peer_primary;
|
||||
const peer_handle = activeConv.peer_handle;
|
||||
const peer_primary = conv.peer_primary;
|
||||
const peer_handle = conv.peer_handle;
|
||||
let localSeq: number;
|
||||
try {
|
||||
localSeq = await appendOutbound({
|
||||
@ -586,6 +745,17 @@
|
||||
status: "sending",
|
||||
});
|
||||
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) {
|
||||
alert(`Local append failed: ${(e as Error).message}`);
|
||||
composing = false;
|
||||
@ -608,11 +778,21 @@
|
||||
// depend on the chat-server; if the server's down, we still
|
||||
// publish to relays and the recipient still gets the message.
|
||||
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
|
||||
// most recent message to us — usually the lowest-latency
|
||||
// path for the round-trip. Falls back to our default set if
|
||||
// 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", {
|
||||
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"
|
||||
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>
|
||||
{/if}
|
||||
</div>
|
||||
@ -949,13 +1129,31 @@
|
||||
{:else}
|
||||
<Avatar seed={activeConv.peer_primary} size={36} />
|
||||
{/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">
|
||||
<span class="truncate">{displayName(activeConv)}</span>
|
||||
{#if activeConv.verified}<VerifiedBadge size={15} />{/if}
|
||||
</p>
|
||||
<p class="text-[10px] text-text-muted truncate font-mono">{activeConv.peer_primary}</p>
|
||||
</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>
|
||||
|
||||
<!-- Messages -->
|
||||
@ -1006,14 +1204,25 @@
|
||||
<span class="text-xs">⚠ {m.attachment.filename}: assembly failed</span>
|
||||
</div>
|
||||
{:else if att?.data_url && m.attachment.mime?.startsWith("image/")}
|
||||
<!-- Image preview. Capped at 320px in the chat;
|
||||
tap could open a full-screen viewer (TODO). -->
|
||||
<img
|
||||
src={att.data_url}
|
||||
alt={m.attachment.filename}
|
||||
class="max-w-full max-h-80 rounded-lg block"
|
||||
<!-- Image preview. Tap to open fullscreen
|
||||
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
|
||||
src={att.data_url}
|
||||
alt={m.attachment.filename}
|
||||
class="max-w-full max-h-80 rounded-lg block"
|
||||
/>
|
||||
</button>
|
||||
{:else if att?.data_url}
|
||||
<!-- Non-image: filename + size + download link. -->
|
||||
<a
|
||||
@ -1034,7 +1243,34 @@
|
||||
{/if}
|
||||
{/await}
|
||||
{:else}
|
||||
<span class="whitespace-pre-wrap break-words align-middle">{m.body}</span>
|
||||
<!-- 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}
|
||||
<span class="whitespace-pre-wrap break-words align-middle">{m.body}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
<!--
|
||||
Inline timestamp + delivery status. `float-right` +
|
||||
@ -1103,14 +1339,14 @@
|
||||
{/if}
|
||||
<form class="p-3 flex gap-2 items-center" onsubmit={(e) => { e.preventDefault(); send(); }}>
|
||||
<EmojiButton onpick={insertEmoji} />
|
||||
<!-- Paperclip — image picker. Hidden file input driven
|
||||
by a styled button. accept=image/* keeps the OS picker
|
||||
showing photos only on mobile; if you want arbitrary
|
||||
files later, drop the accept. -->
|
||||
<!-- Paperclip — generic file picker. No `accept` filter so
|
||||
the OS picker offers everything (photos, PDFs, docs,
|
||||
whatever). The transport decides inline vs chunked by
|
||||
size; the bubble decides image-preview vs file-chip
|
||||
render by mime. -->
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
onchange={(e) => {
|
||||
const f = (e.currentTarget as HTMLInputElement).files?.[0];
|
||||
@ -1153,6 +1389,92 @@
|
||||
</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
|
||||
header avatar. Black backdrop fills the viewport; the picture
|
||||
@ -1166,7 +1488,6 @@
|
||||
type="button"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm cursor-zoom-out"
|
||||
onclick={closeZoomedAvatar}
|
||||
onkeydown={onZoomKeydown}
|
||||
aria-label="Close profile picture"
|
||||
>
|
||||
<div class="relative flex flex-col items-center gap-3 max-w-[90vw]">
|
||||
@ -1178,7 +1499,7 @@
|
||||
orientations. -->
|
||||
<img
|
||||
src={peerPic}
|
||||
alt="profile picture"
|
||||
alt="{displayName(activeConv)}"
|
||||
class="object-cover rounded-2xl shadow-2xl"
|
||||
style="width: min(70vw, 70vh); height: min(70vw, 70vh);"
|
||||
/>
|
||||
|
||||
@ -11,6 +11,23 @@
|
||||
import { scrambleImage } from "../lib/visual-crypto.js";
|
||||
import { hexToBytes } from "@noble/hashes/utils";
|
||||
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 {
|
||||
hasStoredBiometric,
|
||||
@ -55,6 +72,104 @@
|
||||
let biometricError = $state<string | null>(null);
|
||||
|
||||
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 testNotifResult = $state<{ ok: boolean; reason?: string } | null>(null);
|
||||
|
||||
@ -145,6 +260,8 @@
|
||||
}
|
||||
await refreshBiometricStatus();
|
||||
notifSupported = notificationsSupported();
|
||||
void refreshHeartbeatStatus();
|
||||
refreshRelayUi();
|
||||
notifPerm = notificationsPermission();
|
||||
webPushOk = pushSupported();
|
||||
pwaInstalled = isStandalonePwa();
|
||||
@ -609,6 +726,196 @@
|
||||
{/if}
|
||||
</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 -->
|
||||
<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>
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
getStoredBiometricMeta,
|
||||
} from "../lib/webauthn.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 passphrase = $state("");
|
||||
@ -76,6 +77,9 @@
|
||||
async function forget() {
|
||||
if (!confirm("Delete the local copy of this account? You'll need your seed to restore.")) return;
|
||||
await deleteStoredIdentity();
|
||||
// Heartbeat privates can decrypt retained messages without the
|
||||
// seed — they must not outlive the account on this device.
|
||||
await wipeAllHeartbeats();
|
||||
session.lock();
|
||||
push("/");
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user