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
|
# the path below is just a historical fence-post in case anyone has the
|
||||||
# old in-repo cache lying around from before that move.
|
# old in-repo cache lying around from before that move.
|
||||||
kez-chat/deploy/.buildx-cache/
|
kez-chat/deploy/.buildx-cache/
|
||||||
|
|
||||||
|
# Syncthing per-folder ignore config — machine-local, not project source
|
||||||
|
.stignore
|
||||||
|
|||||||
@ -31,10 +31,11 @@ rand = "0.8"
|
|||||||
nostr-sdk = { version = "0.36", default-features = false, features = ["all-nips"] }
|
nostr-sdk = { version = "0.36", default-features = false, features = ["all-nips"] }
|
||||||
hkdf = "0.12"
|
hkdf = "0.12"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
# Proxies /v1/sigchains/* to the internal sig-server (api.rs).
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||||
tower-http = { version = "0.6", features = ["trace", "cors", "fs"] }
|
tower-http = { version = "0.6", features = ["trace", "cors", "fs"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
@ -1,168 +1,355 @@
|
|||||||
# kez-chat-server
|
# kez-chat
|
||||||
|
|
||||||
Home server for the kez-chat application. One Rust binary that hosts:
|
End-to-end encrypted chat where **your account is a key, not a phone
|
||||||
|
number or an email address.** Messages travel over public
|
||||||
|
[nostr](https://nostr.com) relays; nobody in the middle can read them,
|
||||||
|
and the server that hosts the app never sees them at all.
|
||||||
|
|
||||||
- Handle registry (`POST /v1/register`, `GET /v1/u/:handle`)
|
Live at **<https://kez.lat>** — installable as a PWA on iOS, Android, and
|
||||||
- WebFinger discovery (`GET /.well-known/webfinger`)
|
desktop.
|
||||||
- NATS auth callout endpoint (`POST /internal/nats/auth`) — stub in v0.1
|
|
||||||
- Static SPA serving (`GET /`) — placeholder until the Svelte build lands
|
|
||||||
- Healthz (`GET /v1/healthz`)
|
|
||||||
|
|
||||||
Designed in [`document.md`](document.md). Spec for the underlying KEZ
|
kez-chat is the flagship app for [KEZ](../README.md), a portable,
|
||||||
identity layer in [`../SPEC.md`](../SPEC.md).
|
decentralized identity graph. Your chat identity is a KEZ identity, so
|
||||||
|
you can *prove* who you are by linking accounts you already own (GitHub,
|
||||||
|
Bluesky, DNS, your website, nostr) — and your contacts
|
||||||
|
can verify those links themselves, without trusting anyone.
|
||||||
|
|
||||||
## What's in v0.1 (this is the scaffold)
|
---
|
||||||
|
|
||||||
✅ HTTP API end-to-end
|
## What you get
|
||||||
✅ Ed25519-signed handle registration with replay protection
|
|
||||||
✅ SQLite-backed registry with uniqueness on both handle and primary key
|
|
||||||
✅ WebFinger endpoint
|
|
||||||
✅ Placeholder SPA at `/`
|
|
||||||
✅ docker-compose for full stack (chat + nats + sig-server)
|
|
||||||
✅ Multi-stage Dockerfiles
|
|
||||||
✅ 13 integration tests against a live router
|
|
||||||
|
|
||||||
⚠️ NATS auth callout returns 501 — wired up in v0.2
|
- **An account in seconds, no personal data.** Pick a handle
|
||||||
⚠️ Svelte SPA build pipeline not yet in place — placeholder HTML for now
|
(`you@kez.lat`); the app generates a key on your device. Your backup is
|
||||||
⚠️ TLS terminated upstream (no cert handling in this binary)
|
a 12-word recovery phrase.
|
||||||
|
- **End-to-end encrypted messages and files.** Text, emoji, photos, and
|
||||||
|
arbitrary files up to 10 MB. iPhone HEIC photos are converted to JPEG
|
||||||
|
and large photos are downscaled (which also strips GPS/EXIF data).
|
||||||
|
- **Forward secrecy.** Decryption keys rotate daily and are deleted after
|
||||||
|
a window you choose (3–60 days, default 16). Once gone, those messages
|
||||||
|
can't be decrypted by anyone — including you, or someone who later
|
||||||
|
steals your device.
|
||||||
|
- **Post-quantum protection.** Messages use a hybrid of X25519 and
|
||||||
|
ML-KEM-768 (the NIST post-quantum standard), so ciphertext harvested
|
||||||
|
from public relays today stays safe against a future quantum computer.
|
||||||
|
- **Delivery receipts.** Sending → sent (a relay accepted it) →
|
||||||
|
delivered (the recipient's device decrypted it and sent back a
|
||||||
|
receipt signed with their KEZ key).
|
||||||
|
- **Verified identity.** Link your GitHub, Bluesky, domain, website,
|
||||||
|
or nostr account with a signed claim (fediverse coming). Contacts see a
|
||||||
|
verified badge they checked themselves.
|
||||||
|
- **Private profile pictures.** Your avatar is published *visually
|
||||||
|
encrypted*: strangers see colored noise, contacts see your face.
|
||||||
|
- **Push notifications** that tell the push provider nothing — the
|
||||||
|
payload is empty.
|
||||||
|
- **Unlock with Face ID / Touch ID / Windows Hello / a security key**
|
||||||
|
(WebAuthn passkeys), or a passphrase.
|
||||||
|
- **Your relays, your choice.** Reorder, add, or remove nostr relays in
|
||||||
|
Settings.
|
||||||
|
|
||||||
## Quick start (local development)
|
---
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────── your device (browser / PWA) ────────────┐
|
||||||
|
│ seed (12-word phrase) ─► Ed25519 identity │
|
||||||
|
│ encrypt + sign locally ─► sealed envelope │
|
||||||
|
└───────────────┬──────────────────────────────────────┘
|
||||||
|
│ publish / subscribe (encrypted only)
|
||||||
|
▼
|
||||||
|
public nostr relays (damus, nos.lol, primal, …)
|
||||||
|
▲
|
||||||
|
│ subscribe by routing tag
|
||||||
|
┌───────────────┴──────────── their device ────────────┐
|
||||||
|
│ verify signature ─► decrypt ─► ack back │
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
kez.lat chat-server (this crate): handle directory, serves the app,
|
||||||
|
Web Push fan-out, proxy to the sigchain store. Never sees plaintext
|
||||||
|
and never carries nostr-transport messages.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Identity
|
||||||
|
|
||||||
|
Your account is a 32-byte **seed**, derived from your 12-word recovery
|
||||||
|
phrase (BIP-39 wordlist, KEZ derivation — compatible with the Rust,
|
||||||
|
Node, and Python KEZ CLIs, which also accept 24-word phrases). From the
|
||||||
|
seed come:
|
||||||
|
|
||||||
|
| Key | Used for |
|
||||||
|
|---|---|
|
||||||
|
| **Ed25519** | Your KEZ identity (`ed25519:<hex>`). Signs every message, heartbeat, ack, and claim. |
|
||||||
|
| **X25519** | Identity-level key exchange (first contact only — see below). |
|
||||||
|
| **secp256k1** (HKDF-derived) | Signs the nostr events that carry your messages. A transport credential only — never your "real" nostr account. |
|
||||||
|
|
||||||
|
On the device, the seed is stored in IndexedDB encrypted under your
|
||||||
|
passphrase (PBKDF2 → AES-GCM), and optionally under a passkey via the
|
||||||
|
WebAuthn PRF extension. A 30-day session, wrapped under a non-extractable
|
||||||
|
WebCrypto key, keeps you signed in while the PWA is backgrounded, so push
|
||||||
|
notifications stay useful. **Lock** ends it; **Forget this account**
|
||||||
|
wipes the device.
|
||||||
|
|
||||||
|
Your **handle** (`you@kez.lat`) is registered with the chat-server by a
|
||||||
|
request signed with your identity key. It's a public directory: handle
|
||||||
|
→ public key.
|
||||||
|
|
||||||
|
### Messages
|
||||||
|
|
||||||
|
A message is sealed on your device into an **envelope**, then published
|
||||||
|
as a nostr event (kind `4242`) tagged with a routing tag derived from the
|
||||||
|
recipient's public key. The recipient's app subscribes to its own tag on
|
||||||
|
the same relays. Relays can't read the message, and the sender's KEZ
|
||||||
|
identity only appears *inside* the ciphertext. They do see the event's
|
||||||
|
signing key, though — your app-specific nostr key, which is stable and
|
||||||
|
whose public profile carries your handle — plus the recipient's routing
|
||||||
|
tag and the time. See the metadata note under Security.
|
||||||
|
|
||||||
|
Envelope versions (all still decryptable; the app sends the strongest the
|
||||||
|
recipient supports):
|
||||||
|
|
||||||
|
| v | Key agreement | Protects against |
|
||||||
|
|---|---|---|
|
||||||
|
| **4** | X25519 **+ ML-KEM-768** to the recipient's current heartbeat, per-message ephemeral | relays, stolen keys (forward secrecy), future quantum computers |
|
||||||
|
| 3 | X25519 to the recipient's current heartbeat | relays, stolen keys (forward secrecy) |
|
||||||
|
| 3 (`fallback`) | X25519 to the recipient's identity key | relays — used only for the very first message to someone who has never opened the app |
|
||||||
|
| 1–2 | legacy | receive-only |
|
||||||
|
|
||||||
|
In every version the body is AES-256-GCM, the routing fields are bound
|
||||||
|
as authenticated data, and the whole envelope is signed with the
|
||||||
|
sender's Ed25519 key.
|
||||||
|
|
||||||
|
### Heartbeats — forward secrecy and post-quantum keys
|
||||||
|
|
||||||
|
Instead of Signal's per-message Double Ratchet (which needs session state
|
||||||
|
and a prekey server), kez-chat uses a **heartbeat ratchet** that fits
|
||||||
|
nostr's publish-and-forget model:
|
||||||
|
|
||||||
|
1. Once a day while you're active, your device generates a fresh random
|
||||||
|
X25519 key **and** an ML-KEM-768 key, and publishes the public halves
|
||||||
|
to nostr (kind `10142`) — **signed by your KEZ identity**, so a
|
||||||
|
sender can check the heartbeat really belongs to you.
|
||||||
|
2. Senders encrypt to your latest heartbeat rather than your long-term
|
||||||
|
identity key.
|
||||||
|
3. Your device keeps each retired heartbeat's private keys for the
|
||||||
|
retention window (counted from when it was *replaced*), then deletes
|
||||||
|
them. After that, those messages are undecryptable by anyone.
|
||||||
|
|
||||||
|
Because heartbeat keys are random — not derived from your seed — someone
|
||||||
|
who later steals your seed or recovery phrase still can't decrypt old
|
||||||
|
messages. Senders cache your last verified heartbeat, so a relay that
|
||||||
|
hides it can't quietly downgrade them to weaker encryption.
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
Files up to 80 KB go inline in one envelope. Larger files (up to 10 MB)
|
||||||
|
are split into encrypted chunks plus a pointer message; the recipient
|
||||||
|
reassembles them locally. Attachments live in IndexedDB on each device;
|
||||||
|
**Clear chat** removes them too.
|
||||||
|
|
||||||
|
### Delivery receipts and duplicates
|
||||||
|
|
||||||
|
When a message decrypts, the recipient's app publishes an ack
|
||||||
|
(kind `4244`) carrying their KEZ signature over the message id, so a
|
||||||
|
third party can't fake "delivered". Receipts are
|
||||||
|
retried if you were offline. Replayed or re-broadcast envelopes are
|
||||||
|
recognized by their signature and ignored.
|
||||||
|
|
||||||
|
### Verified claims (KEZ)
|
||||||
|
|
||||||
|
On **Identity → Add claim** you sign a statement like "`github:you` is
|
||||||
|
me" with your KEZ key and publish the proof where only that account's
|
||||||
|
owner could put it: a gist, a Bluesky post, a DNS TXT record,
|
||||||
|
`<your-site>/.well-known/kez.json`, or a nostr note (a NIP-07 extension
|
||||||
|
like Alby can sign and publish it for you). Anyone can re-verify it.
|
||||||
|
ActivityPub verification is planned — most instances block the browser
|
||||||
|
from fetching WebFinger.
|
||||||
|
|
||||||
|
Each claim is also appended to your **sigchain** — a signed, append-only
|
||||||
|
log of identity events — stored by the KEZ sig-server behind
|
||||||
|
`https://kez.lat/v1/sigchains/...`. If a sync fails, retry it from the
|
||||||
|
Claims page. The sigchain is a convenience mirror; the published proofs
|
||||||
|
are the source of truth.
|
||||||
|
|
||||||
|
### Profiles
|
||||||
|
|
||||||
|
Your display name and avatar go out as a standard nostr profile
|
||||||
|
(kind `0`). With visual encryption on (the default), the picture is
|
||||||
|
pixel-scrambled under a key that's individually wrapped for each
|
||||||
|
contact, so it stays private even though the profile event is public.
|
||||||
|
A NIP-65 relay list (kind `10002`) tells other clients which relays to
|
||||||
|
reach you on; it's republished when you change your relays.
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
|
||||||
|
The chat-server watches the relays for events addressed to registered
|
||||||
|
handles and sends a Web Push (VAPID) with an **empty payload**. Your
|
||||||
|
device wakes up, fetches from the relays, and decrypts locally. The push
|
||||||
|
provider (Apple, Google, Mozilla) learns only that kez-chat woke a
|
||||||
|
device.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
The full, plain-language threat model — what's protected and, honestly,
|
||||||
|
what isn't — is in **[SECURITY.md](SECURITY.md)**. The short version of
|
||||||
|
the limits:
|
||||||
|
|
||||||
|
- **Metadata:** relays can see which app key sent an event to which
|
||||||
|
routing tag, and when — enough to map who talks to whom. There's no
|
||||||
|
onion routing or padding.
|
||||||
|
- **The first message** to someone who has never opened kez-chat has no
|
||||||
|
forward secrecy and no post-quantum protection.
|
||||||
|
- **Signatures are still classical** (Ed25519, secp256k1). A future
|
||||||
|
quantum attacker could impersonate someone in real time, but not
|
||||||
|
decrypt past messages.
|
||||||
|
- **One device per account** for now — signing in on a second device
|
||||||
|
starts a fresh set of heartbeats.
|
||||||
|
- A device compromised *during* the retention window exposes what's
|
||||||
|
stored on it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
```
|
||||||
|
kez-chat/
|
||||||
|
├── src/ chat-server (Rust, axum)
|
||||||
|
│ ├── api.rs routes, handle directory, WebFinger, sigchain proxy
|
||||||
|
│ ├── messages.rs server-transport mailbox (POST /v1/messages, inbox + SSE)
|
||||||
|
│ ├── nostr_listener.rs watches relays → Web Push fan-out
|
||||||
|
│ ├── push.rs Web Push / VAPID
|
||||||
|
│ ├── rate_limit.rs per-IP token bucket
|
||||||
|
│ └── store.rs SQLite handle registry
|
||||||
|
├── web/ the app (Svelte 5 + Vite + Tailwind, PWA)
|
||||||
|
│ └── src/lib/
|
||||||
|
│ ├── crypto.ts envelopes v1–v4, heartbeat signing
|
||||||
|
│ ├── nostr-transport.ts relays, heartbeats, receipts, files
|
||||||
|
│ ├── heartbeat-store.ts rotation + retention of heartbeat keys
|
||||||
|
│ ├── inbox-service.svelte.ts receive pipeline
|
||||||
|
│ ├── identity-store.ts / webauthn.ts / persistent-session.ts
|
||||||
|
│ ├── visual-crypto.ts scrambled profile pictures
|
||||||
|
│ ├── verifiers/ github, bluesky, dns, web, nostr (ap: planned)
|
||||||
|
│ └── heartbeat.selfcheck.ts runnable crypto self-check
|
||||||
|
├── deploy/ Dockerfiles + docker-compose
|
||||||
|
├── SECURITY.md threat model
|
||||||
|
├── TODO.md roadmap + review notes
|
||||||
|
├── document.md original design doc (partly historical)
|
||||||
|
└── web/NOSTR-CHAT.md nostr transport deep-dive
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running it locally
|
||||||
|
|
||||||
|
**Chat-server** (Rust, from `kez-chat/`):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Run from source
|
|
||||||
cargo run -- --bind 127.0.0.1:6969 --db ./kez-chat.db --server kez.lat
|
cargo run -- --bind 127.0.0.1:6969 --db ./kez-chat.db --server kez.lat
|
||||||
|
|
||||||
# Or install once
|
|
||||||
cargo install --path .
|
|
||||||
kez-chat-server --bind 127.0.0.1:6969 --server kez.lat
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Configuration via flags or env vars:
|
**Web app** (from `kez-chat/web/`) — Vite proxies `/v1`, `/internal`, and
|
||||||
|
`/.well-known` to the server on `:6969`:
|
||||||
| Flag | Env | Default |
|
|
||||||
|---|---|---|
|
|
||||||
| `--bind` | `KEZ_CHAT_BIND` | `0.0.0.0:6969` |
|
|
||||||
| `--db` | `KEZ_CHAT_DB` | `kez-chat.db` |
|
|
||||||
| `--server` | `KEZ_CHAT_SERVER` | `kez.lat` |
|
|
||||||
| `--sig-server-url` | `KEZ_CHAT_SIG_SERVER_URL` | `http://localhost:7878` |
|
|
||||||
| `--web-dir` | `KEZ_CHAT_WEB_DIR` | _(unset → placeholder page)_ |
|
|
||||||
|
|
||||||
Logging: `RUST_LOG=debug,hyper=info` etc.
|
|
||||||
|
|
||||||
## Quick start (Docker compose, full stack)
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd deploy
|
npm ci
|
||||||
docker compose up -d --build
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Brings up three services:
|
For sigchain sync you also need the sig-server
|
||||||
|
([`../rust-sig-server`](../rust-sig-server/)) running on `:7878`.
|
||||||
|
|
||||||
| Service | Port(s) | What it does |
|
### Server configuration
|
||||||
|
|
||||||
|
| Flag | Env | Default | |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `--bind` | `KEZ_CHAT_BIND` | `0.0.0.0:6969` | HTTP listen address |
|
||||||
|
| `--db` | `KEZ_CHAT_DB` | `kez-chat.db` | SQLite file |
|
||||||
|
| `--server` | `KEZ_CHAT_SERVER` | `kez.lat` | Handle domain; also the public host in `sigchain_url` |
|
||||||
|
| `--sig-server-url` | `KEZ_CHAT_SIG_SERVER_URL` | `http://localhost:7878` | *Internal* sig-server address the proxy forwards to — never shown to clients |
|
||||||
|
| `--web-dir` | `KEZ_CHAT_WEB_DIR` | unset | Built SPA to serve at `/` |
|
||||||
|
| `--vapid-key-path` | `KEZ_CHAT_VAPID_KEY` | `vapid-key.txt` | Web Push key (auto-generated) |
|
||||||
|
| `--vapid-subject` | `KEZ_CHAT_VAPID_SUBJECT` | `mailto:admin@kez.lat` | VAPID contact |
|
||||||
|
| `--nostr-relays` | `KEZ_CHAT_NOSTR_RELAYS` | damus, nos.lol, primal, snort, nostr.wine | Relays watched for push; empty disables |
|
||||||
|
|
||||||
|
Logging: `RUST_LOG=info` (or `debug`).
|
||||||
|
|
||||||
|
### Web configuration (`web/.env`)
|
||||||
|
|
||||||
|
| Variable | Default | |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `chat-server` | 6969 | HTTP API + SPA |
|
| `VITE_TRANSPORT` | `server` (`.env` sets `nostr`) | `nostr` = relays (production); `server` = HTTP mailbox on the chat-server |
|
||||||
| `nats` | 4222 (native), 8443 (WebSocket), 8222 (monitoring) | Dumb broker, JetStream enabled |
|
| `VITE_NOSTR_RELAYS` | 5 default relays | Build-time default relay list; users can override in Settings |
|
||||||
| `sig-server` | 7878 | Sigchain storage (the existing `rust-sig-server`) |
|
|
||||||
|
|
||||||
Then point a reverse proxy / Cloudflare tunnel at `localhost:6969`.
|
---
|
||||||
|
|
||||||
## Testing
|
## HTTP API
|
||||||
|
|
||||||
|
| Method | Path | |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/v1/healthz` | Liveness |
|
||||||
|
| GET | `/v1/u/:handle` | Handle → primary key, `sigchain_url`, proofs |
|
||||||
|
| GET | `/v1/by-primary/:primary` | Reverse lookup |
|
||||||
|
| POST | `/v1/register` | Claim a handle (Ed25519-signed, ±5 min freshness) |
|
||||||
|
| PUT | `/v1/profile/:handle/proofs` | Publish verified-proof summary (signed) |
|
||||||
|
| GET/POST | `/v1/sigchains/:scheme/:id[/head\|/events]` | Proxied to the sig-server |
|
||||||
|
| POST | `/v1/push/subscribe/:handle`, `/unsubscribe/:handle` | Web Push subscriptions (signed) |
|
||||||
|
| GET | `/v1/push/vapid-public-key` | VAPID public key |
|
||||||
|
| POST | `/v1/messages` · GET `/v1/inbox/:handle[/stream]` | Server-transport mailbox (not used when `VITE_TRANSPORT=nostr`) |
|
||||||
|
| GET | `/.well-known/webfinger` | Fediverse-style discovery |
|
||||||
|
|
||||||
|
### Nostr events
|
||||||
|
|
||||||
|
| Kind | What |
|
||||||
|
|---|---|
|
||||||
|
| `4242` | Encrypted message envelope, routed by `q` tag |
|
||||||
|
| `4244` | Signed delivery receipt |
|
||||||
|
| `10142` | Heartbeat (X25519 + ML-KEM-768 public keys, KEZ-signed) |
|
||||||
|
| `0` | Profile (optionally visually-encrypted avatar) |
|
||||||
|
| `10002` | NIP-65 relay list |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deploying
|
||||||
|
|
||||||
|
`deploy/docker-compose.yml` runs the stack:
|
||||||
|
|
||||||
|
| Service | Port | |
|
||||||
|
|---|---|---|
|
||||||
|
| `chat-server` | 6969 | API + app. Put TLS (Caddy, nginx, Cloudflare tunnel) in front |
|
||||||
|
| `sig-server` | 7878 | Sigchain store; reached publicly through the chat-server proxy |
|
||||||
|
| `nats` | 4222 / 8443 / 8222 | Provisioned for future multi-instance fan-out; not used yet |
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo test # 13 integration tests (real server, real HTTP)
|
cd deploy && docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
The tests stand up the router on a random local port and exercise it
|
Images build from the **repo root** as context (the server depends on
|
||||||
via `reqwest`. No mocks. They cover: healthz, lookup, registration
|
`rust/crates/kez-core`). For production, kez.lat uses a local
|
||||||
(success + duplicate + wrong-server + reserved-name + tampered-sig +
|
cross-build + rsync script (`deploy/deploy-fast.local.sh`, gitignored
|
||||||
stale-timestamp), WebFinger, the placeholder SPA, and the NATS auth
|
because it holds host credentials) that builds the binary and app on a
|
||||||
callout stub.
|
fast machine and restarts only the runtime container.
|
||||||
|
|
||||||
## Endpoints in detail
|
## Checks
|
||||||
|
|
||||||
### `GET /v1/healthz`
|
```sh
|
||||||
|
cd web
|
||||||
```json
|
npm run check # svelte-check + types
|
||||||
{ "status": "ok", "server": "kez.lat", "version": "0.1.0" }
|
npx esbuild src/lib/heartbeat.selfcheck.ts --bundle --platform=node --format=esm \
|
||||||
|
| node --input-type=module # crypto self-check (heartbeats, retention, v4)
|
||||||
```
|
```
|
||||||
|
|
||||||
### `GET /v1/u/:handle`
|
`cargo test` (server integration tests in `tests/http.rs`) currently
|
||||||
|
needs updating for newer config fields before it compiles.
|
||||||
|
|
||||||
Returns:
|
---
|
||||||
|
|
||||||
```json
|
## More reading
|
||||||
{
|
|
||||||
"handle": "tudisco",
|
|
||||||
"fqhn": "tudisco@kez.lat",
|
|
||||||
"primary": "ed25519:2152f8d19b...",
|
|
||||||
"sigchain_url": "https://sig.kez.lat/v1/sigchains/ed25519/2152f8d19b...",
|
|
||||||
"registered_at": "2026-05-25T03:00:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Returns 404 if the handle isn't registered.
|
- [SECURITY.md](SECURITY.md) — threat model and crypto, in plain language
|
||||||
|
- [web/NOSTR-CHAT.md](web/NOSTR-CHAT.md) — how messages ride on nostr
|
||||||
### `POST /v1/register`
|
- [TODO.md](TODO.md) — roadmap and design notes (heartbeat ratchet, v4)
|
||||||
|
- [../SPEC.md](../SPEC.md) — the KEZ identity protocol
|
||||||
Request body — a signed registration envelope:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"kez": "handle_registration",
|
|
||||||
"payload": {
|
|
||||||
"type": "kez.chat.handle_registration",
|
|
||||||
"version": 1,
|
|
||||||
"handle": "tudisco",
|
|
||||||
"primary": "ed25519:2152f8d19b...",
|
|
||||||
"server": "kez.lat",
|
|
||||||
"created_at": "2026-05-25T03:00:00Z"
|
|
||||||
},
|
|
||||||
"signature": {
|
|
||||||
"alg": "ed25519-sha512-jcs",
|
|
||||||
"key": "ed25519:2152f8d19b...",
|
|
||||||
"sig": "<128-char-hex>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Server validates:
|
|
||||||
1. Envelope tag is `"handle_registration"`
|
|
||||||
2. Payload type is `"kez.chat.handle_registration"`, version 1
|
|
||||||
3. `signature.key` equals `payload.primary`
|
|
||||||
4. Signature verifies against the primary key (Ed25519 only for chat)
|
|
||||||
5. `payload.server` matches this server's configured domain
|
|
||||||
6. `payload.handle` passes validation (length 3-32, `a-z0-9_-`,
|
|
||||||
starts with letter/digit, not in reserved list)
|
|
||||||
7. `payload.created_at` is within 5 minutes of server time
|
|
||||||
|
|
||||||
On success: `201 Created` with the same body as `GET /v1/u/:handle`.
|
|
||||||
|
|
||||||
### `GET /.well-known/webfinger?resource=acct:user@server`
|
|
||||||
|
|
||||||
Standard fediverse-style discovery. Returns the user's KEZ identity
|
|
||||||
info as a WebFinger JRD. Used by other servers (federated lookup,
|
|
||||||
future) and by tools like fediverse browsers.
|
|
||||||
|
|
||||||
### `POST /internal/nats/auth`
|
|
||||||
|
|
||||||
NATS auth callout endpoint. **Stub in v0.1** — returns 501. The real
|
|
||||||
implementation (v0.2) will: parse the NATS auth request JWT, extract
|
|
||||||
the connecting client's nkey, look up the corresponding handle, sign
|
|
||||||
a response permitting `kez.inbox.<pubkey>.>` subjects.
|
|
||||||
|
|
||||||
## Deployment notes
|
|
||||||
|
|
||||||
- The `chat-server` Docker image is built from the **repo root** as
|
|
||||||
context (so it can copy `rust/crates/kez-core` for the path dep).
|
|
||||||
`docker-compose.yml` sets this correctly.
|
|
||||||
- The `sig-server` is the existing [`../rust-sig-server`](../rust-sig-server/)
|
|
||||||
binary, built into a separate image via `Dockerfile.sig-server`.
|
|
||||||
- NATS config (`nats.conf`) has WebSocket enabled on port 8443 so the
|
|
||||||
browser SPA can connect via `nats.ws`. The `issuer` field in
|
|
||||||
`auth_callout` is a placeholder — generate a real nkey and replace
|
|
||||||
before going to production.
|
|
||||||
- TLS is **not** handled by this binary. Put a reverse proxy (Caddy,
|
|
||||||
nginx, Cloudflare tunnel) in front for HTTPS.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
406
kez-chat/SECURITY.md
Normal file
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
|
## Day 1 — ~half a day of work, biggest wins
|
||||||
|
|
||||||
### #1. Strip envelope metadata (ephemeral x25519 per message) [TODO]
|
### #1. Strip envelope metadata (ephemeral x25519 per message) [DONE]
|
||||||
|
|
||||||
**Why it matters:** the `SealedEnvelope.from` (KEZ identity) and `to` (handle)
|
**Why it matters:** the `SealedEnvelope.from` (KEZ identity) and `to` (handle)
|
||||||
fields sit in cleartext alongside the ciphertext in `event.content`. Any
|
fields sit in cleartext alongside the ciphertext in `event.content`. Any
|
||||||
@ -46,7 +46,7 @@ message.
|
|||||||
**Migration:** envelope `v: 1 → v: 2`. Recipient accepts both during a
|
**Migration:** envelope `v: 1 → v: 2`. Recipient accepts both during a
|
||||||
1-week window, then drops v1 support.
|
1-week window, then drops v1 support.
|
||||||
|
|
||||||
### #3. Empty Web Push payload [TODO]
|
### #3. Empty Web Push payload [DONE]
|
||||||
|
|
||||||
**Why it matters:** we send `{type, to: <handle>, seq}` to FCM/APNs/Mozilla
|
**Why it matters:** we send `{type, to: <handle>, seq}` to FCM/APNs/Mozilla
|
||||||
on every fanout. RFC 8291 encrypts the payload so the push provider can't
|
on every fanout. RFC 8291 encrypts the payload so the push provider can't
|
||||||
@ -64,7 +64,7 @@ which navigates to the conversation list. Deep-linking to a specific peer
|
|||||||
goes away on cold-open (acceptable trade-off — one extra tap to open the
|
goes away on cold-open (acceptable trade-off — one extra tap to open the
|
||||||
right thread is the price of *not* exporting metadata to FCM).
|
right thread is the price of *not* exporting metadata to FCM).
|
||||||
|
|
||||||
### #5. Rename the routing tag from `h` to something less claimed [TODO]
|
### #5. Rename the routing tag from `h` to something less claimed [DONE]
|
||||||
|
|
||||||
**Why it matters:** `h` is informally used by NIP-29 (Simple Groups) as the
|
**Why it matters:** `h` is informally used by NIP-29 (Simple Groups) as the
|
||||||
group id. Today's three relays don't enforce NIP-29 semantics, but the
|
group id. Today's three relays don't enforce NIP-29 semantics, but the
|
||||||
@ -81,7 +81,7 @@ NIP-01). Bump envelope/event `v` so the listener can tell old-tag events
|
|||||||
from new ones during the migration window. Server-side listener subscribes
|
from new ones during the migration window. Server-side listener subscribes
|
||||||
to BOTH `#h` and `#q` for one week.
|
to BOTH `#h` and `#q` for one week.
|
||||||
|
|
||||||
### #17. Demote handle-revealing logs to `debug!` [TODO]
|
### #17. Demote handle-revealing logs to `debug!` [DONE]
|
||||||
|
|
||||||
**Why it matters:** every fanout currently logs `push: fanout triggered
|
**Why it matters:** every fanout currently logs `push: fanout triggered
|
||||||
handle=<plain handle> sub_count=N` at INFO level. Operator-side log
|
handle=<plain handle> sub_count=N` at INFO level. Operator-side log
|
||||||
@ -155,7 +155,48 @@ server vouches.
|
|||||||
|
|
||||||
## Roadmap — multi-day, needs design pass
|
## Roadmap — multi-day, needs design pass
|
||||||
|
|
||||||
### #6. Forward secrecy (Double Ratchet) [ROADMAP]
|
### #6. Forward secrecy (Heartbeat Ratchet) [DONE — alternative design to Double Ratchet]
|
||||||
|
|
||||||
|
Implemented a custom forward-secrecy protocol that fits nostr's
|
||||||
|
shape better than Signal's Double Ratchet would. Full reasoning
|
||||||
|
in `heartbeat-store.ts` and `crypto.ts` (search for "v3").
|
||||||
|
|
||||||
|
Summary:
|
||||||
|
- Each user publishes a fresh random x25519 pub once per active
|
||||||
|
day as a replaceable event (kind 10142, NIP-01 10000–19999
|
||||||
|
range; d-tag "heartbeat-v1" informational). Content is signed
|
||||||
|
by the KEZ identity; senders verify it against the recipient's
|
||||||
|
primary before use.
|
||||||
|
- Private half kept on the publishing device only; deleted after
|
||||||
|
the user-configurable retention window (default 16 days,
|
||||||
|
settable 3–60 in Settings).
|
||||||
|
- Senders fetch the recipient's latest heartbeat and ECDH against
|
||||||
|
it instead of the recipient's long-term identity key (v3
|
||||||
|
envelope).
|
||||||
|
- First-ever message to a brand-new contact (no heartbeat
|
||||||
|
fetchable yet) falls back to identity ECDH with `fallback: true`
|
||||||
|
on the envelope. Single message, no FS. Subsequent messages get
|
||||||
|
full v3.
|
||||||
|
- Receiver-side forward secrecy at retention-window granularity:
|
||||||
|
after the window elapses, even seed compromise can't decrypt
|
||||||
|
historical messages.
|
||||||
|
- Sender-side FS preserved from v2 (per-message ephemeral).
|
||||||
|
- Post-compromise security: one-shot seed compromise (no
|
||||||
|
persistence) becomes invisible after retention window, because
|
||||||
|
fresh heartbeats are random and live only on the legitimate
|
||||||
|
device.
|
||||||
|
- Migration: dual-decrypt. v1/v2/v3 all accepted; only v3 emitted.
|
||||||
|
- Single-device scope for v0.1. Multi-device with shared heartbeat
|
||||||
|
state is future work.
|
||||||
|
- v4 (post-quantum hybrid): each heartbeat also carries an
|
||||||
|
ML-KEM-768 key (signed with the rest of the heartbeat). Senders
|
||||||
|
encapsulate to it and derive the AES key from
|
||||||
|
HKDF(x25519_ss ‖ mlkem_ss) — PQXDH-style, so harvested ciphertexts
|
||||||
|
need BOTH X25519 and ML-KEM broken. Emitted whenever the peer's
|
||||||
|
heartbeat has an ML-KEM key; v3 otherwise. Identity-key fallback
|
||||||
|
(first message to a new contact) stays classical-only. Signatures
|
||||||
|
(Ed25519, nostr secp256k1) are still classical — ML-DSA is future
|
||||||
|
work; forgery needs a live quantum attacker, unlike decryption.
|
||||||
|
|
||||||
**Why it matters:** today's static-static x25519 means whoever compromises
|
**Why it matters:** today's static-static x25519 means whoever compromises
|
||||||
a seed once decrypts ALL retained history that any relay still has — and
|
a seed once decrypts ALL retained history that any relay still has — and
|
||||||
|
|||||||
@ -4,6 +4,8 @@
|
|||||||
//! GET /v1/healthz liveness
|
//! GET /v1/healthz liveness
|
||||||
//! GET /v1/u/:handle handle → primary + sigchain pointer + endpoints
|
//! GET /v1/u/:handle handle → primary + sigchain pointer + endpoints
|
||||||
//! POST /v1/register claim a handle (signed body)
|
//! POST /v1/register claim a handle (signed body)
|
||||||
|
//! GET /v1/sigchains/:scheme/:id[/head] } proxied to the internal
|
||||||
|
//! POST /v1/sigchains/:scheme/:id/events } sig-server
|
||||||
//! GET /.well-known/webfinger?resource=... fediverse-style discovery
|
//! GET /.well-known/webfinger?resource=... fediverse-style discovery
|
||||||
//! POST /internal/nats/auth NATS auth callout (stub in v0.1)
|
//! POST /internal/nats/auth NATS auth callout (stub in v0.1)
|
||||||
|
|
||||||
@ -53,6 +55,9 @@ pub fn router(state: AppState) -> axum::Router {
|
|||||||
.route("/v1/push/subscribe/:handle", post(push_subscribe))
|
.route("/v1/push/subscribe/:handle", post(push_subscribe))
|
||||||
.route("/v1/push/unsubscribe/:handle", post(push_unsubscribe))
|
.route("/v1/push/unsubscribe/:handle", post(push_unsubscribe))
|
||||||
.route("/v1/push/subscriptions/:handle", get(push_list_subscriptions))
|
.route("/v1/push/subscriptions/:handle", get(push_list_subscriptions))
|
||||||
|
.route("/v1/sigchains/:scheme/:id", get(sigchain_proxy))
|
||||||
|
.route("/v1/sigchains/:scheme/:id/head", get(sigchain_proxy))
|
||||||
|
.route("/v1/sigchains/:scheme/:id/events", post(sigchain_proxy))
|
||||||
.route("/.well-known/webfinger", get(webfinger))
|
.route("/.well-known/webfinger", get(webfinger))
|
||||||
.route("/internal/nats/auth", post(nats_auth_callout));
|
.route("/internal/nats/auth", post(nats_auth_callout));
|
||||||
|
|
||||||
@ -149,12 +154,10 @@ fn handle_response(config: &Config, record: &HandleRecord) -> HandleResponse {
|
|||||||
handle: record.handle.clone(),
|
handle: record.handle.clone(),
|
||||||
fqhn: format!("{}@{}", record.handle, config.server),
|
fqhn: format!("{}@{}", record.handle, config.server),
|
||||||
primary: record.primary.to_string(),
|
primary: record.primary.to_string(),
|
||||||
sigchain_url: format!(
|
// Public address, served by `sigchain_proxy` below. The
|
||||||
"{}/v1/sigchains/{}/{}",
|
// sig-server itself (`sig_server_url`) is only reachable inside
|
||||||
config.sig_server_url.trim_end_matches('/'),
|
// the deploy network, so it must never be handed to clients.
|
||||||
scheme,
|
sigchain_url: format!("https://{}/v1/sigchains/{}/{}", config.server, scheme, id),
|
||||||
id
|
|
||||||
),
|
|
||||||
registered_at: record.registered_at.to_rfc3339(),
|
registered_at: record.registered_at.to_rfc3339(),
|
||||||
proofs: record
|
proofs: record
|
||||||
.proofs
|
.proofs
|
||||||
@ -649,3 +652,59 @@ for the design doc and progress.</p>
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Forward a sigchain request to the internal sig-server, same path and
|
||||||
|
/// body. The sig-server sits on the private deploy network (compose
|
||||||
|
/// service `sig-server`), so this is how browsers and external
|
||||||
|
/// verifiers reach it via the one public origin. It re-validates every
|
||||||
|
/// event itself (tag, seq, prev, signature), so no checks here.
|
||||||
|
async fn sigchain_proxy(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
method: axum::http::Method,
|
||||||
|
uri: axum::http::Uri,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
body: axum::body::Bytes,
|
||||||
|
) -> axum::response::Response {
|
||||||
|
static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
|
||||||
|
let client = CLIENT.get_or_init(|| {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
|
.build()
|
||||||
|
.expect("reqwest client")
|
||||||
|
});
|
||||||
|
let url = format!(
|
||||||
|
"{}{}",
|
||||||
|
state.config.sig_server_url.trim_end_matches('/'),
|
||||||
|
uri.path()
|
||||||
|
);
|
||||||
|
let mut req = client.request(method, &url).body(body);
|
||||||
|
if let Some(ct) = headers.get(header::CONTENT_TYPE) {
|
||||||
|
req = req.header(header::CONTENT_TYPE, ct);
|
||||||
|
}
|
||||||
|
match req.send().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
let status = resp.status();
|
||||||
|
let ct = resp.headers().get(header::CONTENT_TYPE).cloned();
|
||||||
|
match resp.bytes().await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
let mut out = (status, bytes).into_response();
|
||||||
|
if let Some(ct) = ct {
|
||||||
|
out.headers_mut().insert(header::CONTENT_TYPE, ct);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
Err(e) => sig_server_unavailable(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => sig_server_unavailable(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sig_server_unavailable(e: reqwest::Error) -> axum::response::Response {
|
||||||
|
tracing::warn!(error = %e, "sig-server proxy failed");
|
||||||
|
(
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
Json(json!({"error": {"code": "sig_server_unavailable", "message": "sigchain store unreachable"}})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|||||||
@ -5,5 +5,8 @@
|
|||||||
VITE_TRANSPORT=nostr
|
VITE_TRANSPORT=nostr
|
||||||
|
|
||||||
# Relays to publish to / read from (comma-separated). Optional — the
|
# Relays to publish to / read from (comma-separated). Optional — the
|
||||||
# transport falls back to these same defaults if unset.
|
# transport falls back to these same defaults if unset. The set
|
||||||
VITE_NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net
|
# below mirrors the source-of-truth default in nostr-transport.ts,
|
||||||
|
# profile-store.ts, peer-profile-store.ts, and the rust
|
||||||
|
# nostr_listener config. Update all four when changing the default.
|
||||||
|
VITE_NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr.wine
|
||||||
|
|||||||
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",
|
"@bokuweb/zstd-wasm": "^0.0.22",
|
||||||
"@noble/curves": "^1.6.0",
|
"@noble/curves": "^1.6.0",
|
||||||
"@noble/hashes": "^1.5.0",
|
"@noble/hashes": "^1.5.0",
|
||||||
|
"@noble/post-quantum": "^0.7.1",
|
||||||
"@scure/base": "^1.1.9",
|
"@scure/base": "^1.1.9",
|
||||||
"@scure/bip39": "^2.2.0",
|
"@scure/bip39": "^2.2.0",
|
||||||
"canonicalize": "^2.0.0",
|
"canonicalize": "^2.0.0",
|
||||||
@ -2148,9 +2149,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2168,9 +2166,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2188,9 +2183,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2208,9 +2200,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2228,9 +2217,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2248,9 +2234,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2268,9 +2251,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2294,9 +2274,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2320,9 +2297,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2346,9 +2320,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2372,9 +2343,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2398,9 +2366,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2586,6 +2551,62 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@noble/post-quantum": {
|
||||||
|
"version": "0.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.7.1.tgz",
|
||||||
|
"integrity": "sha512-+P9981IiAnVh+rmcubozzVwrEy3XsN/tMhTnvsjV9VDaYpOnNCqWqKo2FLWxbu92YHfjGIlE5XnW175UK+ln+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/ciphers": "2.4.0",
|
||||||
|
"@noble/curves": "2.4.0",
|
||||||
|
"@noble/hashes": "2.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@noble/post-quantum/node_modules/@noble/ciphers": {
|
||||||
|
"version": "2.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
|
||||||
|
"integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@noble/post-quantum/node_modules/@noble/curves": {
|
||||||
|
"version": "2.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz",
|
||||||
|
"integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/hashes": "2.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@noble/post-quantum/node_modules/@noble/hashes": {
|
||||||
|
"version": "2.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
|
||||||
|
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@quansync/fs": {
|
"node_modules/@quansync/fs": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz",
|
||||||
@ -2811,9 +2832,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2828,9 +2846,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2845,9 +2860,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2862,9 +2874,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2879,9 +2888,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2896,9 +2902,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2913,9 +2916,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2930,9 +2930,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2947,9 +2944,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2964,9 +2958,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2981,9 +2972,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -2998,9 +2986,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -3015,9 +3000,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -3384,9 +3366,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -3404,9 +3383,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -3424,9 +3400,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -3444,9 +3417,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -5743,9 +5713,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -5767,9 +5734,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -5791,9 +5755,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -5815,9 +5776,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
"@bokuweb/zstd-wasm": "^0.0.22",
|
"@bokuweb/zstd-wasm": "^0.0.22",
|
||||||
"@noble/curves": "^1.6.0",
|
"@noble/curves": "^1.6.0",
|
||||||
"@noble/hashes": "^1.5.0",
|
"@noble/hashes": "^1.5.0",
|
||||||
|
"@noble/post-quantum": "^0.7.1",
|
||||||
"@scure/base": "^1.1.9",
|
"@scure/base": "^1.1.9",
|
||||||
"@scure/bip39": "^2.2.0",
|
"@scure/bip39": "^2.2.0",
|
||||||
"canonicalize": "^2.0.0",
|
"canonicalize": "^2.0.0",
|
||||||
|
|||||||
@ -67,7 +67,7 @@
|
|||||||
src={picture}
|
src={picture}
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
alt="profile picture"
|
alt=""
|
||||||
class="shrink-0 object-cover"
|
class="shrink-0 object-cover"
|
||||||
style="border-radius: {Math.max(4, size * 0.2)}px; {ring
|
style="border-radius: {Math.max(4, size * 0.2)}px; {ring
|
||||||
? 'box-shadow: 0 0 0 2px var(--color-accent);'
|
? 'box-shadow: 0 0 0 2px var(--color-accent);'
|
||||||
|
|||||||
@ -19,8 +19,8 @@
|
|||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="verified"
|
aria-label="verified"
|
||||||
{title}
|
|
||||||
>
|
>
|
||||||
|
<title>{title}</title>
|
||||||
<!-- Scalloped seal + check, the familiar verified glyph. -->
|
<!-- Scalloped seal + check, the familiar verified glyph. -->
|
||||||
<path d="M12 1.5l2.3 1.7 2.85-.2 1 2.67 2.45 1.46-.83 2.74.83 2.74-2.45 1.46-1 2.67-2.85-.2L12 22.5l-2.3-1.7-2.85.2-1-2.67-2.45-1.46.83-2.74-.83-2.74 2.45-1.46 1-2.67 2.85.2z"/>
|
<path d="M12 1.5l2.3 1.7 2.85-.2 1 2.67 2.45 1.46-.83 2.74.83 2.74-2.45 1.46-1 2.67-2.85-.2L12 22.5l-2.3-1.7-2.85.2-1-2.67-2.45-1.46.83-2.74-.83-2.74 2.45-1.46 1-2.67 2.85.2z"/>
|
||||||
<path d="M10.6 14.6l-2.2-2.2-1.4 1.4 3.6 3.6 6-6-1.4-1.4z" fill="var(--color-bg)"/>
|
<path d="M10.6 14.6l-2.2-2.2-1.4 1.4 3.6 3.6 6-6-1.4-1.4z" fill="var(--color-bg)"/>
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { ed25519 } from "@noble/curves/ed25519";
|
|||||||
import { hexToBytes } from "@noble/hashes/utils";
|
import { hexToBytes } from "@noble/hashes/utils";
|
||||||
import { get, set } from "idb-keyval";
|
import { get, set } from "idb-keyval";
|
||||||
import type { Identity } from "./kez.js";
|
import type { Identity } from "./kez.js";
|
||||||
|
import { deleteAttachment, deleteChunkBuffer } from "./attachment-store.js";
|
||||||
|
|
||||||
// :v2 — schema reshaped to key by peer_primary (canonical identity) instead
|
// :v2 — schema reshaped to key by peer_primary (canonical identity) instead
|
||||||
// of an ad-hoc string. Old data under :conversations (no version) is
|
// of an ad-hoc string. Old data under :conversations (no version) is
|
||||||
@ -250,6 +251,64 @@ export async function markConversationRead(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Delete the stored bytes (and any half-assembled chunk buffer)
|
||||||
|
* behind every attachment bubble in `messages`. A cleared chat must
|
||||||
|
* not leave its images sitting in IndexedDB. */
|
||||||
|
async function deleteMessageAttachments(
|
||||||
|
peer_primary: Identity,
|
||||||
|
messages: ConversationMessage[],
|
||||||
|
): Promise<void> {
|
||||||
|
await Promise.all(
|
||||||
|
messages
|
||||||
|
.filter((m) => m.attachment)
|
||||||
|
.flatMap((m) => [
|
||||||
|
deleteAttachment(peer_primary, m.seq),
|
||||||
|
...(m.attachment!.file_id ? [deleteChunkBuffer(m.attachment!.file_id)] : []),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wipe every message from one conversation — including attachment
|
||||||
|
* bytes — while keeping the peer row itself (handle, primary, nostr
|
||||||
|
* pubkey, via_relay) so future messages from them land in the same
|
||||||
|
* thread. Returns the number of messages removed (diagnostic).
|
||||||
|
*/
|
||||||
|
export async function clearConversationMessages(
|
||||||
|
peer_primary: Identity,
|
||||||
|
): Promise<number> {
|
||||||
|
const s = await read();
|
||||||
|
const conv = s.by_peer[peer_primary];
|
||||||
|
if (!conv) return 0;
|
||||||
|
const n = conv.messages.length;
|
||||||
|
const removed = conv.messages;
|
||||||
|
conv.messages = [];
|
||||||
|
conv.unread_count = 0;
|
||||||
|
// last_seq stays so the inbox-service notification watermark
|
||||||
|
// doesn't badge-storm us with already-seen messages if they get
|
||||||
|
// re-broadcast by a relay.
|
||||||
|
await write(s);
|
||||||
|
await deleteMessageAttachments(peer_primary, removed);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wipe the entire conversation row (messages + peer metadata).
|
||||||
|
* Use when the user wants the contact gone, not just the messages.
|
||||||
|
* Future messages from this peer will rebuild a fresh row.
|
||||||
|
*/
|
||||||
|
export async function deleteConversation(
|
||||||
|
peer_primary: Identity,
|
||||||
|
): Promise<void> {
|
||||||
|
const s = await read();
|
||||||
|
const conv = s.by_peer[peer_primary];
|
||||||
|
if (conv) {
|
||||||
|
delete s.by_peer[peer_primary];
|
||||||
|
await write(s);
|
||||||
|
await deleteMessageAttachments(peer_primary, conv.messages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append an outbound message and return its synthetic `seq` so the
|
* Append an outbound message and return its synthetic `seq` so the
|
||||||
* caller can update the status later (sending → sent → delivered).
|
* caller can update the status later (sending → sent → delivered).
|
||||||
|
|||||||
@ -23,10 +23,23 @@ import { ed25519, x25519 } from "@noble/curves/ed25519";
|
|||||||
import { sha256 } from "@noble/hashes/sha2";
|
import { sha256 } from "@noble/hashes/sha2";
|
||||||
import { hkdf } from "@noble/hashes/hkdf";
|
import { hkdf } from "@noble/hashes/hkdf";
|
||||||
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
|
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
|
||||||
|
import { ml_kem768 } from "@noble/post-quantum/ml-kem.js";
|
||||||
import { canonicalBytes, type Identity } from "./kez.js";
|
import { canonicalBytes, type Identity } from "./kez.js";
|
||||||
|
|
||||||
const HKDF_INFO_V1 = new TextEncoder().encode("kez-chat-msg-v1");
|
const HKDF_INFO_V1 = new TextEncoder().encode("kez-chat-msg-v1");
|
||||||
const HKDF_INFO_V2 = new TextEncoder().encode("kez-chat-msg-v2");
|
const HKDF_INFO_V2 = new TextEncoder().encode("kez-chat-msg-v2");
|
||||||
|
const HKDF_INFO_V3 = new TextEncoder().encode("kez-chat-msg-v3");
|
||||||
|
const HKDF_INFO_V4 = new TextEncoder().encode("kez-chat-msg-v4");
|
||||||
|
|
||||||
|
/** Heartbeat private keys the receiver resolves for an incoming v3/v4
|
||||||
|
* envelope. `mlkem` is absent for heartbeats minted before v4. */
|
||||||
|
export interface HeartbeatPrivs {
|
||||||
|
x25519: Uint8Array;
|
||||||
|
mlkem?: Uint8Array;
|
||||||
|
}
|
||||||
|
export type ResolveHeartbeatPrivs = (
|
||||||
|
x25519PubHex: string,
|
||||||
|
) => Promise<HeartbeatPrivs | undefined>;
|
||||||
|
|
||||||
/** What the sender stores in the encrypted blob. */
|
/** What the sender stores in the encrypted blob. */
|
||||||
export interface MessagePlaintext {
|
export interface MessagePlaintext {
|
||||||
@ -40,24 +53,49 @@ export interface MessagePlaintext {
|
|||||||
|
|
||||||
/** What goes on the wire to POST /v1/messages. */
|
/** What goes on the wire to POST /v1/messages. */
|
||||||
/**
|
/**
|
||||||
* Two envelope shapes coexist during the v1 → v2 migration window.
|
* Three envelope shapes coexist during the v1/v2 → v3 migration.
|
||||||
*
|
*
|
||||||
* v1: legacy — `from` (KEZ identity) and `to` (handle) in cleartext
|
* v1: legacy — `from` (KEZ identity) and `to` (handle) in cleartext
|
||||||
* NEXT TO the ciphertext. Relays could JSON-parse `event.content`
|
* NEXT TO the ciphertext. Relays could JSON-parse `event.content`
|
||||||
* and build a perfect social graph. Decrypt-only support; we
|
* and build a perfect social graph. Decrypt-only support; we
|
||||||
* never emit new v1 envelopes.
|
* never emit new v1 envelopes.
|
||||||
*
|
*
|
||||||
* v2: fixed — only an EPHEMERAL x25519 public key (per-message,
|
* v2: fixed metadata leak — only an EPHEMERAL x25519 public key
|
||||||
* discarded right after sealing) and the ciphertext are visible
|
* (per-message, discarded right after sealing) and the
|
||||||
* outside the encrypted blob. `from` (KEZ identity) lives INSIDE
|
* ciphertext are visible outside the encrypted blob. `from`
|
||||||
* the plaintext, verified post-decrypt via ed25519 sig. Forward
|
* (KEZ identity) lives INSIDE the plaintext, verified post-
|
||||||
* secrecy: a compromised long-term ed25519 seed can no longer
|
* decrypt via ed25519 sig. Per-message forward secrecy from
|
||||||
* decrypt past captured ciphertexts (the ephemeral private key
|
* the SENDER side: a compromised long-term ed25519 seed cannot
|
||||||
* was destroyed at send time).
|
* decrypt past captured ciphertexts because the sender
|
||||||
|
* ephemeral private was destroyed at send. RECEIVER side still
|
||||||
|
* uses their long-term x25519 identity — so receiver-seed
|
||||||
|
* compromise DOES decrypt history.
|
||||||
*
|
*
|
||||||
* See kez-chat/TODO.md Day 1 #1.
|
* v3: heartbeat ratchet — RECEIVER side also forward-secure.
|
||||||
|
* Sender encrypts to a daily-rotating "heartbeat" public key
|
||||||
|
* the recipient publishes to nostr, instead of to their long-
|
||||||
|
* term x25519 identity. Receiver retains the matching private
|
||||||
|
* for a configurable window (default 16 days) then deletes
|
||||||
|
* it. After deletion, no one — not even the receiver — can
|
||||||
|
* decrypt those messages. Coarser-grained FS than Double
|
||||||
|
* Ratchet but vastly simpler: no per-peer session state, no
|
||||||
|
* prekey-distribution server, no out-of-order bookkeeping.
|
||||||
|
* See heartbeat-store.ts.
|
||||||
|
*
|
||||||
|
* v4: v3 + post-quantum hybrid. The recipient's heartbeat also
|
||||||
|
* carries an ML-KEM-768 key; the sender encapsulates to it and
|
||||||
|
* the AES key comes from HKDF(x25519_ss ‖ mlkem_ss). Breaking a
|
||||||
|
* message now needs BOTH X25519 and ML-KEM broken — so
|
||||||
|
* ciphertexts harvested off public relays today stay safe
|
||||||
|
* against a future quantum computer (same construction as
|
||||||
|
* Signal PQXDH). Emitted whenever the recipient's heartbeat has
|
||||||
|
* an ML-KEM key; otherwise v3.
|
||||||
*/
|
*/
|
||||||
export type SealedEnvelope = SealedEnvelopeV1 | SealedEnvelopeV2;
|
export type SealedEnvelope =
|
||||||
|
| SealedEnvelopeV1
|
||||||
|
| SealedEnvelopeV2
|
||||||
|
| SealedEnvelopeV3
|
||||||
|
| SealedEnvelopeV4;
|
||||||
|
|
||||||
/** @deprecated v1 — leaks sender + recipient identity to relays. */
|
/** @deprecated v1 — leaks sender + recipient identity to relays. */
|
||||||
export interface SealedEnvelopeV1 {
|
export interface SealedEnvelopeV1 {
|
||||||
@ -88,6 +126,51 @@ export interface SealedEnvelopeV2 {
|
|||||||
sender_sig: string;
|
sender_sig: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SealedEnvelopeV3 {
|
||||||
|
v: 3;
|
||||||
|
/** Sender's ephemeral x25519 pub for this single message. Same
|
||||||
|
* semantics as v2. */
|
||||||
|
eph_pub: string;
|
||||||
|
/** Recipient's HEARTBEAT x25519 pub the sender ECDH'd against.
|
||||||
|
* Recipient looks up the matching private from their local
|
||||||
|
* heartbeat store (heartbeat-store.ts). If they don't have it
|
||||||
|
* (too old, never theirs), decrypt fails. This is the field that
|
||||||
|
* makes v3 receiver-side forward-secure. */
|
||||||
|
recipient_heartbeat_pub: string;
|
||||||
|
/** Indicates the sender fell back to the recipient's long-term
|
||||||
|
* identity key instead of a heartbeat — happens for the first
|
||||||
|
* message to a brand-new contact who hasn't published one yet.
|
||||||
|
* When true, `recipient_heartbeat_pub` actually holds the
|
||||||
|
* recipient's identity-derived x25519 pub (still derived from
|
||||||
|
* their KEZ primary). No FS for this single message. */
|
||||||
|
fallback?: boolean;
|
||||||
|
/** 12-byte AES-GCM nonce, hex. */
|
||||||
|
nonce: string;
|
||||||
|
/** AES-256-GCM(plaintext_json), hex. AAD binds
|
||||||
|
* {v, eph_pub, recipient_heartbeat_pub, nonce, fallback?} so a
|
||||||
|
* relay-in-the-middle can't swap routing without breaking auth. */
|
||||||
|
ciphertext: string;
|
||||||
|
/** ed25519 sig over canonical(envelope minus sig). */
|
||||||
|
sender_sig: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SealedEnvelopeV4 {
|
||||||
|
v: 4;
|
||||||
|
/** Sender's ephemeral x25519 pub (as v3). */
|
||||||
|
eph_pub: string;
|
||||||
|
/** Recipient heartbeat x25519 pub — also the lookup id for the
|
||||||
|
* matching heartbeat's ML-KEM private. */
|
||||||
|
recipient_heartbeat_pub: string;
|
||||||
|
/** ML-KEM-768 ciphertext (1088 bytes), hex. */
|
||||||
|
kem_ct: string;
|
||||||
|
nonce: string;
|
||||||
|
/** AES-256-GCM(plaintext_json), hex. AAD binds
|
||||||
|
* {v, eph_pub, recipient_heartbeat_pub, kem_ct, nonce}. */
|
||||||
|
ciphertext: string;
|
||||||
|
/** ed25519 sig over canonical(envelope minus sig). */
|
||||||
|
sender_sig: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Key derivation
|
// Key derivation
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@ -238,12 +321,31 @@ export async function openMessage(opts: {
|
|||||||
envelope: SealedEnvelope;
|
envelope: SealedEnvelope;
|
||||||
myHandle: string;
|
myHandle: string;
|
||||||
mySeed: Uint8Array;
|
mySeed: Uint8Array;
|
||||||
|
/** v3 only — supplied by the caller (inbox-service) so this
|
||||||
|
* module doesn't have to depend on heartbeat-store directly.
|
||||||
|
* Returns the matching heartbeat private if we still have it
|
||||||
|
* in the retention window; undefined otherwise. */
|
||||||
|
resolveHeartbeatPriv?: ResolveHeartbeatPrivs;
|
||||||
}): Promise<MessagePlaintext> {
|
}): Promise<MessagePlaintext> {
|
||||||
const env = opts.envelope;
|
const env = opts.envelope;
|
||||||
let plaintext: MessagePlaintext;
|
let plaintext: MessagePlaintext;
|
||||||
if (env.v === 1) plaintext = await openMessageV1(env, opts.myHandle, opts.mySeed);
|
if (env.v === 1) plaintext = await openMessageV1(env, opts.myHandle, opts.mySeed);
|
||||||
else if (env.v === 2) plaintext = await openMessageV2(env, opts.mySeed);
|
else if (env.v === 2) plaintext = await openMessageV2(env, opts.mySeed);
|
||||||
else {
|
else if (env.v === 3) {
|
||||||
|
if (!opts.resolveHeartbeatPriv) {
|
||||||
|
throw new Error(
|
||||||
|
"v3 envelope received but no heartbeat resolver supplied — caller bug",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
plaintext = await openMessageV3(env, opts.mySeed, opts.resolveHeartbeatPriv);
|
||||||
|
} else if (env.v === 4) {
|
||||||
|
if (!opts.resolveHeartbeatPriv) {
|
||||||
|
throw new Error(
|
||||||
|
"v4 envelope received but no heartbeat resolver supplied — caller bug",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
plaintext = await openMessageV4(env, opts.resolveHeartbeatPriv);
|
||||||
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`unsupported envelope version: ${(env as { v: number }).v}`,
|
`unsupported envelope version: ${(env as { v: number }).v}`,
|
||||||
);
|
);
|
||||||
@ -372,3 +474,367 @@ async function openMessageV2(
|
|||||||
|
|
||||||
return plaintext;
|
return plaintext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// v3 — heartbeat-ratcheted, receiver-side forward secure
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an envelope for `recipient_heartbeat_pub` — typically the
|
||||||
|
* recipient's latest published heartbeat from nostr. Pass
|
||||||
|
* `fallback: true` when the recipient has no heartbeat available
|
||||||
|
* and we're falling back to their identity-derived x25519 pub for
|
||||||
|
* just this one message (no FS for it).
|
||||||
|
*
|
||||||
|
* The caller (nostr-transport) is responsible for fetching the
|
||||||
|
* recipient's heartbeat and passing the right `recipient_heartbeat_pub`.
|
||||||
|
* This function only does the crypto.
|
||||||
|
*/
|
||||||
|
export async function sealMessageV3(opts: {
|
||||||
|
senderSeed: Uint8Array;
|
||||||
|
senderPrimary: Identity;
|
||||||
|
/** x25519 pub the recipient is currently advertising via nostr.
|
||||||
|
* When `fallback` is true, this is allowed to be derived from
|
||||||
|
* the recipient's identity instead. */
|
||||||
|
recipientHeartbeatPub: Uint8Array;
|
||||||
|
fallback?: boolean;
|
||||||
|
body: string;
|
||||||
|
}): Promise<SealedEnvelopeV3> {
|
||||||
|
// Fresh ephemeral on the sender side — identical FS posture to v2
|
||||||
|
// on the SENDER half.
|
||||||
|
const ephPriv = x25519.utils.randomSecretKey();
|
||||||
|
const ephPub = x25519.getPublicKey(ephPriv);
|
||||||
|
|
||||||
|
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const aesKey = await deriveAesKey(
|
||||||
|
ephPriv,
|
||||||
|
opts.recipientHeartbeatPub,
|
||||||
|
nonce,
|
||||||
|
HKDF_INFO_V3,
|
||||||
|
);
|
||||||
|
|
||||||
|
const plaintext: MessagePlaintext = {
|
||||||
|
from: opts.senderPrimary,
|
||||||
|
body: opts.body,
|
||||||
|
sent_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const ptBytes = new TextEncoder().encode(JSON.stringify(plaintext));
|
||||||
|
|
||||||
|
// AAD binds the full routing context. A relay tampering with
|
||||||
|
// recipient_heartbeat_pub or fallback would fail the auth tag.
|
||||||
|
const aad = canonicalBytes({
|
||||||
|
v: 3,
|
||||||
|
eph_pub: bytesToHex(ephPub),
|
||||||
|
recipient_heartbeat_pub: bytesToHex(opts.recipientHeartbeatPub),
|
||||||
|
nonce: bytesToHex(nonce),
|
||||||
|
fallback: !!opts.fallback,
|
||||||
|
});
|
||||||
|
const ctBytes = new Uint8Array(
|
||||||
|
await crypto.subtle.encrypt(
|
||||||
|
{
|
||||||
|
name: "AES-GCM",
|
||||||
|
iv: asBuffer(nonce),
|
||||||
|
additionalData: asBuffer(aad),
|
||||||
|
},
|
||||||
|
aesKey,
|
||||||
|
asBuffer(ptBytes),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const partial = {
|
||||||
|
v: 3 as const,
|
||||||
|
eph_pub: bytesToHex(ephPub),
|
||||||
|
recipient_heartbeat_pub: bytesToHex(opts.recipientHeartbeatPub),
|
||||||
|
nonce: bytesToHex(nonce),
|
||||||
|
ciphertext: bytesToHex(ctBytes),
|
||||||
|
...(opts.fallback ? { fallback: true } : {}),
|
||||||
|
};
|
||||||
|
const sig = ed25519.sign(canonicalBytes(partial), opts.senderSeed);
|
||||||
|
|
||||||
|
return { ...partial, sender_sig: bytesToHex(sig) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a v3 envelope. The recipient private is the identity-derived
|
||||||
|
* x25519 key for `fallback` envelopes (sender had no heartbeat for us),
|
||||||
|
* otherwise the heartbeat private looked up via the caller-supplied
|
||||||
|
* resolver — keeps this module free of IDB (heartbeat-store) imports.
|
||||||
|
* Throws if we no longer hold that heartbeat (expired or never ours).
|
||||||
|
*/
|
||||||
|
async function openMessageV3(
|
||||||
|
env: SealedEnvelopeV3,
|
||||||
|
mySeed: Uint8Array,
|
||||||
|
resolveHeartbeatPriv: ResolveHeartbeatPrivs,
|
||||||
|
): Promise<MessagePlaintext> {
|
||||||
|
const recipientPriv = env.fallback
|
||||||
|
? x25519PrivFromEd25519Seed(mySeed)
|
||||||
|
: (await resolveHeartbeatPriv(env.recipient_heartbeat_pub))?.x25519;
|
||||||
|
if (!recipientPriv) {
|
||||||
|
throw new Error(
|
||||||
|
"envelope addressed to an expired/unknown heartbeat (no matching private key on this device)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ephPub = hexToBytes(env.eph_pub);
|
||||||
|
const nonce = hexToBytes(env.nonce);
|
||||||
|
const aesKey = await deriveAesKey(
|
||||||
|
recipientPriv,
|
||||||
|
ephPub,
|
||||||
|
nonce,
|
||||||
|
HKDF_INFO_V3,
|
||||||
|
);
|
||||||
|
|
||||||
|
const aad = canonicalBytes({
|
||||||
|
v: 3,
|
||||||
|
eph_pub: env.eph_pub,
|
||||||
|
recipient_heartbeat_pub: env.recipient_heartbeat_pub,
|
||||||
|
nonce: env.nonce,
|
||||||
|
fallback: !!env.fallback,
|
||||||
|
});
|
||||||
|
const ptBytes = new Uint8Array(
|
||||||
|
await crypto.subtle.decrypt(
|
||||||
|
{
|
||||||
|
name: "AES-GCM",
|
||||||
|
iv: asBuffer(nonce),
|
||||||
|
additionalData: asBuffer(aad),
|
||||||
|
},
|
||||||
|
aesKey,
|
||||||
|
asBuffer(hexToBytes(env.ciphertext)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const plaintext = JSON.parse(
|
||||||
|
new TextDecoder().decode(ptBytes),
|
||||||
|
) as MessagePlaintext;
|
||||||
|
|
||||||
|
if (!plaintext.from?.startsWith("ed25519:")) {
|
||||||
|
throw new Error(`unsupported sender primary scheme: ${plaintext.from}`);
|
||||||
|
}
|
||||||
|
const senderPubKey = hexToBytes(plaintext.from.slice("ed25519:".length));
|
||||||
|
const partial = {
|
||||||
|
v: env.v,
|
||||||
|
eph_pub: env.eph_pub,
|
||||||
|
recipient_heartbeat_pub: env.recipient_heartbeat_pub,
|
||||||
|
nonce: env.nonce,
|
||||||
|
ciphertext: env.ciphertext,
|
||||||
|
...(env.fallback ? { fallback: true } : {}),
|
||||||
|
};
|
||||||
|
const sigOk = ed25519.verify(
|
||||||
|
hexToBytes(env.sender_sig),
|
||||||
|
canonicalBytes(partial),
|
||||||
|
senderPubKey,
|
||||||
|
);
|
||||||
|
if (!sigOk) throw new Error("envelope signature did not verify");
|
||||||
|
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// v4 — hybrid X25519 + ML-KEM-768 (post-quantum)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** HKDF over BOTH shared secrets, X25519 first (PQXDH order). Nonce as
|
||||||
|
* salt, as v1–v3. An attacker needs both secrets to get the key. */
|
||||||
|
async function deriveHybridAesKey(
|
||||||
|
x25519Shared: Uint8Array,
|
||||||
|
mlkemShared: Uint8Array,
|
||||||
|
nonce: Uint8Array,
|
||||||
|
): Promise<CryptoKey> {
|
||||||
|
const ikm = new Uint8Array(x25519Shared.length + mlkemShared.length);
|
||||||
|
ikm.set(x25519Shared, 0);
|
||||||
|
ikm.set(mlkemShared, x25519Shared.length);
|
||||||
|
const keyBytes = hkdf(sha256, ikm, nonce, HKDF_INFO_V4, 32);
|
||||||
|
return crypto.subtle.importKey("raw", asBuffer(keyBytes), "AES-GCM", false, [
|
||||||
|
"encrypt",
|
||||||
|
"decrypt",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function v4Aad(env: Omit<SealedEnvelopeV4, "ciphertext" | "sender_sig">): Uint8Array {
|
||||||
|
return canonicalBytes({
|
||||||
|
v: 4,
|
||||||
|
eph_pub: env.eph_pub,
|
||||||
|
recipient_heartbeat_pub: env.recipient_heartbeat_pub,
|
||||||
|
kem_ct: env.kem_ct,
|
||||||
|
nonce: env.nonce,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seal to a recipient heartbeat that carries an ML-KEM-768 key. */
|
||||||
|
export async function sealMessageV4(opts: {
|
||||||
|
senderSeed: Uint8Array;
|
||||||
|
senderPrimary: Identity;
|
||||||
|
recipientHeartbeatPub: Uint8Array;
|
||||||
|
recipientMlkemPub: Uint8Array;
|
||||||
|
body: string;
|
||||||
|
}): Promise<SealedEnvelopeV4> {
|
||||||
|
const ephPriv = x25519.utils.randomSecretKey();
|
||||||
|
const ephPub = x25519.getPublicKey(ephPriv);
|
||||||
|
const xShared = x25519.getSharedSecret(ephPriv, opts.recipientHeartbeatPub);
|
||||||
|
const { cipherText, sharedSecret } = ml_kem768.encapsulate(opts.recipientMlkemPub);
|
||||||
|
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const aesKey = await deriveHybridAesKey(xShared, sharedSecret, nonce);
|
||||||
|
|
||||||
|
const plaintext: MessagePlaintext = {
|
||||||
|
from: opts.senderPrimary,
|
||||||
|
body: opts.body,
|
||||||
|
sent_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const head = {
|
||||||
|
v: 4 as const,
|
||||||
|
eph_pub: bytesToHex(ephPub),
|
||||||
|
recipient_heartbeat_pub: bytesToHex(opts.recipientHeartbeatPub),
|
||||||
|
kem_ct: bytesToHex(cipherText),
|
||||||
|
nonce: bytesToHex(nonce),
|
||||||
|
};
|
||||||
|
const ctBytes = new Uint8Array(
|
||||||
|
await crypto.subtle.encrypt(
|
||||||
|
{ name: "AES-GCM", iv: asBuffer(nonce), additionalData: asBuffer(v4Aad(head)) },
|
||||||
|
aesKey,
|
||||||
|
asBuffer(new TextEncoder().encode(JSON.stringify(plaintext))),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const partial = { ...head, ciphertext: bytesToHex(ctBytes) };
|
||||||
|
const sig = ed25519.sign(canonicalBytes(partial), opts.senderSeed);
|
||||||
|
return { ...partial, sender_sig: bytesToHex(sig) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMessageV4(
|
||||||
|
env: SealedEnvelopeV4,
|
||||||
|
resolveHeartbeatPriv: ResolveHeartbeatPrivs,
|
||||||
|
): Promise<MessagePlaintext> {
|
||||||
|
const privs = await resolveHeartbeatPriv(env.recipient_heartbeat_pub);
|
||||||
|
if (!privs?.mlkem) {
|
||||||
|
throw new Error(
|
||||||
|
"envelope addressed to an expired/unknown heartbeat (no matching private key on this device)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const nonce = hexToBytes(env.nonce);
|
||||||
|
const xShared = x25519.getSharedSecret(privs.x25519, hexToBytes(env.eph_pub));
|
||||||
|
const kemShared = ml_kem768.decapsulate(hexToBytes(env.kem_ct), privs.mlkem);
|
||||||
|
const aesKey = await deriveHybridAesKey(xShared, kemShared, nonce);
|
||||||
|
const ptBytes = new Uint8Array(
|
||||||
|
await crypto.subtle.decrypt(
|
||||||
|
{ name: "AES-GCM", iv: asBuffer(nonce), additionalData: asBuffer(v4Aad(env)) },
|
||||||
|
aesKey,
|
||||||
|
asBuffer(hexToBytes(env.ciphertext)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const plaintext = JSON.parse(new TextDecoder().decode(ptBytes)) as MessagePlaintext;
|
||||||
|
|
||||||
|
if (!plaintext.from?.startsWith("ed25519:")) {
|
||||||
|
throw new Error(`unsupported sender primary scheme: ${plaintext.from}`);
|
||||||
|
}
|
||||||
|
const partial = {
|
||||||
|
v: env.v,
|
||||||
|
eph_pub: env.eph_pub,
|
||||||
|
recipient_heartbeat_pub: env.recipient_heartbeat_pub,
|
||||||
|
kem_ct: env.kem_ct,
|
||||||
|
nonce: env.nonce,
|
||||||
|
ciphertext: env.ciphertext,
|
||||||
|
};
|
||||||
|
const sigOk = ed25519.verify(
|
||||||
|
hexToBytes(env.sender_sig),
|
||||||
|
canonicalBytes(partial),
|
||||||
|
hexToBytes(plaintext.from.slice("ed25519:".length)),
|
||||||
|
);
|
||||||
|
if (!sigOk) throw new Error("envelope signature did not verify");
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Heartbeat binding — ties a published heartbeat pub to a KEZ identity
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The nostr event carrying a heartbeat is signed by a nostr key, but
|
||||||
|
// senders learn a peer's nostr pubkey from the OUTER event of an
|
||||||
|
// inbound DM — which anyone can re-wrap around a replayed envelope.
|
||||||
|
// So the nostr signature alone proves nothing about who the heartbeat
|
||||||
|
// belongs to. The KEZ ed25519 signature below does: senders verify it
|
||||||
|
// against the recipient's KEZ primary before ECDH'ing to the pub.
|
||||||
|
|
||||||
|
/** Signed heartbeat payload — the JSON content of the nostr event. */
|
||||||
|
export interface SignedHeartbeat {
|
||||||
|
pub: string; // x25519 pub, hex
|
||||||
|
/** ML-KEM-768 encapsulation key, hex (1184 bytes). Absent on
|
||||||
|
* pre-v4 heartbeats → senders use v3. Covered by `sig`. */
|
||||||
|
mlkem_pub?: string;
|
||||||
|
created_at: string; // ISO
|
||||||
|
sig: string; // ed25519 over canonical({t, pub, created_at}), hex
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEARTBEAT_SIG_TAG = "kez-heartbeat-v1";
|
||||||
|
|
||||||
|
/** Reject heartbeats older than this even if validly signed — bounds
|
||||||
|
* how far back an attacker can replay a superseded (possibly already
|
||||||
|
* deleted) heartbeat to make our messages undecryptable. */
|
||||||
|
const MAX_HEARTBEAT_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
const MAX_HEARTBEAT_SKEW_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Signed fields. `mlkem_pub` is only included when present so
|
||||||
|
* pre-v4 heartbeats keep verifying. */
|
||||||
|
function heartbeatSigBytes(h: Omit<SignedHeartbeat, "sig">): Uint8Array {
|
||||||
|
return canonicalBytes({
|
||||||
|
t: HEARTBEAT_SIG_TAG,
|
||||||
|
pub: h.pub,
|
||||||
|
created_at: h.created_at,
|
||||||
|
...(h.mlkem_pub ? { mlkem_pub: h.mlkem_pub } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signHeartbeat(
|
||||||
|
seed: Uint8Array,
|
||||||
|
pubHex: string,
|
||||||
|
createdAt: string,
|
||||||
|
mlkemPubHex?: string,
|
||||||
|
): SignedHeartbeat {
|
||||||
|
const body = {
|
||||||
|
pub: pubHex,
|
||||||
|
created_at: createdAt,
|
||||||
|
...(mlkemPubHex ? { mlkem_pub: mlkemPubHex } : {}),
|
||||||
|
};
|
||||||
|
return { ...body, sig: bytesToHex(ed25519.sign(heartbeatSigBytes(body), seed)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A verified peer heartbeat's public keys. */
|
||||||
|
export interface HeartbeatPubs {
|
||||||
|
x25519: Uint8Array;
|
||||||
|
mlkem?: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the heartbeat's public keys if `hb` is a fresh heartbeat
|
||||||
|
* signed by `primary`, else null. Never throws. */
|
||||||
|
export function verifyHeartbeat(
|
||||||
|
hb: unknown,
|
||||||
|
primary: Identity,
|
||||||
|
now = Date.now(),
|
||||||
|
): HeartbeatPubs | null {
|
||||||
|
try {
|
||||||
|
const h = hb as SignedHeartbeat;
|
||||||
|
if (
|
||||||
|
!primary.startsWith("ed25519:") ||
|
||||||
|
typeof h?.pub !== "string" ||
|
||||||
|
typeof h.created_at !== "string" ||
|
||||||
|
typeof h.sig !== "string" ||
|
||||||
|
!/^[0-9a-f]{64}$/i.test(h.pub) ||
|
||||||
|
(h.mlkem_pub !== undefined &&
|
||||||
|
!(typeof h.mlkem_pub === "string" && /^[0-9a-f]{2368}$/i.test(h.mlkem_pub)))
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const age = now - Date.parse(h.created_at);
|
||||||
|
if (!(age <= MAX_HEARTBEAT_AGE_MS && age >= -MAX_HEARTBEAT_SKEW_MS)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const ok = ed25519.verify(
|
||||||
|
hexToBytes(h.sig),
|
||||||
|
heartbeatSigBytes(h),
|
||||||
|
hexToBytes(primary.slice("ed25519:".length)),
|
||||||
|
);
|
||||||
|
if (!ok) return null;
|
||||||
|
return {
|
||||||
|
x25519: hexToBytes(h.pub),
|
||||||
|
...(h.mlkem_pub ? { mlkem: hexToBytes(h.mlkem_pub) } : {}),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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
|
/** Rough byte size of a data URL. Cheap — we just look at the
|
||||||
* base64 payload length. Used by Settings to surface "your picture
|
* base64 payload length. Used by Settings to surface "your picture
|
||||||
* is X KB" so the user knows what they're publishing. */
|
* is X KB" so the user knows what they're publishing. */
|
||||||
|
|||||||
@ -43,12 +43,20 @@ import {
|
|||||||
} from "./conversations-store.js";
|
} from "./conversations-store.js";
|
||||||
import type { Identity } from "./kez.js";
|
import type { Identity } from "./kez.js";
|
||||||
import { peerProfiles } from "./peer-profile-cell.svelte.js";
|
import { peerProfiles } from "./peer-profile-cell.svelte.js";
|
||||||
|
import { hexToBytes } from "@noble/hashes/utils";
|
||||||
|
import { get as idbGet, update as idbUpdate } from "idb-keyval";
|
||||||
import {
|
import {
|
||||||
parseFileBody,
|
parseFileBody,
|
||||||
base64ToBytes,
|
base64ToBytes,
|
||||||
assembleChunks,
|
assembleChunks,
|
||||||
bytesToDataUrl,
|
bytesToDataUrl,
|
||||||
} from "./file-transfer.js";
|
} from "./file-transfer.js";
|
||||||
|
import {
|
||||||
|
findHeartbeatPrivateByPub,
|
||||||
|
gcExpiredHeartbeats,
|
||||||
|
getOrRotateCurrentHeartbeat,
|
||||||
|
} from "./heartbeat-store.js";
|
||||||
|
import { publishHeartbeat } from "./nostr-transport.js";
|
||||||
import {
|
import {
|
||||||
chunkBufferIsComplete,
|
chunkBufferIsComplete,
|
||||||
deleteChunkBuffer,
|
deleteChunkBuffer,
|
||||||
@ -60,6 +68,15 @@ import {
|
|||||||
|
|
||||||
const POLL_INTERVAL_MS = 30_000;
|
const POLL_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
|
/** Envelopes we've already ingested, keyed by (a prefix of) their KEZ
|
||||||
|
* `sender_sig`. ed25519 sigs are deterministic, so the same envelope
|
||||||
|
* always carries the same sig — even when someone re-wraps it in a
|
||||||
|
* new nostr event (new event id → new seq, which seq-dedupe misses),
|
||||||
|
* or a relay re-broadcasts it after the user cleared the chat.
|
||||||
|
* FIFO-capped; survives clearConversationMessages on purpose. */
|
||||||
|
const SEEN_ENVELOPES_KEY = "kez-chat:seen-envelopes:v1";
|
||||||
|
const SEEN_ENVELOPES_MAX = 5000;
|
||||||
|
|
||||||
export type StreamStatus = "off" | "connecting" | "live" | "reconnecting";
|
export type StreamStatus = "off" | "connecting" | "live" | "reconnecting";
|
||||||
|
|
||||||
class InboxService {
|
class InboxService {
|
||||||
@ -80,6 +97,20 @@ class InboxService {
|
|||||||
* was either already-known on session start (so the user has seen it
|
* was either already-known on session start (so the user has seen it
|
||||||
* on this or another device) or already notified about. */
|
* on this or another device) or already notified about. */
|
||||||
#notifiedThroughSeq = -1;
|
#notifiedThroughSeq = -1;
|
||||||
|
#seenEnvelopes: Set<string> | null = null;
|
||||||
|
|
||||||
|
async #seenSet(): Promise<Set<string>> {
|
||||||
|
return (this.#seenEnvelopes ??= new Set(
|
||||||
|
(await idbGet<string[]>(SEEN_ENVELOPES_KEY)) ?? [],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
async #markEnvelopeSeen(key: string): Promise<void> {
|
||||||
|
(await this.#seenSet()).add(key);
|
||||||
|
await idbUpdate<string[]>(SEEN_ENVELOPES_KEY, (a = []) =>
|
||||||
|
[...a.filter((k) => k !== key), key].slice(-SEEN_ENVELOPES_MAX),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Start streaming for this session. Called from App.svelte on unlock. */
|
/** Start streaming for this session. Called from App.svelte on unlock. */
|
||||||
async start(handle: string, seed: Uint8Array) {
|
async start(handle: string, seed: Uint8Array) {
|
||||||
@ -107,6 +138,13 @@ class InboxService {
|
|||||||
onAck: (eventId, sigHex) => void this.#ingestAck(eventId, sigHex),
|
onAck: (eventId, sigHex) => void this.#ingestAck(eventId, sigHex),
|
||||||
onStatus: (s) => (this.status = s),
|
onStatus: (s) => (this.status = s),
|
||||||
});
|
});
|
||||||
|
// Heartbeat ratchet. Always publish on session start (not only
|
||||||
|
// when freshly rotated): the event is replaceable so it's cheap,
|
||||||
|
// and it self-heals a publish that failed last time or a relay
|
||||||
|
// list the user changed since. Must run before the first poll,
|
||||||
|
// whose non-forcing ratchet call would otherwise win the
|
||||||
|
// single-flight and skip the publish.
|
||||||
|
void this.#heartbeatRatchet(true);
|
||||||
this.#pollTimer = setInterval(() => void this.#heartbeat(), POLL_INTERVAL_MS);
|
this.#pollTimer = setInterval(() => void this.#heartbeat(), POLL_INTERVAL_MS);
|
||||||
// Eager first poll so we catch up anything queued before this session.
|
// Eager first poll so we catch up anything queued before this session.
|
||||||
void this.#heartbeat();
|
void this.#heartbeat();
|
||||||
@ -116,6 +154,48 @@ class InboxService {
|
|||||||
void flushPendingAcks(seed);
|
void flushPendingAcks(seed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#ratchetInFlight: Promise<void> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate the heartbeat if it's past the rotation interval, publish
|
||||||
|
* it (always when `forcePublish`, otherwise only if freshly minted),
|
||||||
|
* then sweep entries superseded longer ago than the retention
|
||||||
|
* window. Order matters: rotate first, so the previously-current
|
||||||
|
* heartbeat (which the backlog we're about to ingest was encrypted
|
||||||
|
* to) counts as superseded NOW and stays for the full window.
|
||||||
|
*
|
||||||
|
* Runs on start and on every poll tick, so a PWA left open for days
|
||||||
|
* still rotates. Single-flight so overlapping ticks can't double-
|
||||||
|
* rotate.
|
||||||
|
*/
|
||||||
|
#heartbeatRatchet(forcePublish: boolean): Promise<void> {
|
||||||
|
const seed = this.#seed;
|
||||||
|
if (!seed) return Promise.resolve();
|
||||||
|
if (this.#ratchetInFlight) return this.#ratchetInFlight;
|
||||||
|
this.#ratchetInFlight = (async () => {
|
||||||
|
try {
|
||||||
|
const { heartbeat, needs_publish } =
|
||||||
|
await getOrRotateCurrentHeartbeat();
|
||||||
|
if (forcePublish || needs_publish) {
|
||||||
|
await publishHeartbeat({
|
||||||
|
seed,
|
||||||
|
heartbeat_pub: hexToBytes(heartbeat.pub),
|
||||||
|
mlkem_pub: heartbeat.mlkem_pub,
|
||||||
|
created_at: heartbeat.created_at,
|
||||||
|
}).catch((e) => {
|
||||||
|
console.warn("heartbeat publish failed:", e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await gcExpiredHeartbeats();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("heartbeat lifecycle failed:", e);
|
||||||
|
} finally {
|
||||||
|
this.#ratchetInFlight = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return this.#ratchetInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
/** Stop everything. Called on lock + on tab close. */
|
/** Stop everything. Called on lock + on tab close. */
|
||||||
stop() {
|
stop() {
|
||||||
this.#stream?.close();
|
this.#stream?.close();
|
||||||
@ -143,6 +223,7 @@ class InboxService {
|
|||||||
|
|
||||||
async #heartbeat() {
|
async #heartbeat() {
|
||||||
if (!this.#handle || !this.#seed) return;
|
if (!this.#handle || !this.#seed) return;
|
||||||
|
void this.#heartbeatRatchet(false);
|
||||||
try {
|
try {
|
||||||
const since = await getGlobalCursor();
|
const since = await getGlobalCursor();
|
||||||
const { messages } = await pollInbox({
|
const { messages } = await pollInbox({
|
||||||
@ -160,8 +241,17 @@ class InboxService {
|
|||||||
|
|
||||||
async #ingest(m: InboxMessage) {
|
async #ingest(m: InboxMessage) {
|
||||||
if (!this.#handle || !this.#seed) return;
|
if (!this.#handle || !this.#seed) return;
|
||||||
|
// Only marked seen AFTER a verified decrypt + persist below, so a
|
||||||
|
// forged envelope that copies a real sig can't pre-poison this.
|
||||||
|
const envKey = m.envelope.sender_sig?.slice(0, 32);
|
||||||
|
if (envKey && (await this.#seenSet()).has(envKey)) return;
|
||||||
try {
|
try {
|
||||||
const pt = await decrypt(m.envelope, this.#handle, this.#seed);
|
const pt = await decrypt(
|
||||||
|
m.envelope,
|
||||||
|
this.#handle,
|
||||||
|
this.#seed,
|
||||||
|
findHeartbeatPrivateByPub,
|
||||||
|
);
|
||||||
// Resolve sender's display handle (cache on conversation row).
|
// Resolve sender's display handle (cache on conversation row).
|
||||||
let displayName = "";
|
let displayName = "";
|
||||||
const existing = await getConversation(pt.from);
|
const existing = await getConversation(pt.from);
|
||||||
@ -196,6 +286,7 @@ class InboxService {
|
|||||||
// peer-profile fetch, badge bump) to run for inline files
|
// peer-profile fetch, badge bump) to run for inline files
|
||||||
// and pointers, but NOT for chunks (which are user-invisible
|
// and pointers, but NOT for chunks (which are user-invisible
|
||||||
// plumbing). Bail early on chunks.
|
// plumbing). Bail early on chunks.
|
||||||
|
if (envKey) await this.#markEnvelopeSeen(envKey);
|
||||||
if (fileBody.type === "kez-file-chunk-v1") return;
|
if (fileBody.type === "kez-file-chunk-v1") return;
|
||||||
} else {
|
} else {
|
||||||
await appendInbound({
|
await appendInbound({
|
||||||
@ -207,6 +298,7 @@ class InboxService {
|
|||||||
peer_nostr_pubkey: m.sender_nostr_pubkey,
|
peer_nostr_pubkey: m.sender_nostr_pubkey,
|
||||||
via_relay: m.via_relay,
|
via_relay: m.via_relay,
|
||||||
});
|
});
|
||||||
|
if (envKey) await this.#markEnvelopeSeen(envKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// First time we've seen this peer's nostr pubkey? Kick off a
|
// First time we've seen this peer's nostr pubkey? Kick off a
|
||||||
|
|||||||
@ -6,7 +6,13 @@
|
|||||||
import { ed25519 } from "@noble/curves/ed25519";
|
import { ed25519 } from "@noble/curves/ed25519";
|
||||||
import { bytesToHex } from "@noble/hashes/utils";
|
import { bytesToHex } from "@noble/hashes/utils";
|
||||||
import { lookup } from "./api.js";
|
import { lookup } from "./api.js";
|
||||||
import { sealMessage, openMessage, type SealedEnvelope, type MessagePlaintext } from "./crypto.js";
|
import {
|
||||||
|
sealMessage,
|
||||||
|
openMessage,
|
||||||
|
type SealedEnvelope,
|
||||||
|
type MessagePlaintext,
|
||||||
|
type ResolveHeartbeatPrivs,
|
||||||
|
} from "./crypto.js";
|
||||||
import type { Identity } from "./kez.js";
|
import type { Identity } from "./kez.js";
|
||||||
|
|
||||||
/** Server base URL (always same-origin for the SPA). */
|
/** Server base URL (always same-origin for the SPA). */
|
||||||
@ -94,6 +100,9 @@ export async function sendMessage(opts: {
|
|||||||
* POST /v1/messages — so it can't help when the server is down —
|
* POST /v1/messages — so it can't help when the server is down —
|
||||||
* but skipping the extra round-trip is faster. */
|
* but skipping the extra round-trip is faster. */
|
||||||
recipientPrimary?: Identity;
|
recipientPrimary?: Identity;
|
||||||
|
/** v3 heartbeat parity — ignored on server transport (no
|
||||||
|
* heartbeats in play; the chat-server is itself the rendezvous). */
|
||||||
|
recipientNostrPubkey?: string;
|
||||||
}): Promise<{ seq: number; event_id: string; accepted_by?: string }> {
|
}): Promise<{ seq: number; event_id: string; accepted_by?: string }> {
|
||||||
const recipientHandle = opts.recipient.split("@")[0];
|
const recipientHandle = opts.recipient.split("@")[0];
|
||||||
let resolvedPrimary: Identity;
|
let resolvedPrimary: Identity;
|
||||||
@ -164,13 +173,22 @@ export async function pollInbox(opts: {
|
|||||||
return (await resp.json()) as { messages: InboxMessage[]; cursor: number };
|
return (await resp.json()) as { messages: InboxMessage[]; cursor: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Decrypt one inbox envelope. Wrap in try/catch — bad sigs throw. */
|
/** Decrypt one inbox envelope. Wrap in try/catch — bad sigs throw.
|
||||||
|
* `resolveHeartbeatPriv` is only consulted for v3 envelopes; pass
|
||||||
|
* it through from the inbox-service so this module stays free of
|
||||||
|
* IDB imports. v1/v2 envelopes ignore it. */
|
||||||
export async function decrypt(
|
export async function decrypt(
|
||||||
envelope: SealedEnvelope,
|
envelope: SealedEnvelope,
|
||||||
myHandle: string,
|
myHandle: string,
|
||||||
mySeed: Uint8Array,
|
mySeed: Uint8Array,
|
||||||
|
resolveHeartbeatPriv?: ResolveHeartbeatPrivs,
|
||||||
): Promise<MessagePlaintext> {
|
): Promise<MessagePlaintext> {
|
||||||
return openMessage({ envelope, myHandle, mySeed });
|
return openMessage({
|
||||||
|
envelope,
|
||||||
|
myHandle,
|
||||||
|
mySeed,
|
||||||
|
resolveHeartbeatPriv,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@ -268,6 +286,7 @@ export async function sendAck(_opts: {
|
|||||||
ackedEventId: string;
|
ackedEventId: string;
|
||||||
originalSenderNostrPubkey?: string;
|
originalSenderNostrPubkey?: string;
|
||||||
preferRelay?: string;
|
preferRelay?: string;
|
||||||
|
extraRelays?: string[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
/* server transport: receipts not implemented in v0.1 */
|
/* server transport: receipts not implemented in v0.1 */
|
||||||
}
|
}
|
||||||
@ -292,6 +311,8 @@ export async function sendFile(_opts: {
|
|||||||
mime: string;
|
mime: string;
|
||||||
raw: Uint8Array;
|
raw: Uint8Array;
|
||||||
preferRelay?: string;
|
preferRelay?: string;
|
||||||
|
extraRelays?: string[];
|
||||||
|
recipientNostrPubkey?: string;
|
||||||
progress?: (done: number, total: number) => void;
|
progress?: (done: number, total: number) => void;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
pointer_event_id: string;
|
pointer_event_id: string;
|
||||||
|
|||||||
@ -18,7 +18,17 @@
|
|||||||
import { ed25519 } from "@noble/curves/ed25519";
|
import { ed25519 } from "@noble/curves/ed25519";
|
||||||
import { bytesToHex } from "@noble/hashes/utils";
|
import { bytesToHex } from "@noble/hashes/utils";
|
||||||
import { SimplePool, finalizeEvent, type Event, type EventTemplate } from "nostr-tools";
|
import { SimplePool, finalizeEvent, type Event, type EventTemplate } from "nostr-tools";
|
||||||
import { sealMessage, type SealedEnvelope } from "./crypto.js";
|
import {
|
||||||
|
sealMessageV3,
|
||||||
|
sealMessageV4,
|
||||||
|
signHeartbeat,
|
||||||
|
verifyHeartbeat,
|
||||||
|
x25519PubFromPrimary,
|
||||||
|
type SealedEnvelope,
|
||||||
|
type SignedHeartbeat,
|
||||||
|
type HeartbeatPubs,
|
||||||
|
} from "./crypto.js";
|
||||||
|
import { get as idbGet, set as idbSet } from "idb-keyval";
|
||||||
import {
|
import {
|
||||||
bytesToBase64,
|
bytesToBase64,
|
||||||
chunkifyBytes,
|
chunkifyBytes,
|
||||||
@ -94,18 +104,15 @@ export function getRelayStatuses(): RelayStatus[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Relays to publish to / read from. Override with VITE_NOSTR_RELAYS (csv).
|
/** Relays to publish to / read from. Loaded synchronously from
|
||||||
* Default set: 3 long-running general-purpose relays (damus, nos, primal)
|
* relay-store at module init: user override from localStorage,
|
||||||
* + 2 popular extras (snort.social, nostr.wine) — chosen for redundancy
|
* falling back to env override (VITE_NOSTR_RELAYS), falling back
|
||||||
* and geographic diversity. If any single relay is slow/down our publish
|
* to a hardcoded 5-relay default. Order matters — earlier entries
|
||||||
* still succeeds as long as one accepts. */
|
* win the publish race more often, so users can express
|
||||||
const RELAYS: string[] = (
|
* preference in Settings. Changes from Settings require a page
|
||||||
import.meta.env.VITE_NOSTR_RELAYS ??
|
* reload to apply (we do that with a clear UI prompt). */
|
||||||
"wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr.wine"
|
import { loadCurrentRelays } from "./relay-store.js";
|
||||||
)
|
const RELAYS: string[] = loadCurrentRelays();
|
||||||
.split(",")
|
|
||||||
.map((r: string) => r.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
/** One pool for the whole session — relay connections are reused.
|
/** One pool for the whole session — relay connections are reused.
|
||||||
* `attachSigner(seed)` recreates the pool with `automaticallyAuth`
|
* `attachSigner(seed)` recreates the pool with `automaticallyAuth`
|
||||||
@ -316,17 +323,234 @@ function toInboxMessage(ev: Event): InboxMessage | null {
|
|||||||
/**
|
/**
|
||||||
* Order relays for this send: any preferred relay (typically: the
|
* Order relays for this send: any preferred relay (typically: the
|
||||||
* relay that delivered the most recent inbound message from this
|
* relay that delivered the most recent inbound message from this
|
||||||
* peer) goes first, then the rest of the configured set, deduped.
|
* peer) goes first, then the peer's NIP-65 read-relays (so their
|
||||||
* Putting a preferred relay first means the WebSocket open + publish
|
* client subscribed to those receives the event), then the rest of
|
||||||
* race usually completes against it — same network path the inbound
|
* the configured set. Deduped + capped so a peer that lists 20
|
||||||
* message came in on, so geographically/network-wise the fastest.
|
* relays doesn't make every send a relay flood.
|
||||||
|
*
|
||||||
|
* Putting the preferred relay first means the WebSocket open +
|
||||||
|
* publish race usually completes against it — same network path
|
||||||
|
* the inbound came in on.
|
||||||
*/
|
*/
|
||||||
function orderedRelaysForSend(preferRelay?: string): string[] {
|
const MAX_RELAYS_PER_SEND = 10;
|
||||||
if (!preferRelay) return RELAYS;
|
function orderedRelaysForSend(
|
||||||
const norm = preferRelay.replace(/\/$/, "");
|
preferRelay?: string,
|
||||||
const matches = RELAYS.filter((r) => r.replace(/\/$/, "") === norm);
|
extraRelays?: string[],
|
||||||
const others = RELAYS.filter((r) => r.replace(/\/$/, "") !== norm);
|
): string[] {
|
||||||
return [...matches, ...others];
|
const norm = (u: string) => u.replace(/\/$/, "").toLowerCase();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
const push = (url: string) => {
|
||||||
|
const n = norm(url);
|
||||||
|
if (seen.has(n)) return;
|
||||||
|
if (out.length >= MAX_RELAYS_PER_SEND) return;
|
||||||
|
seen.add(n);
|
||||||
|
out.push(url);
|
||||||
|
};
|
||||||
|
if (preferRelay) push(preferRelay);
|
||||||
|
if (extraRelays) for (const r of extraRelays) push(r);
|
||||||
|
for (const r of RELAYS) push(r);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Heartbeat ratchet — publish + fetch
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Each user publishes a fresh ephemeral x25519 pub once per active
|
||||||
|
// day via a replaceable event (kind 10142 — NIP-01 range 10000–19999,
|
||||||
|
// one live event per author; the d-tag is informational). The
|
||||||
|
// PRIVATE half lives only on the
|
||||||
|
// publishing device (heartbeat-store.ts). Senders fetch the most
|
||||||
|
// recent heartbeat from relays and use it as the ECDH target for
|
||||||
|
// v3 envelopes instead of the recipient's long-term identity.
|
||||||
|
//
|
||||||
|
// Properties (full reasoning in TODO.md "Heartbeat Ratchet"):
|
||||||
|
// * Per-message forward secrecy on the SENDER side via ephemeral pub
|
||||||
|
// * Window-grained forward secrecy on the RECEIVER side via
|
||||||
|
// heartbeat rotation + retention deletion
|
||||||
|
// * Authentication: the content is a SignedHeartbeat carrying an
|
||||||
|
// ed25519 signature from the user's KEZ identity, verified by
|
||||||
|
// senders against the recipient's primary (the outer nostr sig
|
||||||
|
// alone proves nothing — see fetchPeerHeartbeat)
|
||||||
|
// * No prekey server, no per-peer session state, no out-of-order
|
||||||
|
// bookkeeping; nostr's replaceable-event semantics carry it.
|
||||||
|
|
||||||
|
export const HEARTBEAT_KIND = 10142;
|
||||||
|
const HEARTBEAT_D_TAG = "heartbeat-v1";
|
||||||
|
|
||||||
|
/** Publish a heartbeat public key to relays. Caller (heartbeat-
|
||||||
|
* service) generates the keypair, persists the private to IDB,
|
||||||
|
* and hands the public bytes here. Content is a SignedHeartbeat —
|
||||||
|
* the pub signed by our KEZ identity, so senders can verify it
|
||||||
|
* belongs to us regardless of which nostr key they think we have.
|
||||||
|
* Throws if no relay accepts. */
|
||||||
|
export async function publishHeartbeat(opts: {
|
||||||
|
seed: Uint8Array;
|
||||||
|
heartbeat_pub: Uint8Array;
|
||||||
|
/** ML-KEM-768 encapsulation key (hex) — lets peers send v4
|
||||||
|
* (post-quantum hybrid) envelopes. */
|
||||||
|
mlkem_pub?: string;
|
||||||
|
created_at: string;
|
||||||
|
}): Promise<{ event_id: string; accepted_by?: string }> {
|
||||||
|
const sk = nostrSecretFromSeed(opts.seed);
|
||||||
|
const pubHex = bytesToHex(opts.heartbeat_pub);
|
||||||
|
const tmpl: EventTemplate = {
|
||||||
|
kind: HEARTBEAT_KIND,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
["d", HEARTBEAT_D_TAG],
|
||||||
|
// Pub in a tag for filterability; the authoritative, KEZ-signed
|
||||||
|
// copy is the content.
|
||||||
|
["x25519", pubHex],
|
||||||
|
],
|
||||||
|
content: JSON.stringify(
|
||||||
|
signHeartbeat(opts.seed, pubHex, opts.created_at, opts.mlkem_pub),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const signed = finalizeEvent(tmpl, sk);
|
||||||
|
const publishPromises = pool().publish(RELAYS, signed);
|
||||||
|
let acceptedBy: string | undefined;
|
||||||
|
try {
|
||||||
|
acceptedBy = await Promise.any(
|
||||||
|
publishPromises.map((p, i) => p.then(() => RELAYS[i])),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
const results = await Promise.allSettled(publishPromises);
|
||||||
|
if (!results.some((r) => r.status === "fulfilled")) {
|
||||||
|
throw new Error("no relay accepted the heartbeat");
|
||||||
|
}
|
||||||
|
return { event_id: signed.id, accepted_by: acceptedBy };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the most-recent heartbeat published by a given nostr
|
||||||
|
* pubkey that carries a valid, fresh signature from `peerPrimary`'s
|
||||||
|
* KEZ key. Returns the 32-byte x25519 public key, or null (→ caller
|
||||||
|
* falls back to identity ECDH).
|
||||||
|
*
|
||||||
|
* The KEZ signature check is what makes this safe: `peerNostrPubkey`
|
||||||
|
* comes from the outer event of an inbound DM and can be spoofed by
|
||||||
|
* anyone re-wrapping a replayed envelope. An attacker's heartbeat
|
||||||
|
* won't carry the peer's KEZ signature, so it's rejected here.
|
||||||
|
*
|
||||||
|
* Raw relay fetch — senders go through `resolvePeerHeartbeat`, which
|
||||||
|
* adds caching + downgrade protection on top.
|
||||||
|
*/
|
||||||
|
export async function fetchPeerHeartbeat(
|
||||||
|
peerNostrPubkey: string,
|
||||||
|
peerPrimary: Identity,
|
||||||
|
): Promise<SignedHeartbeat | null> {
|
||||||
|
try {
|
||||||
|
const events = await pool().querySync(RELAYS, {
|
||||||
|
kinds: [HEARTBEAT_KIND],
|
||||||
|
authors: [peerNostrPubkey],
|
||||||
|
"#d": [HEARTBEAT_D_TAG],
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
// Replaceable events: should be one, but if multiple snuck in
|
||||||
|
// (race between relays), take the most recent that verifies.
|
||||||
|
events.sort((a, b) => b.created_at - a.created_at);
|
||||||
|
for (const ev of events) {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(ev.content);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (verifyHeartbeat(parsed, peerPrimary)) return parsed as SignedHeartbeat;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`fetchPeerHeartbeat failed for ${peerNostrPubkey}:`, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last verified heartbeat per peer primary. Two jobs:
|
||||||
|
* • Skip the relay round-trip for HEARTBEAT_CACHE_TTL_MS — a chunked
|
||||||
|
* file is ~125 sends, and querySync waits on every relay.
|
||||||
|
* • Anti-downgrade: if a later fetch comes back empty or invalid
|
||||||
|
* (relay withholding, spoofed nostr pubkey, network blip), keep
|
||||||
|
* sealing to the last known-good heartbeat while verifyHeartbeat
|
||||||
|
* still considers it fresh, instead of silently dropping to
|
||||||
|
* identity ECDH. An older heartbeat never replaces a newer one.
|
||||||
|
* Persisted to IDB so the anti-downgrade survives reloads.
|
||||||
|
*/
|
||||||
|
const PEER_HEARTBEATS_KEY = "kez-chat:peer-heartbeats:v1";
|
||||||
|
const HEARTBEAT_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
const heartbeatCache = new Map<string, { hb: SignedHeartbeat; fetched_at: number }>();
|
||||||
|
|
||||||
|
async function resolvePeerHeartbeat(
|
||||||
|
peerNostrPubkey: string | undefined,
|
||||||
|
peerPrimary: Identity,
|
||||||
|
): Promise<HeartbeatPubs | null> {
|
||||||
|
let cached = heartbeatCache.get(peerPrimary);
|
||||||
|
if (!cached) {
|
||||||
|
const stored = (await idbGet<Record<string, SignedHeartbeat>>(PEER_HEARTBEATS_KEY))?.[peerPrimary];
|
||||||
|
if (stored) cached = { hb: stored, fetched_at: 0 };
|
||||||
|
}
|
||||||
|
if (cached && Date.now() - cached.fetched_at < HEARTBEAT_CACHE_TTL_MS) {
|
||||||
|
const pub = verifyHeartbeat(cached.hb, peerPrimary);
|
||||||
|
if (pub) return pub;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fresh = peerNostrPubkey
|
||||||
|
? await fetchPeerHeartbeat(peerNostrPubkey, peerPrimary)
|
||||||
|
: null;
|
||||||
|
let best = cached?.hb;
|
||||||
|
if (fresh && (!best || Date.parse(fresh.created_at) >= Date.parse(best.created_at))) {
|
||||||
|
best = fresh;
|
||||||
|
}
|
||||||
|
if (fresh && best) {
|
||||||
|
heartbeatCache.set(peerPrimary, { hb: best, fetched_at: Date.now() });
|
||||||
|
const all = (await idbGet<Record<string, SignedHeartbeat>>(PEER_HEARTBEATS_KEY)) ?? {};
|
||||||
|
all[peerPrimary] = best;
|
||||||
|
await idbSet(PEER_HEARTBEATS_KEY, all);
|
||||||
|
}
|
||||||
|
const pub = best ? verifyHeartbeat(best, peerPrimary) : null;
|
||||||
|
if (!pub && cached) {
|
||||||
|
console.warn(
|
||||||
|
`heartbeat for ${peerPrimary} unavailable or stale — sending without receiver-side forward secrecy`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return pub;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Envelope for a recipient, strongest first:
|
||||||
|
* v4 — heartbeat has an ML-KEM key → X25519 + ML-KEM-768 hybrid
|
||||||
|
* v3 — heartbeat without ML-KEM (pre-v4 client) → X25519 only
|
||||||
|
* v3 fallback — no heartbeat at all (brand-new contact) → identity
|
||||||
|
* key, no receiver-side FS, no PQ. */
|
||||||
|
async function sealForRecipient(opts: {
|
||||||
|
senderSeed: Uint8Array;
|
||||||
|
senderPrimary: Identity;
|
||||||
|
recipientPrimary: Identity;
|
||||||
|
recipientNostrPubkey?: string;
|
||||||
|
body: string;
|
||||||
|
}): Promise<SealedEnvelope> {
|
||||||
|
const hb = await resolvePeerHeartbeat(opts.recipientNostrPubkey, opts.recipientPrimary);
|
||||||
|
// Post-quantum hybrid whenever the recipient's heartbeat offers an
|
||||||
|
// ML-KEM key (every heartbeat minted by a v4-capable client).
|
||||||
|
if (hb?.mlkem) {
|
||||||
|
return sealMessageV4({
|
||||||
|
senderSeed: opts.senderSeed,
|
||||||
|
senderPrimary: opts.senderPrimary,
|
||||||
|
recipientHeartbeatPub: hb.x25519,
|
||||||
|
recipientMlkemPub: hb.mlkem,
|
||||||
|
body: opts.body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return sealMessageV3({
|
||||||
|
senderSeed: opts.senderSeed,
|
||||||
|
senderPrimary: opts.senderPrimary,
|
||||||
|
recipientHeartbeatPub: hb?.x25519 ?? x25519PubFromPrimary(opts.recipientPrimary),
|
||||||
|
fallback: !hb,
|
||||||
|
body: opts.body,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendMessage(opts: {
|
export async function sendMessage(opts: {
|
||||||
@ -339,6 +563,12 @@ export async function sendMessage(opts: {
|
|||||||
* recipient's last message arrived on. Falls back to the full
|
* recipient's last message arrived on. Falls back to the full
|
||||||
* configured relay set if unset or unknown. */
|
* configured relay set if unset or unknown. */
|
||||||
preferRelay?: string;
|
preferRelay?: string;
|
||||||
|
/** Additional relays to include in the publish target set (in
|
||||||
|
* addition to our configured default RELAYS) — typically the
|
||||||
|
* peer's NIP-65 read-relays so their client sees the event even
|
||||||
|
* when they're not subscribed to our defaults. Deduped + capped
|
||||||
|
* inside orderedRelaysForSend. */
|
||||||
|
extraRelays?: string[];
|
||||||
/**
|
/**
|
||||||
* Recipient's KEZ primary, if the caller already has it (e.g. from
|
* Recipient's KEZ primary, if the caller already has it (e.g. from
|
||||||
* the conversation row). Lets us skip the `/v1/u/:handle` lookup
|
* the conversation row). Lets us skip the `/v1/u/:handle` lookup
|
||||||
@ -347,6 +577,17 @@ export async function sendMessage(opts: {
|
|||||||
* brand-new conversations (no cached primary) still need lookup.
|
* brand-new conversations (no cached primary) still need lookup.
|
||||||
*/
|
*/
|
||||||
recipientPrimary?: Identity;
|
recipientPrimary?: Identity;
|
||||||
|
/**
|
||||||
|
* Recipient's nostr pubkey (hex), if the caller has learned it
|
||||||
|
* from a previous inbound DM. Used to fetch the recipient's
|
||||||
|
* current heartbeat from relays for v3 envelope sealing. When
|
||||||
|
* absent we fall back to identity-derived ECDH for this message
|
||||||
|
* (the `fallback: true` v3 branch) — first messages to a new
|
||||||
|
* contact end up here. Once the contact replies once, the
|
||||||
|
* conversation row caches their pubkey and all future sends are
|
||||||
|
* full v3.
|
||||||
|
*/
|
||||||
|
recipientNostrPubkey?: string;
|
||||||
}): Promise<{ seq: number; event_id: string; accepted_by?: string }> {
|
}): Promise<{ seq: number; event_id: string; accepted_by?: string }> {
|
||||||
const recipientHandle = opts.recipient.split("@")[0];
|
const recipientHandle = opts.recipient.split("@")[0];
|
||||||
// Skip the server lookup if the caller already has the primary
|
// Skip the server lookup if the caller already has the primary
|
||||||
@ -360,12 +601,19 @@ export async function sendMessage(opts: {
|
|||||||
recipientPrimary = record.primary as Identity;
|
recipientPrimary = record.primary as Identity;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Our own encryption layer — identical to the server transport.
|
// ─── envelope selection: v3 (heartbeat) by default, fallback ─
|
||||||
const envelope = await sealMessage({
|
// We always emit v3 envelopes. The receiver's HEARTBEAT pub is
|
||||||
|
// what we ECDH against — except for the first-ever message to a
|
||||||
|
// brand-new contact who hasn't published a heartbeat yet, where
|
||||||
|
// we fall back to their identity-derived x25519 pub and mark the
|
||||||
|
// envelope `fallback: true`. That one message has no receiver-
|
||||||
|
// side forward secrecy; everything after does. See TODO.md
|
||||||
|
// "Heartbeat Ratchet".
|
||||||
|
const envelope = await sealForRecipient({
|
||||||
senderSeed: opts.senderSeed,
|
senderSeed: opts.senderSeed,
|
||||||
senderPrimary: opts.senderPrimary,
|
senderPrimary: opts.senderPrimary,
|
||||||
recipientHandle,
|
|
||||||
recipientPrimary,
|
recipientPrimary,
|
||||||
|
recipientNostrPubkey: opts.recipientNostrPubkey,
|
||||||
body: opts.body,
|
body: opts.body,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -382,7 +630,7 @@ export async function sendMessage(opts: {
|
|||||||
// record which relay accepted first so the UI can show "sent via
|
// record which relay accepted first so the UI can show "sent via
|
||||||
// X" and so the next message in this thread can prefer the same
|
// X" and so the next message in this thread can prefer the same
|
||||||
// relay.
|
// relay.
|
||||||
const ordered = orderedRelaysForSend(opts.preferRelay);
|
const ordered = orderedRelaysForSend(opts.preferRelay, opts.extraRelays);
|
||||||
const publishPromises = pool().publish(ordered, signed);
|
const publishPromises = pool().publish(ordered, signed);
|
||||||
// Promise.any returns the first-fulfilled value; if every relay
|
// Promise.any returns the first-fulfilled value; if every relay
|
||||||
// rejects, it throws AggregateError. We still want best-effort
|
// rejects, it throws AggregateError. We still want best-effort
|
||||||
@ -442,14 +690,18 @@ async function publishRawBody(opts: {
|
|||||||
recipientPrimary: Identity;
|
recipientPrimary: Identity;
|
||||||
body: string;
|
body: string;
|
||||||
preferRelay?: string;
|
preferRelay?: string;
|
||||||
|
/** Additional relays to include in the publish target set (in
|
||||||
|
* addition to our configured default RELAYS) — typically the
|
||||||
|
* peer's NIP-65 read-relays so their client sees the event even
|
||||||
|
* when they're not subscribed to our defaults. Deduped + capped
|
||||||
|
* inside orderedRelaysForSend. */
|
||||||
|
extraRelays?: string[];
|
||||||
|
recipientNostrPubkey?: string;
|
||||||
}): Promise<{ event_id: string; accepted_by?: string }> {
|
}): Promise<{ event_id: string; accepted_by?: string }> {
|
||||||
const envelope = await sealMessage({
|
// Same v3 / fallback decision as sendMessage. File-transfer chunks
|
||||||
senderSeed: opts.senderSeed,
|
// and pointers go through here, so they get heartbeat-ratcheted
|
||||||
senderPrimary: opts.senderPrimary,
|
// automatically too.
|
||||||
recipientHandle: opts.recipientHandle,
|
const envelope = await sealForRecipient(opts);
|
||||||
recipientPrimary: opts.recipientPrimary,
|
|
||||||
body: opts.body,
|
|
||||||
});
|
|
||||||
const sk = nostrSecretFromSeed(opts.senderSeed);
|
const sk = nostrSecretFromSeed(opts.senderSeed);
|
||||||
const tmpl: EventTemplate = {
|
const tmpl: EventTemplate = {
|
||||||
kind: KEZ_DM_KIND,
|
kind: KEZ_DM_KIND,
|
||||||
@ -458,7 +710,7 @@ async function publishRawBody(opts: {
|
|||||||
content: JSON.stringify(envelope),
|
content: JSON.stringify(envelope),
|
||||||
};
|
};
|
||||||
const signed = finalizeEvent(tmpl, sk);
|
const signed = finalizeEvent(tmpl, sk);
|
||||||
const ordered = orderedRelaysForSend(opts.preferRelay);
|
const ordered = orderedRelaysForSend(opts.preferRelay, opts.extraRelays);
|
||||||
const publishPromises = pool().publish(ordered, signed);
|
const publishPromises = pool().publish(ordered, signed);
|
||||||
let acceptedBy: string | undefined;
|
let acceptedBy: string | undefined;
|
||||||
try {
|
try {
|
||||||
@ -505,6 +757,15 @@ export async function sendFile(opts: {
|
|||||||
mime: string;
|
mime: string;
|
||||||
raw: Uint8Array;
|
raw: Uint8Array;
|
||||||
preferRelay?: string;
|
preferRelay?: string;
|
||||||
|
/** Additional relays to include in the publish target set (in
|
||||||
|
* addition to our configured default RELAYS) — typically the
|
||||||
|
* peer's NIP-65 read-relays so their client sees the event even
|
||||||
|
* when they're not subscribed to our defaults. Deduped + capped
|
||||||
|
* inside orderedRelaysForSend. */
|
||||||
|
extraRelays?: string[];
|
||||||
|
/** Threaded into v3 envelope sealing — same semantics as
|
||||||
|
* sendMessage. */
|
||||||
|
recipientNostrPubkey?: string;
|
||||||
progress?: (done: number, total: number) => void;
|
progress?: (done: number, total: number) => void;
|
||||||
}): Promise<SendFileResult> {
|
}): Promise<SendFileResult> {
|
||||||
const { raw } = opts;
|
const { raw } = opts;
|
||||||
@ -683,6 +944,12 @@ export async function sendAck(opts: {
|
|||||||
* the same relay, since that path was demonstrably working for
|
* the same relay, since that path was demonstrably working for
|
||||||
* this peer. Falls back to the full set if unset. */
|
* this peer. Falls back to the full set if unset. */
|
||||||
preferRelay?: string;
|
preferRelay?: string;
|
||||||
|
/** Additional relays to include in the publish target set (in
|
||||||
|
* addition to our configured default RELAYS) — typically the
|
||||||
|
* peer's NIP-65 read-relays so their client sees the event even
|
||||||
|
* when they're not subscribed to our defaults. Deduped + capped
|
||||||
|
* inside orderedRelaysForSend. */
|
||||||
|
extraRelays?: string[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const sk = nostrSecretFromSeed(opts.ackingSeed);
|
const sk = nostrSecretFromSeed(opts.ackingSeed);
|
||||||
const targetAddr = addrFromPrimary(opts.originalSenderPrimary);
|
const targetAddr = addrFromPrimary(opts.originalSenderPrimary);
|
||||||
@ -704,7 +971,7 @@ export async function sendAck(opts: {
|
|||||||
content: "",
|
content: "",
|
||||||
};
|
};
|
||||||
const signed = finalizeEvent(tmpl, sk);
|
const signed = finalizeEvent(tmpl, sk);
|
||||||
const ordered = orderedRelaysForSend(opts.preferRelay);
|
const ordered = orderedRelaysForSend(opts.preferRelay, opts.extraRelays);
|
||||||
const results = await Promise.allSettled(pool().publish(ordered, signed));
|
const results = await Promise.allSettled(pool().publish(ordered, signed));
|
||||||
const acceptedSomewhere = results.some((r) => r.status === "fulfilled");
|
const acceptedSomewhere = results.some((r) => r.status === "fulfilled");
|
||||||
if (!acceptedSomewhere) {
|
if (!acceptedSomewhere) {
|
||||||
|
|||||||
@ -26,6 +26,7 @@
|
|||||||
// on access (peers may have updated their picture).
|
// on access (peers may have updated their picture).
|
||||||
|
|
||||||
import { get, set } from "idb-keyval";
|
import { get, set } from "idb-keyval";
|
||||||
|
import { isAllowedRelayUrl, loadCurrentRelays } from "./relay-store.js";
|
||||||
import { hexToBytes } from "@noble/hashes/utils";
|
import { hexToBytes } from "@noble/hashes/utils";
|
||||||
import { SimplePool } from "nostr-tools";
|
import { SimplePool } from "nostr-tools";
|
||||||
|
|
||||||
@ -43,14 +44,8 @@ const CACHE_KEY = "kez-chat:peer-profiles:v1";
|
|||||||
const STALE_AFTER_MS = 6 * 60 * 60 * 1000;
|
const STALE_AFTER_MS = 6 * 60 * 60 * 1000;
|
||||||
const FETCH_TIMEOUT_MS = 8000;
|
const FETCH_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
/** Same default-relay list as nostr-transport.ts. */
|
/** Same relay list as nostr-transport.ts (user override → env → default). */
|
||||||
const RELAYS: string[] = (
|
const RELAYS: string[] = loadCurrentRelays();
|
||||||
(import.meta.env.VITE_NOSTR_RELAYS as string | undefined) ??
|
|
||||||
"wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr.wine"
|
|
||||||
)
|
|
||||||
.split(",")
|
|
||||||
.map((r) => r.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
export interface CachedPeerProfile {
|
export interface CachedPeerProfile {
|
||||||
/** The KEZ primary key — canonical identifier; never changes. */
|
/** The KEZ primary key — canonical identifier; never changes. */
|
||||||
@ -73,6 +68,15 @@ export interface CachedPeerProfile {
|
|||||||
fetched_at: string;
|
fetched_at: string;
|
||||||
/** Event id of the kind:0 we descrambled — debug breadcrumb. */
|
/** Event id of the kind:0 we descrambled — debug breadcrumb. */
|
||||||
source_event_id?: string;
|
source_event_id?: string;
|
||||||
|
/**
|
||||||
|
* NIP-65 (kind:10002) preferred relays the peer published. Senders
|
||||||
|
* should union the peer's READ relays with our own write set when
|
||||||
|
* publishing a DM addressed to this peer. Absent if the peer hasn't
|
||||||
|
* published a kind:10002 yet (most clients today). Each entry is a
|
||||||
|
* normalized wss:// URL.
|
||||||
|
*/
|
||||||
|
read_relays?: string[];
|
||||||
|
write_relays?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type Cache = Record<string, CachedPeerProfile>;
|
type Cache = Record<string, CachedPeerProfile>;
|
||||||
@ -133,7 +137,9 @@ export async function fetchPeerProfile(opts: {
|
|||||||
try {
|
try {
|
||||||
const events = await Promise.race([
|
const events = await Promise.race([
|
||||||
pool.querySync(RELAYS, {
|
pool.querySync(RELAYS, {
|
||||||
kinds: [0],
|
// Pull kind:0 (profile) AND kind:10002 (NIP-65 relay list)
|
||||||
|
// in one query so we only pay one round-trip per peer.
|
||||||
|
kinds: [0, 10002],
|
||||||
authors: [opts.peer_nostr_pubkey],
|
authors: [opts.peer_nostr_pubkey],
|
||||||
limit: 5, // grab a few in case relays disagree on `latest`
|
limit: 5, // grab a few in case relays disagree on `latest`
|
||||||
}),
|
}),
|
||||||
@ -142,14 +148,53 @@ export async function fetchPeerProfile(opts: {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
if (!events.length) return cached; // nothing to update
|
if (!events.length) return cached; // nothing to update
|
||||||
const latest = events.sort((a, b) => b.created_at - a.created_at)[0];
|
|
||||||
|
|
||||||
let metadata: Record<string, unknown>;
|
// Split the response: kind:0 carries display metadata + the
|
||||||
|
// visual-encryption wraps; kind:10002 carries the NIP-65 relay
|
||||||
|
// list. We take the latest of each kind independently.
|
||||||
|
const kind0Events = events.filter((e) => e.kind === 0);
|
||||||
|
const kind10002Events = events.filter((e) => e.kind === 10002);
|
||||||
|
const latestKind0 = kind0Events.sort((a, b) => b.created_at - a.created_at)[0];
|
||||||
|
const latestKind10002 = kind10002Events.sort((a, b) => b.created_at - a.created_at)[0];
|
||||||
|
|
||||||
|
let metadata: Record<string, unknown> = {};
|
||||||
|
if (latestKind0) {
|
||||||
try {
|
try {
|
||||||
metadata = JSON.parse(latest.content);
|
metadata = JSON.parse(latestKind0.content);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`peer-profile: bad kind:0 content for ${opts.peer_primary}`, e);
|
console.warn(`peer-profile: bad kind:0 content for ${opts.peer_primary}`, e);
|
||||||
return cached;
|
// Don't bail — we can still pick up kind:10002 even if kind:0 is malformed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── NIP-65: parse the peer's preferred relay list ─────────
|
||||||
|
// kind:10002 tags are ["r", <wss url>, <"read"|"write"|"">]. The
|
||||||
|
// optional marker tells us whether it's a read-relay, a write-
|
||||||
|
// relay, or both (when missing). Senders to this peer should
|
||||||
|
// include their READ relays in the publish set so the peer's
|
||||||
|
// client (which is subscribed to their own read-relays) sees
|
||||||
|
// the event.
|
||||||
|
let read_relays: string[] | undefined;
|
||||||
|
let write_relays: string[] | undefined;
|
||||||
|
if (latestKind10002) {
|
||||||
|
const reads: string[] = [];
|
||||||
|
const writes: string[] = [];
|
||||||
|
for (const tag of latestKind10002.tags) {
|
||||||
|
if (tag[0] !== "r" || typeof tag[1] !== "string") continue;
|
||||||
|
const url = tag[1].trim();
|
||||||
|
// Skip ws:// etc. — we'd leak routing metadata in cleartext.
|
||||||
|
if (!isAllowedRelayUrl(url)) continue;
|
||||||
|
const marker = tag[2]?.toLowerCase();
|
||||||
|
if (marker === "read") reads.push(url);
|
||||||
|
else if (marker === "write") writes.push(url);
|
||||||
|
else {
|
||||||
|
// No marker = both.
|
||||||
|
reads.push(url);
|
||||||
|
writes.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (reads.length) read_relays = [...new Set(reads)];
|
||||||
|
if (writes.length) write_relays = [...new Set(writes)];
|
||||||
}
|
}
|
||||||
|
|
||||||
const profile: CachedPeerProfile = {
|
const profile: CachedPeerProfile = {
|
||||||
@ -157,7 +202,9 @@ export async function fetchPeerProfile(opts: {
|
|||||||
name: typeof metadata.name === "string" ? metadata.name : undefined,
|
name: typeof metadata.name === "string" ? metadata.name : undefined,
|
||||||
about: typeof metadata.about === "string" ? metadata.about : undefined,
|
about: typeof metadata.about === "string" ? metadata.about : undefined,
|
||||||
fetched_at: new Date().toISOString(),
|
fetched_at: new Date().toISOString(),
|
||||||
source_event_id: latest.id,
|
source_event_id: latestKind0?.id,
|
||||||
|
read_relays,
|
||||||
|
write_relays,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── descramble path ────────────────────────────────────────
|
// ─── descramble path ────────────────────────────────────────
|
||||||
|
|||||||
@ -27,6 +27,7 @@
|
|||||||
// helper which keeps a $state cell in sync.
|
// helper which keeps a $state cell in sync.
|
||||||
|
|
||||||
import { get, set, del } from "idb-keyval";
|
import { get, set, del } from "idb-keyval";
|
||||||
|
import { loadCurrentRelays } from "./relay-store.js";
|
||||||
import { hexToBytes } from "@noble/hashes/utils";
|
import { hexToBytes } from "@noble/hashes/utils";
|
||||||
import {
|
import {
|
||||||
finalizeEvent,
|
finalizeEvent,
|
||||||
@ -42,15 +43,8 @@ import type { Identity } from "./kez.js";
|
|||||||
|
|
||||||
const PROFILE_KEY = "kez-chat:my-profile:v1";
|
const PROFILE_KEY = "kez-chat:my-profile:v1";
|
||||||
|
|
||||||
/** Same default-relay list as nostr-transport.ts. Profile publish is
|
/** Same relay list as nostr-transport.ts (user override → env → default). */
|
||||||
* best-effort; if relays change, we just re-publish on next save. */
|
const RELAYS: string[] = loadCurrentRelays();
|
||||||
const RELAYS: string[] = (
|
|
||||||
(import.meta.env.VITE_NOSTR_RELAYS as string | undefined) ??
|
|
||||||
"wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr.wine"
|
|
||||||
)
|
|
||||||
.split(",")
|
|
||||||
.map((r) => r.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
export interface StoredProfile {
|
export interface StoredProfile {
|
||||||
/** The picture WE see locally — always cleartext. When `encrypted`
|
/** The picture WE see locally — always cleartext. When `encrypted`
|
||||||
@ -104,16 +98,26 @@ export async function clearMyProfile(): Promise<void> {
|
|||||||
* reach us. TODO.md Day 3 Option B #10.
|
* reach us. TODO.md Day 3 Option B #10.
|
||||||
*/
|
*/
|
||||||
const BASELINE_PUBLISHED_KEY = "kez-chat:nostr-baseline-published:v1";
|
const BASELINE_PUBLISHED_KEY = "kez-chat:nostr-baseline-published:v1";
|
||||||
|
/** Relay set our last kind:10002 advertised. Re-published whenever the
|
||||||
|
* user's relay list changes, so peers stop sending to relays we've
|
||||||
|
* dropped. Tracked separately from the kind:0 baseline, which must
|
||||||
|
* NOT be re-sent (it would clobber a real profile). */
|
||||||
|
const RELAYLIST_PUBLISHED_KEY = "kez-chat:nip65-published:v1";
|
||||||
|
|
||||||
export async function publishKind0BaselineIfNeeded(
|
export async function publishKind0BaselineIfNeeded(
|
||||||
seed: Uint8Array,
|
seed: Uint8Array,
|
||||||
handle: string,
|
handle: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const relaySig = RELAYS.join(",");
|
||||||
|
let baselineDone = false;
|
||||||
|
let relayListDone = false;
|
||||||
try {
|
try {
|
||||||
if (localStorage.getItem(BASELINE_PUBLISHED_KEY) === "1") return;
|
baselineDone = localStorage.getItem(BASELINE_PUBLISHED_KEY) === "1";
|
||||||
|
relayListDone = localStorage.getItem(RELAYLIST_PUBLISHED_KEY) === relaySig;
|
||||||
} catch {
|
} catch {
|
||||||
/* private mode — proceed anyway, worst case we publish once per tab */
|
/* private mode — proceed anyway, worst case we publish once per tab */
|
||||||
}
|
}
|
||||||
|
if (baselineDone && relayListDone) return;
|
||||||
try {
|
try {
|
||||||
const sk = nostrSecretFromSeed(seed);
|
const sk = nostrSecretFromSeed(seed);
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
@ -142,13 +146,18 @@ export async function publishKind0BaselineIfNeeded(
|
|||||||
|
|
||||||
const pool = new SimplePool();
|
const pool = new SimplePool();
|
||||||
try {
|
try {
|
||||||
|
if (!baselineDone) {
|
||||||
await Promise.allSettled(pool.publish(RELAYS, signedKind0));
|
await Promise.allSettled(pool.publish(RELAYS, signedKind0));
|
||||||
|
}
|
||||||
|
if (!relayListDone) {
|
||||||
await Promise.allSettled(pool.publish(RELAYS, signedKind10002));
|
await Promise.allSettled(pool.publish(RELAYS, signedKind10002));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
pool.close(RELAYS);
|
pool.close(RELAYS);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(BASELINE_PUBLISHED_KEY, "1");
|
localStorage.setItem(BASELINE_PUBLISHED_KEY, "1");
|
||||||
|
localStorage.setItem(RELAYLIST_PUBLISHED_KEY, relaySig);
|
||||||
} catch {
|
} catch {
|
||||||
/* private mode */
|
/* private mode */
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
fetchAcksForEventIds,
|
||||||
type RelayStatus,
|
type RelayStatus,
|
||||||
} from "../lib/transport.js";
|
} from "../lib/transport.js";
|
||||||
import { MAX_FILE_BYTES, INLINE_LIMIT } from "../lib/file-transfer.js";
|
import { MAX_FILE_BYTES } from "../lib/file-transfer.js";
|
||||||
import {
|
import {
|
||||||
loadAttachment,
|
loadAttachment,
|
||||||
saveAttachment,
|
saveAttachment,
|
||||||
type StoredAttachment,
|
|
||||||
} from "../lib/attachment-store.js";
|
} from "../lib/attachment-store.js";
|
||||||
|
import { prepareImageForSend } from "../lib/image-utils.js";
|
||||||
import { lookup, lookupByPrimary, ApiError } from "../lib/api.js";
|
import { lookup, lookupByPrimary, ApiError } from "../lib/api.js";
|
||||||
import { inboxService } from "../lib/inbox-service.svelte.js";
|
import { inboxService } from "../lib/inbox-service.svelte.js";
|
||||||
import { verifySubject } from "../lib/verify.js";
|
import { verifySubject } from "../lib/verify.js";
|
||||||
@ -34,6 +34,7 @@
|
|||||||
import {
|
import {
|
||||||
appendOutbound,
|
appendOutbound,
|
||||||
appendOutboundAttachment,
|
appendOutboundAttachment,
|
||||||
|
clearConversationMessages,
|
||||||
ensureConversation,
|
ensureConversation,
|
||||||
listConversations,
|
listConversations,
|
||||||
markConversationRead,
|
markConversationRead,
|
||||||
@ -42,6 +43,7 @@
|
|||||||
setVerified,
|
setVerified,
|
||||||
type Conversation,
|
type Conversation,
|
||||||
} from "../lib/conversations-store.js";
|
} from "../lib/conversations-store.js";
|
||||||
|
import { parseFileBody } from "../lib/file-transfer.js";
|
||||||
import type { Identity } from "../lib/kez.js";
|
import type { Identity } from "../lib/kez.js";
|
||||||
|
|
||||||
let conversations = $state<Conversation[]>([]);
|
let conversations = $state<Conversation[]>([]);
|
||||||
@ -98,19 +100,37 @@
|
|||||||
* and render an optimistic local echo of the attachment so the
|
* and render an optimistic local echo of the attachment so the
|
||||||
* sender sees their image immediately.
|
* sender sees their image immediately.
|
||||||
*/
|
*/
|
||||||
async function onFilePicked(file: File) {
|
async function onFilePicked(rawFile: File) {
|
||||||
if (!session.unlocked || !activeConv) return;
|
if (!session.unlocked || !activeConv) return;
|
||||||
|
// Snapshot the thread NOW — the user can switch conversations
|
||||||
|
// during the awaits below, and every field must come from the
|
||||||
|
// thread the file was picked in.
|
||||||
|
const conv = activeConv;
|
||||||
fileSendError = null;
|
fileSendError = null;
|
||||||
|
composing = true;
|
||||||
|
|
||||||
|
// HEIC → JPEG (most recipients can't render HEIC) and big photos
|
||||||
|
// downscaled; see prepareImageForSend. Throws with user-friendly
|
||||||
|
// guidance if this browser can't decode HEIC (e.g. desktop Chrome).
|
||||||
|
let file: File;
|
||||||
|
try {
|
||||||
|
file = await prepareImageForSend(rawFile);
|
||||||
|
} catch (e) {
|
||||||
|
composing = false;
|
||||||
|
fileSendError = (e as Error).message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (file.size > MAX_FILE_BYTES) {
|
if (file.size > MAX_FILE_BYTES) {
|
||||||
|
composing = false;
|
||||||
fileSendError = `Files larger than ${MAX_FILE_BYTES / 1024 / 1024} MB aren't supported yet.`;
|
fileSendError = `Files larger than ${MAX_FILE_BYTES / 1024 / 1024} MB aren't supported yet.`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
composing = true;
|
|
||||||
const raw = new Uint8Array(await file.arrayBuffer());
|
const raw = new Uint8Array(await file.arrayBuffer());
|
||||||
const filename = file.name || "file";
|
const filename = file.name || "file";
|
||||||
const mime = file.type || "application/octet-stream";
|
const mime = file.type || "application/octet-stream";
|
||||||
const peer_primary = activeConv.peer_primary;
|
const peer_primary = conv.peer_primary;
|
||||||
const peer_handle = activeConv.peer_handle;
|
const peer_handle = conv.peer_handle;
|
||||||
|
|
||||||
// ─── optimistic local echo ─────────────────────────────────
|
// ─── optimistic local echo ─────────────────────────────────
|
||||||
// Save the raw bytes locally so the bubble can render
|
// Save the raw bytes locally so the bubble can render
|
||||||
@ -139,6 +159,7 @@
|
|||||||
data_url: dataUrl,
|
data_url: dataUrl,
|
||||||
});
|
});
|
||||||
await refresh();
|
await refresh();
|
||||||
|
scrollChatToBottom();
|
||||||
|
|
||||||
composing = false;
|
composing = false;
|
||||||
|
|
||||||
@ -153,7 +174,9 @@
|
|||||||
filename,
|
filename,
|
||||||
mime,
|
mime,
|
||||||
raw,
|
raw,
|
||||||
preferRelay: activeConv?.peer_via_relay,
|
preferRelay: conv.peer_via_relay,
|
||||||
|
recipientNostrPubkey: conv.peer_nostr_pubkey,
|
||||||
|
extraRelays: peerProfiles.byPrimary[peer_primary]?.read_relays,
|
||||||
});
|
});
|
||||||
await markOutboundStatus(peer_primary, localSeq, "sent", {
|
await markOutboundStatus(peer_primary, localSeq, "sent", {
|
||||||
event_id: result.pointer_event_id,
|
event_id: result.pointer_event_id,
|
||||||
@ -231,6 +254,98 @@
|
|||||||
|
|
||||||
/** Scroll container for the thread view — bound for auto-scroll. */
|
/** Scroll container for the thread view — bound for auto-scroll. */
|
||||||
let scrollEl: HTMLDivElement | null = $state(null);
|
let scrollEl: HTMLDivElement | null = $state(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll the active thread to the bottom. Compound problem we're
|
||||||
|
* solving:
|
||||||
|
*
|
||||||
|
* 1. Single rAF isn't enough — Svelte commits DOM nodes but
|
||||||
|
* browser layout hasn't run; scrollHeight reads stale.
|
||||||
|
* 2. Image previews inside the new bubble decode async and grow
|
||||||
|
* the bubble AFTER our first scroll lands.
|
||||||
|
* 3. THE BIG ONE: on mobile, submitting a form blurs the input,
|
||||||
|
* keyboard hides, visualViewport HEIGHT GROWS. Our scroll
|
||||||
|
* already fired against the pre-resize bottom — now there's
|
||||||
|
* space below the last message and the compose form sits
|
||||||
|
* where the user expected the message.
|
||||||
|
*
|
||||||
|
* Two-pronged fix:
|
||||||
|
* • Keep the keyboard UP after send (refocus the compose input
|
||||||
|
* in the user-gesture handler — same as WhatsApp / iMessage).
|
||||||
|
* If the keyboard never hides, there's no resize.
|
||||||
|
* • A visualViewport resize listener (separate effect, runs
|
||||||
|
* anytime in a thread) that re-pins to bottom whenever the
|
||||||
|
* user was already at the bottom and the layout shifts.
|
||||||
|
* Belt + suspenders.
|
||||||
|
*/
|
||||||
|
function scrollChatToBottom() {
|
||||||
|
// Where our last pin landed. A retry only re-pins if the user
|
||||||
|
// hasn't scrolled UP since — content growing below (image decode)
|
||||||
|
// raises scrollHeight but leaves scrollTop where we put it.
|
||||||
|
let pinnedAt = -1;
|
||||||
|
const doScroll = () => {
|
||||||
|
const el = scrollEl;
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
pinnedAt = el.scrollTop;
|
||||||
|
};
|
||||||
|
const retry = () => {
|
||||||
|
if (scrollEl && scrollEl.scrollTop >= pinnedAt - 2) doScroll();
|
||||||
|
};
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
doScroll();
|
||||||
|
// Retry windows catch image decode + (on iOS) any straggling
|
||||||
|
// visualViewport adjustments even if our listener missed.
|
||||||
|
setTimeout(retry, 100);
|
||||||
|
setTimeout(retry, 350);
|
||||||
|
setTimeout(retry, 750);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Are we within 80px of the bottom of the thread? Used as the
|
||||||
|
* "did the user want to stay pinned to bottom" heuristic for the
|
||||||
|
* visualViewport resize listener. 80px tolerates a few lines of
|
||||||
|
* drift while still recognising "user scrolled up to read older
|
||||||
|
* messages". */
|
||||||
|
function isNearBottom(): boolean {
|
||||||
|
const el = scrollEl;
|
||||||
|
if (!el) return true;
|
||||||
|
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mobile keyboard show/hide reshapes the visualViewport. If the
|
||||||
|
* user was at the bottom of the thread when that happens, re-pin
|
||||||
|
* them. Otherwise leave their scroll position alone — if they
|
||||||
|
* were reading older messages, an auto-jump down would be rude. */
|
||||||
|
$effect(() => {
|
||||||
|
if (!activePrimary) return;
|
||||||
|
const vp = (
|
||||||
|
window as unknown as { visualViewport?: VisualViewport }
|
||||||
|
).visualViewport;
|
||||||
|
if (!vp) return;
|
||||||
|
let wasNearBottom = true;
|
||||||
|
const onResize = () => {
|
||||||
|
if (wasNearBottom) {
|
||||||
|
// Two-frame wait so the layout settles before we measure.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (scrollEl) scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onScroll = () => {
|
||||||
|
wasNearBottom = isNearBottom();
|
||||||
|
};
|
||||||
|
vp.addEventListener("resize", onResize);
|
||||||
|
scrollEl?.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => {
|
||||||
|
vp.removeEventListener("resize", onResize);
|
||||||
|
scrollEl?.removeEventListener("scroll", onScroll);
|
||||||
|
};
|
||||||
|
});
|
||||||
/** Tracks the previously-rendered conversation so we know when the
|
/** Tracks the previously-rendered conversation so we know when the
|
||||||
* user just OPENED a different thread (always scroll to bottom) vs.
|
* user just OPENED a different thread (always scroll to bottom) vs.
|
||||||
* when a new message landed in the current one (scroll only if they
|
* when a new message landed in the current one (scroll only if they
|
||||||
@ -288,8 +403,50 @@
|
|||||||
function closeZoomedAvatar() {
|
function closeZoomedAvatar() {
|
||||||
zoomedAvatarOpen = false;
|
zoomedAvatarOpen = false;
|
||||||
}
|
}
|
||||||
function onZoomKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") closeZoomedAvatar();
|
// Clear-chat confirmation modal.
|
||||||
|
let clearChatOpen = $state(false);
|
||||||
|
let clearChatBusy = $state(false);
|
||||||
|
|
||||||
|
function openClearChat() {
|
||||||
|
if (!activeConv) return;
|
||||||
|
clearChatOpen = true;
|
||||||
|
}
|
||||||
|
function closeClearChat() {
|
||||||
|
clearChatOpen = false;
|
||||||
|
}
|
||||||
|
async function confirmClearChat() {
|
||||||
|
if (!activeConv || clearChatBusy) return;
|
||||||
|
clearChatBusy = true;
|
||||||
|
try {
|
||||||
|
await clearConversationMessages(activeConv.peer_primary);
|
||||||
|
await refresh();
|
||||||
|
clearChatOpen = false;
|
||||||
|
} finally {
|
||||||
|
clearChatBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tap-to-fullscreen for attached images. Click an image bubble →
|
||||||
|
// overlay with the picture upscaled. ESC + click-backdrop dismiss.
|
||||||
|
let zoomedAttachment = $state<{
|
||||||
|
src: string;
|
||||||
|
filename: string;
|
||||||
|
} | null>(null);
|
||||||
|
function openZoomedAttachment(src: string, filename: string) {
|
||||||
|
zoomedAttachment = { src, filename };
|
||||||
|
}
|
||||||
|
function closeZoomedAttachment() {
|
||||||
|
zoomedAttachment = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escape closes whichever overlay is open. Window-level so it
|
||||||
|
* works without the overlay holding focus. */
|
||||||
|
function onWindowKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key !== "Escape") return;
|
||||||
|
if (clearChatOpen && !clearChatBusy) closeClearChat();
|
||||||
|
closeZoomedAttachment();
|
||||||
|
closeZoomedAvatar();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Relay state ──────────────────────────────────────────────────────
|
// ─── Relay state ──────────────────────────────────────────────────────
|
||||||
@ -565,6 +722,8 @@
|
|||||||
|
|
||||||
async function send() {
|
async function send() {
|
||||||
if (!session.unlocked || !activeConv || !composeText.trim()) return;
|
if (!session.unlocked || !activeConv || !composeText.trim()) return;
|
||||||
|
// Snapshot the thread — see onFilePicked.
|
||||||
|
const conv = activeConv;
|
||||||
composing = true;
|
composing = true;
|
||||||
const body = composeText;
|
const body = composeText;
|
||||||
composeText = "";
|
composeText = "";
|
||||||
@ -574,8 +733,8 @@
|
|||||||
// the user sees their own message immediately. Status icon
|
// the user sees their own message immediately. Status icon
|
||||||
// flips to "sent" once at least one relay accepts, then to
|
// flips to "sent" once at least one relay accepts, then to
|
||||||
// "delivered" when the recipient's client publishes an ack.
|
// "delivered" when the recipient's client publishes an ack.
|
||||||
const peer_primary = activeConv.peer_primary;
|
const peer_primary = conv.peer_primary;
|
||||||
const peer_handle = activeConv.peer_handle;
|
const peer_handle = conv.peer_handle;
|
||||||
let localSeq: number;
|
let localSeq: number;
|
||||||
try {
|
try {
|
||||||
localSeq = await appendOutbound({
|
localSeq = await appendOutbound({
|
||||||
@ -586,6 +745,17 @@
|
|||||||
status: "sending",
|
status: "sending",
|
||||||
});
|
});
|
||||||
await refresh();
|
await refresh();
|
||||||
|
// Keep the keyboard up by re-focusing the compose input — same
|
||||||
|
// UX as iMessage / WhatsApp. If we DON'T refocus, the form
|
||||||
|
// submit blurs the input, the soft keyboard slides away, the
|
||||||
|
// visualViewport grows, and the freshly-sent bubble ends up
|
||||||
|
// way above where the user expected. The
|
||||||
|
// visualViewport-resize effect above also catches this case;
|
||||||
|
// this is the primary defence.
|
||||||
|
composeEl?.focus();
|
||||||
|
// Scroll to the bottom AFTER the new message is in the DOM —
|
||||||
|
// requestAnimationFrame ensures Svelte's render commit ran.
|
||||||
|
scrollChatToBottom();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(`Local append failed: ${(e as Error).message}`);
|
alert(`Local append failed: ${(e as Error).message}`);
|
||||||
composing = false;
|
composing = false;
|
||||||
@ -608,11 +778,21 @@
|
|||||||
// depend on the chat-server; if the server's down, we still
|
// depend on the chat-server; if the server's down, we still
|
||||||
// publish to relays and the recipient still gets the message.
|
// publish to relays and the recipient still gets the message.
|
||||||
recipientPrimary: peer_primary,
|
recipientPrimary: peer_primary,
|
||||||
|
// Their nostr pubkey is needed to fetch their current
|
||||||
|
// heartbeat for v3 envelope sealing. Cached on the conv row
|
||||||
|
// since the first inbound DM; undefined for never-replied
|
||||||
|
// contacts (first send falls back to identity ECDH for that
|
||||||
|
// one message).
|
||||||
|
recipientNostrPubkey: conv.peer_nostr_pubkey,
|
||||||
// Reply over the same relay that delivered the recipient's
|
// Reply over the same relay that delivered the recipient's
|
||||||
// most recent message to us — usually the lowest-latency
|
// most recent message to us — usually the lowest-latency
|
||||||
// path for the round-trip. Falls back to our default set if
|
// path for the round-trip. Falls back to our default set if
|
||||||
// unset.
|
// unset.
|
||||||
preferRelay: activeConv?.peer_via_relay,
|
preferRelay: conv.peer_via_relay,
|
||||||
|
// NIP-65: union the peer's preferred read-relays into our
|
||||||
|
// publish set, so their client (subscribed to those) sees
|
||||||
|
// our event even if it's on a different relay pool than us.
|
||||||
|
extraRelays: peerProfiles.byPrimary[peer_primary]?.read_relays,
|
||||||
});
|
});
|
||||||
await markOutboundStatus(peer_primary, localSeq, "sent", {
|
await markOutboundStatus(peer_primary, localSeq, "sent", {
|
||||||
event_id: result.event_id,
|
event_id: result.event_id,
|
||||||
@ -891,7 +1071,7 @@
|
|||||||
class="min-w-[18px] h-[18px] px-1.5 inline-flex items-center justify-center rounded-full bg-accent text-accent-contrast text-[10px] font-semibold leading-none"
|
class="min-w-[18px] h-[18px] px-1.5 inline-flex items-center justify-center rounded-full bg-accent text-accent-contrast text-[10px] font-semibold leading-none"
|
||||||
aria-label="{c.unread_count} unread message{c.unread_count === 1 ? '' : 's'}"
|
aria-label="{c.unread_count} unread message{c.unread_count === 1 ? '' : 's'}"
|
||||||
>
|
>
|
||||||
{c.unread_count > 99 ? "99+" : c.unread_count}
|
{(c.unread_count ?? 0) > 99 ? "99+" : c.unread_count}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@ -949,13 +1129,31 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<Avatar seed={activeConv.peer_primary} size={36} />
|
<Avatar seed={activeConv.peer_primary} size={36} />
|
||||||
{/if}
|
{/if}
|
||||||
<div class="min-w-0">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="font-mono text-sm font-semibold text-text truncate flex items-center gap-1">
|
<p class="font-mono text-sm font-semibold text-text truncate flex items-center gap-1">
|
||||||
<span class="truncate">{displayName(activeConv)}</span>
|
<span class="truncate">{displayName(activeConv)}</span>
|
||||||
{#if activeConv.verified}<VerifiedBadge size={15} />{/if}
|
{#if activeConv.verified}<VerifiedBadge size={15} />{/if}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-[10px] text-text-muted truncate font-mono">{activeConv.peer_primary}</p>
|
<p class="text-[10px] text-text-muted truncate font-mono">{activeConv.peer_primary}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Clear chat — wipes local message history for this thread
|
||||||
|
only. Future messages still land here (the row itself
|
||||||
|
persists). Confirmation modal because this is destructive. -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 text-text-secondary hover:text-danger -mr-1 p-1"
|
||||||
|
onclick={openClearChat}
|
||||||
|
aria-label="Clear chat history"
|
||||||
|
title="Clear chat history (local only)"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 6h18"/>
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/>
|
||||||
|
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||||
|
<line x1="10" y1="11" x2="10" y2="17"/>
|
||||||
|
<line x1="14" y1="11" x2="14" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Messages -->
|
<!-- Messages -->
|
||||||
@ -1006,14 +1204,25 @@
|
|||||||
<span class="text-xs">⚠ {m.attachment.filename}: assembly failed</span>
|
<span class="text-xs">⚠ {m.attachment.filename}: assembly failed</span>
|
||||||
</div>
|
</div>
|
||||||
{:else if att?.data_url && m.attachment.mime?.startsWith("image/")}
|
{:else if att?.data_url && m.attachment.mime?.startsWith("image/")}
|
||||||
<!-- Image preview. Capped at 320px in the chat;
|
<!-- Image preview. Tap to open fullscreen
|
||||||
tap could open a full-screen viewer (TODO). -->
|
viewer. Capped at 320px tall in chat. -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="block w-full p-0 border-0 bg-transparent cursor-zoom-in"
|
||||||
|
style="margin: -2px -8px 4px -8px;"
|
||||||
|
onclick={() =>
|
||||||
|
openZoomedAttachment(
|
||||||
|
att.data_url,
|
||||||
|
m.attachment!.filename,
|
||||||
|
)}
|
||||||
|
aria-label="View {m.attachment.filename} fullscreen"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src={att.data_url}
|
src={att.data_url}
|
||||||
alt={m.attachment.filename}
|
alt={m.attachment.filename}
|
||||||
class="max-w-full max-h-80 rounded-lg block"
|
class="max-w-full max-h-80 rounded-lg block"
|
||||||
style="margin: -2px -8px 4px -8px;"
|
|
||||||
/>
|
/>
|
||||||
|
</button>
|
||||||
{:else if att?.data_url}
|
{:else if att?.data_url}
|
||||||
<!-- Non-image: filename + size + download link. -->
|
<!-- Non-image: filename + size + download link. -->
|
||||||
<a
|
<a
|
||||||
@ -1033,9 +1242,36 @@
|
|||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{/await}
|
{/await}
|
||||||
|
{:else}
|
||||||
|
<!-- Defensive: if the message body looks like a
|
||||||
|
file payload but the attachment field is
|
||||||
|
missing (e.g. ingested by an older build
|
||||||
|
before the file-router existed, or recovered
|
||||||
|
from an envelope edge case), render an
|
||||||
|
attachment chip rather than dumping the raw
|
||||||
|
JSON. The fileBodyFallback derives just
|
||||||
|
enough to show "📎 filename" + a kind hint. -->
|
||||||
|
{@const fb = parseFileBody(m.body)}
|
||||||
|
{#if fb && fb.type === "kez-file-v1" && fb.mode === "inline"}
|
||||||
|
<div class="flex items-center gap-2 py-1 text-xs opacity-90">
|
||||||
|
<span class="text-base">📎</span>
|
||||||
|
<span class="break-all">{fb.filename}</span>
|
||||||
|
<span class="opacity-70">({(fb.size / 1024).toFixed(0)} KB)</span>
|
||||||
|
</div>
|
||||||
|
{:else if fb && fb.type === "kez-file-v1" && fb.mode === "chunked"}
|
||||||
|
<div class="flex items-center gap-2 py-1 text-xs opacity-90">
|
||||||
|
<span class="text-base">📎</span>
|
||||||
|
<span class="break-all">{fb.filename}</span>
|
||||||
|
<span class="opacity-70">(chunked, {fb.n} parts)</span>
|
||||||
|
</div>
|
||||||
|
{:else if fb && fb.type === "kez-file-chunk-v1"}
|
||||||
|
<!-- A loose chunk that escaped routing. Don't
|
||||||
|
dump it in the chat — it's plumbing. -->
|
||||||
|
<span class="text-[10px] opacity-50 italic">(file chunk, hidden)</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="whitespace-pre-wrap break-words align-middle">{m.body}</span>
|
<span class="whitespace-pre-wrap break-words align-middle">{m.body}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
<!--
|
<!--
|
||||||
Inline timestamp + delivery status. `float-right` +
|
Inline timestamp + delivery status. `float-right` +
|
||||||
a leading non-breaking space pulls the cluster onto
|
a leading non-breaking space pulls the cluster onto
|
||||||
@ -1103,14 +1339,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<form class="p-3 flex gap-2 items-center" onsubmit={(e) => { e.preventDefault(); send(); }}>
|
<form class="p-3 flex gap-2 items-center" onsubmit={(e) => { e.preventDefault(); send(); }}>
|
||||||
<EmojiButton onpick={insertEmoji} />
|
<EmojiButton onpick={insertEmoji} />
|
||||||
<!-- Paperclip — image picker. Hidden file input driven
|
<!-- Paperclip — generic file picker. No `accept` filter so
|
||||||
by a styled button. accept=image/* keeps the OS picker
|
the OS picker offers everything (photos, PDFs, docs,
|
||||||
showing photos only on mobile; if you want arbitrary
|
whatever). The transport decides inline vs chunked by
|
||||||
files later, drop the accept. -->
|
size; the bubble decides image-preview vs file-chip
|
||||||
|
render by mime. -->
|
||||||
<input
|
<input
|
||||||
bind:this={fileInput}
|
bind:this={fileInput}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
|
||||||
class="hidden"
|
class="hidden"
|
||||||
onchange={(e) => {
|
onchange={(e) => {
|
||||||
const f = (e.currentTarget as HTMLInputElement).files?.[0];
|
const f = (e.currentTarget as HTMLInputElement).files?.[0];
|
||||||
@ -1153,6 +1389,92 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onWindowKeydown} />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Clear-chat confirmation. Wipes only the local message history for
|
||||||
|
the active thread; the contact row + their nostr pubkey + their
|
||||||
|
via_relay are preserved so future inbound messages still route to
|
||||||
|
this same thread instead of creating a duplicate.
|
||||||
|
-->
|
||||||
|
{#if clearChatOpen && activeConv}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Clear chat history"
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-sm bg-surface border border-border rounded-xl p-5 space-y-4 shadow-2xl">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h3 class="text-base font-semibold text-text">Clear this chat?</h3>
|
||||||
|
<p class="text-xs text-text-secondary">
|
||||||
|
Removes all local messages and attachments with
|
||||||
|
<span class="font-mono text-text">{displayName(activeConv)}</span>
|
||||||
|
from THIS device. Their copy on their device is untouched.
|
||||||
|
Anything you've sent that's still in flight on relays may
|
||||||
|
arrive again as new messages.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-end gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-2 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text disabled:opacity-50"
|
||||||
|
disabled={clearChatBusy}
|
||||||
|
onclick={closeClearChat}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-2 text-sm bg-danger text-white font-semibold rounded-md hover:opacity-90 disabled:opacity-50"
|
||||||
|
disabled={clearChatBusy}
|
||||||
|
onclick={confirmClearChat}
|
||||||
|
>
|
||||||
|
{clearChatBusy ? "Clearing…" : "Clear"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Attached-image zoom overlay. A full-bleed close button sits BEHIND
|
||||||
|
the picture (the img ignores pointer events, so tapping it closes
|
||||||
|
too); the Download link sits above it as a sibling — links can't
|
||||||
|
live inside a <button>.
|
||||||
|
-->
|
||||||
|
{#if zoomedAttachment}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex flex-col items-center justify-center gap-3 bg-black/85 backdrop-blur-sm p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={zoomedAttachment.filename}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute inset-0 cursor-zoom-out"
|
||||||
|
onclick={closeZoomedAttachment}
|
||||||
|
aria-label="Close image"
|
||||||
|
></button>
|
||||||
|
<img
|
||||||
|
src={zoomedAttachment.src}
|
||||||
|
alt={zoomedAttachment.filename}
|
||||||
|
class="relative object-contain rounded-lg shadow-2xl pointer-events-none"
|
||||||
|
style="max-width: 92vw; max-height: 80vh;"
|
||||||
|
/>
|
||||||
|
<div class="relative flex items-center gap-3 text-xs text-white/80">
|
||||||
|
<span class="truncate max-w-[60vw] font-mono">{zoomedAttachment.filename}</span>
|
||||||
|
<a
|
||||||
|
href={zoomedAttachment.src}
|
||||||
|
download={zoomedAttachment.filename}
|
||||||
|
class="px-2 py-1 rounded border border-white/30 hover:border-white/60 hover:bg-white/10"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Avatar zoom overlay. Only renders when the user tapped the thread
|
Avatar zoom overlay. Only renders when the user tapped the thread
|
||||||
header avatar. Black backdrop fills the viewport; the picture
|
header avatar. Black backdrop fills the viewport; the picture
|
||||||
@ -1166,7 +1488,6 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm cursor-zoom-out"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm cursor-zoom-out"
|
||||||
onclick={closeZoomedAvatar}
|
onclick={closeZoomedAvatar}
|
||||||
onkeydown={onZoomKeydown}
|
|
||||||
aria-label="Close profile picture"
|
aria-label="Close profile picture"
|
||||||
>
|
>
|
||||||
<div class="relative flex flex-col items-center gap-3 max-w-[90vw]">
|
<div class="relative flex flex-col items-center gap-3 max-w-[90vw]">
|
||||||
@ -1178,7 +1499,7 @@
|
|||||||
orientations. -->
|
orientations. -->
|
||||||
<img
|
<img
|
||||||
src={peerPic}
|
src={peerPic}
|
||||||
alt="profile picture"
|
alt="{displayName(activeConv)}"
|
||||||
class="object-cover rounded-2xl shadow-2xl"
|
class="object-cover rounded-2xl shadow-2xl"
|
||||||
style="width: min(70vw, 70vh); height: min(70vw, 70vh);"
|
style="width: min(70vw, 70vh); height: min(70vw, 70vh);"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -11,6 +11,23 @@
|
|||||||
import { scrambleImage } from "../lib/visual-crypto.js";
|
import { scrambleImage } from "../lib/visual-crypto.js";
|
||||||
import { hexToBytes } from "@noble/hashes/utils";
|
import { hexToBytes } from "@noble/hashes/utils";
|
||||||
import Avatar from "../lib/Avatar.svelte";
|
import Avatar from "../lib/Avatar.svelte";
|
||||||
|
import {
|
||||||
|
loadHeartbeatSettings,
|
||||||
|
saveHeartbeatSettings,
|
||||||
|
describeHeartbeats,
|
||||||
|
gcExpiredHeartbeats,
|
||||||
|
DEFAULT_RETENTION_DAYS,
|
||||||
|
MIN_RETENTION_DAYS,
|
||||||
|
MAX_RETENTION_DAYS,
|
||||||
|
} from "../lib/heartbeat-store.js";
|
||||||
|
import {
|
||||||
|
loadCurrentRelays,
|
||||||
|
saveCustomRelays,
|
||||||
|
clearCustomRelays,
|
||||||
|
isUsingDefaultRelays,
|
||||||
|
isAllowedRelayUrl,
|
||||||
|
DEFAULT_RELAYS,
|
||||||
|
} from "../lib/relay-store.js";
|
||||||
import { hasStoredPhrase, unlockIdentity } from "../lib/identity-store.js";
|
import { hasStoredPhrase, unlockIdentity } from "../lib/identity-store.js";
|
||||||
import {
|
import {
|
||||||
hasStoredBiometric,
|
hasStoredBiometric,
|
||||||
@ -55,6 +72,104 @@
|
|||||||
let biometricError = $state<string | null>(null);
|
let biometricError = $state<string | null>(null);
|
||||||
|
|
||||||
let notifSupported = $state(false);
|
let notifSupported = $state(false);
|
||||||
|
|
||||||
|
// ─── Nostr relays ────────────────────────────────────────────────
|
||||||
|
let relayList = $state<string[]>([]);
|
||||||
|
let relayDirty = $state(false);
|
||||||
|
let relayUsingDefaults = $state(true);
|
||||||
|
let relayNewUrl = $state("");
|
||||||
|
let relayError = $state<string | null>(null);
|
||||||
|
let relayShowReload = $state(false);
|
||||||
|
|
||||||
|
function refreshRelayUi() {
|
||||||
|
relayList = loadCurrentRelays();
|
||||||
|
relayUsingDefaults = isUsingDefaultRelays();
|
||||||
|
relayDirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveRelay(i: number, dir: -1 | 1) {
|
||||||
|
const j = i + dir;
|
||||||
|
if (j < 0 || j >= relayList.length) return;
|
||||||
|
const next = [...relayList];
|
||||||
|
[next[i], next[j]] = [next[j], next[i]];
|
||||||
|
relayList = next;
|
||||||
|
relayDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRelay(i: number) {
|
||||||
|
relayList = relayList.filter((_, idx) => idx !== i);
|
||||||
|
relayDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRelay() {
|
||||||
|
const t = relayNewUrl.trim();
|
||||||
|
if (!t) return;
|
||||||
|
if (!isAllowedRelayUrl(t)) {
|
||||||
|
relayError = "Relay URL must start with wss:// (ws:// only for localhost).";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (relayList.some((r) => r.toLowerCase().replace(/\/+$/, "") === t.toLowerCase().replace(/\/+$/, ""))) {
|
||||||
|
relayError = "That relay is already in the list.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
relayList = [...relayList, t];
|
||||||
|
relayNewUrl = "";
|
||||||
|
relayDirty = true;
|
||||||
|
relayError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRelays() {
|
||||||
|
relayError = null;
|
||||||
|
try {
|
||||||
|
saveCustomRelays(relayList);
|
||||||
|
relayUsingDefaults = isUsingDefaultRelays();
|
||||||
|
relayDirty = false;
|
||||||
|
relayShowReload = true;
|
||||||
|
} catch (e) {
|
||||||
|
relayError = (e as Error).message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreRelayDefaults() {
|
||||||
|
clearCustomRelays();
|
||||||
|
relayList = [...DEFAULT_RELAYS];
|
||||||
|
relayUsingDefaults = true;
|
||||||
|
relayDirty = false;
|
||||||
|
relayShowReload = true;
|
||||||
|
relayError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function doReload() {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Heartbeat ratchet ───────────────────────────────────────────
|
||||||
|
let heartbeatRetention = $state(DEFAULT_RETENTION_DAYS);
|
||||||
|
/** Last saved value — lets us warn when the user is SHORTENING it. */
|
||||||
|
let heartbeatRetentionSaved = $state(DEFAULT_RETENTION_DAYS);
|
||||||
|
let heartbeatRetentionDirty = $state(false);
|
||||||
|
let heartbeatDescribe = $state<{
|
||||||
|
count: number;
|
||||||
|
newest_age_hours?: number;
|
||||||
|
oldest_age_hours?: number;
|
||||||
|
current_pub?: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
async function refreshHeartbeatStatus() {
|
||||||
|
const s = await loadHeartbeatSettings();
|
||||||
|
heartbeatRetention = s.retention_days;
|
||||||
|
heartbeatRetentionSaved = s.retention_days;
|
||||||
|
heartbeatRetentionDirty = false;
|
||||||
|
heartbeatDescribe = await describeHeartbeats();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveHeartbeatRetention() {
|
||||||
|
await saveHeartbeatSettings({ retention_days: heartbeatRetention });
|
||||||
|
// Apply now rather than at next session start — a user shortening
|
||||||
|
// retention expects old keys gone when they hit Save.
|
||||||
|
await gcExpiredHeartbeats();
|
||||||
|
await refreshHeartbeatStatus();
|
||||||
|
}
|
||||||
let notifPerm = $state<NotificationPermission | "unsupported">("default");
|
let notifPerm = $state<NotificationPermission | "unsupported">("default");
|
||||||
let testNotifResult = $state<{ ok: boolean; reason?: string } | null>(null);
|
let testNotifResult = $state<{ ok: boolean; reason?: string } | null>(null);
|
||||||
|
|
||||||
@ -145,6 +260,8 @@
|
|||||||
}
|
}
|
||||||
await refreshBiometricStatus();
|
await refreshBiometricStatus();
|
||||||
notifSupported = notificationsSupported();
|
notifSupported = notificationsSupported();
|
||||||
|
void refreshHeartbeatStatus();
|
||||||
|
refreshRelayUi();
|
||||||
notifPerm = notificationsPermission();
|
notifPerm = notificationsPermission();
|
||||||
webPushOk = pushSupported();
|
webPushOk = pushSupported();
|
||||||
pwaInstalled = isStandalonePwa();
|
pwaInstalled = isStandalonePwa();
|
||||||
@ -609,6 +726,196 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Nostr relays — user-editable list with reorder + restore -->
|
||||||
|
<section class="bg-surface border border-border rounded-xl p-6">
|
||||||
|
<h2 class="text-sm font-semibold text-text uppercase tracking-wider mb-1">
|
||||||
|
Nostr relays
|
||||||
|
</h2>
|
||||||
|
<p class="text-xs text-text-secondary">
|
||||||
|
The publish/subscribe servers your messages route through.
|
||||||
|
Earlier entries are tried first when sending — use the arrows
|
||||||
|
to move a relay up to prefer it. Changes require a page reload
|
||||||
|
to take effect.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="mt-4 space-y-1">
|
||||||
|
{#each relayList as r, i (r)}
|
||||||
|
<li class="flex items-center gap-2 bg-elevated border border-border rounded-md px-2 py-1.5">
|
||||||
|
<span class="font-mono text-xs text-text-secondary w-6 text-right">
|
||||||
|
{i + 1}.
|
||||||
|
</span>
|
||||||
|
<code class="font-mono text-xs text-text flex-1 truncate" title={r}>
|
||||||
|
{r}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-1.5 py-0.5 text-text-muted hover:text-text disabled:opacity-30"
|
||||||
|
disabled={i === 0}
|
||||||
|
onclick={() => moveRelay(i, -1)}
|
||||||
|
aria-label="Move up"
|
||||||
|
title="Move up (prefer this relay)"
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-1.5 py-0.5 text-text-muted hover:text-text disabled:opacity-30"
|
||||||
|
disabled={i === relayList.length - 1}
|
||||||
|
onclick={() => moveRelay(i, 1)}
|
||||||
|
aria-label="Move down"
|
||||||
|
title="Move down"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-1.5 py-0.5 text-text-muted hover:text-danger"
|
||||||
|
onclick={() => removeRelay(i)}
|
||||||
|
aria-label="Remove relay"
|
||||||
|
title="Remove this relay"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{#if relayList.length === 0}
|
||||||
|
<li class="text-xs text-text-muted italic px-2 py-1">
|
||||||
|
No relays — add at least one or restore defaults.
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="mt-3 flex gap-2"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
addRelay();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={relayNewUrl}
|
||||||
|
placeholder="wss://relay.example.com"
|
||||||
|
class="flex-1 px-3 py-1.5 text-xs bg-elevated border border-border rounded-md text-text placeholder:text-text-muted focus:border-accent focus:outline-none font-mono"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-3 py-1.5 text-xs border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text disabled:opacity-50"
|
||||||
|
disabled={!relayNewUrl.trim()}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if relayError}
|
||||||
|
<p class="mt-2 text-xs text-danger">{relayError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-4 flex items-center justify-between gap-2">
|
||||||
|
{#if !relayUsingDefaults}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-1.5 text-xs border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text"
|
||||||
|
onclick={restoreRelayDefaults}
|
||||||
|
>
|
||||||
|
Restore defaults ({DEFAULT_RELAYS.length})
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span class="text-[10px] text-text-muted italic">Using built-in defaults</span>
|
||||||
|
{/if}
|
||||||
|
{#if relayDirty}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-1.5 text-xs bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim disabled:opacity-50"
|
||||||
|
disabled={relayList.length === 0}
|
||||||
|
onclick={saveRelays}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if relayShowReload}
|
||||||
|
<div class="mt-3 px-3 py-2 bg-accent/10 border border-accent/40 rounded-md flex items-center justify-between gap-2">
|
||||||
|
<p class="text-xs text-text">
|
||||||
|
Relay list saved. Reload kez-chat to apply.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-1 text-xs bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim"
|
||||||
|
onclick={doReload}
|
||||||
|
>
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Heartbeat ratchet (forward secrecy retention) -->
|
||||||
|
<section class="bg-surface border border-border rounded-xl p-6">
|
||||||
|
<h2 class="text-sm font-semibold text-text uppercase tracking-wider mb-1">
|
||||||
|
Forward secrecy
|
||||||
|
</h2>
|
||||||
|
<p class="text-xs text-text-secondary">
|
||||||
|
Each day, your device generates a fresh decryption key and
|
||||||
|
publishes its public half to nostr (called a "heartbeat"). Old
|
||||||
|
keys are deleted on a schedule — once a key is gone, NOBODY
|
||||||
|
can decrypt messages addressed to it, not even you. Longer
|
||||||
|
retention forgives long offline periods; shorter retention
|
||||||
|
leaks less if your device is ever stolen.
|
||||||
|
</p>
|
||||||
|
<div class="mt-4 flex items-center gap-3">
|
||||||
|
<label
|
||||||
|
for="hb-retention"
|
||||||
|
class="text-sm text-text-secondary shrink-0"
|
||||||
|
>
|
||||||
|
Retention:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="hb-retention"
|
||||||
|
type="range"
|
||||||
|
min={MIN_RETENTION_DAYS}
|
||||||
|
max={MAX_RETENTION_DAYS}
|
||||||
|
step="1"
|
||||||
|
bind:value={heartbeatRetention}
|
||||||
|
oninput={() => (heartbeatRetentionDirty = true)}
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<span class="font-mono text-sm text-text shrink-0 w-16 text-right">
|
||||||
|
{heartbeatRetention} days
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if heartbeatRetentionDirty && heartbeatRetention < heartbeatRetentionSaved}
|
||||||
|
<p class="mt-2 text-xs text-danger">
|
||||||
|
Saving deletes keys retired more than {heartbeatRetention} days
|
||||||
|
ago right away. Messages sent to those keys that haven't
|
||||||
|
arrived yet can never be decrypted.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<div class="mt-3 flex items-center justify-between">
|
||||||
|
{#if heartbeatDescribe}
|
||||||
|
<p class="text-[10px] text-text-muted">
|
||||||
|
{heartbeatDescribe.count} key{heartbeatDescribe.count === 1 ? "" : "s"} in storage
|
||||||
|
{#if heartbeatDescribe.newest_age_hours !== undefined}
|
||||||
|
· current age {Math.round(heartbeatDescribe.newest_age_hours)} h
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<span></span>
|
||||||
|
{/if}
|
||||||
|
{#if heartbeatRetentionDirty}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-1.5 text-xs bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim"
|
||||||
|
onclick={saveHeartbeatRetention}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Account / About -->
|
<!-- Account / About -->
|
||||||
<section class="bg-surface border border-border rounded-xl p-6 space-y-4">
|
<section class="bg-surface border border-border rounded-xl p-6 space-y-4">
|
||||||
<h2 class="text-sm font-semibold text-text uppercase tracking-wider">Account</h2>
|
<h2 class="text-sm font-semibold text-text uppercase tracking-wider">Account</h2>
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
getStoredBiometricMeta,
|
getStoredBiometricMeta,
|
||||||
} from "../lib/webauthn.js";
|
} from "../lib/webauthn.js";
|
||||||
import { session } from "../lib/store.svelte.js";
|
import { session } from "../lib/store.svelte.js";
|
||||||
|
import { wipeAllHeartbeats } from "../lib/heartbeat-store.js";
|
||||||
|
|
||||||
let meta = $state<{ handle: string; server: string; primary: string; created_at: string } | null>(null);
|
let meta = $state<{ handle: string; server: string; primary: string; created_at: string } | null>(null);
|
||||||
let passphrase = $state("");
|
let passphrase = $state("");
|
||||||
@ -76,6 +77,9 @@
|
|||||||
async function forget() {
|
async function forget() {
|
||||||
if (!confirm("Delete the local copy of this account? You'll need your seed to restore.")) return;
|
if (!confirm("Delete the local copy of this account? You'll need your seed to restore.")) return;
|
||||||
await deleteStoredIdentity();
|
await deleteStoredIdentity();
|
||||||
|
// Heartbeat privates can decrypt retained messages without the
|
||||||
|
// seed — they must not outlive the account on this device.
|
||||||
|
await wipeAllHeartbeats();
|
||||||
session.lock();
|
session.lock();
|
||||||
push("/");
|
push("/");
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user