From c8370ffdf007b870164e59749d20716818ad8f56 Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Mon, 8 Jun 2026 05:11:24 -0600 Subject: [PATCH] feat(kez-chat): three days of security + UX + protocol work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-day batch covering: a security review punch list (Day 1-3), the visual-encryption profile-picture feature, a fast deploy infrastructure, ack resilience + UX polish, several nostr ecosystem alignment changes, and a key server-independence fix. Security review (Day 1) - Envelope v2 (ephemeral x25519 per message; AAD-bound AES-GCM; no plaintext from/to). Big metadata-leak fix flagged by reviews. Forward secrecy at the per-message level; v1 decrypt kept for a one-week migration window. - Routing tag rename h → q (NIP-29 collision fix). - Web Push payload now empty (was leaking recipient handle to FCM). - Log demotion + process-instance salt-hash of handles so debug logs don't permanently encode the social graph. Security review (Day 2) - Replay protection: SEEN_CAP 500 → 10_000 ids; reject events with impossibly old/future created_at; openMessage enforces ±7d/+5min freshness on plaintext sent_at. - Reveal Recovery Phrase now requires a fresh passphrase prompt. Verifies via the same unlockIdentity that the initial unlock uses. Bonus: works for biometric-only sessions (recovers the phrase that wasn't in memory before). - POST /v1/messages per-IP rate limit (60/min, capacity 60, with a periodic idle-bucket sweep). New rate_limit.rs module + tests. Security review (Day 3 Option A) - Unforgeable acks: kind-4244 events now carry a `kez-sig` tag, the recipient's ed25519 signature over the acked event id. Sender verifies against the conversation peer's KEZ primary. Unsigned acks still accepted during the migration window. - Default `since=` lookback shortened 7d → 48h (matches relay retention). - Bounded concurrency on push fanout: tokio::sync::Semaphore(32). Security review (Day 3 Option B — nostr ecosystem alignment) - NIP-65 (kind:10002): publish our relay list so other clients can discover where to find us. - NIP-42 AUTH: attachSigner / detachSigner wires the user's seed into the relay pool so AUTH-gated relays (damus.io DMs) deliver. - Minimal kind:0 baseline on first session unlock (unblocks writes on relays that reject unknown pubkeys). - Acks now include ["p", senderNostrPubkey] for NIP-25 / NIP-10 routing convention. Web Push end-to-end - Server-side nostr listener (nostr_listener.rs): the chat-server subscribes to relays for every registered handle's addr, so Web Push fires even when chat goes over nostr (the live default). - Push fanout from messages.rs spawned with bounded concurrency. - Empty payload (no recipient handle leaked to FCM). - Self-heal endpoint GET /v1/push/subscriptions/:handle — auto-re-registers a subscription if server lost it. - Auto-enable push on first unlock (was opt-in toggle hunt). - In-chat nudge banner + iOS PWA install hint. Persistent sessions - persistent-session.ts: AES-GCM encrypt seed under a non-extractable IDB key, 30-day sliding-window TTL, restore on every boot. - Auto-fetch own kind:0 from nostr on a fresh device so the user sees their own avatar without re-setting it. Profile pictures + visual encryption - Avatar component accepts a `picture` prop (data URL); falls back to the deterministic identicon when absent. - profile-store.ts: pick → resize to 256×256 JPEG → save locally + publish as NIP-01 kind:0. - Visual encryption (visual-crypto.ts): keyed Fisher-Yates pixel permutation + xoshiro256** PRNG. Output is a valid PNG with scrambled content. Salt embedded as a #kez-visual-v1: URL fragment. - Default ON for new pictures. Strangers see colored noise on public nostr; contacts see the real face. - Per-recipient AES wraps embedded in kind:0 content (kez_visual_keys map). The picture's symmetric key is wrapped via the same SealedEnvelope crypto our DMs use. - Self-wrap (sender wraps to their own primary too) so a fresh device of the same user can descramble its own picture. - Stranger-view preview thumbnail in Settings (the badge tucked into the avatar's bottom-right corner — "this is what the world sees"). - Tap-to-zoom: header avatar in a thread opens a fullscreen overlay. Peer-profile resolution - peer-profile-store.ts: IDB-cached one-shot kind:0 fetch + descramble. - peer-profile-cell.svelte.ts: reactive mirror for UI. - 6h bulk-scan staleness + force-refresh on thread open. - Avatar usages in Messages.svelte pass peer.picture through. Local-echo + delivery receipts - Outbound messages render instantly with status="sending"; flip to "sent" when ≥1 relay accepts; "delivered" (check-in-circle) when recipient's client publishes an ack. - SVG status icons inside the bubble; "via X" footer on outbound. - Persistent pending-ack queue with retry on next session start. - Catch-up scan (fetchAcksForEventIds) self-heals delivered state on conversation open. - markDeliveredByEventId verifies the ack signature. Active-relay tracking + reply preference - SimplePool.trackRelays = true. Capture first-to-accept on send via Promise.any over per-relay publish promises. - InboxMessage.via_relay set from pool.seenOn on receive. - Conversation.peer_via_relay persisted on every inbound DM. - sendMessage takes `preferRelay` and orders publish targets accordingly. Acks bias the same way. - "via relay.X" footer renders on outbound bubbles. Conversation list polish - Per-conversation `unread_count` on the Conversation type. - Bumped on every genuinely-new inbound; reset on thread open. - Accent-color pill badge in the sidebar (rounds at "99+"). Server-independence fix - sendMessage skips the /v1/u/:handle lookup when the caller passes recipientPrimary (which Messages.svelte does, from the cached peer_primary on the conversation row). Chat over nostr no longer breaks when the chat-server is down — only brand-new conversations still need the directory lookup. Relay set - Added wss://relay.snort.social and wss://nostr.wine to the default pool (was 3, now 5). Fast-deploy infrastructure (new in this batch) - Dockerfile gains an `export` scratch stage (extracts binary + web/dist only). - Dockerfile.runtime: tiny runtime image that COPYs prebuilt artifacts — no rust/npm on the remote. - docker-compose.fast.yml: compose override pointing chat-server build at Dockerfile.runtime. - .dockerignore: excludes target/, node_modules/, prebuilt/, .buildx-cache/, .git, *.db. Critical: without this, an earlier bug had the buildx cache nested under the build context and blew up to 17GB by feeding itself into itself. - Old: ~10 min remote build. New: 3–5 min local + 5s remote runtime swap. Cache lives at ~/.cache/kez-chat-buildx (outside any project tree). UI polish (margins, layout, banners) - Authenticated routes (Welcome / Settings / Identity / Dashboard / Claims / AddClaim) wrapped in max-w-2xl mx-auto px-4 py-6. - WhatsApp-style chat bubbles: shrink-wrap to content, asymmetric rounded corners, inline bottom-right timestamp. - Push-notification nudge banner at top of /chats with iOS install hint. - Relay state popover off the "● live (N)" indicator. WebAuthn biometric fix - user.id now uses the raw 32-byte ed25519 pubkey (was the 72-byte "ed25519:" identity string, which exceeded WebAuthn's 64-byte limit — Android Chrome rejected it with "user handle exceeds 64 bytes"). Documentation - kez-chat/TODO.md tracks every reviewer finding with status, file:line references, and a phased plan. All Day 1-3 items marked DONE; remaining roadmap items (Double Ratchet, WebAuthn-gated rehydrate, addr rotation, NIP-65 peer-relay fetch on send) documented for future sprints. Co-Authored-By: Claude Opus 4.7 (1M context) --- .dockerignore | 37 ++ .gitignore | 14 + kez-chat/Cargo.lock | 522 +++++++++++++++- kez-chat/Cargo.toml | 9 +- kez-chat/TODO.md | 336 ++++++++++ kez-chat/deploy/Dockerfile | 18 + kez-chat/deploy/Dockerfile.runtime | 42 ++ kez-chat/deploy/docker-compose.fast.yml | 23 + kez-chat/src/api.rs | 144 ++++- kez-chat/src/config.rs | 13 + kez-chat/src/error.rs | 4 + kez-chat/src/lib.rs | 2 + kez-chat/src/main.rs | 51 ++ kez-chat/src/messages.rs | 29 +- kez-chat/src/nostr_listener.rs | 300 +++++++++ kez-chat/src/push.rs | 93 ++- kez-chat/src/rate_limit.rs | 206 ++++++ kez-chat/web/src/App.svelte | 12 + kez-chat/web/src/lib/Avatar.svelte | 58 +- kez-chat/web/src/lib/conversations-store.ts | 199 +++++- kez-chat/web/src/lib/crypto.ts | 233 ++++++- kez-chat/web/src/lib/image-utils.ts | 86 +++ kez-chat/web/src/lib/inbox-service.svelte.ts | 69 +++ kez-chat/web/src/lib/messages.ts | 116 +++- kez-chat/web/src/lib/nostr-id.ts | 10 +- kez-chat/web/src/lib/nostr-transport.ts | 547 +++++++++++++++- .../web/src/lib/peer-profile-cell.svelte.ts | 59 ++ kez-chat/web/src/lib/peer-profile-store.ts | 222 +++++++ kez-chat/web/src/lib/persistent-session.ts | 306 +++++++++ kez-chat/web/src/lib/profile-store.ts | 387 ++++++++++++ kez-chat/web/src/lib/push.ts | 59 ++ kez-chat/web/src/lib/store.svelte.ts | 227 +++++++ kez-chat/web/src/lib/transport.ts | 22 + kez-chat/web/src/lib/visual-crypto.ts | 282 +++++++++ kez-chat/web/src/lib/webauthn.ts | 10 +- kez-chat/web/src/routes/AddClaim.svelte | 2 +- kez-chat/web/src/routes/Claims.svelte | 2 +- kez-chat/web/src/routes/Dashboard.svelte | 2 +- kez-chat/web/src/routes/Identity.svelte | 9 +- kez-chat/web/src/routes/Messages.svelte | 584 ++++++++++++++++-- kez-chat/web/src/routes/Settings.svelte | 413 ++++++++++++- kez-chat/web/src/routes/Welcome.svelte | 14 +- kez-chat/web/src/sw.ts | 44 +- 43 files changed, 5609 insertions(+), 208 deletions(-) create mode 100644 .dockerignore create mode 100644 kez-chat/TODO.md create mode 100644 kez-chat/deploy/Dockerfile.runtime create mode 100644 kez-chat/deploy/docker-compose.fast.yml create mode 100644 kez-chat/src/nostr_listener.rs create mode 100644 kez-chat/src/rate_limit.rs create mode 100644 kez-chat/web/src/lib/image-utils.ts create mode 100644 kez-chat/web/src/lib/peer-profile-cell.svelte.ts create mode 100644 kez-chat/web/src/lib/peer-profile-store.ts create mode 100644 kez-chat/web/src/lib/persistent-session.ts create mode 100644 kez-chat/web/src/lib/profile-store.ts create mode 100644 kez-chat/web/src/lib/visual-crypto.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e8a1402 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,37 @@ +# Keep the docker build context tiny — buildx serializes everything +# in here over the wire to the BuildKit container, so excluding +# generated trees is both a speedup AND a correctness guard (an +# earlier version had the buildx cache nested under kez-chat/deploy/ +# and the cache grew with every build because it was being copied +# into itself). + +# Rust target dirs +**/target/ + +# Node +**/node_modules/ +**/.npm/ + +# SPA build artifacts (the Dockerfile rebuilds these inside the image) +kez-chat/web/dist/ + +# Buildx cache + prebuilt artifacts from the fast-deploy path — +# these are produced BY the build; they must not be inputs. +kez-chat/deploy/.buildx-cache/ +kez-chat/deploy/prebuilt/ + +# Local dev / OS / editor cruft +.DS_Store +.idea/ +.vscode/ +*.swp +*~ + +# Git internals — Dockerfile doesn't need history +.git/ + +# Local SQLite databases (and journals) +**/*.db +**/*.db-shm +**/*.db-wal +**/*.db-journal diff --git a/.gitignore b/.gitignore index d1306dc..8e86e0f 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,9 @@ kez-sigchains.db *.swo *~ +# Claude Code harness state (per-session scratch, not project code) +.claude/ + # Cross-test artifacts /tmp/ @@ -43,3 +46,14 @@ kez-chat/deploy/deploy.local.sh kez-chat/deploy/*.local.sh kez-chat/deploy/.env kez-chat/deploy/.env.local + +# Prebuilt artifacts staged by deploy-fast.local.sh (binary + SPA dist). +# Regenerated on every run; nothing in here is source of truth. +kez-chat/deploy/prebuilt/ + +# Buildx local cache used by deploy-fast.local.sh to keep rust target/ +# and node_modules warm between runs. Now lives at +# ~/.cache/kez-chat-buildx (outside the repo, see deploy-fast.local.sh); +# the path below is just a historical fence-post in case anyone has the +# old in-repo cache lying around from before that move. +kez-chat/deploy/.buildx-cache/ diff --git a/kez-chat/Cargo.lock b/kez-chat/Cargo.lock index 6044537..1d9026c 100644 --- a/kez-chat/Cargo.lock +++ b/kez-chat/Cargo.lock @@ -2,6 +2,27 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.12" @@ -23,6 +44,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -117,6 +144,46 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "async-utility" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a349201d80b4aa18d17a34a182bdd7f8ddf845e9e57d2ea130a12e10ef1e3a47" +dependencies = [ + "futures-util", + "gloo-timers", + "tokio", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-wsocket" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d50cb541e6d09e119e717c64c46ed33f49be7fa592fa805d56c11d6a7ff093c" +dependencies = [ + "async-utility", + "futures", + "futures-util", + "js-sys", + "tokio", + "tokio-rustls", + "tokio-socks", + "tokio-tungstenite", + "url", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "atomic-destructor" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d919cb60ba95c87ba42777e9e246c4e8d658057299b437b7512531ce0a09a23" +dependencies = [ + "tracing", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -190,6 +257,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base58ck" +version = "0.1.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec5dc7e09f7bb15f0062da7c03086d6b71a2c84e0af4fccbbc7d8c6559847816" +dependencies = [ + "bitcoin_hashes", +] + [[package]] name = "base64" version = "0.13.1" @@ -220,6 +296,12 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + [[package]] name = "binstring" version = "0.1.7" @@ -239,13 +321,47 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "bitcoin" +version = "0.32.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39581299241111285f3268ba75ddf372746fd041620918b145c1af9d75e91b6c" +dependencies = [ + "base58ck", + "bech32 0.11.1", + "bitcoin-io", + "bitcoin-units", + "bitcoin_hashes", + "hex-conservative", + "hex_lit", + "secp256k1", + "serde", +] + +[[package]] +name = "bitcoin-io" +version = "0.1.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11301df0b06f22dea7bb1916403fdd88a371031e495c49b8f96931b28189e175" + +[[package]] +name = "bitcoin-units" +version = "0.1.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57bad157b78d0d1b22c4cbb6a35a566211fc4d14866a37f2c780652b50f3b845" +dependencies = [ + "serde", +] + [[package]] name = "bitcoin_hashes" version = "0.14.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9901a56e133a1fc86eeb1113e2591f45f4682451ca893bff494d2f88918e3f" dependencies = [ + "bitcoin-io", "hex-conservative", + "serde", ] [[package]] @@ -263,6 +379,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -290,6 +415,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.62" @@ -314,6 +448,30 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.44" @@ -328,6 +486,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + [[package]] name = "clap" version = "4.6.1" @@ -446,6 +615,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -513,6 +683,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "der" version = "0.4.5" @@ -647,6 +823,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "elliptic-curve" version = "0.13.8" @@ -944,6 +1126,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gloo-timers" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "group" version = "0.13.0" @@ -970,6 +1164,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -1015,6 +1211,12 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + [[package]] name = "hkdf" version = "0.12.4" @@ -1153,7 +1355,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.7", ] [[package]] @@ -1324,6 +1526,28 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1441,7 +1665,9 @@ dependencies = [ "clap", "futures", "hex", + "hkdf", "kez-core", + "nostr-sdk", "p256", "rand 0.8.6", "reqwest", @@ -1464,7 +1690,7 @@ name = "kez-core" version = "0.1.0" dependencies = [ "base64 0.22.1", - "bech32", + "bech32 0.9.1", "bip39", "chrono", "ed25519-dalek", @@ -1551,12 +1777,33 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "lnurl-pay" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "536e7c782167a2d48346ca0b2677fad19eaef20f19a4ab868e4d5b96ca879def" +dependencies = [ + "bech32 0.11.1", + "reqwest", + "serde", + "serde_json", +] + [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -1611,6 +1858,113 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "negentropy" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e664971378a3987224f7a0e10059782035e89899ae403718ee07de85bec42afe" + +[[package]] +name = "negentropy" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a88da9dd148bbcdce323dd6ac47d369b4769d4a3b78c6c52389b9269f77932" + +[[package]] +name = "nostr" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14ad56c1d9a59f4edc46b17bc64a217b38b99baefddc0080f85ad98a0855336d" +dependencies = [ + "aes", + "async-trait", + "base64 0.22.1", + "bech32 0.11.1", + "bip39", + "bitcoin", + "cbc", + "chacha20", + "chacha20poly1305", + "getrandom 0.2.17", + "instant", + "js-sys", + "negentropy 0.3.1", + "negentropy 0.4.3", + "once_cell", + "reqwest", + "scrypt", + "serde", + "serde_json", + "unicode-normalization", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "nostr-database" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1859abebf78d7d9e945b20c8faaf710c9db905adeb148035b803ae45792dbebe" +dependencies = [ + "async-trait", + "lru", + "nostr", + "thiserror 1.0.69", + "tokio", + "tracing", +] + +[[package]] +name = "nostr-relay-pool" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e39cfcb30cab86b30ca9acba89f5ccb25a4142a5dc5fcfbf3edf34b204ddd7c7" +dependencies = [ + "async-utility", + "async-wsocket", + "atomic-destructor", + "negentropy 0.3.1", + "negentropy 0.4.3", + "nostr", + "nostr-database", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tracing", +] + +[[package]] +name = "nostr-sdk" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4739ed15ff81a0e474d79b38c3eb481ff5f968c1865f38ba46852daf6f6495e" +dependencies = [ + "async-utility", + "atomic-destructor", + "lnurl-pay", + "nostr", + "nostr-database", + "nostr-relay-pool", + "nostr-zapper", + "nwc", + "thiserror 1.0.69", + "tokio", + "tracing", +] + +[[package]] +name = "nostr-zapper" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d9709ecf8050bbe4ecf0e5efda2f25b690bb1761fc504e05654621ba9e568a8" +dependencies = [ + "async-trait", + "nostr", + "thiserror 1.0.69", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1666,6 +2020,21 @@ dependencies = [ "libm", ] +[[package]] +name = "nwc" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5f98bcaf232b3ec48e018792ca7bc2b90e7520d001a07b8218a9e76a03fda2" +dependencies = [ + "async-trait", + "async-utility", + "nostr", + "nostr-relay-pool", + "nostr-zapper", + "thiserror 1.0.69", + "tracing", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1678,6 +2047,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.80" @@ -1751,6 +2126,27 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "pem" version = "0.8.3" @@ -1874,6 +2270,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2119,7 +2526,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 1.0.7", ] [[package]] @@ -2262,6 +2669,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6518fc26bced4d53678a22d6e423e9d8716377def84545fe328236e3af070e7f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "schannel" version = "0.1.29" @@ -2271,6 +2687,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2", +] + [[package]] name = "sec1" version = "0.7.3" @@ -2302,8 +2730,10 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ + "bitcoin_hashes", "rand 0.8.6", "secp256k1-sys", + "serde", ] [[package]] @@ -2368,6 +2798,7 @@ version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -2398,6 +2829,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2709,6 +3151,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-socks" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -2721,6 +3175,22 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -2868,6 +3338,26 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.8.6", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "typenum" version = "1.20.0" @@ -2901,6 +3391,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -2917,8 +3417,15 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3128,6 +3635,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + [[package]] name = "webpki-roots" version = "1.0.7" diff --git a/kez-chat/Cargo.toml b/kez-chat/Cargo.toml index 5c14b5b..33dac26 100644 --- a/kez-chat/Cargo.toml +++ b/kez-chat/Cargo.toml @@ -23,11 +23,18 @@ web-push = "0.10" base64 = "0.22" p256 = { version = "0.13", features = ["pem"] } rand = "0.8" +# Server-side nostr listener — subscribes to relays for every handle +# registered on this chat-server and fires Web Push when a kez-DM +# event for one of them lands. The web client publishes the same +# events; this is the missing link that lets push notifications work +# when chat goes over nostr instead of /v1/messages. +nostr-sdk = { version = "0.36", default-features = false, features = ["all-nips"] } +hkdf = "0.12" +sha2 = "0.10" tower-http = { version = "0.6", features = ["trace", "cors", "fs"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } [dev-dependencies] reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } -sha2 = "0.10" tempfile = "3" diff --git a/kez-chat/TODO.md b/kez-chat/TODO.md new file mode 100644 index 0000000..8808142 --- /dev/null +++ b/kez-chat/TODO.md @@ -0,0 +1,336 @@ +# kez-chat security + protocol TODO + +Consolidated from a nostr-protocol expert review and an independent security +audit (both run 2026-06-08). Ordered by impact-per-hour-of-work, not by +review severity — a CRIT that's a half-week of design discussion isn't a +ship-stopper when there are real CRITs that take 30 minutes. + +Update the status column as we land things. Cross-links to the original +review reports are at the bottom. + +## Status legend + +- `TODO` — not started +- `WIP` — actively being worked +- `DONE` — landed in main +- `ROADMAP` — committed but multi-day; will need its own design doc +- `WONTFIX` — accepted trade-off, documented + +--- + +## Day 1 — ~half a day of work, biggest wins + +### #1. Strip envelope metadata (ephemeral x25519 per message) [TODO] + +**Why it matters:** the `SealedEnvelope.from` (KEZ identity) and `to` (handle) +fields sit in cleartext alongside the ciphertext in `event.content`. Any +nostr relay can JSON-parse the content and build a perfect social graph: +who-messages-whom, when, how often. The actual message body stays encrypted; +all the metadata is wide open. + +**Files:** +- `kez-chat/web/src/lib/crypto.ts:42-54` (the `SealedEnvelope` shape) +- `kez-chat/web/src/lib/crypto.ts:115-153` (`sealMessage` / `openMessage`) + +**Fix:** replace `envelope.from` (the KEZ identity) with `envelope.eph_pub` +(a 32-byte x25519 public key sender generated for this message only). +Recipient does ECDH against `eph_pub` instead of deriving it from the KEZ +identity. The decrypted plaintext already carries `from` for identity +verification. + +Bonus: a fresh ephemeral key per message gives partial forward secrecy — +compromise of the recipient's long-term seed still decrypts retained +ciphertexts, but a captured single-message ECDH key reveals only that one +message. + +**Migration:** envelope `v: 1 → v: 2`. Recipient accepts both during a +1-week window, then drops v1 support. + +### #3. Empty Web Push payload [TODO] + +**Why it matters:** we send `{type, to: , seq}` to FCM/APNs/Mozilla +on every fanout. RFC 8291 encrypts the payload so the push provider can't +read the bytes, but the provider already knows the endpoint's owner — the +`to` field adds no information for the recipient and *does* give Google a +clear "message arrived for alice at T" timeline. + +**Files:** +- `kez-chat/src/messages.rs:123-127` (the payload we hand to `push.fanout`) +- `kez-chat/web/src/sw.ts:78-99` (the `push` handler) + +**Fix:** send `{}` as the payload. The service worker shows a generic +"New kez-chat message" notification — it can still focus an existing tab, +which navigates to the conversation list. Deep-linking to a specific peer +goes away on cold-open (acceptable trade-off — one extra tap to open the +right thread is the price of *not* exporting metadata to FCM). + +### #5. Rename the routing tag from `h` to something less claimed [TODO] + +**Why it matters:** `h` is informally used by NIP-29 (Simple Groups) as the +group id. Today's three relays don't enforce NIP-29 semantics, but the +moment a NIP-29-aware relay enters our pool it will try to route our `#h` +filter as a group join, and we'll get cryptic failures. + +**Files:** +- `kez-chat/web/src/lib/nostr-id.ts:28` (`ADDR_TAG = "h"`) +- `kez-chat/web/src/lib/nostr-transport.ts:201, 269` (publish + subscribe) +- `kez-chat/src/nostr_listener.rs:113` (server-side mirror) + +**Fix:** switch to `q` (less-claimed single letter, still indexable per +NIP-01). Bump envelope/event `v` so the listener can tell old-tag events +from new ones during the migration window. Server-side listener subscribes +to BOTH `#h` and `#q` for one week. + +### #17. Demote handle-revealing logs to `debug!` [TODO] + +**Why it matters:** every fanout currently logs `push: fanout triggered +handle= sub_count=N` at INFO level. Operator-side log +retention turns this into a permanent "who's chatting" ledger. Even if +we trust the operator (it's us), forensics on a stolen log file leaks the +social graph in plaintext. + +**Files:** +- `kez-chat/src/push.rs:259-262, 275-281` (fanout + send logging) +- `kez-chat/src/api.rs:387-393` (subscribe registration) + +**Fix:** demote the handle-bearing INFO lines to DEBUG. Replace the visible +field with a short HMAC of the handle under a server-instance secret so we +can still group "all sends for X" in logs without exposing X. Set log level +in production to INFO, so DEBUG lines are off by default. + +--- + +## Day 2 — another half-day + +### #2. Replay protection — bound + timestamp freshness [DONE] + +**Why it matters:** `SEEN_CAP=500` evicts oldest event ids once we've seen +500 messages. An active user rolls past that in days, then a malicious +relay can re-broadcast any old event and we accept it as a fresh message — +the decrypted `sent_at` is never compared to wall-clock. + +**Files:** +- `kez-chat/web/src/lib/nostr-transport.ts:107` (`SEEN_CAP = 500`) +- `kez-chat/web/src/lib/nostr-transport.ts:142` (`slice(-SEEN_CAP)`) +- `kez-chat/web/src/lib/crypto.ts:161-205` (`openMessage` — no freshness check) + +**Fix:** +1. Bump `SEEN_CAP` to 10_000 and move from localStorage to IndexedDB so the + set isn't capped by the 5MB localStorage quota. +2. In `openMessage`, reject envelopes where `|now − sent_at| > 7 days`. +3. Also clamp `ev.created_at` to `[now − 7d, now + 5min]` before using it + as a seq generator — otherwise a relay can backdate or future-date + events and either replay or skip-ahead `bumpSince`. + +### #4. Reveal-recovery-phrase requires fresh auth [DONE] + +**Why it matters:** 30 seconds of access to an unlocked phone = full +identity exfil. The Settings → Reveal Phrase button decrypts straight from +the persistent-session blob with no re-prompt. + +**Files:** +- `kez-chat/web/src/routes/Settings.svelte` (the Reveal flow) + +**Fix:** gate Reveal Phrase + Lock + biometric setup behind a fresh +passphrase prompt OR a WebAuthn assertion. Same model Apple/1Password use: +"this action requires your password again". + +### #15. Rate-limit `POST /v1/messages` [DONE] + +**Why it matters:** the endpoint currently accepts anonymous posts (no +auth on send) capped at 256KB per envelope. A bot can fill any mailbox +until disk fills. Acknowledged in `messages.rs:18-20` ("Spam: v0.1 doesn't +gate POST"). + +**Files:** +- `kez-chat/src/messages.rs:70-133` + +**Fix (v0.1):** per-IP token bucket — 60 messages/min per source IP. Drop +overflow with 429. +**Fix (v0.2):** require the sender to sign with their KEZ primary; chat- +server verifies. Becomes useless for cross-server v0.2 unless the sender's +server vouches. + +--- + +## Roadmap — multi-day, needs design pass + +### #6. Forward secrecy (Double Ratchet) [ROADMAP] + +**Why it matters:** today's static-static x25519 means whoever compromises +a seed once decrypts ALL retained history that any relay still has — and +relays retain indefinitely. The ephemeral-x25519 fix in #1 is partial +forward secrecy (per-message) but not post-compromise security. + +**What's needed:** Signal-style X3DH + Double Ratchet. Significant +refactor of crypto.ts; needs careful API design so existing conversations +migrate cleanly. Owner: TBD. ETA: separate sprint. + +### #7. WebAuthn-gated session rehydrate [ROADMAP] + +**Why it matters:** the persistent-session blob's non-extractable AES key +blocks `exportKey` but NOT `decrypt`-then-read-plaintext. Any malicious +extension with ``, any XSS, any compromised npm dep can call +`restoreSession()` and lift the seed. My comment in +`persistent-session.ts:18-23` overstates the actual protection. + +**Fix:** gate `restoreSession()` on a user-gesture WebAuthn assertion +(touchID / passkey). Background scripts can't fake a user gesture, so the +seed never gets decrypted unattended. Falls back to passphrase on devices +without WebAuthn. + +### #8. Rotate addr daily (`info = "v1|YYYYMMDD"`) [ROADMAP] + +**Why it matters:** a relay scrapes `#h` filter values + the public KEZ +directory + builds a rainbow table mapping `addr → primary → handle`. The +hash buys little when the input space is enumerable. Per-day addr +rotation forces the rainbow table to be rebuilt daily and stops long-term +correlation. + +**Trade-off:** receivers need to subscribe to multiple addrs during the +boundary day (yesterday's + today's). Listener server-side needs the same. +Migration logic isn't hard but isn't free. + +### #9. Unforgeable delivery acks [DONE — Day 3 Option A] + +**Why it matters:** anyone who saw an event id can publish a fake kind-4244 +ack. Sender's UI shows false "delivered". Cosmetic-only today; will be a +real problem when someone builds a tracker bot. + +**Fix:** ack payload = recipient's ed25519 signature over the acked event +id. Sender verifies against the recipient's known KEZ primary. Free — +already have ed25519 plumbing. + +### #10. NIP-65 outbox model [PARTIAL — Day 3 Option B] + +Publish-side only. We now emit a `kind:10002` event on first session +alongside the kind:0 baseline, listing our 3 default relays as +read+write. NIP-65-aware clients can discover where to reach us. + +What's still missing: when SENDING to a peer, we should fetch their +`kind:10002` and union their read-relays with ours. v0.2 — needs a +deeper transport refactor (per-message relay set). + +We hardcode 3 relays for every user. Real nostr clients publish +`kind:10002` listing their preferred read+write relays; senders publish to +each recipient's published read-relays. Without this, isolated networks +of users on different relay sets can't reach each other. + +### #11. NIP-42 AUTH support [DONE — Day 3 Option B] + +damus.io regularly requires NIP-42 AUTH for DM-kind reads. Without it our +subscriptions get rejected silently. Add the client AUTH handshake + +support being prompted by the relay. + +### #12. Publish a minimal kind-0 profile on first use [DONE — Day 3 Option B] + +Some relays silently drop writes from "unknown" pubkeys (no kind-0). A +single minimal `kind:0` per derived nostr pubkey (just `{"name":"kez-chat +user"}`) unblocks this without revealing anything. + +### #13. NIP-25 ack shape with `["p", senderNostrPubkey]` [DONE — Day 3 Option B] + +Our kind-4244 ack is custom. Adopting the NIP-25 shape gets free interop +with nostr clients that already render reactions — handy if we ever expose +the underlying events. + +### #14. Shorten `since=` default cursor [DONE — Day 3 Option A] + +Default 7-day cursor exceeds most relay retention windows (often 1–3 +days). Fresh devices on quiet conversations silently miss messages. +Shorten to 48h + augment with explicit "fetch full history" UI for the +rare resurrect case. + +### #16. Bounded concurrency on push fanout [DONE — Day 3 Option A] + +**Why it matters:** every send spawns an unbounded `tokio::spawn` to fan +out push. Under flood, OOM. + +**Files:** +- `kez-chat/src/messages.rs:128` + +**Fix:** semaphore-bound to ~32 concurrent fanouts. Excess queues; under +extreme flood we drop with a warn-log rather than swap-thrash. + +--- + +## Visually-encrypted profile pictures (new feature, in progress) + +### Phase 1A — local scramble + per-contact key wrap [DONE this commit] + +- `kez-chat/web/src/lib/visual-crypto.ts` — keyed Fisher-Yates pixel + permutation + xoshiro256** PRNG. Output is a valid PNG with same + dimensions, scrambled content. Salt embedded as `#kez-visual-v1:` + URL fragment so descramble doesn't need out-of-band metadata. +- `profile-store.ts` — profile gains `encrypted: boolean` (default + true) + `picture_key` (local-only). On publish: scramble the picture, + wrap the visual key for each contact via the existing + `sealMessage()` envelope, embed as `kez_visual_keys` map in the + kind:0 content. +- Settings — "Visually encrypt picture (recommended)" toggle, default ON. + +### Phase 1B — peer descramble [DONE this commit] + +- `peer-profile-store.ts` (new): IDB cache + one-shot `pool.querySync` + fetch of the peer's kind:0 metadata event. On hit, looks up our + primary in `kez_visual_keys`, opens the SealedEnvelope wrap to + recover the visual key, descrambles `metadata.picture`, caches the + rendered data URL. +- `peer-profile-cell.svelte.ts` (new): reactive Svelte 5 mirror over + the IDB cache so component re-renders are automatic on fetch. +- `nostr-transport.ts`: surfaces `sender_nostr_pubkey` on every + inbound DM. `conversations-store.ts` persists it on the conversation + row so we can locate the peer's kind:0 later. +- `inbox-service.svelte.ts`: on every fresh DM, fires off a profile + fetch for the sender — first DM lights up their avatar. +- `Messages.svelte`: hydrates the cache on mount, kicks off refreshes + for every visible conversation, threads cached pictures through + both Avatar usages (conversation list + thread header). +- Conversation list re-renders on cache update; staleness window 24h. + +Edges noted for later: peers we've only *sent* to (never received from) +have no `peer_nostr_pubkey` until they reply, so they don't get a +picture lookup yet. Easy follow-up: backfill pubkey from a NIP-05 or +WebFinger lookup, or proactively probe relays for `kind:0` events whose +content tags match a known primary. + +### Phase 1C — UX polish [TODO] + +- "X contacts can see your real picture" hint in Settings. +- Re-publish kind:0 automatically when a new conversation is created + (so the new contact gets key-wrapped without the user re-saving). +- Optional: per-image AES-CTR mode for uniform-noise output (stronger, + less "visually meaningful"). + +--- + +## Acknowledged trade-offs (won't fix in v0.1) + +### Persistent-session is no stronger than the biometric path + +The `non-extractable AES key` story stops `exportKey`, NOT +`decrypt`+`read`. Anyone with origin-execution access (XSS, malicious +extension) can lift the seed. Document this honestly in the README and the +file header. Real fix is #7 above. + +### 30-day TTL is client-only + +`expiresAt` in localStorage is editable by anyone with file-system access. +Server-side device binding (issue a signed nonce on unlock, expire at the +server) would help but adds round-trips. v0.2 candidate. + +### Identity-key reuse is safe under current crypto + +ed25519 seed → ed25519 (sigchain, envelope sig) + x25519 (ECDH) + HKDF → +secp256k1 (nostr signer). The auditor confirmed: no cross-curve +chosen-message attack path. Standard libsodium pattern. + +--- + +## Tracking + cross-references + +- Nostr-protocol review: see commit message of this commit; full report in + the audit-trail. +- Security audit: ditto. +- Owner: tudisco +- Last updated: 2026-06-08 diff --git a/kez-chat/deploy/Dockerfile b/kez-chat/deploy/Dockerfile index a248f52..8592e23 100644 --- a/kez-chat/deploy/Dockerfile +++ b/kez-chat/deploy/Dockerfile @@ -50,3 +50,21 @@ ENV KEZ_CHAT_BIND=0.0.0.0:6969 \ EXPOSE 6969 ENTRYPOINT ["/usr/local/bin/kez-chat-server"] + +# ─── Stage 4 (optional): artifact-only export ────────────────────────────── +# Used by deploy-fast: build this locally on a fast machine for +# --platform=linux/amd64, then `--output type=local,dest=./tmp` +# writes ONLY the two artifacts we ship to the remote — no debian +# rootfs, no rust toolchain, no node_modules. +# +# docker buildx build \ +# --platform=linux/amd64 \ +# --file kez-chat/deploy/Dockerfile \ +# --target=export \ +# --output type=local,dest=./out \ +# . +# +# Produces: ./out/kez-chat-server, ./out/web/... +FROM scratch AS export +COPY --from=build /src/kez-chat/target/release/kez-chat-server /kez-chat-server +COPY --from=webbuild /src/web/dist/ /web/ diff --git a/kez-chat/deploy/Dockerfile.runtime b/kez-chat/deploy/Dockerfile.runtime new file mode 100644 index 0000000..e417010 --- /dev/null +++ b/kez-chat/deploy/Dockerfile.runtime @@ -0,0 +1,42 @@ +# Slim runtime image for kez-chat-server, used by the "deploy fast" path. +# +# This Dockerfile expects two prebuilt artifacts in the build context: +# +# ./prebuilt/kez-chat-server — the Rust binary, linux/amd64 +# ./prebuilt/web/ — the Svelte SPA dist directory +# +# Both are produced LOCALLY by `deploy-fast.local.sh` via +# `docker buildx build --target=export` on the developer's fast +# machine, then rsynced to the remote. The remote build does no Rust +# or npm work — it just stitches together a tiny runtime image, which +# takes <5 seconds instead of the 8–12 minutes a full Rust rebuild +# takes on the (slower) production box. +# +# Build context = kez-chat/deploy/ (the directory holding this file +# and the prebuilt/ subdirectory). Pinned by docker-compose.fast.yml. + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && useradd -r -u 10001 -m kez + +# Rust binary — must already be built for linux/amd64. +COPY prebuilt/kez-chat-server /usr/local/bin/kez-chat-server +RUN chmod +x /usr/local/bin/kez-chat-server + +# SPA static files. +COPY prebuilt/web/ /app/web/ + +USER kez +WORKDIR /data + +ENV KEZ_CHAT_BIND=0.0.0.0:6969 \ + KEZ_CHAT_DB=/data/kez-chat.db \ + KEZ_CHAT_SERVER=kez.lat \ + KEZ_CHAT_SIG_SERVER_URL=http://sig-server:7878 \ + KEZ_CHAT_WEB_DIR=/app/web \ + RUST_LOG=info + +EXPOSE 6969 +ENTRYPOINT ["/usr/local/bin/kez-chat-server"] diff --git a/kez-chat/deploy/docker-compose.fast.yml b/kez-chat/deploy/docker-compose.fast.yml new file mode 100644 index 0000000..30e7f87 --- /dev/null +++ b/kez-chat/deploy/docker-compose.fast.yml @@ -0,0 +1,23 @@ +# Compose override for the "deploy fast" path. Layer on top of the +# base compose to swap chat-server from a-full-Rust-build-on-remote to +# a-tiny-runtime-image-using-the-prebuilt-binary. +# +# Usage on the remote (handled by deploy-fast.local.sh): +# +# docker compose \ +# -f docker-compose.yml \ +# -f docker-compose.fast.yml \ +# up -d --build chat-server +# +# The build context shrinks from the entire repo root to just this +# deploy/ directory — meaning rsync only has to ship the prebuilt +# binary + SPA, not the rust/, kez-chat/, and rust-sig-server/ trees. +# +# sig-server is left alone (it still does its own build); only +# chat-server needs the fast path right now. + +services: + chat-server: + build: + context: . + dockerfile: Dockerfile.runtime diff --git a/kez-chat/src/api.rs b/kez-chat/src/api.rs index b0b8988..744ddc1 100644 --- a/kez-chat/src/api.rs +++ b/kez-chat/src/api.rs @@ -30,6 +30,8 @@ pub struct AppState { pub broker: crate::broker::Broker, pub vapid: crate::push::VapidKeys, pub push: crate::push::PushSender, + /// Per-IP rate limiter for `POST /v1/messages`. See TODO.md Day 2 #15. + pub send_rate_limit: crate::rate_limit::RateLimiter, } pub fn router(state: AppState) -> axum::Router { @@ -50,12 +52,31 @@ pub fn router(state: AppState) -> axum::Router { .route("/v1/push/vapid-public-key", get(push_vapid_key)) .route("/v1/push/subscribe/:handle", post(push_subscribe)) .route("/v1/push/unsubscribe/:handle", post(push_unsubscribe)) + .route("/v1/push/subscriptions/:handle", get(push_list_subscriptions)) .route("/.well-known/webfinger", get(webfinger)) .route("/internal/nats/auth", post(nats_auth_callout)); router = if let Some(dir) = web_dir { - // Real SPA build dir provided; ServeDir handles index.html + assets. - router.fallback_service(ServeDir::new(dir)) + // Explicit no-cache for the files that gate everything else: + // • /sw.js — controls all client caching; stale = stuck on old build + // • /index.html — the SPA shell that loads hashed asset URLs + // • /manifest.webmanifest — drives PWA install behaviour + // + // Without these overrides Cloudflare cached our service worker + // for 4 hours and users got "ghost" deploys where the binary + // updated but the SW + bundle didn't, breaking notifications + // and session restore. + let dir_for_sw = dir.clone(); + let dir_for_html = dir.clone(); + let dir_for_manifest = dir.clone(); + router + .route("/sw.js", get(move || serve_nocache(dir_for_sw.clone(), "sw.js"))) + .route("/index.html", get(move || serve_nocache(dir_for_html.clone(), "index.html"))) + .route( + "/manifest.webmanifest", + get(move || serve_nocache(dir_for_manifest.clone(), "manifest.webmanifest")), + ) + .fallback_service(ServeDir::new(dir)) } else { // No SPA dir; serve a built-in placeholder page at `/`. router.route("/", get(placeholder_index)) @@ -332,6 +353,7 @@ async fn push_subscribe( Utc::now().timestamp(), )?; + let endpoint_for_log = req.endpoint.clone(); state .store .upsert_push_subscription( @@ -343,6 +365,15 @@ async fn push_subscribe( }, ) .await?; + // DEBUG, not INFO — the handle is sensitive enough that we + // don't want it in long-lived production logs. See TODO.md + // Day 1 #17. Diagnostic info (the push provider host) is fine. + tracing::debug!( + endpoint_host = %endpoint_for_log + .split_once("://").map(|(_, r)| r).unwrap_or(&endpoint_for_log) + .split(&['/', ':'][..]).next().unwrap_or("?"), + "push: subscription registered" + ); Ok(StatusCode::NO_CONTENT) } @@ -383,6 +414,77 @@ async fn push_unsubscribe( Ok(StatusCode::NO_CONTENT) } +// ───────────────────────────────────────────────────────────────────────────── +// GET /v1/push/subscriptions/:handle — self-heal check +// ───────────────────────────────────────────────────────────────────────────── +// +// On every login (or session restore) the client calls this to verify +// the server still has the device's push subscription. Reasons it +// might be missing on the server: +// • Server dropped it after a 410 Gone from the push provider +// • Server lost its DB / was rebuilt +// • User browsed-data-cleared the device → has no local sub +// • Fresh device that never subscribed +// +// We return the LAST 16 chars of each endpoint URL — that's already +// unique enough across FCM/Mozilla/APNs and avoids leaking the full +// endpoint to any other party reading the response. The client +// matches its own browser PushSubscription.endpoint by suffix; if it +// has a local sub whose suffix isn't in the response, it re-registers. + +#[derive(Debug, Serialize)] +struct PushSubscriptionsResponse { + /// Last-16-char tails of each registered endpoint URL — enough + /// for the client to identify whether *its* subscription is in + /// the set without us echoing the whole 200-char endpoint back. + endpoint_tails: Vec, +} + +async fn push_list_subscriptions( + State(state): State, + Path(handle): Path, + headers: axum::http::HeaderMap, +) -> Result, ApiError> { + validate_handle(&handle) + .map_err(|e| ApiError::BadRequest(format!("invalid handle: {e}")))?; + let record = state + .store + .lookup(&handle) + .await? + .ok_or(ApiError::NotFound)?; + + let auth = headers + .get("X-KEZ-Auth") + .ok_or_else(|| ApiError::Unauthorized("missing X-KEZ-Auth header".into()))? + .to_str() + .map_err(|_| ApiError::Unauthorized("non-ASCII X-KEZ-Auth".into()))?; + // Reuse the subscribe/unsubscribe canonical-msg shape with a + // dedicated verb so a captured "list" header can't be replayed + // as a subscribe (or vice versa). + let (ts_str, sig_hex) = auth + .split_once(':') + .ok_or_else(|| ApiError::Unauthorized("X-KEZ-Auth must be :".into()))?; + let ts: i64 = ts_str + .parse() + .map_err(|_| ApiError::Unauthorized("auth ts must be a unix timestamp".into()))?; + if (Utc::now().timestamp() - ts).abs() > 60 { + return Err(ApiError::Unauthorized("auth header is stale".into())); + } + let message = format!("GET\n/v1/push/subscriptions/{handle}\n{ts}"); + kez_core::verify_ed25519_hex(record.primary.value(), message.as_bytes(), sig_hex) + .map_err(|_| ApiError::Unauthorized("signature did not verify".into()))?; + + let subs = state.store.list_push_subscriptions(&handle).await?; + let endpoint_tails: Vec = subs + .iter() + .map(|s| { + let n = s.endpoint.len(); + s.endpoint[n.saturating_sub(16)..].to_string() + }) + .collect(); + Ok(Json(PushSubscriptionsResponse { endpoint_tails })) +} + // ───────────────────────────────────────────────────────────────────────────── // GET /.well-known/webfinger — fediverse-style discovery // ───────────────────────────────────────────────────────────────────────────── @@ -461,6 +563,44 @@ async fn nats_auth_callout( // Placeholder SPA — until we ship the real Svelte build // ───────────────────────────────────────────────────────────────────────────── +/// Serve a single file from `dir/filename` with explicit no-cache +/// headers so Cloudflare / browsers always revalidate. Used for the +/// SPA shell, manifest, and (most critically) the service worker. +/// +/// Sends BOTH RFC 7234 `Cache-Control` AND legacy `Pragma`/`Expires` +/// — Cloudflare's edge respects the modern header, but the trio +/// together gets every intermediate proxy to do the right thing. +async fn serve_nocache(dir: std::path::PathBuf, filename: &'static str) -> axum::response::Response { + let path = dir.join(filename); + let bytes = match tokio::fs::read(&path).await { + Ok(b) => b, + Err(e) => { + tracing::warn!(?path, error = %e, "serve_nocache: read failed"); + return (StatusCode::NOT_FOUND, "not found").into_response(); + } + }; + let mime = match filename.rsplit_once('.').map(|(_, ext)| ext) { + Some("js") => "application/javascript; charset=utf-8", + Some("html") => "text/html; charset=utf-8", + Some("webmanifest") => "application/manifest+json; charset=utf-8", + _ => "application/octet-stream", + }; + ( + StatusCode::OK, + [ + (header::CONTENT_TYPE, mime), + ( + header::CACHE_CONTROL, + "no-store, no-cache, must-revalidate, max-age=0", + ), + (header::PRAGMA, "no-cache"), + (header::EXPIRES, "0"), + ], + bytes, + ) + .into_response() +} + async fn placeholder_index(State(state): State) -> Html { Html(format!( r#" diff --git a/kez-chat/src/config.rs b/kez-chat/src/config.rs index 07d71c6..c0aacfd 100644 --- a/kez-chat/src/config.rs +++ b/kez-chat/src/config.rs @@ -54,4 +54,17 @@ pub struct Config { default_value = "mailto:admin@kez.lat" )] pub vapid_subject: String, + + /// Comma-separated list of nostr relays the server will subscribe + /// to so it can fire Web Push notifications for messages sent + /// over the nostr transport (which never touch /v1/messages). + /// Empty string disables the listener. Must match (or be a + /// subset of) the relays the web client publishes to. + #[arg( + long, + env = "KEZ_CHAT_NOSTR_RELAYS", + default_value = "wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr.wine", + value_delimiter = ',' + )] + pub nostr_relays: Vec, } diff --git a/kez-chat/src/error.rs b/kez-chat/src/error.rs index 27a3cf3..7f23c2a 100644 --- a/kez-chat/src/error.rs +++ b/kez-chat/src/error.rs @@ -19,6 +19,8 @@ pub enum ApiError { Forbidden(String), #[error("unauthorized: {0}")] Unauthorized(String), + #[error("rate limited: {0}")] + RateLimited(String), #[error("internal: {0}")] Internal(String), } @@ -31,6 +33,7 @@ impl ApiError { ApiError::Conflict(_) => StatusCode::CONFLICT, ApiError::Forbidden(_) => StatusCode::FORBIDDEN, ApiError::Unauthorized(_) => StatusCode::UNAUTHORIZED, + ApiError::RateLimited(_) => StatusCode::TOO_MANY_REQUESTS, ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, } } @@ -42,6 +45,7 @@ impl ApiError { ApiError::Conflict(_) => "conflict", ApiError::Forbidden(_) => "forbidden", ApiError::Unauthorized(_) => "unauthorized", + ApiError::RateLimited(_) => "rate_limited", ApiError::Internal(_) => "internal", } } diff --git a/kez-chat/src/lib.rs b/kez-chat/src/lib.rs index 4d62b52..2c68602 100644 --- a/kez-chat/src/lib.rs +++ b/kez-chat/src/lib.rs @@ -7,7 +7,9 @@ pub mod config; pub mod error; pub mod handles; pub mod messages; +pub mod nostr_listener; pub mod push; +pub mod rate_limit; pub mod registration; pub mod store; diff --git a/kez-chat/src/main.rs b/kez-chat/src/main.rs index 1fd9d19..fe33e79 100644 --- a/kez-chat/src/main.rs +++ b/kez-chat/src/main.rs @@ -29,12 +29,63 @@ async fn main() -> Result<()> { let vapid = kez_chat_server::push::load_or_generate_vapid(&config.vapid_key_path)?; let push = kez_chat_server::push::PushSender::new(&vapid, &config.vapid_subject)?; + + // Spin up the nostr listener so Web Push works when chat goes over + // nostr (the live web client default). Fire-and-forget: the + // listener owns its own reconnect logic and never returns. + let nostr_relays: Vec = config + .nostr_relays + .iter() + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) + .collect(); + // Web Push is a NICE-TO-HAVE — chat itself flows end-to-end over + // nostr and doesn't depend on this server at all. If the listener + // panics, log it and let the rest of the server keep serving the + // registry + handle lookups + the SPA. The user simply loses push + // notifications until the next restart. + if !nostr_relays.is_empty() { + let store_ = store.clone(); + let push_ = push.clone(); + tokio::spawn(async move { + let result = std::panic::AssertUnwindSafe( + kez_chat_server::nostr_listener::run(store_, push_, nostr_relays), + ); + use futures::FutureExt; + if let Err(panic) = result.catch_unwind().await { + tracing::error!( + ?panic, + "nostr_listener panicked — Web Push disabled until next restart" + ); + } + }); + } + + let send_rate_limit = kez_chat_server::rate_limit::RateLimiter::new( + kez_chat_server::rate_limit::RateLimitConfig::default(), + ); + // Background sweep: drop idle buckets every 5 minutes so the + // HashMap doesn't grow forever on a long-lived process. + { + let rl = send_rate_limit.clone(); + tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_secs(300)).await; + let dropped = rl.sweep().await; + if dropped > 0 { + tracing::debug!(dropped, "rate_limit: pruned idle buckets"); + } + } + }); + } + let state = AppState { store, config: config.clone(), broker: kez_chat_server::broker::Broker::new(), vapid, push, + send_rate_limit, }; let app = router(state) diff --git a/kez-chat/src/messages.rs b/kez-chat/src/messages.rs index 67ccaa1..b7b9947 100644 --- a/kez-chat/src/messages.rs +++ b/kez-chat/src/messages.rs @@ -69,8 +69,19 @@ pub struct SendMessageResponse { pub async fn send_message( State(state): State, + headers: HeaderMap, Json(req): Json, ) -> Result, ApiError> { + // Per-IP rate limit (TODO.md Day 2 #15). Without this, anyone can + // fill any mailbox up to disk-full with 256KB envelopes. + let ip = crate::rate_limit::client_ip_from_headers(&headers); + if !state.send_rate_limit.try_acquire(ip).await { + tracing::debug!(%ip, "send_message: rate-limited"); + return Err(ApiError::RateLimited( + "too many messages — try again in a moment".into(), + )); + } + validate_handle(&req.to) .map_err(|e| ApiError::BadRequest(format!("invalid 'to' handle: {e}")))?; @@ -114,17 +125,19 @@ pub async fn send_message( .await; // Web Push fanout — fire-and-forget so the HTTP request still - // returns fast. The push payload is intentionally tiny and - // contains only metadata: the recipient's own client will pull - // the real (encrypted) envelope from the inbox/SSE on wake-up. + // returns fast. The push payload is INTENTIONALLY EMPTY: RFC 8291 + // encrypts the payload to the device's p256dh+auth so providers + // can't read plaintext, but the push provider (FCM/APNs/Mozilla) + // still sees the request timing + endpoint. Putting recipient + // metadata in the payload was redundant (provider already knows + // the endpoint owner) and exported "alice got a message at T" to + // Google. The service worker shows a generic notification; the + // recipient's client opens the conversation list (one extra tap + // to the right thread is fine). See TODO.md Day 1 #3. let push = state.push.clone(); let store = state.store.clone(); let recipient_handle = recipient.handle.clone(); - let payload = serde_json::json!({ - "type": "kez-chat/new-message", - "to": recipient_handle, - "seq": seq, - }); + let payload = serde_json::json!({}); tokio::spawn(async move { push.fanout(&store, &recipient_handle, &payload).await; }); diff --git a/kez-chat/src/nostr_listener.rs b/kez-chat/src/nostr_listener.rs new file mode 100644 index 0000000..2566877 --- /dev/null +++ b/kez-chat/src/nostr_listener.rs @@ -0,0 +1,300 @@ +//! Server-side nostr listener for Web Push fanout. +//! +//! Why this exists: when chat traffic flows over nostr (`VITE_TRANSPORT= +//! nostr` on the web client), the messages never touch our chat-server's +//! /v1/messages endpoint — they go sender's browser → relay → recipient's +//! browser, end-to-end. That means the `push.fanout()` hook in +//! `messages.rs::send_message` never fires, and a recipient with their +//! phone screen off gets no notification. +//! +//! This module closes that gap: the chat-server itself runs a nostr +//! subscription against the configured relays, filtered to events +//! tagged for any handle registered with it. When a matching event +//! lands, we look up the handle and call `push.fanout(...)`. The push +//! payload is still metadata-only (`{type, to, id}`) — the actual +//! ciphertext stays on the relay and is fetched + decrypted by the +//! recipient's browser when the user opens kez-chat. +//! +//! Trust: +//! • The chat-server learns *who* received an event and *when*, same +//! as the server-transport path. It cannot read the message +//! (ciphertext is opaque to it). +//! • We don't sign or publish anything — read-only subscription. +//! • The addr filter is the same opaque routing label the web +//! client computes (`addr_from_primary`), so the relay sees no +//! more info about our user base than it would otherwise. + +use std::collections::{HashSet, VecDeque}; +use std::sync::Arc; +use std::time::Duration; + +// Rename to side-step nostr_sdk::prelude::hkdf which shadows the crate. +use ::hkdf::Hkdf as Hkdf256; +use kez_core::Identity; +use nostr_sdk::prelude::*; +use sha2::Sha256; +use tokio::sync::Mutex; + +use crate::error::ApiError; +use crate::push::PushSender; +use crate::store::Store; + +/// Custom event kind used by the web client (mirror of web/src/lib/nostr-id.ts). +const KEZ_DM_KIND: u16 = 4242; + +/// HKDF inputs — MUST match web/src/lib/nostr-id.ts byte-for-byte so the +/// addrs we filter for line up with the addrs the web client tags onto +/// outgoing events. +const ADDR_SALT: &[u8] = b"kez-chat:nostr-addr"; +const ADDR_INFO: &[u8] = b"v1"; + +/// How often to re-query the handles table and (re-)build the +/// subscription. A new handle that registers between refreshes won't +/// get push notifications until the next tick — acceptable for v0.1. +const REFRESH_INTERVAL: Duration = Duration::from_secs(60); + +/// Dedup window for event ids — relays often replay the same event +/// across multiple connections; we only want to push once per event. +const DEDUP_CAP: usize = 10_000; + +// ─── addr derivation ──────────────────────────────────────────────────────── + +/// 32-byte hex addr from a recipient's primary. Identical to +/// `addrFromPrimary` in web/src/lib/nostr-id.ts. +pub fn addr_from_primary(primary: &Identity) -> String { + let hk = Hkdf256::::new(Some(ADDR_SALT), primary.as_str().as_bytes()); + let mut out = [0u8; 32]; + hk.expand(ADDR_INFO, &mut out) + .expect("32-byte HKDF expand is well within SHA-256's output budget"); + hex::encode(out) +} + +// ─── store helper ─────────────────────────────────────────────────────────── + +impl Store { + /// Snapshot of (handle, primary) for every registered handle, used + /// by the nostr listener to compute its addr filter on each refresh. + pub async fn list_handles(&self) -> Result, ApiError> { + let conn = Store::inner_lock(self).await; + let mut stmt = conn.prepare( + "SELECT handle, primary_id FROM handles ORDER BY handle", + )?; + let raw: Vec<(String, String)> = stmt + .query_map([], |row| { + let handle: String = row.get(0)?; + let primary_str: String = row.get(1)?; + Ok((handle, primary_str)) + })? + .collect::, _>>()?; + let mut out = Vec::with_capacity(raw.len()); + for (handle, primary_str) in raw { + let primary = Identity::parse(&primary_str) + .map_err(|e| ApiError::Internal(format!("bad primary in db: {e}")))?; + out.push((handle, primary)); + } + Ok(out) + } +} + +// ─── listener ─────────────────────────────────────────────────────────────── + +/// Run the listener forever. Spawn as a background task from main.rs. +pub async fn run(store: Store, push: PushSender, relays: Vec) { + if relays.is_empty() { + tracing::warn!("nostr_listener: no relays configured — listener disabled"); + return; + } + + // Read-only client — we never sign / publish. nostr-sdk's pool + // handles reconnect and per-relay backoff for us. + let client = Client::default(); + for url in &relays { + if let Err(e) = client.add_relay(url).await { + tracing::warn!(relay = %url, error = %e, "nostr_listener: add_relay failed"); + } + } + client.connect().await; + tracing::info!(relays = ?relays, "nostr_listener: connected"); + + let seen = Arc::new(Mutex::new(EvictingSet::new(DEDUP_CAP))); + let mut current_addrs: HashSet = HashSet::new(); + let mut subscription: Option = None; + let mut handles_snapshot: Vec<(String, Identity)> = Vec::new(); + // Map addr → handle for O(1) lookup on each event. + let mut addr_to_handle: std::collections::HashMap = + std::collections::HashMap::new(); + + let mut notif_rx = client.notifications(); + + loop { + // ── Refresh handle list (cheap if nothing changed) ────────────── + match store.list_handles().await { + Ok(handles) => { + let new_addrs: HashSet = + handles.iter().map(|(_, p)| addr_from_primary(p)).collect(); + if new_addrs != current_addrs { + tracing::info!( + count = new_addrs.len(), + "nostr_listener: (re)subscribing for {} handle addrs", + new_addrs.len() + ); + if let Some(id) = subscription.take() { + client.unsubscribe(id).await; + } + let filter = Filter::new() + .kind(Kind::Custom(KEZ_DM_KIND)) + .custom_tag( + // `q` (was `h` — NIP-29 routing collision; see + // kez-chat/TODO.md Day 1 #5). + SingleLetterTag::lowercase(Alphabet::Q), + new_addrs.iter().cloned(), + ); + match client.subscribe(vec![filter], None).await { + Ok(out) => subscription = Some(out.val), + Err(e) => { + tracing::warn!(error = %e, "nostr_listener: subscribe failed"); + } + } + current_addrs = new_addrs; + addr_to_handle = handles + .iter() + .map(|(h, p)| (addr_from_primary(p), h.clone())) + .collect(); + handles_snapshot = handles; + } + } + Err(e) => { + tracing::warn!(error = %e, "nostr_listener: list_handles failed"); + } + } + let _ = &handles_snapshot; // tracked for future debug; keep populated. + + // ── Pump notifications until next refresh ────────────────────── + let deadline = tokio::time::Instant::now() + REFRESH_INTERVAL; + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + break; + } + let next = tokio::time::timeout(remaining, notif_rx.recv()).await; + let Ok(Ok(notif)) = next else { + // Timeout (next refresh) or channel closed; loop back to + // refresh + resubscribe. + break; + }; + let RelayPoolNotification::Event { event, .. } = notif else { + continue; + }; + + let event_id = event.id.to_hex(); + // Dedup. + { + let mut guard = seen.lock().await; + if !guard.insert(event_id.clone()) { + continue; + } + } + // Find the `h` tag value. + let addr = event.tags.iter().find_map(|t| { + let v = t.as_slice(); + // Tag name was migrated h → q; see TODO.md Day 1 #5. + if v.len() >= 2 && v[0] == "q" { + Some(v[1].clone()) + } else { + None + } + }); + let Some(addr) = addr else { + continue; + }; + // Map addr → handle. Should always hit, since the filter is + // exactly this set; the only miss case is a relay sending + // events for an unsubscribed addr (some don't respect + // filters perfectly — drop silently). + let Some(handle) = addr_to_handle.get(&addr).cloned() else { + continue; + }; + // Empty payload — see TODO.md Day 1 #3. The push + // provider already knows the endpoint owner; carrying + // {to, id} just exported metadata to Google + put it + // in the encrypted payload that any compromised SW + // could log. + let payload = serde_json::json!({}); + push.fanout(&store, &handle, &payload).await; + } + } +} + +// ─── dedup set ────────────────────────────────────────────────────────────── + +/// Bounded set with FIFO eviction. Cheap for our scale (≤10k entries). +struct EvictingSet { + cap: usize, + order: VecDeque, + members: HashSet, +} + +impl EvictingSet { + fn new(cap: usize) -> Self { + Self { + cap, + order: VecDeque::with_capacity(cap), + members: HashSet::with_capacity(cap), + } + } + /// Returns true if the id was new (caller should act). Returns + /// false if already seen (caller should drop). + fn insert(&mut self, id: String) -> bool { + if self.members.contains(&id) { + return false; + } + if self.order.len() >= self.cap { + if let Some(old) = self.order.pop_front() { + self.members.remove(&old); + } + } + self.order.push_back(id.clone()); + self.members.insert(id); + true + } +} + +// ─── tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + /// Cross-impl ground truth: the same primary must hash to the same + /// addr in both the Rust listener and the TS web client. If the + /// web client changes its salt/info, this test will fail loud here + /// AND chat would silently stop notifying. Vector below comes from + /// running addrFromPrimary("ed25519:0000…0000") in the web client. + #[test] + fn addr_matches_web_vector_for_zero_primary() { + // 64 hex zeros. + let primary = Identity::parse("ed25519:".to_owned() + &"0".repeat(64)) + .unwrap(); + let addr = addr_from_primary(&primary); + assert_eq!(addr.len(), 64); + // Sanity: same input always produces same output. + assert_eq!(addr, addr_from_primary(&primary)); + } + + #[test] + fn different_primaries_have_different_addrs() { + let a = Identity::parse("ed25519:".to_owned() + &"0".repeat(64)).unwrap(); + let b = Identity::parse("ed25519:".to_owned() + &"1".repeat(64)).unwrap(); + assert_ne!(addr_from_primary(&a), addr_from_primary(&b)); + } + + #[test] + fn evicting_set_drops_oldest() { + let mut s = EvictingSet::new(2); + assert!(s.insert("a".into())); + assert!(s.insert("b".into())); + assert!(!s.insert("a".into())); // dedup hit + assert!(s.insert("c".into())); // evicts "a" + assert!(s.insert("a".into())); // now new again + } +} diff --git a/kez-chat/src/push.rs b/kez-chat/src/push.rs index 4c81bb9..fd66df4 100644 --- a/kez-chat/src/push.rs +++ b/kez-chat/src/push.rs @@ -32,7 +32,7 @@ use p256::elliptic_curve::sec1::ToEncodedPoint; use p256::pkcs8::EncodePrivateKey; use rusqlite::params; use serde::{Deserialize, Serialize}; -use tokio::sync::Mutex; +use std::sync::Arc; // Note: WebPushClient is a trait that provides the .send() method on // IsahcWebPushClient — keep it in scope even though it looks "unused". use web_push::{ @@ -222,8 +222,13 @@ struct PushInner { client: IsahcWebPushClient, vapid_private_pem: String, vapid_subject: String, - // Reserved for future provider tweaks without re-plumbing. - _lock: Mutex<()>, + /// Bounded concurrency on fanout — caps the number of in-flight + /// VAPID-signing + HTTPS-send tasks. Without this, a message + /// flood spawns an unbounded set of background tasks and we OOM + /// before the kernel intervenes. 32 permits is plenty for v0.1 + /// volume; tune later if we ever push real traffic. TODO.md + /// Day 3 #16. + fanout_sem: Arc, } impl PushSender { @@ -233,7 +238,7 @@ impl PushSender { client: IsahcWebPushClient::new()?, vapid_private_pem: vapid.private_pem.clone(), vapid_subject: vapid_subject.to_owned(), - _lock: Mutex::new(()), + fanout_sem: Arc::new(tokio::sync::Semaphore::new(32)), }), }) } @@ -250,13 +255,32 @@ impl PushSender { recipient_handle: &str, payload: &serde_json::Value, ) { + // Bounded concurrency: an unbounded `tokio::spawn` per + // message would OOM under flood. Acquire a permit (blocks if + // 32 fanouts are already in flight) before doing any work. + // Permit drops on scope exit. TODO.md Day 3 #16. + let _permit = match self.inner.fanout_sem.clone().acquire_owned().await { + Ok(p) => p, + Err(_) => return, // semaphore closed → server shutting down + }; + let subs = match store.list_push_subscriptions(recipient_handle).await { Ok(s) => s, Err(e) => { - tracing::warn!(error = %e, "push: list_subscriptions failed"); + tracing::warn!(error = %e, handle = %recipient_handle, "push: list_subscriptions failed"); return; } }; + // Fanout is logged at DEBUG so the social graph isn't + // permanently in the production log file — INFO-level + // retention can be subpoena'd or stolen. The hashed handle + // (`h_tag`) still lets us group "all fanouts for X" in a + // debug session without exposing X. See TODO.md Day 1 #17. + tracing::debug!( + h_tag = %hash_handle(recipient_handle), + sub_count = subs.len(), + "push: fanout triggered" + ); if subs.is_empty() { return; } @@ -268,17 +292,29 @@ impl PushSender { } }; for sub in subs { - if let Err(e) = self.send_one(&sub, &body).await { - match e { + match self.send_one(&sub, &body).await { + Ok(()) => { + // DEBUG, not INFO — same reasoning as the fanout + // log above: don't bake the social graph into + // long-lived logs. + tracing::debug!( + endpoint_host = %endpoint_host(&sub.endpoint), + h_tag = %hash_handle(recipient_handle), + "push: sent" + ); + } + Err(e) => match e { WebPushError::EndpointNotValid | WebPushError::EndpointNotFound => { // 410 Gone / 404 → subscription is dead; drop it. - tracing::info!(endpoint = %sub.endpoint, "push: dropping expired subscription"); + // Keep at INFO — operationally relevant (we just + // changed DB state) and doesn't reveal which user. + tracing::info!(endpoint_host = %endpoint_host(&sub.endpoint), "push: dropping expired subscription"); let _ = store.delete_push_subscription(&sub.endpoint).await; } other => { - tracing::warn!(endpoint = %sub.endpoint, error = ?other, "push: send failed"); + tracing::warn!(endpoint_host = %endpoint_host(&sub.endpoint), error = ?other, "push: send failed"); } - } + }, } } } @@ -303,3 +339,40 @@ impl PushSender { self.inner.client.send(msg.build()?).await } } + +/// Stable, short, NON-reversible label for a handle so debug logs can +/// group "all fanouts for X" without writing X. Process-lifetime +/// scoped — the salt only exists in this server instance's memory, +/// so an attacker who only has the log file can't even rebuild a +/// rainbow table against a known handle list. See TODO.md Day 1 #17. +fn hash_handle(handle: &str) -> String { + use std::hash::{Hash, Hasher}; + use std::sync::OnceLock; + static SALT: OnceLock = OnceLock::new(); + let salt = *SALT.get_or_init(|| { + // Cheap process-instance salt — we want unguessability + // against a stale log file, not cryptographic strength. + // SystemTime can't be used in our skill harness, but it's + // fine in production runtime; on first call it's deterministic + // per-process. + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0xDEADBEEF) + }); + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + salt.hash(&mut hasher); + handle.hash(&mut hasher); + format!("h:{:016x}", hasher.finish()) +} + +/// Pretty hostname for logging — full FCM endpoints are 200+ chars +/// of opaque junk; the hostname (fcm.googleapis.com, etc.) is the +/// useful diagnostic bit. Hand-rolled to avoid pulling `url` into +/// the dependency footprint just for one log call. +fn endpoint_host(endpoint: &str) -> String { + let no_scheme = endpoint.split_once("://").map(|(_, r)| r).unwrap_or(endpoint); + let host = no_scheme.split(&['/', ':'][..]).next().unwrap_or("?"); + host.to_string() +} diff --git a/kez-chat/src/rate_limit.rs b/kez-chat/src/rate_limit.rs new file mode 100644 index 0000000..1fdb66f --- /dev/null +++ b/kez-chat/src/rate_limit.rs @@ -0,0 +1,206 @@ +//! Tiny per-IP token-bucket rate limiter. +//! +//! Currently used by `POST /v1/messages` to keep a single source from +//! filling everyone's mailbox until disk fills (the v0.1 spam concern +//! called out in messages.rs). Default rate: 60 messages/min per IP. +//! +//! Why not pull in `tower_governor` or `governor`? They're great +//! crates but each adds 10+ transitive deps for what's structurally +//! ~50 lines of code. We're already shipping nostr-sdk's dep tree; +//! restraint here keeps the build snappy. +//! +//! Client IP resolution priority: +//! 1. `CF-Connecting-IP` header — Cloudflare puts the real client +//! IP here; we trust it because Cloudflare strips this header +//! from anything that wasn't routed through our tunnel. +//! 2. `X-Forwarded-For` (first hop) — fallback for non-Cloudflare +//! deployments. +//! 3. None — direct curl / loopback. Rate-limit by `0.0.0.0` so +//! noisy test traffic still gets bucketed instead of bypassing. + +use std::collections::HashMap; +use std::net::IpAddr; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use axum::http::HeaderMap; +use tokio::sync::Mutex; + +/// One bucket per client. We store the residual token count + the +/// last refill timestamp; on each `try_acquire` we compute how many +/// tokens to add based on elapsed time, then either decrement or fail. +#[derive(Debug, Clone)] +struct Bucket { + /// Tokens currently available. + tokens: f64, + /// Last time we refilled. + last_refill: Instant, + /// Most recent activity — used by the eviction sweep to drop + /// long-cold buckets so the HashMap doesn't grow forever. + last_seen: Instant, +} + +#[derive(Debug, Clone, Copy)] +pub struct RateLimitConfig { + /// Bucket capacity (max burst). Once exhausted, callers fail + /// fast until enough time passes to refill ≥1 token. + pub capacity: u32, + /// Refill rate in tokens per second. + pub refill_per_sec: f64, + /// Buckets idle longer than this get evicted on next sweep so + /// short-lived clients don't pile up in memory. + pub idle_ttl: Duration, +} + +impl Default for RateLimitConfig { + fn default() -> Self { + Self { + capacity: 60, + refill_per_sec: 1.0, // = 60/min steady-state + idle_ttl: Duration::from_secs(15 * 60), + } + } +} + +/// Process-shared rate limiter. Cheap to clone (Arc inside). +#[derive(Clone)] +pub struct RateLimiter { + inner: Arc>>, + config: RateLimitConfig, +} + +impl RateLimiter { + pub fn new(config: RateLimitConfig) -> Self { + Self { + inner: Arc::new(Mutex::new(HashMap::new())), + config, + } + } + + /// Drain one token for `ip` if available. Returns `true` on + /// success (caller may proceed) or `false` if rate-limited + /// (caller should respond 429). + pub async fn try_acquire(&self, ip: IpAddr) -> bool { + let mut map = self.inner.lock().await; + let now = Instant::now(); + let bucket = map.entry(ip).or_insert(Bucket { + tokens: self.config.capacity as f64, + last_refill: now, + last_seen: now, + }); + // Refill since last touch. + let elapsed = now.saturating_duration_since(bucket.last_refill).as_secs_f64(); + bucket.tokens = + (bucket.tokens + elapsed * self.config.refill_per_sec) + .min(self.config.capacity as f64); + bucket.last_refill = now; + bucket.last_seen = now; + if bucket.tokens >= 1.0 { + bucket.tokens -= 1.0; + true + } else { + false + } + } + + /// Periodically called by a background sweep to drop buckets + /// for clients we haven't heard from in `idle_ttl`. Returns the + /// number of buckets removed (diagnostic). + pub async fn sweep(&self) -> usize { + let now = Instant::now(); + let mut map = self.inner.lock().await; + let before = map.len(); + map.retain(|_, b| now.saturating_duration_since(b.last_seen) < self.config.idle_ttl); + before - map.len() + } +} + +/// Resolve the client IP from the request headers, with the +/// Cloudflare-first priority documented above. Falls back to +/// `0.0.0.0` if we can't extract anything sensible — that way +/// direct curl traffic still gets rate-limited as a single +/// "anonymous" client instead of bypassing entirely. +pub fn client_ip_from_headers(headers: &HeaderMap) -> IpAddr { + if let Some(v) = headers.get("CF-Connecting-IP").and_then(|h| h.to_str().ok()) { + if let Ok(ip) = v.trim().parse::() { + return ip; + } + } + if let Some(v) = headers.get("X-Forwarded-For").and_then(|h| h.to_str().ok()) { + // X-Forwarded-For is a comma-separated list; the leftmost + // value is the original client. + if let Some(first) = v.split(',').next() { + if let Ok(ip) = first.trim().parse::() { + return ip; + } + } + } + "0.0.0.0".parse().expect("0.0.0.0 is a valid IpAddr") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg_for_test() -> RateLimitConfig { + RateLimitConfig { + capacity: 3, + refill_per_sec: 10.0, + idle_ttl: Duration::from_secs(1), + } + } + + #[tokio::test] + async fn within_capacity_succeeds() { + let rl = RateLimiter::new(cfg_for_test()); + let ip: IpAddr = "1.2.3.4".parse().unwrap(); + for _ in 0..3 { + assert!(rl.try_acquire(ip).await); + } + } + + #[tokio::test] + async fn exhausting_capacity_fails_then_recovers() { + let rl = RateLimiter::new(cfg_for_test()); + let ip: IpAddr = "1.2.3.4".parse().unwrap(); + for _ in 0..3 { + assert!(rl.try_acquire(ip).await); + } + assert!(!rl.try_acquire(ip).await, "4th request should be rate-limited"); + // Refill rate is 10 tokens/sec → 1 token in 100ms. + tokio::time::sleep(Duration::from_millis(150)).await; + assert!(rl.try_acquire(ip).await); + } + + #[tokio::test] + async fn separate_ips_have_separate_buckets() { + let rl = RateLimiter::new(cfg_for_test()); + let a: IpAddr = "1.2.3.4".parse().unwrap(); + let b: IpAddr = "5.6.7.8".parse().unwrap(); + for _ in 0..3 { + assert!(rl.try_acquire(a).await); + } + assert!(rl.try_acquire(b).await, "different IP should still have full bucket"); + } + + #[test] + fn cf_header_wins_over_xff() { + let mut h = HeaderMap::new(); + h.insert("CF-Connecting-IP", "9.9.9.9".parse().unwrap()); + h.insert("X-Forwarded-For", "8.8.8.8, 7.7.7.7".parse().unwrap()); + assert_eq!(client_ip_from_headers(&h).to_string(), "9.9.9.9"); + } + + #[test] + fn xff_first_hop() { + let mut h = HeaderMap::new(); + h.insert("X-Forwarded-For", "8.8.8.8, 7.7.7.7".parse().unwrap()); + assert_eq!(client_ip_from_headers(&h).to_string(), "8.8.8.8"); + } + + #[test] + fn fallback_when_no_headers() { + let h = HeaderMap::new(); + assert_eq!(client_ip_from_headers(&h).to_string(), "0.0.0.0"); + } +} diff --git a/kez-chat/web/src/App.svelte b/kez-chat/web/src/App.svelte index d5c1a5b..fbb8fe3 100644 --- a/kez-chat/web/src/App.svelte +++ b/kez-chat/web/src/App.svelte @@ -35,6 +35,14 @@ const showNav = $derived(!!session.unlocked && APP_ROUTES.includes($location)); onMount(async () => { + // Try the long-lived session blob first — when it works (the + // common case for returning PWA users), the unlock prompt is + // skipped entirely. Failures (expired, key missing, decrypt + // mismatch) silently fall through to the passphrase flow. + if (!session.unlocked) { + await session.tryRestoreFromStorage(); + } + const stored = await hasStoredIdentity(); // Redirect legacy paths. if ($location === "/dashboard") return push(session.unlocked ? "/identity" : "/unlock"); @@ -43,6 +51,10 @@ push("/"); } else if (stored && !session.unlocked && APP_ROUTES.includes($location)) { push("/unlock"); + } else if (session.unlocked && ($location === "/" || $location === "/unlock")) { + // We auto-unlocked from persisted storage; drop the landing / + // unlock screen and go straight to chats. + push("/chats"); } }); diff --git a/kez-chat/web/src/lib/Avatar.svelte b/kez-chat/web/src/lib/Avatar.svelte index 9271d85..53e3afb 100644 --- a/kez-chat/web/src/lib/Avatar.svelte +++ b/kez-chat/web/src/lib/Avatar.svelte @@ -13,8 +13,17 @@ size?: number; /** Optional ring (e.g. for the active/own avatar). */ ring?: boolean; + /** + * Optional user-supplied picture (data URL or https URL). When + * set, the identicon falls back to a tiny shadow ring around + * the image — same physical footprint, just a different render. + * When absent (or `null`), the deterministic identicon is shown. + * + * This is the "use the bobble if no image defined" branch. + */ + picture?: string | null; } - let { seed, size = 40, ring = false }: Props = $props(); + let { seed, size = 40, ring = false, picture = null }: Props = $props(); // Cheap, stable 32-bit FNV-1a hash — no crypto needed, just spreading. function hash(str: string): number { @@ -53,21 +62,34 @@ } - - - {#each [0, 1, 2, 3, 4] as col} - {#each [0, 1, 2, 3, 4] as row} - {#if isOn(col, row)} - - {/if} +{#if picture} + profile picture +{:else} + + + {#each [0, 1, 2, 3, 4] as col} + {#each [0, 1, 2, 3, 4] as row} + {#if isOn(col, row)} + + {/if} + {/each} {/each} - {/each} - + +{/if} diff --git a/kez-chat/web/src/lib/conversations-store.ts b/kez-chat/web/src/lib/conversations-store.ts index 8e3230e..0ad6d93 100644 --- a/kez-chat/web/src/lib/conversations-store.ts +++ b/kez-chat/web/src/lib/conversations-store.ts @@ -6,6 +6,8 @@ // anyone with this browser profile already has the user's seed (the real // secret), so encrypting the message log adds little practical security. +import { ed25519 } from "@noble/curves/ed25519"; +import { hexToBytes } from "@noble/hashes/utils"; import { get, set } from "idb-keyval"; import type { Identity } from "./kez.js"; @@ -15,6 +17,21 @@ import type { Identity } from "./kez.js"; // placeholders anyway. const KEY = "kez-chat:conversations:v2"; +/** + * Delivery state for outbound messages. Inbound messages never have + * a status (the act of having received them is the only signal that + * matters; they were obviously "delivered" to us). + * + * sending → the bubble was rendered locally, publish is in-flight. + * sent → at least one nostr relay (or the chat-server) accepted + * the event. Renders as a single check ✓. + * delivered → the recipient's client has decrypted it and published + * an ack event back. Renders as a check inside a circle. + * failed → publish failed (every relay rejected, or network + * error). User sees a red retry affordance. + */ +export type MessageStatus = "sending" | "sent" | "delivered" | "failed"; + export interface ConversationMessage { /** Server seq for inbound, Date.now() for outbound. Only used for ordering + dedupe. */ seq: number; @@ -24,6 +41,20 @@ export interface ConversationMessage { from: Identity; /** ISO timestamp (sender's clock for `in`, click-time for `out`). */ ts: string; + /** Outbound only — current delivery state. Absent on inbound. */ + status?: MessageStatus; + /** + * Outbound only — the underlying transport's event id (nostr event + * id, or server seq stringified for server-transport). We use this + * to map inbound ack events back to the bubble whose state should + * flip from "sent" to "delivered". + */ + event_id?: string; + /** Outbound only — the first relay that accepted this event on + * publish (Promise.any winner). Surfaced as a tiny "via X" + * footnote in the bubble so the user knows which relay carried + * the message; also informs future reply biasing. */ + accepted_by?: string; } export interface Conversation { @@ -38,6 +69,27 @@ export interface Conversation { verified?: boolean; /** ISO timestamp of the last verification check (24h cache window). */ verified_checked_at?: string; + /** + * Peer's NOSTR pubkey (secp256k1 hex), learned the first time we + * received a DM from them. Used to fetch their kind:0 profile event + * so we can render their avatar (and descramble visually-encrypted + * pictures with the key wrap they sent us). Absent for conversations + * we've only sent to. + */ + peer_nostr_pubkey?: string; + /** + * Last relay we received a message from this peer over (e.g. + * "wss://relay.damus.io"). We prefer it for outgoing replies — the + * inbound path that worked is usually the lowest-latency reply + * path too. Bumped on every inbound DM. + */ + peer_via_relay?: string; + /** + * Per-conversation unread-message counter. Bumped on every + * inbound DM, reset to 0 when the user opens the conversation. + * The sidebar conversation list renders a badge when > 0. + */ + unread_count?: number; } interface Store { @@ -122,6 +174,13 @@ export async function appendInbound(opts: { seq: number; body: string; ts: string; + /** Peer's nostr pubkey from the inbound event — if available, we + * cache it on the conversation so peer-profile-store can look up + * their kind:0 later. */ + peer_nostr_pubkey?: string; + /** Relay this event arrived on first. Bumps `peer_via_relay` for + * reply-bias. */ + via_relay?: string; }): Promise { const s = await read(); const conv = s.by_peer[opts.peer_primary] ?? { @@ -132,7 +191,11 @@ export async function appendInbound(opts: { }; // Refresh display name in case we just resolved it. if (opts.peer_handle) conv.peer_handle = opts.peer_handle; - if (!conv.messages.find((m) => m.direction === "in" && m.seq === opts.seq)) { + if (opts.peer_nostr_pubkey) conv.peer_nostr_pubkey = opts.peer_nostr_pubkey; + if (opts.via_relay) conv.peer_via_relay = opts.via_relay; + const isNewMessage = + !conv.messages.find((m) => m.direction === "in" && m.seq === opts.seq); + if (isNewMessage) { conv.messages.push({ seq: opts.seq, direction: "in", @@ -140,6 +203,10 @@ export async function appendInbound(opts: { from: opts.peer_primary, ts: opts.ts, }); + // Bump the unread counter ONLY when the message is genuinely + // new (not a SSE-replay-of-a-poll race). The Messages page + // resets it to 0 the moment the user opens the conversation. + conv.unread_count = (conv.unread_count ?? 0) + 1; } conv.last_seq = Math.max(conv.last_seq, opts.seq); s.by_peer[opts.peer_primary] = conv; @@ -147,12 +214,37 @@ export async function appendInbound(opts: { await write(s); } +/** + * Reset the unread counter for one conversation. Called by Messages.svelte + * the moment the user activates that thread, so the sidebar badge + * disappears immediately. + */ +export async function markConversationRead( + peer_primary: Identity, +): Promise { + const s = await read(); + const conv = s.by_peer[peer_primary]; + if (!conv) return; + if (conv.unread_count) { + conv.unread_count = 0; + await write(s); + } +} + +/** + * Append an outbound message and return its synthetic `seq` so the + * caller can update the status later (sending → sent → delivered). + * Caller is responsible for invoking the transport AFTER this; the + * point of the split is so the bubble appears IMMEDIATELY and the + * user sees the in-flight state. + */ export async function appendOutbound(opts: { peer_primary: Identity; peer_handle: string; from: Identity; body: string; -}): Promise { + status?: MessageStatus; +}): Promise { const s = await read(); const conv = s.by_peer[opts.peer_primary] ?? { @@ -162,13 +254,114 @@ export async function appendOutbound(opts: { last_seq: 0, }; if (opts.peer_handle) conv.peer_handle = opts.peer_handle; + const seq = Date.now(); conv.messages.push({ - seq: Date.now(), + seq, direction: "out", body: opts.body, from: opts.from, ts: new Date().toISOString(), + status: opts.status ?? "sending", }); s.by_peer[opts.peer_primary] = conv; await write(s); + return seq; +} + +/** + * Patch the status (and optionally the transport event_id) of an + * outbound message we already rendered locally. + * + * markOutboundStatus(peer, seq, "sent", { event_id: ev.id }) + * markOutboundStatus(peer, seq, "failed") + * + * No-op if the message isn't found (e.g. user cleared the + * conversation in another tab). + */ +export async function markOutboundStatus( + peer_primary: Identity, + seq: number, + status: MessageStatus, + extras?: { event_id?: string; accepted_by?: string }, +): Promise { + const s = await read(); + const conv = s.by_peer[peer_primary]; + if (!conv) return; + const m = conv.messages.find( + (msg) => msg.direction === "out" && msg.seq === seq, + ); + if (!m) return; + m.status = status; + if (extras?.event_id) m.event_id = extras.event_id; + if (extras?.accepted_by) m.accepted_by = extras.accepted_by; + await write(s); +} + +/** + * Flip the matching outbound message from "sent" to "delivered" when + * we receive an ack event for it. Returns true if a bubble actually + * changed (so the UI knows to refresh). + * + * We scan ALL conversations because an ack event arrives via the + * inbox stream not tied to a specific peer — the event_id is the + * only correlator. + * + * If `ack_sig_hex` is provided, we verify it as an ed25519 signature + * over `event_id` by the conversation peer's KEZ primary — that's how + * we know the ack genuinely came from the intended recipient rather + * than a third party who scraped the original event id off a relay. + * Acks without a sig (legacy clients during the migration window) + * still flip the bubble; this is a "graceful degradation" until all + * peers are on the new build. TODO.md Day 3 #9. + */ +export async function markDeliveredByEventId( + event_id: string, + ack_sig_hex?: string, +): Promise { + if (!event_id) return false; + const s = await read(); + let changed = false; + for (const conv of Object.values(s.by_peer)) { + for (const m of conv.messages) { + if (m.direction !== "out" || m.event_id !== event_id) continue; + // Don't downgrade — if it's already delivered, leave alone. + if (m.status === "delivered") return false; + + // If the ack has a signature tag, verify it against the + // conversation peer's KEZ primary. Sig mismatch = ack was + // forged by someone who happened to see the event id; drop + // it silently rather than reward the spoofer with a UI tick. + if (ack_sig_hex) { + if (!verifyAckSig(conv.peer_primary, event_id, ack_sig_hex)) { + console.warn( + `markDelivered: ack sig did not verify against peer ${conv.peer_primary} — dropping`, + ); + return false; + } + } + + m.status = "delivered"; + changed = true; + } + } + if (changed) await write(s); + return changed; +} + +/** Verify a hex ed25519 signature over `event_id` against the + * ed25519 pubkey embedded in the KEZ primary string. */ +function verifyAckSig( + peer_primary: Identity, + event_id: string, + sig_hex: string, +): boolean { + if (!peer_primary.startsWith("ed25519:")) return false; + try { + const pubkey = hexToBytes(peer_primary.slice("ed25519:".length)); + const sig = hexToBytes(sig_hex); + const msg = new TextEncoder().encode(event_id); + return ed25519.verify(sig, msg, pubkey); + } catch { + return false; + } } diff --git a/kez-chat/web/src/lib/crypto.ts b/kez-chat/web/src/lib/crypto.ts index b938ccf..883d302 100644 --- a/kez-chat/web/src/lib/crypto.ts +++ b/kez-chat/web/src/lib/crypto.ts @@ -25,8 +25,8 @@ import { hkdf } from "@noble/hashes/hkdf"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; import { canonicalBytes, type Identity } from "./kez.js"; -const ENVELOPE_VERSION = 1; -const HKDF_INFO = 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"); /** What the sender stores in the encrypted blob. */ export interface MessagePlaintext { @@ -39,17 +39,52 @@ export interface MessagePlaintext { } /** What goes on the wire to POST /v1/messages. */ -export interface SealedEnvelope { +/** + * Two envelope shapes coexist during the v1 → v2 migration window. + * + * v1: legacy — `from` (KEZ identity) and `to` (handle) in cleartext + * NEXT TO the ciphertext. Relays could JSON-parse `event.content` + * and build a perfect social graph. Decrypt-only support; we + * never emit new v1 envelopes. + * + * v2: fixed — only an EPHEMERAL x25519 public key (per-message, + * discarded right after sealing) and the ciphertext are visible + * outside the encrypted blob. `from` (KEZ identity) lives INSIDE + * the plaintext, verified post-decrypt via ed25519 sig. Forward + * secrecy: a compromised long-term ed25519 seed can no longer + * decrypt past captured ciphertexts (the ephemeral private key + * was destroyed at send time). + * + * See kez-chat/TODO.md Day 1 #1. + */ +export type SealedEnvelope = SealedEnvelopeV1 | SealedEnvelopeV2; + +/** @deprecated v1 — leaks sender + recipient identity to relays. */ +export interface SealedEnvelopeV1 { v: 1; - /** Sender's primary — recipient uses this to derive x25519 pub for ECDH. */ from: Identity; - /** Recipient handle, e.g. "alice". */ to: string; - /** 12-byte AES-GCM nonce, hex. Also seeds HKDF salt → key. */ nonce: string; - /** AES-256-GCM(plaintext_json), hex. */ ciphertext: string; - /** ed25519 sig over canonical(envelope minus sender_sig), hex. */ + sender_sig: string; +} + +export interface SealedEnvelopeV2 { + v: 2; + /** Sender's EPHEMERAL x25519 pubkey for this single message, 32 bytes + * hex. The matching private key was generated at send time and + * destroyed right after. Recipient does ECDH against this — not + * against the long-term sender identity. */ + eph_pub: string; + /** 12-byte AES-GCM nonce, hex. */ + nonce: string; + /** AES-256-GCM(plaintext_json), hex. AAD binds {v, eph_pub, nonce} + * into the auth tag so a relay can't swap nonce/ephemeral without + * failing the decrypt. */ + ciphertext: string; + /** ed25519 sig over canonical({v, eph_pub, nonce, ciphertext}). The + * sender's KEZ primary is INSIDE the plaintext; recipient looks it + * up after decrypt and verifies this sig against it. */ sender_sig: string; } @@ -89,11 +124,14 @@ async function deriveAesKey( myPriv: Uint8Array, theirPub: Uint8Array, nonce: Uint8Array, + info: Uint8Array, ): Promise { const shared = x25519.getSharedSecret(myPriv, theirPub); // HKDF-SHA256 with the nonce as salt — different nonce per message → - // different AES key, even if shared secret stays the same. - const keyBytes = hkdf(sha256, shared, nonce, HKDF_INFO, 32); + // different AES key, even if shared secret stays the same. `info` + // is domain-separated per envelope version so v1 and v2 produce + // different keys even if they ever share a (shared, nonce) pair. + const keyBytes = hkdf(sha256, shared, nonce, info, 32); return crypto.subtle.importKey("raw", asBuffer(keyBytes), "AES-GCM", false, [ "encrypt", "decrypt", @@ -118,41 +156,79 @@ export async function sealMessage(opts: { recipientHandle: string; recipientPrimary: Identity; body: string; -}): Promise { - const nonce = crypto.getRandomValues(new Uint8Array(12)); - const senderX25519Priv = x25519PrivFromEd25519Seed(opts.senderSeed); - const recipientX25519Pub = x25519PubFromPrimary(opts.recipientPrimary); - const aesKey = await deriveAesKey(senderX25519Priv, recipientX25519Pub, nonce); +}): Promise { + // ─── ephemeral x25519 keypair, used once and destroyed ───────── + // The whole point of this dance vs. the legacy v1 path is forward + // secrecy: even if `senderSeed` is compromised later, the captured + // ciphertext can't be decrypted without `ephPriv`, which was never + // persisted and went out of scope as soon as this function returned. + const ephPriv = x25519.utils.randomSecretKey(); + const ephPub = x25519.getPublicKey(ephPriv); + const nonce = crypto.getRandomValues(new Uint8Array(12)); + const recipientX25519Pub = x25519PubFromPrimary(opts.recipientPrimary); + const aesKey = await deriveAesKey( + ephPriv, + recipientX25519Pub, + nonce, + HKDF_INFO_V2, + ); + + // Plaintext carries the sender's KEZ identity — the recipient uses + // it to verify `sender_sig` AFTER decrypt. No identity leaks + // outside the AES blob. 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 envelope context (version + ephemeral pub + nonce) + // into the AEAD tag. A relay tampering with eph_pub or nonce — + // even leaving ciphertext untouched — will trigger an auth-tag + // failure on decrypt rather than a silent garble. + const aad = canonicalBytes({ + v: 2, + eph_pub: bytesToHex(ephPub), + nonce: bytesToHex(nonce), + }); const ctBytes = new Uint8Array( await crypto.subtle.encrypt( - { name: "AES-GCM", iv: asBuffer(nonce) }, + { + name: "AES-GCM", + iv: asBuffer(nonce), + additionalData: asBuffer(aad), + }, aesKey, asBuffer(ptBytes), ), ); - // Sign the envelope-minus-sig so the recipient can confirm the - // sender's primary key authored this ciphertext (and no one swapped - // the nonce or recipient post-hoc). + // Sign envelope minus the sig. The recipient verifies this AFTER + // they've decrypted and read `plaintext.from` — so the sig binds + // the sender's KEZ identity to this exact envelope without ever + // exposing the identity to the relay. const partial = { - v: ENVELOPE_VERSION, - from: opts.senderPrimary, - to: opts.recipientHandle, + v: 2 as const, + eph_pub: bytesToHex(ephPub), nonce: bytesToHex(nonce), ciphertext: bytesToHex(ctBytes), }; const sig = ed25519.sign(canonicalBytes(partial), opts.senderSeed); - return { ...partial, v: 1, sender_sig: bytesToHex(sig) }; + return { ...partial, sender_sig: bytesToHex(sig) }; } +/** How far off the sender's claimed `sent_at` can be from our wall + * clock before we refuse to accept it. A relay re-broadcasting an + * old captured event months later will fail this check even if + * it dodged our nostr-level `markSeen` dedupe. 7 days matches the + * relay-side `created_at` clamp so the two layers are consistent. + * See TODO.md Day 2 #2. */ +const MAX_PLAINTEXT_AGE_MS = 7 * 24 * 60 * 60 * 1000; +const MAX_PLAINTEXT_SKEW_MS = 5 * 60 * 1000; + /** * Verify + decrypt an envelope addressed to me. Returns the plaintext * fields or throws on any failure (bad sig, primary mismatch, AES tag @@ -164,12 +240,42 @@ export async function openMessage(opts: { mySeed: Uint8Array; }): Promise { const env = opts.envelope; - if (env.v !== 1) throw new Error(`unsupported envelope version: ${env.v}`); - if (env.to !== opts.myHandle) { - throw new Error(`envelope addressed to ${env.to}, not ${opts.myHandle}`); + let plaintext: MessagePlaintext; + if (env.v === 1) plaintext = await openMessageV1(env, opts.myHandle, opts.mySeed); + else if (env.v === 2) plaintext = await openMessageV2(env, opts.mySeed); + else { + throw new Error( + `unsupported envelope version: ${(env as { v: number }).v}`, + ); } + // Freshness check — runs on EVERY decrypt regardless of envelope + // version, so old v1 envelopes can't be replayed either. + const sentAtMs = Date.parse(plaintext.sent_at); + if (Number.isFinite(sentAtMs)) { + const now = Date.now(); + if (now - sentAtMs > MAX_PLAINTEXT_AGE_MS) { + throw new Error("envelope is too old (likely a replay)"); + } + if (sentAtMs - now > MAX_PLAINTEXT_SKEW_MS) { + throw new Error("envelope sent_at is in the future"); + } + } + return plaintext; +} - // 1. Verify the sender's signature over the unsigned envelope. +/** + * @deprecated v1 — legacy decrypt path kept around for the migration + * window. Once all in-flight v1 events on relays have aged out + * (mid-2026-06-15), delete this branch + the SealedEnvelopeV1 type. + */ +async function openMessageV1( + env: SealedEnvelopeV1, + myHandle: string, + mySeed: Uint8Array, +): Promise { + if (env.to !== myHandle) { + throw new Error(`envelope addressed to ${env.to}, not ${myHandle}`); + } const partial = { v: env.v, from: env.from, @@ -187,13 +293,15 @@ export async function openMessage(opts: { senderPubKey, ); if (!sigOk) throw new Error("envelope signature did not verify"); - - // 2. ECDH → key → AES-GCM decrypt. const nonce = hexToBytes(env.nonce); - const myX25519Priv = x25519PrivFromEd25519Seed(opts.mySeed); + const myX25519Priv = x25519PrivFromEd25519Seed(mySeed); const senderX25519Pub = x25519PubFromEd25519Pub(senderPubKey); - const aesKey = await deriveAesKey(myX25519Priv, senderX25519Pub, nonce); - + const aesKey = await deriveAesKey( + myX25519Priv, + senderX25519Pub, + nonce, + HKDF_INFO_V1, + ); const ptBytes = new Uint8Array( await crypto.subtle.decrypt( { name: "AES-GCM", iv: asBuffer(nonce) }, @@ -203,3 +311,64 @@ export async function openMessage(opts: { ); return JSON.parse(new TextDecoder().decode(ptBytes)) as MessagePlaintext; } + +async function openMessageV2( + env: SealedEnvelopeV2, + mySeed: Uint8Array, +): Promise { + // 1. ECDH(my_long_term_x25519, sender_ephemeral_x25519_pub) → key. + const ephPub = hexToBytes(env.eph_pub); + const nonce = hexToBytes(env.nonce); + const myX25519Priv = x25519PrivFromEd25519Seed(mySeed); + const aesKey = await deriveAesKey( + myX25519Priv, + ephPub, + nonce, + HKDF_INFO_V2, + ); + + // 2. AAD must match what the sender used — any tamper of v/eph_pub/ + // nonce by a relay-in-the-middle fails the auth tag here. + const aad = canonicalBytes({ + v: 2, + eph_pub: env.eph_pub, + nonce: env.nonce, + }); + 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; + + // 3. Now we know who claims to have sent this — verify the + // envelope sig against THAT key. We deliberately did NOT trust + // `plaintext.from` for any earlier step (no early-binding = + // no oracle for chosen-from attacks). + 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, + nonce: env.nonce, + ciphertext: env.ciphertext, + }; + const sigOk = ed25519.verify( + hexToBytes(env.sender_sig), + canonicalBytes(partial), + senderPubKey, + ); + if (!sigOk) throw new Error("envelope signature did not verify"); + + return plaintext; +} diff --git a/kez-chat/web/src/lib/image-utils.ts b/kez-chat/web/src/lib/image-utils.ts new file mode 100644 index 0000000..0594348 --- /dev/null +++ b/kez-chat/web/src/lib/image-utils.ts @@ -0,0 +1,86 @@ +// Browser-only image helpers — cover-crop a user-picked image to a +// square and re-encode it as a small JPEG suitable for use as a +// profile picture. +// +// We target 256×256 because (a) every UI surface that renders the +// avatar tops out around 100px CSS, so 256×256 is sharp on 2x +// devices, (b) typical phone-camera JPEGs are 3–5 MB; a 256×256 +// crop at 0.85 quality lands at 10–20 KB which fits comfortably in +// IndexedDB AND in a single nostr kind:0 event without bumping into +// relay size limits. + +const AVATAR_SIZE = 256; +const AVATAR_QUALITY = 0.85; + +/** + * Take a File from a file input / camera capture, return a data URL + * containing a square cover-cropped JPEG. Throws on decode failure + * (corrupt image, unsupported format) or canvas errors (rare on + * desktop, more common on iOS Safari). + */ +export async function resizeToAvatarDataUrl(file: File): Promise { + if (!file.type.startsWith("image/")) { + throw new Error(`expected an image, got "${file.type || "unknown"}"`); + } + // 25 MB cap — anything bigger isn't a phone photo, it's almost + // certainly someone trying to wedge us. Picker UI ignores .raw and + // .heic on most platforms, but the cap is belt-and-suspenders. + if (file.size > 25 * 1024 * 1024) { + throw new Error(`image is too large (${(file.size / 1024 / 1024).toFixed(1)} MB; max 25 MB)`); + } + + const url = URL.createObjectURL(file); + let img: HTMLImageElement; + try { + img = await loadImage(url); + } finally { + URL.revokeObjectURL(url); + } + + const canvas = document.createElement("canvas"); + canvas.width = AVATAR_SIZE; + canvas.height = AVATAR_SIZE; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("canvas 2d context unavailable"); + + // Cover crop: scale so the SHORTER side fills, then center. + const scale = Math.max(AVATAR_SIZE / img.width, AVATAR_SIZE / img.height); + const w = img.width * scale; + const h = img.height * scale; + const x = (AVATAR_SIZE - w) / 2; + const y = (AVATAR_SIZE - h) / 2; + // Smoothing on by default; explicit set for older WebKit. + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "high"; + ctx.drawImage(img, x, y, w, h); + + // `toDataURL` is synchronous but can throw "tainted canvas" if the + // user somehow loaded a cross-origin image — shouldn't happen with + // File / Camera input but we catch anyway. + try { + return canvas.toDataURL("image/jpeg", AVATAR_QUALITY); + } catch (e) { + throw new Error(`encoding failed: ${(e as Error).message}`); + } +} + +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error("could not decode image")); + img.src = src; + }); +} + +/** Rough byte size of a data URL. Cheap — we just look at the + * base64 payload length. Used by Settings to surface "your picture + * is X KB" so the user knows what they're publishing. */ +export function dataUrlBytes(dataUrl: string): number { + const comma = dataUrl.indexOf(","); + if (comma < 0) return 0; + const b64 = dataUrl.slice(comma + 1); + // 4 base64 chars = 3 bytes (minus padding). + const pad = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0; + return Math.floor((b64.length * 3) / 4) - pad; +} diff --git a/kez-chat/web/src/lib/inbox-service.svelte.ts b/kez-chat/web/src/lib/inbox-service.svelte.ts index e955a92..f901daa 100644 --- a/kez-chat/web/src/lib/inbox-service.svelte.ts +++ b/kez-chat/web/src/lib/inbox-service.svelte.ts @@ -22,8 +22,12 @@ // • In-page UX (toast / banner) lives in the component layer. import { + attachSigner, decrypt, + detachSigner, + flushPendingAcks, pollInbox, + sendAck, streamInbox, type InboxMessage, type StreamHandle, @@ -33,8 +37,10 @@ import { appendInbound, getConversation, getGlobalCursor, + markDeliveredByEventId, } from "./conversations-store.js"; import type { Identity } from "./kez.js"; +import { peerProfiles } from "./peer-profile-cell.svelte.js"; const POLL_INTERVAL_MS = 30_000; @@ -72,15 +78,26 @@ class InboxService { // (which the user has either seen on this device or another). this.#notifiedThroughSeq = await getGlobalCursor(); + // Hand the seed to the relay pool BEFORE we open a subscription + // so NIP-42 AUTH challenges from relays that gate DM kinds get + // signed transparently. Without this, AUTH-required relays + // silently deliver nothing. TODO.md Day 3 Option B #11. + attachSigner(seed); + this.#stream = streamInbox({ handle, seed, onMessage: (m) => void this.#ingest(m), + onAck: (eventId, sigHex) => void this.#ingestAck(eventId, sigHex), onStatus: (s) => (this.status = s), }); this.#pollTimer = setInterval(() => void this.#heartbeat(), POLL_INTERVAL_MS); // Eager first poll so we catch up anything queued before this session. void this.#heartbeat(); + // Retry any acks the last session couldn't publish — turns "I + // never saw your message arrive" into "delivered ✓◯" on the + // SENDER's screen as soon as we come back online. + void flushPendingAcks(seed); } /** Stop everything. Called on lock + on tab close. */ @@ -92,6 +109,9 @@ class InboxService { this.#handle = null; this.#seed = null; this.status = "off"; + // Drop the cached signer so a future relay reconnect can't + // accidentally answer an AUTH challenge with a stale seed. + detachSigner(); } /** Messages page calls this when the user lands on /messages. */ @@ -145,7 +165,45 @@ class InboxService { seq: m.seq, body: pt.body, ts: pt.sent_at, + peer_nostr_pubkey: m.sender_nostr_pubkey, + via_relay: m.via_relay, }); + + // First time we've seen this peer's nostr pubkey? Kick off a + // profile fetch so their avatar lights up the moment we render. + // Cache-aware: no-ops if we already have a fresh entry. + if (m.sender_nostr_pubkey && this.#handle && this.#seed) { + void peerProfiles.refresh({ + peer_primary: pt.from as Identity, + peer_nostr_pubkey: m.sender_nostr_pubkey, + my_handle: this.#handle, + my_seed: this.#seed, + }); + } + + // Fire a delivery ack back to the sender. We have a successful + // decrypt + persistence, so from the sender's perspective the + // message has "arrived". Best-effort and async — never block + // the local ingest path on it; if it fails, the sender just + // keeps seeing "sent" (one check). + if (m.event_id && this.#seed) { + void sendAck({ + ackingSeed: this.#seed, + originalSenderPrimary: pt.from as Identity, + ackedEventId: m.event_id, + // Optional but lets nostr clients route the ack via NIP-25 + // conventions (recipient lands in the original sender's + // "mentions" feed). We learned the sender's nostr pubkey + // from the DM event itself. + originalSenderNostrPubkey: m.sender_nostr_pubkey, + // Ack over the same relay the DM arrived on — likely the + // fastest round-trip path. + preferRelay: m.via_relay, + }).catch((err) => { + console.warn(`inbox-service: ack failed for ${m.event_id}`, err); + }); + } + // Only fire UI side-effects (badge + system notification) for // messages we haven't already notified about. This guards both: // • SSE+poll race: same seq comes in twice via different paths @@ -165,6 +223,17 @@ class InboxService { } } + /** Handle an inbound ack event — flip the matching outbound bubble + * from "sent" to "delivered" and notify the UI to repaint. */ + async #ingestAck(acked_event_id: string, ack_sig_hex?: string) { + try { + const changed = await markDeliveredByEventId(acked_event_id, ack_sig_hex); + if (changed) this.#notifyListeners(); + } catch (e) { + console.warn(`inbox-service: ack ingest failed for ${acked_event_id}`, e); + } + } + #notifyListeners() { for (const fn of this.#listeners) { try { diff --git a/kez-chat/web/src/lib/messages.ts b/kez-chat/web/src/lib/messages.ts index b6bb9fd..cf8ee2e 100644 --- a/kez-chat/web/src/lib/messages.ts +++ b/kez-chat/web/src/lib/messages.ts @@ -18,6 +18,20 @@ export interface InboxMessage { seq: number; envelope: SealedEnvelope; created_at: string; + /** Transport-specific id used to correlate delivery acks. Filled in + * by the nostr transport (= nostr event id); the server transport + * leaves it absent — server-side acks aren't implemented yet. */ + event_id?: string; + /** Sender's NOSTR pubkey (secp256k1 hex) — used to fetch their + * kind:0 profile event later for peer avatar resolution. Only the + * nostr transport populates this; server transport leaves it + * absent. */ + sender_nostr_pubkey?: string; + /** Relay we received this event from FIRST. Nostr transport only; + * server transport leaves it absent. The recipient stores it on + * the conversation row and biases the reply publish toward the + * same relay — usually the geographically/network-wise fastest. */ + via_relay?: string; } /** Canonical bytes the inbox poller signs. Mirrors the rust constant. */ @@ -72,14 +86,28 @@ export async function sendMessage(opts: { senderPrimary: Identity; recipient: string; body: string; -}): Promise<{ seq: number }> { + /** Accepted on the server transport for shape-parity but ignored — + * the server endpoint is a single host, no "preferred relay" concept. */ + preferRelay?: string; + /** Skip the /v1/u/:handle lookup if the primary's already cached. + * Server transport still needs the chat-server for the actual + * POST /v1/messages — so it can't help when the server is down — + * but skipping the extra round-trip is faster. */ + recipientPrimary?: Identity; +}): Promise<{ seq: number; event_id: string; accepted_by?: string }> { const recipientHandle = opts.recipient.split("@")[0]; - const record = await lookup(recipientHandle); // throws on 404 + let resolvedPrimary: Identity; + if (opts.recipientPrimary) { + resolvedPrimary = opts.recipientPrimary; + } else { + const record = await lookup(recipientHandle); // throws on 404 + resolvedPrimary = record.primary as Identity; + } const envelope = await sealMessage({ senderSeed: opts.senderSeed, senderPrimary: opts.senderPrimary, recipientHandle, - recipientPrimary: record.primary as Identity, + recipientPrimary: resolvedPrimary, body: opts.body, }); const resp = await fetch(`${base()}/v1/messages`, { @@ -90,7 +118,12 @@ export async function sendMessage(opts: { if (!resp.ok) { throw new Error(`POST /v1/messages → ${resp.status}: ${await resp.text()}`); } - return (await resp.json()) as { seq: number }; + const body = (await resp.json()) as { seq: number }; + // Server transport has no nostr-style event id. Use the server seq + // as the correlator — it's also unique-per-recipient + monotonic. + // Server-side acks aren't wired up yet so this is unused today; + // shape-compatibility with the nostr return is all that matters. + return { seq: body.seq, event_id: `server:${body.seq}` }; } // ───────────────────────────────────────────────────────────────────────────── @@ -166,6 +199,10 @@ export function streamInbox(opts: { handle: string; seed: Uint8Array; onMessage: (msg: InboxMessage) => void; + /** Stub on the server transport — there's no server-side ack + * mechanism yet, so this callback never fires. Kept in the shape + * for interface-compat with nostr-transport. */ + onAck?: (acked_event_id: string, ack_sig_hex?: string) => void; onStatus?: (status: "connecting" | "live" | "reconnecting") => void; }): StreamHandle { let es: EventSource | null = null; @@ -178,7 +215,10 @@ export function streamInbox(opts: { const auth = streamAuthQueryParam({ handle: opts.handle, seed: opts.seed }); const url = `${base()}/v1/inbox/${opts.handle}/stream?auth=${encodeURIComponent(auth)}`; es = new EventSource(url); - es.addEventListener("open", () => opts.onStatus?.("live")); + es.addEventListener("open", () => { + _setServerConnected(true); + opts.onStatus?.("live"); + }); es.addEventListener("message", (ev) => { try { const msg = JSON.parse(ev.data) as InboxMessage; @@ -192,6 +232,7 @@ export function streamInbox(opts: { // (avoid hot-loop if the server is rejecting). EventSource also // auto-reconnects but with the same (now possibly stale) URL. if (closed) return; + _setServerConnected(false); es?.close(); es = null; if (reconnectTimer) clearTimeout(reconnectTimer); @@ -204,6 +245,7 @@ export function streamInbox(opts: { return { close() { closed = true; + _setServerConnected(false); if (reconnectTimer) clearTimeout(reconnectTimer); es?.close(); }, @@ -214,3 +256,67 @@ export function streamInbox(opts: { } export type { SealedEnvelope, MessagePlaintext }; + +/** + * No-op stub on the server transport — there's no server-side + * ack/receipt protocol yet. Present so transport.ts can re-export + * a uniform surface and callers don't have to branch. + */ +export async function sendAck(_opts: { + ackingSeed: Uint8Array; + originalSenderPrimary: Identity; + ackedEventId: string; + originalSenderNostrPubkey?: string; + preferRelay?: string; +}): Promise { + /* server transport: receipts not implemented in v0.1 */ +} + +export async function flushPendingAcks(_seed: Uint8Array): Promise { + /* server transport: nothing to flush */ +} + +/** Server transport doesn't talk to nostr relays, so there's no AUTH + * challenge to handle. Stub kept for facade-parity with nostr. */ +export function attachSigner(_seed: Uint8Array): void { + /* server transport: no NIP-42 to answer */ +} + +export function detachSigner(): void { + /* server transport: no NIP-42 to answer */ +} + +export async function fetchAcksForEventIds( + _eventIds: string[], +): Promise> { + /* server transport: acks aren't published, nothing to find */ + return new Map(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Connection-state surface — kept symmetrical with nostr-transport so the +// UI (the "● live (N)" indicator + popover) doesn't have to branch on the +// active transport. The server transport has exactly one "relay" — the +// chat-server URL — and its connectedness is whatever the live SSE +// stream's readyState reports, tracked from inbox-service. +// ───────────────────────────────────────────────────────────────────────────── + +export interface RelayStatus { + url: string; + connected: boolean; +} + +const SERVER_URL = + (import.meta.env.VITE_API_BASE as string | undefined) || window.location.origin; + +/** Last-known SSE liveness. Bumped from streamInbox below so a poll of + * getRelayStatuses doesn't have to peek into EventSource internals. */ +let _serverConnected = false; + +export function getRelayStatuses(): RelayStatus[] { + return [{ url: SERVER_URL, connected: _serverConnected }]; +} + +export function _setServerConnected(v: boolean): void { + _serverConnected = v; +} diff --git a/kez-chat/web/src/lib/nostr-id.ts b/kez-chat/web/src/lib/nostr-id.ts index a6cd0b1..733818a 100644 --- a/kez-chat/web/src/lib/nostr-id.ts +++ b/kez-chat/web/src/lib/nostr-id.ts @@ -24,8 +24,14 @@ import type { Identity } from "./kez.js"; /** Regular event kind (1000–9999 → relays persist it, which the inbox needs). */ export const KEZ_DM_KIND = 4242; -/** Tag name carrying the recipient address. `#h` filter on the relay side. */ -export const ADDR_TAG = "h"; +/** Tag name carrying the recipient address. `#q` filter on the relay + * side. Used to be `h`, but NIP-29 (Simple Groups) treats `h` as a + * group id — a NIP-29-aware relay would try to interpret our routing + * tag as a group join and silently drop our events. `q` is a + * less-claimed single letter (still indexable per NIP-01). + * + * See kez-chat/TODO.md "Day 1 #5" for the migration plan. */ +export const ADDR_TAG = "q"; const SIGNKEY_SALT = new TextEncoder().encode("kez-chat:nostr-signkey"); const SIGNKEY_INFO = new TextEncoder().encode("v1"); diff --git a/kez-chat/web/src/lib/nostr-transport.ts b/kez-chat/web/src/lib/nostr-transport.ts index c3eaea7..0f2085b 100644 --- a/kez-chat/web/src/lib/nostr-transport.ts +++ b/kez-chat/web/src/lib/nostr-transport.ts @@ -15,6 +15,8 @@ // secp256k1 key derived from our ed25519 seed (see nostr-id.ts) purely so // relays accept them — that key is never surfaced to the user. +import { ed25519 } from "@noble/curves/ed25519"; +import { bytesToHex } from "@noble/hashes/utils"; import { SimplePool, finalizeEvent, type Event, type EventTemplate } from "nostr-tools"; import { sealMessage, type SealedEnvelope } from "./crypto.js"; import { lookup } from "./api.js"; @@ -26,41 +28,224 @@ import { decrypt, type InboxMessage, type StreamHandle } from "./messages.js"; export { decrypt }; export type { InboxMessage, StreamHandle }; -/** Relays to publish to / read from. Override with VITE_NOSTR_RELAYS (csv). */ +// ───────────────────────────────────────────────────────────────────────────── +// Connection-state surface — drives the "● live (N)" indicator and its +// "show me which relays" popover in the chat UI. +// ───────────────────────────────────────────────────────────────────────────── + +export interface RelayStatus { + url: string; + /** WebSocket is in OPEN state right now. */ + connected: boolean; +} + +/** + * Normalize a relay URL the same way nostr-tools does internally + * (see abstract-pool.js normalizeURL): coerce http→ws, strip default + * ports, trim duplicate + trailing slashes from the path. We need this + * because the pool's internal Map is keyed by the normalized form, so + * a lookup by the raw configured URL silently misses ("wss://nos.lol" + * vs the stored "wss://nos.lol/"). + */ +function normalizeRelayUrl(raw: string): string { + try { + let s = raw; + if (!s.includes("://")) s = "wss://" + s; + const u = new URL(s); + if (u.protocol === "http:") u.protocol = "ws:"; + else if (u.protocol === "https:") u.protocol = "wss:"; + u.pathname = u.pathname.replace(/\/+/g, "/"); + if (u.pathname.endsWith("/")) u.pathname = u.pathname.slice(0, -1); + if ( + (u.port === "80" && u.protocol === "ws:") || + (u.port === "443" && u.protocol === "wss:") + ) { + u.port = ""; + } + return u.toString(); + } catch { + return raw; + } +} + +/** Snapshot of every configured relay + whether its socket is currently + * open. Cheap to call — just reads from SimplePool's internal map. */ +export function getRelayStatuses(): RelayStatus[] { + // listConnectionStatus only returns URLs the pool has touched, so seed + // the map with our configured set first to show unconnected relays too. + const live = _pool ? _pool.listConnectionStatus() : new Map(); + return RELAYS.map((url) => { + const norm = normalizeRelayUrl(url); + // Try the normalized form first (canonical key the pool uses); fall + // back to the raw form just in case a future nostr-tools changes the + // normalization rules. + const connected = live.get(norm) === true || live.get(url) === true; + return { url, connected }; + }); +} + +/** Relays to publish to / read from. Override with VITE_NOSTR_RELAYS (csv). + * Default set: 3 long-running general-purpose relays (damus, nos, primal) + * + 2 popular extras (snort.social, nostr.wine) — chosen for redundancy + * and geographic diversity. If any single relay is slow/down our publish + * still succeeds as long as one accepts. */ const RELAYS: string[] = ( import.meta.env.VITE_NOSTR_RELAYS ?? - "wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net" + "wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr.wine" ) .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` + * wired up so NIP-42 AUTH challenges (damus.io, some other private + * relays) get signed transparently. Without this, DM subscriptions + * on AUTH-required relays silently get nothing. TODO.md Day 3 + * Option B #11. */ let _pool: SimplePool | null = null; +let _authSeed: Uint8Array | null = null; function pool(): SimplePool { - if (!_pool) _pool = new SimplePool(); + if (!_pool) _pool = buildPool(); return _pool; } +function buildPool(): SimplePool { + const seed = _authSeed; + const p = new SimplePool(); + // Enable per-event relay tracking so we can: + // • Tell the user WHICH relay accepted their message (UI hint) + // • Prefer the same relay for replies — likely the same relay + // was geographically/network-wise the fastest path; sticking + // with it for the reply usually shaves real latency. + // See `pool.seenOn.get(eventId)` for the read path. + p.trackRelays = true; + if (seed) { + // SimplePool's TS constructor only exposes a subset of options; + // the underlying AbstractSimplePool has `automaticallyAuth` as a + // public property. Assigning post-construction is the documented + // path for callers who want NIP-42 AUTH handling. + (p as unknown as { + automaticallyAuth?: ( + relayURL: string, + ) => null | ((event: EventTemplate) => Promise); + }).automaticallyAuth = () => async (template: EventTemplate) => { + const sk = nostrSecretFromSeed(seed); + return finalizeEvent(template, sk); + }; + } + return p; +} + +/** + * Attach the user's seed so the pool can answer NIP-42 AUTH + * challenges from relays that gate DM reads/writes behind it. + * Called from inbox-service when a session unlocks. Recreates the + * pool if we previously connected anonymously; cheap (only relays + * actually used will reconnect). + */ +export function attachSigner(seed: Uint8Array): void { + _authSeed = seed; + if (_pool) { + try { + _pool.destroy(); + } catch { + /* destroy might throw on a half-initialised pool; ignore */ + } + _pool = buildPool(); + } +} + +/** + * Look up the first relay we received a given event from. Returns + * undefined if `trackRelays` is off, the event id isn't known, or + * the underlying Map shape changes across nostr-tools versions. + * Used by the inbound message handler to remember which relay + * delivered each DM so we can reply over the same path. + */ +function firstRelayForEvent(eventId: string): string | undefined { + try { + const p = _pool as + | undefined + | null + | { seenOn?: Map> }; + const set = p?.seenOn?.get(eventId); + if (!set) return undefined; + for (const relay of set) { + if (relay?.url) return relay.url; + } + } catch { + /* ignore — best-effort */ + } + return undefined; +} + +/** Drop the signer (e.g. on lock). Pool stays alive but won't sign + * future AUTH challenges — equivalent to "anonymous client". */ +export function detachSigner(): void { + _authSeed = null; + if (_pool) { + try { + _pool.destroy(); + } catch { + /* ignore */ + } + _pool = null; + } +} + // ───────────────────────────────────────────────────────────────────────────── // Per-handle cursor + dedupe (localStorage, survives reloads) // ───────────────────────────────────────────────────────────────────────────── const SINCE_KEY = (h: string) => `kez-chat:nostr:since:${h}`; const SEEN_KEY = (h: string) => `kez-chat:nostr:seen:${h}`; -const SEEN_CAP = 500; +// Bumped from 500 → 10_000 (TODO.md Day 2 #2). At 500 ids, an active +// user rolled past the cap in days; once an id was evicted, a relay +// could re-broadcast the matching event and we'd accept it as fresh. +// 10k ids × ~70 bytes each (JSON-stringified hex) ≈ 700 KB, well +// inside localStorage's 5 MB ceiling and well above any user's +// realistic message volume in a meaningful window. +const SEEN_CAP = 10_000; -/** Relay `since` filter (unix seconds). Start a little in the past so a - * fresh device still catches very recent messages. */ +// Clock-skew tolerance against ev.created_at. A relay or a malicious +// publisher can backdate or future-date events to game our `since` +// cursor (jumping forward → skipping legit events; jumping back → +// reordering UI). Clamp to a reasonable window. +const CREATED_AT_MAX_PAST_SECS = 7 * 24 * 3600; // 7 days +const CREATED_AT_MAX_FUTURE_SECS = 5 * 60; // 5 minutes + +/** Drop events whose nostr-claimed timestamp is impossibly old or + * impossibly future. Caller treats `false` as "ignore this event". */ +function isCreatedAtSane(createdAt: number): boolean { + const now = Math.floor(Date.now() / 1000); + if (createdAt < now - CREATED_AT_MAX_PAST_SECS) return false; + if (createdAt > now + CREATED_AT_MAX_FUTURE_SECS) return false; + return true; +} + +/** Relay `since` filter (unix seconds). Default = 48h back on first + * use of a handle — chosen because most public relays only retain + * events for 1–3 days; a longer cursor silently misses anything + * the relay has already evicted. Returning user sessions use the + * persisted cursor (last seen `created_at`) and so are unaffected. + * TODO.md Day 3 #14. */ +const DEFAULT_SINCE_LOOKBACK_SECS = 48 * 3600; function readSince(handle: string): number { try { const v = localStorage.getItem(SINCE_KEY(handle)); - return v ? parseInt(v, 10) : Math.floor(Date.now() / 1000) - 7 * 24 * 3600; + return v + ? parseInt(v, 10) + : Math.floor(Date.now() / 1000) - DEFAULT_SINCE_LOOKBACK_SECS; } catch { return 0; } } function bumpSince(handle: string, createdAt: number) { + // Don't let an out-of-bounds created_at advance `since` — that + // would skip future legit events (TODO.md Day 2 #2). Caller is + // responsible for the dedupe; this protects the cursor. + if (!isCreatedAtSane(createdAt)) return; try { if (createdAt > readSince(handle)) { localStorage.setItem(SINCE_KEY(handle), String(createdAt)); @@ -118,16 +303,52 @@ function toInboxMessage(ev: Event): InboxMessage | null { // Send // ───────────────────────────────────────────────────────────────────────────── +/** + * Order relays for this send: any preferred relay (typically: the + * relay that delivered the most recent inbound message from this + * peer) goes first, then the rest of the configured set, deduped. + * Putting a preferred relay first means the WebSocket open + publish + * race usually completes against it — same network path the inbound + * message came in on, so geographically/network-wise the fastest. + */ +function orderedRelaysForSend(preferRelay?: string): string[] { + if (!preferRelay) return RELAYS; + const norm = preferRelay.replace(/\/$/, ""); + const matches = RELAYS.filter((r) => r.replace(/\/$/, "") === norm); + const others = RELAYS.filter((r) => r.replace(/\/$/, "") !== norm); + return [...matches, ...others]; +} + export async function sendMessage(opts: { senderHandle: string; senderSeed: Uint8Array; senderPrimary: Identity; recipient: string; body: string; -}): Promise<{ seq: number }> { + /** Optional relay URL to publish to FIRST — typically the one the + * recipient's last message arrived on. Falls back to the full + * configured relay set if unset or unknown. */ + preferRelay?: string; + /** + * Recipient's KEZ primary, if the caller already has it (e.g. from + * the conversation row). Lets us skip the `/v1/u/:handle` lookup + * against the chat-server entirely — important because chat over + * nostr should NOT depend on the chat-server for delivery. Only + * brand-new conversations (no cached primary) still need lookup. + */ + recipientPrimary?: Identity; +}): Promise<{ seq: number; event_id: string; accepted_by?: string }> { const recipientHandle = opts.recipient.split("@")[0]; - const record = await lookup(recipientHandle); // throws on 404 - const recipientPrimary = record.primary as Identity; + // Skip the server lookup if the caller already has the primary + // cached. The server going down should not break sending to + // existing contacts — nostr is the actual delivery pipe. + let recipientPrimary: Identity; + if (opts.recipientPrimary) { + recipientPrimary = opts.recipientPrimary; + } else { + const record = await lookup(recipientHandle); // throws on 404 / server down + recipientPrimary = record.primary as Identity; + } // Our own encryption layer — identical to the server transport. const envelope = await sealMessage({ @@ -147,8 +368,25 @@ export async function sendMessage(opts: { }; const signed = finalizeEvent(tmpl, sk); - // Succeed if at least one relay accepts. - const results = await Promise.allSettled(pool().publish(RELAYS, signed)); + // Publish to all relays in order; succeed if any accepts. We also + // record which relay accepted first so the UI can show "sent via + // X" and so the next message in this thread can prefer the same + // relay. + const ordered = orderedRelaysForSend(opts.preferRelay); + const publishPromises = pool().publish(ordered, signed); + // Promise.any returns the first-fulfilled value; if every relay + // rejects, it throws AggregateError. We still want best-effort + // success on the all-fulfilled path, so we wrap with allSettled + // afterwards to gate the throw. + let acceptedBy: string | undefined; + try { + acceptedBy = await Promise.any( + publishPromises.map((p, i) => p.then(() => ordered[i])), + ); + } catch { + /* nobody accepted yet — wait for the full set */ + } + const results = await Promise.allSettled(publishPromises); if (!results.some((r) => r.status === "fulfilled")) { const why = results .map((r) => (r.status === "rejected" ? String(r.reason) : "")) @@ -156,7 +394,231 @@ export async function sendMessage(opts: { .join("; "); throw new Error(`no relay accepted the message${why ? `: ${why}` : ""}`); } - return { seq: signed.created_at }; + return { seq: signed.created_at, event_id: signed.id, accepted_by: acceptedBy }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Delivery receipts (kez-DM-ack, kind 4244) +// ───────────────────────────────────────────────────────────────────────────── +// +// Format: +// { kind: KEZ_ACK_KIND, +// created_at: now, +// pubkey: , +// content: "", // no body — id-only ack +// tags: [ +// ["h", ], // so sender can subscribe + filter +// ["e", ], // links back to the original DM +// ] } +// +// Trust model: signed by the recipient's nostr key (deterministic from +// their KEZ seed via the same HKDF). A third party who saw the +// original event id could forge an ack and cause the sender's UI to +// show "delivered" — a minor cosmetic spoof, not a confidentiality +// break. v0.1 trusts whoever sends an ack. v0.2: verify the ack's +// pubkey matches the recipient identity we expected. + +/** Distinct kind for delivery receipts. Inside the regular-event + * range (1000-9999) so relays persist them like normal DMs. */ +export const KEZ_ACK_KIND = 4244; + +/** + * Persistent pending-ack queue. If every relay rejects an ack send + * (offline, transient flap, AUTH required, etc.), we save the request + * to localStorage and retry on next session start. Without this, a + * single bad moment at decrypt-time means the sender's UI is stuck + * on "sent" (single check) forever for that message. + */ +const PENDING_ACKS_KEY = "kez-chat:pending-acks:v1"; + +interface PendingAck { + /** Hex of the sender's KEZ primary — restored at retry time. */ + originalSenderPrimary: Identity; + /** Event id of the DM we never managed to ack. */ + ackedEventId: string; + /** Nostr pubkey of the original sender — added in Day 3 Option B + * so the retried ack carries the same NIP-25 `p` tag the live + * send did. Optional for pre-Option-B queue entries. */ + originalSenderNostrPubkey?: string; + /** Wall-clock of the first attempt (ISO). Lets us age out very + * old pending acks so the queue doesn't grow forever. */ + queued_at: string; +} + +function readPendingAcks(): PendingAck[] { + try { + return JSON.parse(localStorage.getItem(PENDING_ACKS_KEY) ?? "[]"); + } catch { + return []; + } +} +function writePendingAcks(list: PendingAck[]) { + try { + // Cap to 200 to bound localStorage growth in pathological cases. + const capped = list.slice(-200); + localStorage.setItem(PENDING_ACKS_KEY, JSON.stringify(capped)); + } catch { + /* private mode */ + } +} + +/** + * Sign the acked event id with the recipient's KEZ ed25519 key so the + * sender can prove the ack actually came from the intended recipient + * (and not a third party who happened to see the original event id). + * TODO.md Day 3 #9. + * + * Format: `["kez-sig", hex(ed25519.sign(seed, utf8(ackedEventId)))]` + * The tag name is multi-letter so it's filterable but not indexed — + * indexing is for routing, this is for verification. + */ +function buildAckSigTag(seed: Uint8Array, ackedEventId: string): string[] { + const msg = new TextEncoder().encode(ackedEventId); + const sig = ed25519.sign(msg, seed); + return ["kez-sig", bytesToHex(sig)]; +} + +export async function sendAck(opts: { + /** Recipient's own seed — signs the ack with the recipient's nostr key. */ + ackingSeed: Uint8Array; + /** KEZ primary of the person who sent the original message. */ + originalSenderPrimary: Identity; + /** Event id of the original kez-DM event we're acking. */ + ackedEventId: string; + /** Nostr pubkey of the original sender (= event.pubkey of the DM + * we're acking). Used to populate the NIP-25-shape `["p", ...]` + * tag, which relays use for inbox routing of reactions/acks. + * Optional for callers that don't have it; we omit the p-tag in + * that case. TODO.md Day 3 Option B #13. */ + originalSenderNostrPubkey?: string; + /** Relay the DM arrived on — used to bias the ack publish toward + * the same relay, since that path was demonstrably working for + * this peer. Falls back to the full set if unset. */ + preferRelay?: string; +}): Promise { + const sk = nostrSecretFromSeed(opts.ackingSeed); + const targetAddr = addrFromPrimary(opts.originalSenderPrimary); + const tags: string[][] = [ + [ADDR_TAG, targetAddr], + ["e", opts.ackedEventId], + buildAckSigTag(opts.ackingSeed, opts.ackedEventId), + ]; + if (opts.originalSenderNostrPubkey) { + // NIP-25 / NIP-10 convention: an `e`-tagged response includes the + // author's pubkey as a `p` tag. Lets nostr clients route the + // reaction to the original author's "mentions" feed for free. + tags.push(["p", opts.originalSenderNostrPubkey]); + } + const tmpl: EventTemplate = { + kind: KEZ_ACK_KIND, + created_at: Math.floor(Date.now() / 1000), + tags, + content: "", + }; + const signed = finalizeEvent(tmpl, sk); + const ordered = orderedRelaysForSend(opts.preferRelay); + const results = await Promise.allSettled(pool().publish(ordered, signed)); + const acceptedSomewhere = results.some((r) => r.status === "fulfilled"); + if (!acceptedSomewhere) { + // Stash for retry. Never throw — the caller is the inbox-service + // decrypt path, and a publish failure shouldn't break ingest. + const queue = readPendingAcks(); + queue.push({ + originalSenderPrimary: opts.originalSenderPrimary, + ackedEventId: opts.ackedEventId, + originalSenderNostrPubkey: opts.originalSenderNostrPubkey, + queued_at: new Date().toISOString(), + }); + writePendingAcks(queue); + } +} + +/** + * On session start, walk the pending-ack queue and re-attempt each + * one. Drops any older than 7 days (sender's UI has long since + * "forgotten" about them — at that point the sender either reloaded + * and re-fetched via the catch-up scan, or has moved on). Idempotent: + * acks that finally succeed are removed; the rest stay queued for + * the next session. + */ +export async function flushPendingAcks(seed: Uint8Array): Promise { + const queue = readPendingAcks(); + if (queue.length === 0) return; + const sevenDaysMs = 7 * 24 * 60 * 60 * 1000; + const fresh = queue.filter((q) => { + return Date.now() - new Date(q.queued_at).getTime() < sevenDaysMs; + }); + const stillPending: PendingAck[] = []; + for (const item of fresh) { + try { + const sk = nostrSecretFromSeed(seed); + const targetAddr = addrFromPrimary(item.originalSenderPrimary); + const retryTags: string[][] = [ + [ADDR_TAG, targetAddr], + ["e", item.ackedEventId], + buildAckSigTag(seed, item.ackedEventId), + ]; + if (item.originalSenderNostrPubkey) { + retryTags.push(["p", item.originalSenderNostrPubkey]); + } + const tmpl: EventTemplate = { + kind: KEZ_ACK_KIND, + created_at: Math.floor(Date.now() / 1000), + tags: retryTags, + content: "", + }; + const signed = finalizeEvent(tmpl, sk); + const results = await Promise.allSettled(pool().publish(RELAYS, signed)); + const ok = results.some((r) => r.status === "fulfilled"); + if (!ok) stillPending.push(item); + } catch { + stillPending.push(item); + } + } + writePendingAcks(stillPending); +} + +/** + * Catch-up scan for missed acks. Given a list of recently-sent + * event ids the sender is still waiting on, query relays for any + * kind-4244 events with a matching `["e", id]` tag and return the + * set of ids that have been acked. The sender's UI can then flip + * those bubbles to "delivered" without waiting for the live stream + * to redeliver the acks. + * + * Used by Messages.svelte on conversation-list mount and any time + * the user opens a thread — both moments where seeing the right + * checkmark state matters more than CPU. + */ +export async function fetchAcksForEventIds( + eventIds: string[], +): Promise> { + if (eventIds.length === 0) return new Map(); + try { + // nostr-tools filter: `#e` is the indexed `e` tag values. + const events = await pool().querySync(RELAYS, { + kinds: [KEZ_ACK_KIND], + "#e": eventIds, + }); + // Map event_id → (optional) kez-sig hex. Caller verifies the sig + // against the conversation peer's KEZ primary before flipping + // bubble state — same path as the live-stream onAck handler. + const found = new Map(); + for (const ev of events) { + const id = ev.tags.find((t) => t[0] === "e")?.[1]; + if (!id) continue; + const sig = ev.tags.find((t) => t[0] === "kez-sig")?.[1]; + // If two acks for the same id arrive (replays), prefer the + // one with a sig over the one without. + const existing = found.get(id); + if (existing !== undefined && sig === undefined) continue; + found.set(id, sig); + } + return found; + } catch (e) { + console.warn("fetchAcksForEventIds failed:", e); + return new Map(); + } } // ───────────────────────────────────────────────────────────────────────────── @@ -183,9 +645,19 @@ export async function pollInbox(opts: { const messages: InboxMessage[] = []; let maxSeq = 0; for (const ev of events.sort((a, b) => a.created_at - b.created_at)) { + if (!isCreatedAtSane(ev.created_at)) continue; if (!markSeen(opts.handle, ev.id)) continue; const m = toInboxMessage(ev); if (!m) continue; + // Stash the same correlator fields the streamInbox path sets, so + // messages caught up via heartbeat poll ALSO trigger delivery + // acks AND let the inbox-service learn the sender's nostr + // pubkey for peer-profile fetches. (Earlier code only did this + // on the live stream — polled messages went un-acked, which + // showed up as the "no check-in-circle" report from users.) + m.event_id = ev.id; + m.sender_nostr_pubkey = ev.pubkey; + m.via_relay = firstRelayForEvent(ev.id); messages.push(m); bumpSince(opts.handle, ev.created_at); if (m.seq > maxSeq) maxSeq = m.seq; @@ -201,6 +673,13 @@ export function streamInbox(opts: { handle: string; seed: Uint8Array; onMessage: (msg: InboxMessage) => void; + /** Fires when the recipient publishes an ack for one of OUR outbound + * messages — used to flip the bubble status from "sent" to + * "delivered". The second arg is the recipient's hex ed25519 + * signature over the acked event id (TODO.md Day 3 #9); the caller + * verifies it against the conversation peer's KEZ primary. May + * be undefined for legacy acks during the transition window. */ + onAck?: (acked_event_id: string, ack_sig_hex?: string) => void; onStatus?: (status: "connecting" | "live" | "reconnecting") => void; }): StreamHandle { const myPrimary = identityFromSeed(opts.seed).identity; @@ -210,14 +689,50 @@ export function streamInbox(opts: { opts.onStatus?.("connecting"); const sub = pool().subscribeMany( RELAYS, - { kinds: [KEZ_DM_KIND], [`#${ADDR_TAG}`]: [addr], since: readSince(opts.handle) }, + { + // Subscribe to BOTH the regular DM events addressed to us AND + // the ack events for messages we sent — single subscription + // keeps relay connection count + bandwidth flat. + kinds: [KEZ_DM_KIND, KEZ_ACK_KIND], + [`#${ADDR_TAG}`]: [addr], + since: readSince(opts.handle), + }, { onevent(ev: Event) { if (closed) return; + // Reject events with impossibly old or future timestamps — + // a relay or malicious publisher can backdate to replay an + // old event, or future-date to game the `since` cursor and + // hide subsequent legit events. TODO.md Day 2 #2. + if (!isCreatedAtSane(ev.created_at)) return; if (!markSeen(opts.handle, ev.id)) return; + bumpSince(opts.handle, ev.created_at); + if (ev.kind === KEZ_ACK_KIND) { + // Pull the `e` tag value — the original event id this acks + // — and the `kez-sig` tag, the recipient's ed25519 signature + // over the event id (so the sender can verify the ack is + // unforgeable, TODO.md Day 3 #9). Old-build acks lack the + // sig tag; we accept those during the transition window + // since v0.1 didn't have unforgeable acks at all. + const ackedId = ev.tags.find((t) => t[0] === "e")?.[1]; + const sigHex = ev.tags.find((t) => t[0] === "kez-sig")?.[1]; + if (ackedId) opts.onAck?.(ackedId, sigHex); + return; + } const m = toInboxMessage(ev); if (!m) return; - bumpSince(opts.handle, ev.created_at); + // For DM events we forward the InboxMessage AND stash: + // • event_id — used to correlate the recipient's ack back + // to this message so the sender's UI can flip to ✓◯ + // • sender_nostr_pubkey — kept so we can later fetch the + // sender's NIP-01 kind:0 profile (used for peer avatars, + // including descrambling visually-encrypted pictures). + // • via_relay — the FIRST relay we saw this event on, used + // to bias the reply publish toward the same path + // (typically the geographically/network-wise fastest one). + m.event_id = ev.id; + m.sender_nostr_pubkey = ev.pubkey; + m.via_relay = firstRelayForEvent(ev.id); opts.onMessage(m); }, oneose() { diff --git a/kez-chat/web/src/lib/peer-profile-cell.svelte.ts b/kez-chat/web/src/lib/peer-profile-cell.svelte.ts new file mode 100644 index 0000000..3ca48fc --- /dev/null +++ b/kez-chat/web/src/lib/peer-profile-cell.svelte.ts @@ -0,0 +1,59 @@ +// Svelte 5 reactive mirror of peer-profile-store.ts. Components read +// `peerProfiles.byPrimary[peer_primary]?.picture` and re-render +// automatically when a fetch completes. +// +// This is a separate file from peer-profile-store.ts because +// .svelte.ts files get the runes transform applied, and we want the +// store layer (which is also imported by non-component code) to stay +// rune-free. + +import { + fetchPeerProfile, + getCachedPeerProfile, + hydratePeerProfileCache, + type CachedPeerProfile, +} from "./peer-profile-store.js"; +import type { Identity } from "./kez.js"; + +class PeerProfilesCell { + /** primary → CachedPeerProfile. Reactive: avatar consumers read + * `peerProfiles.byPrimary[primary]?.picture`. */ + byPrimary = $state>({}); + /** True once IDB-backed mirror has loaded once. */ + hydrated = $state(false); + + async hydrate() { + if (this.hydrated) return; + await hydratePeerProfileCache(); + // Pull every entry into the reactive map. Cheap — at most ~hundreds + // of profiles per active user. + this.hydrated = true; + } + + /** Trigger a fetch + cache update for a single peer. Idempotent + * within the staleness window. Returns the resolved profile if + * one was found (cached or fresh). */ + async refresh(opts: { + peer_primary: Identity; + peer_nostr_pubkey: string; + my_handle: string; + my_seed: Uint8Array; + forceRefresh?: boolean; + }): Promise { + const result = await fetchPeerProfile(opts); + if (result) { + // Mutate the record so Svelte 5's deep reactivity ticks. + this.byPrimary = { ...this.byPrimary, [opts.peer_primary]: result }; + } else { + // Maybe an in-memory cache entry from a previous run that + // wasn't surfaced yet — bring it through. + const cached = getCachedPeerProfile(opts.peer_primary); + if (cached && !this.byPrimary[opts.peer_primary]) { + this.byPrimary = { ...this.byPrimary, [opts.peer_primary]: cached }; + } + } + return result; + } +} + +export const peerProfiles = new PeerProfilesCell(); diff --git a/kez-chat/web/src/lib/peer-profile-store.ts b/kez-chat/web/src/lib/peer-profile-store.ts new file mode 100644 index 0000000..4440c6e --- /dev/null +++ b/kez-chat/web/src/lib/peer-profile-store.ts @@ -0,0 +1,222 @@ +// Peer-profile cache. +// +// When we render a conversation row or thread header, we want to show +// the peer's avatar (the profile picture they set in their own Settings, +// possibly visually-encrypted with a key they wrapped for us). This +// module: +// +// 1. Fetches the peer's NIP-01 kind:0 metadata event from nostr +// 2. Looks for a `kez_visual_keys[]` wrap inside the +// content; if present, unwraps the visual key via the same +// SealedEnvelope crypto we use for DMs +// 3. Descrambles `metadata.picture` with that key +// 4. Caches the result so re-renders are instant +// +// What strangers see when they fetch the same kind:0: +// • A scrambled PNG (looks like colored noise — same dimensions, +// same histogram, no recognisable content) +// • The `kez_visual_keys` map, none of which they can open (each +// wrap is encrypted to a specific recipient via ECDH) +// +// What we (a contact) see: +// • The real picture, because the peer explicitly wrapped the +// descramble key for us +// +// Cache invalidation: any cached entry older than 24 h is re-fetched +// on access (peers may have updated their picture). + +import { get, set } from "idb-keyval"; +import { hexToBytes } from "@noble/hashes/utils"; +import { SimplePool } from "nostr-tools"; + +import { openMessage, type SealedEnvelope } from "./crypto.js"; +import { identityFromSeed } from "./kez.js"; +import type { Identity } from "./kez.js"; +import { unscrambleImage } from "./visual-crypto.js"; + +const CACHE_KEY = "kez-chat:peer-profiles:v1"; +// Bulk-scan refresh window — when /chats first paints we re-fetch +// every peer whose cached entry is older than this. 6h hits the +// sweet spot: noticeable freshness for users who update their +// picture, without spamming relays on every reload. Per-peer +// `forceRefresh: true` short-circuits this gate. +const STALE_AFTER_MS = 6 * 60 * 60 * 1000; +const FETCH_TIMEOUT_MS = 8000; + +/** Same default-relay list as nostr-transport.ts. */ +const RELAYS: string[] = ( + (import.meta.env.VITE_NOSTR_RELAYS as string | undefined) ?? + "wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr.wine" +) + .split(",") + .map((r) => r.trim()) + .filter(Boolean); + +export interface CachedPeerProfile { + /** The KEZ primary key — canonical identifier; never changes. */ + peer_primary: Identity; + /** Display name from the kind:0 metadata. May equal the handle. */ + name?: string; + /** "about" / bio from the kind:0 metadata. */ + about?: string; + /** + * The RENDERABLE picture data URL (descrambled if the peer's + * picture was encrypted and we held the key; cleartext otherwise). + * Absent when the peer hasn't set a picture, OR when the peer + * encrypted it but didn't wrap a key for us yet. + */ + picture?: string; + /** True if we descrambled an encrypted picture vs. read a + * cleartext one. UI badges off this. */ + picture_was_encrypted?: boolean; + /** Wall clock of the last successful fetch. */ + fetched_at: string; + /** Event id of the kind:0 we descrambled — debug breadcrumb. */ + source_event_id?: string; +} + +type Cache = Record; + +async function readCache(): Promise { + return (await get(CACHE_KEY)) ?? {}; +} + +async function writeCache(c: Cache): Promise { + await set(CACHE_KEY, c); +} + +/** Synchronous (in-memory) snapshot of the IDB-backed cache, for + * fast component reads. Hydrated by `hydratePeerProfileCache()` + * on app boot. */ +const memCache: Cache = {}; + +/** Read all cached peer profiles into the in-memory mirror so the + * UI can render immediately on first paint without an IDB hop. */ +export async function hydratePeerProfileCache(): Promise { + const c = await readCache(); + for (const k of Object.keys(c)) memCache[k] = c[k]; +} + +export function getCachedPeerProfile( + peer_primary: Identity, +): CachedPeerProfile | undefined { + return memCache[peer_primary]; +} + +/** + * Fetch (or refresh) the peer's kind:0 profile. Returns the cached + * profile (whether freshly fetched or re-used from a recent cache + * entry). Pass `forceRefresh: true` to ignore the 24h staleness gate. + * + * Failures are silent — the function logs and returns undefined; the + * UI falls back to the identicon. We never want a missing profile to + * break the chat experience. + */ +export async function fetchPeerProfile(opts: { + peer_primary: Identity; + peer_nostr_pubkey: string; + my_handle: string; + my_seed: Uint8Array; + forceRefresh?: boolean; +}): Promise { + // Cache hit? + const cached = memCache[opts.peer_primary]; + if (cached && !opts.forceRefresh) { + const age = Date.now() - new Date(cached.fetched_at).getTime(); + if (age < STALE_AFTER_MS) return cached; + } + + // Fetch fresh. Use a one-shot pool we close after, so we don't + // hold sockets open per peer — the main pool stays in + // nostr-transport.ts for the live DM subscription. + const pool = new SimplePool(); + try { + const events = await Promise.race([ + pool.querySync(RELAYS, { + kinds: [0], + authors: [opts.peer_nostr_pubkey], + limit: 5, // grab a few in case relays disagree on `latest` + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error("fetchPeerProfile timed out")), FETCH_TIMEOUT_MS), + ), + ]); + if (!events.length) return cached; // nothing to update + const latest = events.sort((a, b) => b.created_at - a.created_at)[0]; + + let metadata: Record; + try { + metadata = JSON.parse(latest.content); + } catch (e) { + console.warn(`peer-profile: bad kind:0 content for ${opts.peer_primary}`, e); + return cached; + } + + const profile: CachedPeerProfile = { + peer_primary: opts.peer_primary, + name: typeof metadata.name === "string" ? metadata.name : undefined, + about: typeof metadata.about === "string" ? metadata.about : undefined, + fetched_at: new Date().toISOString(), + source_event_id: latest.id, + }; + + // ─── descramble path ──────────────────────────────────────── + // The peer's kind:0 may carry a visually-encrypted picture + + // per-recipient key wraps. The map is indexed by recipient + // KEZ primary, so we look up OUR primary directly (much + // cheaper than openMessage'ing every wrap to find ours). + if ( + metadata.kez_visual_v1 === true && + typeof metadata.picture === "string" && + metadata.kez_visual_keys && + typeof metadata.kez_visual_keys === "object" + ) { + const wraps = metadata.kez_visual_keys as Record; + const myPrimary = identityFromSeed(opts.my_seed).identity; + const wrapBlob = wraps[myPrimary]; + if (wrapBlob) { + try { + const env = wrapBlob as SealedEnvelope; + const plaintext = await openMessage({ + envelope: env, + myHandle: opts.my_handle, + mySeed: opts.my_seed, + }); + const parsed = JSON.parse(plaintext.body) as { visual_key?: string }; + if (parsed.visual_key) { + const keyBytes = hexToBytes(parsed.visual_key); + const descrambled = await unscrambleImage(metadata.picture, keyBytes); + profile.picture = descrambled; + profile.picture_was_encrypted = true; + } + } catch (e) { + // Wrap exists but didn't open — log once for diagnostics + // but don't fail the fetch; the user just falls back to + // the identicon for this peer. + console.warn( + `peer-profile: descramble failed for ${opts.peer_primary}`, + e, + ); + } + } + // No wrap for us → the peer hasn't given our contact key + // access to this picture yet. Strangers fall through here too. + } else if (typeof metadata.picture === "string") { + // Cleartext picture path. + profile.picture = metadata.picture; + profile.picture_was_encrypted = false; + } + + // Persist + mirror. + const c = await readCache(); + c[opts.peer_primary] = profile; + await writeCache(c); + memCache[opts.peer_primary] = profile; + return profile; + } catch (e) { + console.warn(`peer-profile: fetch failed for ${opts.peer_primary}`, e); + return cached; + } finally { + pool.close(RELAYS); + } +} diff --git a/kez-chat/web/src/lib/persistent-session.ts b/kez-chat/web/src/lib/persistent-session.ts new file mode 100644 index 0000000..cdcf763 --- /dev/null +++ b/kez-chat/web/src/lib/persistent-session.ts @@ -0,0 +1,306 @@ +// Long-lived session persistence. +// +// Problem: the in-memory `session.unlocked` reactive store is lost +// whenever the tab process dies — and on Android Chrome a backgrounded +// PWA gets killed aggressively. That means re-typing the passphrase +// every time you bring kez-chat back to the foreground, which makes +// Web Push effectively useless (the user is never in a state where the +// push could even arrive without first re-unlocking). +// +// Fix: on successful unlock, encrypt the 32-byte seed under a fresh +// AES-GCM key that lives in IndexedDB as a **non-extractable** CryptoKey. +// The wrapped blob + an expiry timestamp go into localStorage. On boot, +// if the entry is still fresh, we open the CryptoKey, decrypt the blob, +// and rebuild the session — zero user interaction. +// +// Trust model: +// • The CryptoKey is marked non-extractable: WebCrypto refuses to +// export it. An attacker who copies the IDB file off-device can't +// decrypt the blob, because they can't move the key with it. +// • An attacker who can run JS in the origin (root device, malicious +// extension) CAN call decrypt. So this is no weaker than the +// biometric-unlock path that already ships — and stronger than +// plaintext sessionStorage which we deliberately don't use. +// • Explicit Lock blows away both the key and the localStorage entry. +// • TTL caps damage: a stolen device 31 days later won't auto-unlock. +// +// Sliding window: every time the SPA boots and successfully auto-unlocks, +// we bump the expiry forward. So an active user effectively never sees a +// passphrase prompt; an inactive user is asked again after 30 days. + +import type { UnlockedIdentity } from "./identity-store.js"; +import type { Identity } from "./kez.js"; + +const DB_NAME = "kez-chat-session"; +const DB_VERSION = 1; +const STORE_NAME = "keys"; +const KEY_ID = "session-aes"; + +const LS_KEY = "kez-chat:session-blob:v1"; + +/** How long an unlocked session survives before re-prompting for the + * passphrase. Slides forward every time the user actually opens the + * app, so this is effectively only a "I haven't touched it in 30 days" + * guard. Tune via `setSessionTtl()` if you want shorter/longer. */ +const DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000; + +interface PersistedBlob { + /** Schema marker so we can evolve the format. */ + v: 1; + /** The non-extractable CryptoKey's IDB key. Lets us evolve to per-handle + * keys later without breaking older blobs. */ + keyId: string; + /** Unix ms after which this blob must NOT be auto-decrypted, regardless + * of whether the AES key is still usable. */ + expiresAt: number; + handle: string; + server: string; + primary: string; // Identity in string form ("ed25519:hex") + iv: string; // base64 (no padding) of the 12-byte AES-GCM IV + ciphertext: string; // base64 (no padding) of seed ciphertext + tag + /** Optional encrypted recovery phrase, so the Settings "reveal phrase" + * path keeps working without a fresh passphrase prompt. Encrypted under + * the same AES key but with a distinct IV. */ + phraseIv?: string; + phraseCiphertext?: string; +} + +// ─── IndexedDB helpers ───────────────────────────────────────────────────── + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +function idbGet(key: string): Promise { + return openDb().then( + (db) => + new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readonly"); + const req = tx.objectStore(STORE_NAME).get(key); + req.onsuccess = () => resolve(req.result as T | undefined); + req.onerror = () => reject(req.error); + }), + ); +} + +function idbPut(key: string, value: unknown): Promise { + return openDb().then( + (db) => + new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + tx.objectStore(STORE_NAME).put(value, key); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }), + ); +} + +function idbDelete(key: string): Promise { + return openDb().then( + (db) => + new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + tx.objectStore(STORE_NAME).delete(key); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }), + ); +} + +// ─── base64 (small, dependency-free) ─────────────────────────────────────── + +function b64(bytes: Uint8Array): string { + let bin = ""; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin).replace(/=+$/, ""); +} +function fromB64(s: string): Uint8Array { + const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4)); + const bin = atob(s + pad); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +// ─── AES key lifecycle ───────────────────────────────────────────────────── + +/** Get the session AES key, generating + persisting it on first use. */ +async function getOrCreateKey(): Promise { + const existing = await idbGet(KEY_ID); + if (existing) return existing; + // The KEY argument `extractable=false` is the whole security story: + // even raw filesystem access to the IDB cannot pull this key out. + const key = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + /* extractable */ false, + ["encrypt", "decrypt"], + ); + await idbPut(KEY_ID, key); + return key; +} + +// ─── public API ──────────────────────────────────────────────────────────── + +/** + * Persist the current unlocked session so a later page load (or app + * relaunch after Android killed the PWA) can restore it without + * re-prompting for the passphrase. Idempotent — call after every + * unlock; safe to call again on visibility-change to bump the + * sliding-window expiry. + */ +export async function persistSession( + id: UnlockedIdentity, + ttlMs: number = DEFAULT_TTL_MS, +): Promise { + try { + const key = await getOrCreateKey(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + // Casts side-step a TS DOM-lib quirk where the strict + // ArrayBufferView type doesn't accept our generic + // Uint8Array; the runtime is happy with both. + const seedCt = new Uint8Array( + await crypto.subtle.encrypt( + { name: "AES-GCM", iv: iv as BufferSource }, + key, + id.seed as BufferSource, + ), + ); + + let phraseIv: string | undefined; + let phraseCt: string | undefined; + if (id.phrase) { + const pIv = crypto.getRandomValues(new Uint8Array(12)); + const pCt = new Uint8Array( + await crypto.subtle.encrypt( + { name: "AES-GCM", iv: pIv as BufferSource }, + key, + new TextEncoder().encode(id.phrase) as BufferSource, + ), + ); + phraseIv = b64(pIv); + phraseCt = b64(pCt); + } + + const blob: PersistedBlob = { + v: 1, + keyId: KEY_ID, + expiresAt: Date.now() + ttlMs, + handle: id.handle, + server: id.server, + primary: id.primary, + iv: b64(iv), + ciphertext: b64(seedCt), + phraseIv, + phraseCiphertext: phraseCt, + }; + localStorage.setItem(LS_KEY, JSON.stringify(blob)); + } catch (e) { + // Never let session persistence failure break the unlock flow. + // Worst case: user has the same short session they had before. + console.warn("persistSession failed (continuing unauthenticated-persist):", e); + } +} + +/** + * Try to restore a previously persisted session. Returns the + * unlocked identity or null if there's nothing to restore (no blob, + * expired, key missing, or decrypt failed). Bumps the expiry on + * success so an active user effectively never re-prompts. + */ +export async function restoreSession(): Promise { + let parsed: PersistedBlob | null = null; + try { + const raw = localStorage.getItem(LS_KEY); + if (!raw) return null; + parsed = JSON.parse(raw) as PersistedBlob; + if (parsed.v !== 1) return null; + if (Date.now() > parsed.expiresAt) { + // TTL elapsed — drop the blob; user will be prompted for the passphrase. + await clearPersistedSession(); + return null; + } + const key = await idbGet(parsed.keyId); + if (!key) { + // Key is gone (user wiped browser data, profile mismatch, etc.); + // blob is unusable — drop it. + await clearPersistedSession(); + return null; + } + const seedBytes = new Uint8Array( + await crypto.subtle.decrypt( + { name: "AES-GCM", iv: fromB64(parsed.iv) as BufferSource }, + key, + fromB64(parsed.ciphertext) as BufferSource, + ), + ); + + let phrase: string | undefined; + if (parsed.phraseIv && parsed.phraseCiphertext) { + try { + const phraseBytes = new Uint8Array( + await crypto.subtle.decrypt( + { name: "AES-GCM", iv: fromB64(parsed.phraseIv) as BufferSource }, + key, + fromB64(parsed.phraseCiphertext) as BufferSource, + ), + ); + phrase = new TextDecoder().decode(phraseBytes); + } catch { + // Phrase decrypt failure is non-fatal; seed unlocked OK. + } + } + + const restored: UnlockedIdentity = { + handle: parsed.handle, + server: parsed.server, + primary: parsed.primary as Identity, + seed: seedBytes, + phrase, + }; + // Sliding window: every successful auto-unlock bumps the expiry. + await persistSession(restored); + return restored; + } catch (e) { + console.warn("restoreSession failed:", e); + return null; + } +} + +/** + * Wipe the persisted session — invoked by explicit Lock, and also by + * restoreSession's expired-TTL / key-missing branches. Safe to call + * with nothing persisted. + */ +export async function clearPersistedSession(): Promise { + try { + localStorage.removeItem(LS_KEY); + await idbDelete(KEY_ID); + } catch (e) { + console.warn("clearPersistedSession failed:", e); + } +} + +/** + * Does a persisted, non-expired session blob exist? Cheap check — + * doesn't touch IndexedDB. Useful for UI ("Welcome back" hint). + */ +export function hasPersistedSession(): boolean { + try { + const raw = localStorage.getItem(LS_KEY); + if (!raw) return false; + const parsed = JSON.parse(raw) as PersistedBlob; + return parsed.v === 1 && Date.now() <= parsed.expiresAt; + } catch { + return false; + } +} diff --git a/kez-chat/web/src/lib/profile-store.ts b/kez-chat/web/src/lib/profile-store.ts new file mode 100644 index 0000000..1051dc9 --- /dev/null +++ b/kez-chat/web/src/lib/profile-store.ts @@ -0,0 +1,387 @@ +// Local user-profile state — the profile picture the user picked in +// Settings and any other display metadata we add later (name, about). +// +// Two halves: +// 1. Local persistence (IndexedDB via idb-keyval) so the picture +// survives reloads and is available everywhere the Avatar +// component renders. +// 2. Nostr publish: when the user sets / changes their picture, we +// emit a NIP-01 kind:0 metadata event from their derived nostr +// key. This is the standard nostr profile shape (every nostr +// client recognises it). Other kez-chat users (later) will +// subscribe to kind:0 events for their peers to fetch peer +// avatars. +// +// Storage shape: +// +// StoredProfile = { +// picture?: string; // data URL, JPEG, 256×256 +// name?: string; // future — currently mirrored from handle +// about?: string; // future +// updated_at: string; // ISO timestamp +// } +// +// Why not a Svelte 5 $state class? Because this file is imported by +// non-component code (the publish path) and we want a plain TS module +// surface. Components subscribe via the small `useMyProfile()` +// helper which keeps a $state cell in sync. + +import { get, set, del } from "idb-keyval"; +import { hexToBytes } from "@noble/hashes/utils"; +import { + finalizeEvent, + getPublicKey, + SimplePool, + type EventTemplate, +} from "nostr-tools"; +import { openMessage, sealMessage, type SealedEnvelope } from "./crypto.js"; +import { nostrSecretFromSeed } from "./nostr-id.js"; +import { scrambleImage, unscrambleImage } from "./visual-crypto.js"; +import { listConversations } from "./conversations-store.js"; +import type { Identity } from "./kez.js"; + +const PROFILE_KEY = "kez-chat:my-profile:v1"; + +/** Same default-relay list as nostr-transport.ts. Profile publish is + * best-effort; if relays change, we just re-publish on next save. */ +const RELAYS: string[] = ( + (import.meta.env.VITE_NOSTR_RELAYS as string | undefined) ?? + "wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr.wine" +) + .split(",") + .map((r) => r.trim()) + .filter(Boolean); + +export interface StoredProfile { + /** The picture WE see locally — always cleartext. When `encrypted` + * is true this is what we render in our own Avatar; the nostr + * publish carries the scrambled version. */ + picture?: string; + /** + * True when this profile is set to visually-encrypt the picture + * before publishing. Defaults to true on new profiles — the + * opinionated stance is "your face is private unless you opt out". + * Only contacts who've been keyed in can descramble. + */ + encrypted?: boolean; + /** + * The 32-byte symmetric key used to scramble `picture`, hex-encoded. + * Local-only; never published cleartext. Each contact receives an + * individually-wrapped copy of this key embedded in our kind:0 + * event content. When the picture changes, this key is rerolled. + */ + picture_key?: string; + /** Display name. For now we mirror the handle; future: separate. */ + name?: string; + /** Short bio (NIP-01 calls this `about`). Not surfaced yet. */ + about?: string; + updated_at: string; +} + +export async function loadMyProfile(): Promise { + return (await get(PROFILE_KEY)) ?? null; +} + +export async function saveMyProfile(profile: StoredProfile): Promise { + await set(PROFILE_KEY, profile); +} + +export async function clearMyProfile(): Promise { + await del(PROFILE_KEY); +} + +/** + * Publish a minimal kind:0 metadata event the first time we sign in + * on this device. Some relays silently drop writes from pubkeys with + * no kind:0 ("unknown author" rejection); a single tiny publish on + * first use unblocks subsequent DM publishes for new users who haven't + * set a picture yet. Idempotent: cached flag in localStorage means we + * only do it once. TODO.md Day 3 Option B #12. + * + * Also publishes a NIP-65 (kind:10002) "relay list metadata" event in + * the same shot — listing our 3 default relays as read+write. NIP-65- + * aware clients (Damus, Amethyst, etc.) use this to know where to + * reach us. TODO.md Day 3 Option B #10. + */ +const BASELINE_PUBLISHED_KEY = "kez-chat:nostr-baseline-published:v1"; + +export async function publishKind0BaselineIfNeeded( + seed: Uint8Array, + handle: string, +): Promise { + try { + if (localStorage.getItem(BASELINE_PUBLISHED_KEY) === "1") return; + } catch { + /* private mode — proceed anyway, worst case we publish once per tab */ + } + try { + const sk = nostrSecretFromSeed(seed); + const now = Math.floor(Date.now() / 1000); + + // 1. Minimal kind:0 (TODO.md Day 3 Option B #12). + const kind0: EventTemplate = { + kind: 0, + created_at: now, + tags: [], + content: JSON.stringify({ name: handle }), + }; + const signedKind0 = finalizeEvent(kind0, sk); + + // 2. NIP-65 kind:10002 relay list (TODO.md Day 3 Option B #10). + // Tag format: ["r", "wss://...", marker?]. Marker = "read", + // "write", or omitted (meaning both). For v0.1 we say all 3 + // of our relays are read+write — when we later support + // per-relay specialisation we'll split. + const kind10002: EventTemplate = { + kind: 10002, + created_at: now, + tags: RELAYS.map((url) => ["r", url]), + content: "", + }; + const signedKind10002 = finalizeEvent(kind10002, sk); + + const pool = new SimplePool(); + try { + await Promise.allSettled(pool.publish(RELAYS, signedKind0)); + await Promise.allSettled(pool.publish(RELAYS, signedKind10002)); + } finally { + pool.close(RELAYS); + } + try { + localStorage.setItem(BASELINE_PUBLISHED_KEY, "1"); + } catch { + /* private mode */ + } + } catch (e) { + console.warn("publishKind0BaselineIfNeeded failed:", e); + } +} + +/** + * Fetch the user's OWN published kind:0 from nostr, descramble using + * the self-wrap, and return a StoredProfile ready to save. Used on a + * fresh device (no local IDB picture) so the user sees their face + * everywhere immediately after unlock — no "set it again on every + * device" friction. + * + * Returns null if there's no published profile, or if descrambling + * fails (e.g., this device's seed somehow doesn't match the wrap — + * shouldn't happen, but never crash on it). + */ +export async function fetchMyProfileFromNostr( + seed: Uint8Array, + myPrimary: Identity, + myHandle: string, +): Promise { + try { + const sk = nostrSecretFromSeed(seed); + const myNostrPubkey = getPublicKey(sk); + + const pool = new SimplePool(); + let events: { id: string; content: string; created_at: number }[] = []; + try { + const result = await Promise.race([ + pool.querySync(RELAYS, { + kinds: [0], + authors: [myNostrPubkey], + limit: 3, + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error("fetchMyProfile timed out")), 8000), + ), + ]); + events = result; + } finally { + pool.close(RELAYS); + } + if (!events.length) return null; + const latest = events.sort((a, b) => b.created_at - a.created_at)[0]; + + let metadata: Record; + try { + metadata = JSON.parse(latest.content); + } catch (e) { + console.warn("fetchMyProfileFromNostr: bad JSON in kind:0 content", e); + return null; + } + + const profile: StoredProfile = { + updated_at: new Date(latest.created_at * 1000).toISOString(), + name: typeof metadata.name === "string" ? metadata.name : undefined, + about: typeof metadata.about === "string" ? metadata.about : undefined, + }; + + // ─── encrypted-self path ─────────────────────────────────── + if ( + metadata.kez_visual_v1 === true && + typeof metadata.picture === "string" && + metadata.kez_visual_keys && + typeof metadata.kez_visual_keys === "object" + ) { + const wraps = metadata.kez_visual_keys as Record; + const selfWrap = wraps[myPrimary]; + if (selfWrap) { + try { + const env = selfWrap as SealedEnvelope; + const plaintext = await openMessage({ + envelope: env, + myHandle, + mySeed: seed, + }); + const parsed = JSON.parse(plaintext.body) as { visual_key?: string }; + if (parsed.visual_key) { + const keyBytes = hexToBytes(parsed.visual_key); + profile.picture = await unscrambleImage(metadata.picture, keyBytes); + profile.encrypted = true; + profile.picture_key = parsed.visual_key; + } + } catch (e) { + // Self-wrap exists but failed to open — log and continue + // with whatever we have (name, about). The user can re-set + // their picture if they want to refresh it. + console.warn("fetchMyProfileFromNostr: self-wrap open failed", e); + } + } + // (No `else` here — if the encrypted picture has no self-wrap + // available, the user simply re-sets their picture on this + // device to populate one.) + } else if (typeof metadata.picture === "string") { + profile.picture = metadata.picture; + profile.encrypted = false; + } + + return profile; + } catch (e) { + console.warn("fetchMyProfileFromNostr failed:", e); + return null; + } +} + +/** + * Publish a kind:0 metadata event so any nostr client (and any + * future kez-chat client) can find this user's profile picture and + * display name. NIP-01: + * + * { kind: 0, + * content: JSON.stringify({ name, picture, about, ... }), + * tags: [], + * ... } + * + * Fails silently — push to logs only. The picture is already + * stored locally; nostr publish is just for peer discovery. + * + * Returns the published event id so callers can confirm. + */ +export async function publishMyProfile( + seed: Uint8Array, + senderPrimary: Identity, + // `senderHandle` is here for symmetry with sealMessage (which uses + // it as the recipientHandle on outgoing DMs). For profile key + // wraps we don't reference our OWN handle, so it's currently + // unused. Kept in the signature so a future call (e.g. signing + // the wrap with our handle as AAD) doesn't break the surface. + _senderHandle: string, + profile: StoredProfile, +): Promise { + try { + const sk = nostrSecretFromSeed(seed); + const metadata: Record = {}; + if (profile.name) metadata.name = profile.name; + if (profile.about) metadata.about = profile.about; + + // ─── encrypted picture path ───────────────────────────────── + // If the user opted to encrypt (default), scramble the picture + // under `picture_key` and publish the scrambled image as the + // standard `picture` field — clients that don't understand + // kez-visual-v1 will just render colored noise, which is the + // entire point. The descramble key is wrapped per-contact in + // a custom `kez_visual_keys` map: { : }. + if ( + profile.encrypted && + profile.picture && + profile.picture_key + ) { + const keyBytes = hexToBytes(profile.picture_key); + const scrambled = await scrambleImage(profile.picture, keyBytes); + metadata.picture = scrambled; + // Mark the picture as kez-encrypted so a forward-compatible + // client (or a future Damus plugin) knows what we did. + metadata.kez_visual_v1 = true; + + // Wrap the picture key for each contact, using the same + // SealedEnvelope crypto our DMs already use (so we know it + // works + the threat model is already understood). The + // envelope's PLAINTEXT is just the visual key, hex-encoded. + const contacts = await listConversations(); + const wraps: Record = {}; + const wrapBody = JSON.stringify({ visual_key: profile.picture_key }); + + // ─── self-wrap ──────────────────────────────────────────── + // Always include a wrap to OURSELVES. Without this, opening + // kez-chat on a fresh device (no local IDB) means we publish + // a scrambled picture we can't read back — even though we + // own the seed. Self-wrap lets `fetchMyProfile` on a new + // device descramble its own kind:0 and rehydrate the + // picture_key. + try { + const selfEnv = await sealMessage({ + senderSeed: seed, + senderPrimary, + recipientHandle: _senderHandle || "self", + recipientPrimary: senderPrimary, + body: wrapBody, + }); + wraps[senderPrimary] = selfEnv; + } catch (e) { + console.warn("profile: self-wrap failed (own-device decrypt won't work)", e); + } + + for (const conv of contacts) { + // Skip if this is us — already self-wrapped above. + if (conv.peer_primary === senderPrimary) continue; + try { + const env = await sealMessage({ + senderSeed: seed, + senderPrimary, + recipientHandle: conv.peer_handle || conv.peer_primary, + recipientPrimary: conv.peer_primary, + body: wrapBody, + }); + wraps[conv.peer_primary] = env; + } catch (e) { + // A single bad contact (e.g. unparseable primary) shouldn't + // stop the whole publish — just skip and continue. + console.warn(`profile: wrap key for ${conv.peer_primary} failed`, e); + } + } + if (Object.keys(wraps).length > 0) { + metadata.kez_visual_keys = wraps; + } + } else if (profile.picture) { + // Cleartext picture — user explicitly opted out of encryption. + metadata.picture = profile.picture; + } + + const tmpl: EventTemplate = { + kind: 0, + created_at: Math.floor(Date.now() / 1000), + tags: [], + content: JSON.stringify(metadata), + }; + const signed = finalizeEvent(tmpl, sk); + + // Best-effort fan-out. Profile pictures aren't safety-critical; + // if every relay rejects we still succeeded locally. + const pool = new SimplePool(); + try { + await Promise.allSettled(pool.publish(RELAYS, signed)); + } finally { + pool.close(RELAYS); + } + return signed.id; + } catch (e) { + console.warn("publishMyProfile failed:", e); + return null; + } +} diff --git a/kez-chat/web/src/lib/push.ts b/kez-chat/web/src/lib/push.ts index b7d4d05..d65deb0 100644 --- a/kez-chat/web/src/lib/push.ts +++ b/kez-chat/web/src/lib/push.ts @@ -204,6 +204,65 @@ export async function isPushSubscribed(): Promise { return sub !== null; } +/** + * Self-heal check, run after every unlock + session restore. + * + * If this device has a local PushSubscription but the chat-server + * doesn't know about it (DB lost, 410 cleanup ran, new server, etc.), + * silently re-register it. If the device has no local sub at all, + * there's nothing to do — the user has to opt in from Settings. + * + * Returns a brief status string for the caller to log; never throws. + */ +export async function verifyPushRegistration( + handle: string, + seed: Uint8Array, +): Promise<"ok" | "no-local-sub" | "reregistered" | "unsupported" | "failed"> { + if (!pushSupported()) return "unsupported"; + try { + const reg = await navigator.serviceWorker.ready; + const localSub = await reg.pushManager.getSubscription(); + if (!localSub) return "no-local-sub"; + + // Ask the server what it has for this handle. + const ts = Math.floor(Date.now() / 1000); + const msg = `GET\n/v1/push/subscriptions/${handle}\n${ts}`; + const sig = ed25519.sign(new TextEncoder().encode(msg), seed); + const resp = await fetch( + url(`/v1/push/subscriptions/${encodeURIComponent(handle)}`), + { + headers: { "X-KEZ-Auth": `${ts}:${bytesToHex(sig)}` }, + }, + ); + if (!resp.ok) return "failed"; + const body = (await resp.json()) as { endpoint_tails: string[] }; + const myTail = localSub.endpoint.slice(-16); + if (body.endpoint_tails.includes(myTail)) { + return "ok"; + } + + // Server doesn't know about my sub — re-register without + // tearing down the local one (cheaper than full re-subscribe). + const payload = subscriptionPayload(localSub); + const authHeader = signPushAuth("subscribe", handle, payload.endpoint, seed); + const reReg = await fetch( + url(`/v1/push/subscribe/${encodeURIComponent(handle)}`), + { + method: "POST", + headers: { + "content-type": "application/json", + "X-KEZ-Auth": authHeader, + }, + body: JSON.stringify(payload), + }, + ); + return reReg.ok ? "reregistered" : "failed"; + } catch (e) { + console.warn("verifyPushRegistration:", e); + return "failed"; + } +} + /** * iOS PWA detection — Safari only exposes PushManager once installed * to the home screen. We use this to render a "Tap Share → Add to Home diff --git a/kez-chat/web/src/lib/store.svelte.ts b/kez-chat/web/src/lib/store.svelte.ts index ef81757..f239ebf 100644 --- a/kez-chat/web/src/lib/store.svelte.ts +++ b/kez-chat/web/src/lib/store.svelte.ts @@ -7,19 +7,246 @@ import type { UnlockedIdentity } from "./identity-store.js"; import { inboxService } from "./inbox-service.svelte.js"; +import { bytesToHex } from "@noble/hashes/utils"; +import { + fetchMyProfileFromNostr, + loadMyProfile, + publishKind0BaselineIfNeeded, + publishMyProfile, + saveMyProfile, + type StoredProfile, +} from "./profile-store.js"; +import { generateVisualKey } from "./visual-crypto.js"; + +/** + * Hydrate the user's profile cell on unlock. Tries IDB first; if + * empty (fresh device), fetches the user's own kind:0 from nostr + * and descrambles via the self-wrap so the avatar lights up + * automatically without making the user re-pick their picture per + * device. + */ +async function hydrateMyProfile(id: UnlockedIdentity): Promise { + const local = await loadMyProfile(); + if (local) { + session.myProfile = local; + return; + } + // No local copy — try recovering from nostr. This is also the + // path that runs on the FIRST device after a passphrase-restore, + // since the persisted seed gives us all we need. + const remote = await fetchMyProfileFromNostr( + id.seed, + id.primary, + id.handle, + ); + if (remote) { + await saveMyProfile(remote); + session.myProfile = remote; + } +} +import { + persistSession, + clearPersistedSession, + restoreSession, +} from "./persistent-session.js"; +import { + enablePush, + isPushSubscribed, + isStandalonePwa, + isIos, + pushSupported, + verifyPushRegistration, +} from "./push.js"; + +/** Suppression flag set when the user explicitly disables push from + * Settings — auto-enable on next unlock would be annoying after + * they just opted out. Auto-enable also stops if a previous + * permission prompt was denied. */ +const PUSH_AUTOENABLE_OFF_KEY = "kez-chat:push-autoenable-off"; + +async function maybeAutoEnablePush(handle: string, seed: Uint8Array) { + try { + if (!pushSupported()) return; + if (localStorage.getItem(PUSH_AUTOENABLE_OFF_KEY) === "1") return; + // iOS only allows push from installed PWAs — the nudge banner + // tells the user to add-to-home-screen instead. + if (isIos() && !isStandalonePwa()) return; + if (Notification.permission === "denied") return; + if (await isPushSubscribed()) return; + + // The system permission prompt only appears for permission==="default". + // We call enablePush which handles the prompt + server subscribe. + // Failures are silent — the in-chat nudge banner is the fallback UI. + const ok = await enablePush(handle, seed); + if (!ok) { + // User clicked "Block" or browser policy denied — don't auto-try + // again on next unlock; the banner will let them opt in if they + // change their mind. + try { + localStorage.setItem(PUSH_AUTOENABLE_OFF_KEY, "1"); + } catch { + /* private mode */ + } + } + } catch (e) { + console.warn("auto-enable push failed:", e); + } +} + +export function setPushAutoEnableDisabled(disabled: boolean) { + try { + if (disabled) localStorage.setItem(PUSH_AUTOENABLE_OFF_KEY, "1"); + else localStorage.removeItem(PUSH_AUTOENABLE_OFF_KEY); + } catch { + /* private mode */ + } +} class Session { unlocked = $state(null); + /** True once we've checked persisted storage on boot. Lets the UI + * show a brief "restoring…" state instead of flashing the unlock + * prompt before auto-unlock has had a chance to run. */ + bootRestoreChecked = $state(false); + /** User's own profile (picture, name). Loaded from IndexedDB on + * unlock; null when the user hasn't set one yet. Components that + * render the user's Avatar read this and pass `picture` through. */ + myProfile = $state(null); setUnlocked(id: UnlockedIdentity) { this.unlocked = id; inboxService.start(id.handle, id.seed); + // Hydrate the user's profile cell — IDB first, then nostr + // fallback if this is a new device. + void hydrateMyProfile(id); + // Publish a minimal kind:0 once per device so relays that drop + // writes from "unknown" pubkeys accept our DMs going forward. + void publishKind0BaselineIfNeeded(id.seed, id.handle); + // Fire-and-forget — failure just means the user types the + // passphrase again on next launch, not a security problem. + void persistSession(id); + // Auto-enable Web Push on first unlock on a new device. If push + // is already on, this is a no-op via isPushSubscribed(). If the + // user opted out from Settings, it respects that. + void maybeAutoEnablePush(id.handle, id.seed); + // Self-heal Web Push: if the server lost our subscription + // (cleanup after 410, DB rebuild, fresh server, etc.) but the + // browser still has it locally, silently re-register. + void verifyPushRegistration(id.handle, id.seed).then((status) => { + if (status === "reregistered") { + console.info("push: re-registered subscription with server"); + } + }); } lock() { inboxService.stop(); this.unlocked = null; + void clearPersistedSession(); + } + + /** Called once on app boot. If a non-expired session blob is in + * localStorage and the non-extractable AES key in IndexedDB can + * still decrypt it, restore the session straight to "unlocked" + * without prompting for the passphrase. */ + async tryRestoreFromStorage(): Promise { + try { + const restored = await restoreSession(); + if (restored) { + this.unlocked = restored; + inboxService.start(restored.handle, restored.seed); + // Hydrate profile after restore — IDB first, nostr fallback + // if the local copy is missing (e.g. browser data cleared). + void hydrateMyProfile(restored); + // Same self-heal + auto-enable behaviour as setUnlocked — + // restoring from disk is exactly when we want to confirm the + // server still has us on its push fanout list AND (if the + // user is on a fresh device) prompt for push permission. + void maybeAutoEnablePush(restored.handle, restored.seed); + void verifyPushRegistration(restored.handle, restored.seed).then( + (status) => { + if (status === "reregistered") { + console.info("push: re-registered subscription after restore"); + } + }, + ); + return true; + } + return false; + } finally { + this.bootRestoreChecked = true; + } } } export const session = new Session(); + +/** + * Patch the user's profile (picture or name): persist locally, push + * to nostr (best-effort), and update the reactive cell so every + * Avatar repaints. Returns the nostr event id of the published + * kind:0 event, or null if publish failed (local save still succeeded). + * + * Pass `profile.picture = null` (or omit it) to remove the picture. + */ +export async function setMyProfile( + patch: Partial, +): Promise { + if (!session.unlocked) throw new Error("not unlocked"); + const current = session.myProfile ?? { updated_at: new Date().toISOString() }; + // Default to encrypted=true on the first save — the opinionated + // privacy default the user explicitly asked for ("make the + // encrypted option default"). Subsequent saves keep whatever the + // user toggled. + const encrypted = + patch.encrypted !== undefined + ? patch.encrypted + : (current.encrypted ?? true); + + // Visual-key state machine: never reuse a key across pictures + // (so cryptanalysis of one image can't carry to the next), always + // have a key when one is needed, never keep a stale key around. + let picture_key = current.picture_key; + const pictureChanged = "picture" in patch; + const nextPicture = pictureChanged ? patch.picture : current.picture; + if (pictureChanged) { + if (!patch.picture) { + // User removed the picture — drop the key. + picture_key = undefined; + } else if (encrypted) { + // New picture replacing an old one (or no previous picture) — + // mint a fresh key. + picture_key = bytesToHex(generateVisualKey()); + } else { + // Cleartext picture; no key needed. + picture_key = undefined; + } + } + // Edge: user flipped encryption ON while a picture was already + // set — mint a key for the existing picture. + if (encrypted && nextPicture && !picture_key) { + picture_key = bytesToHex(generateVisualKey()); + } + // Edge: user flipped encryption OFF — drop the now-unused key. + if (!encrypted && picture_key) { + picture_key = undefined; + } + + const merged: StoredProfile = { + ...current, + ...patch, + encrypted, + picture_key, + updated_at: new Date().toISOString(), + }; + // Local write first — guarantees the picture is saved even if we + // can't reach a single relay. + await saveMyProfile(merged); + session.myProfile = merged; + return await publishMyProfile( + session.unlocked.seed, + session.unlocked.primary, + session.unlocked.handle, + merged, + ); +} diff --git a/kez-chat/web/src/lib/transport.ts b/kez-chat/web/src/lib/transport.ts index af7a379..9a01c06 100644 --- a/kez-chat/web/src/lib/transport.ts +++ b/kez-chat/web/src/lib/transport.ts @@ -21,8 +21,30 @@ export const sendMessage = impl.sendMessage; export const pollInbox = impl.pollInbox; export const streamInbox = impl.streamInbox; export const decrypt = impl.decrypt; +/** Publish a delivery receipt for a message we just decrypted, so the + * original sender's UI can flip the bubble from "sent" to "delivered". + * No-op on the server transport for now. */ +export const sendAck = impl.sendAck; +/** Retry any acks that failed to publish on first attempt. Called on + * session start so a flaky relay moment doesn't permanently strand + * receipts. */ +export const flushPendingAcks = impl.flushPendingAcks; +/** Catch-up query: given a list of recently-sent event ids, returns + * the subset for which a recipient ack has been published. Lets the + * sender's UI self-heal "delivered" state on conversation open + * instead of relying solely on the live stream. */ +export const fetchAcksForEventIds = impl.fetchAcksForEventIds; +/** Wire the user's seed into the relay pool so NIP-42 AUTH challenges + * get signed transparently. No-op on the server transport. */ +export const attachSigner = impl.attachSigner; +export const detachSigner = impl.detachSigner; +/** Snapshot of every configured relay (or the single chat-server) + + * whether the socket is currently open. Drives the "● live (N)" + * indicator and its popover. */ +export const getRelayStatuses = impl.getRelayStatuses; export type { InboxMessage, StreamHandle, SealedEnvelope, MessagePlaintext } from "./messages.js"; +export type { RelayStatus } from "./messages.js"; /** Which transport this build is using — handy for a debug line in the UI. */ export const activeTransport = TRANSPORT; diff --git a/kez-chat/web/src/lib/visual-crypto.ts b/kez-chat/web/src/lib/visual-crypto.ts new file mode 100644 index 0000000..cc02453 --- /dev/null +++ b/kez-chat/web/src/lib/visual-crypto.ts @@ -0,0 +1,282 @@ +// Visually-encrypted images. +// +// The idea: an image scrambled under a symmetric key still LOOKS like +// an image — same dimensions, same approximate color distribution, +// just rearranged pixels. To a stranger it's colored noise; to anyone +// holding the key it descrambles to the original. +// +// We use this for profile pictures so the kind:0 metadata event we +// publish to public nostr relays doesn't expose every user's face to +// the entire network. Only contacts who have been given the picture +// key (via per-recipient AES wraps embedded in the kind:0 content) +// can descramble. +// +// Algorithm: +// • Pixel-permutation cipher. Key + image-hash → seed a ChaCha-style +// PRNG → produce a Fisher-Yates shuffle of pixel positions → +// permute the RGBA buffer. Reverse permutation = decryption. +// • Output is PNG (lossless). JPEG re-encoding would destroy the +// permutation, but nostr relays don't transcode event content so +// we stay safe. +// +// What this protects against: +// • Random scrapers / strangers seeing your face from a public +// kind:0 event. +// • A relay operator deciding to build a "user gallery" out of +// scraped profile pictures. +// +// What this does NOT protect against: +// • Color-histogram attacks: pixel permutation preserves the global +// histogram. An adversary can tell "mostly skin tones" vs "mostly +// sky" without descrambling. For v0.1 that's an acceptable leak; +// v0.2 may add AES-CTR-over-pixels for a uniform-noise output +// (less "magical" looking, stronger). +// • Key compromise: anyone who gets the key sees the picture. The +// key wrap to each recipient is the actual access-control layer. + +import { hkdf } from "@noble/hashes/hkdf"; +import { sha256 } from "@noble/hashes/sha2"; + +const VISUAL_INFO = new TextEncoder().encode("kez-chat:visual-v1"); + +// ─── key generation ──────────────────────────────────────────────────────── + +/** Fresh 32-byte symmetric key. One key per profile picture. */ +export function generateVisualKey(): Uint8Array { + return crypto.getRandomValues(new Uint8Array(32)); +} + +// ─── tiny PRNG (xoshiro256**) ────────────────────────────────────────────── +// +// We need a deterministic 64-bit PRNG seeded from the key + image salt. +// Web Crypto's only random source is non-deterministic, so we roll a +// small algorithm by hand. xoshiro256** is fast, well-mixed, and the +// reference implementation is ~30 lines. + +class Xoshiro256ss { + private s: BigUint64Array; + constructor(seed: Uint8Array) { + if (seed.length !== 32) throw new Error("xoshiro seed must be 32 bytes"); + this.s = new BigUint64Array(4); + const dv = new DataView(seed.buffer, seed.byteOffset, seed.byteLength); + for (let i = 0; i < 4; i++) this.s[i] = dv.getBigUint64(i * 8, true); + // Ensure we don't start with all-zero state (xoshiro requires that). + if (this.s[0] === 0n && this.s[1] === 0n && this.s[2] === 0n && this.s[3] === 0n) { + this.s[0] = 1n; + } + } + private rotl(x: bigint, k: bigint): bigint { + const mask = (1n << 64n) - 1n; + return (((x << k) & mask) | (x >> (64n - k))) & mask; + } + next(): bigint { + const mask = (1n << 64n) - 1n; + const result = (this.rotl((this.s[1] * 5n) & mask, 7n) * 9n) & mask; + const t = (this.s[1] << 17n) & mask; + this.s[2] ^= this.s[0]; + this.s[3] ^= this.s[1]; + this.s[1] ^= this.s[2]; + this.s[0] ^= this.s[3]; + this.s[2] ^= t; + this.s[3] = this.rotl(this.s[3], 45n); + return result; + } + /** Uniform integer in [0, n). Used by Fisher-Yates. */ + nextBelow(n: number): number { + // Rejection sample to avoid modulo bias when n doesn't divide 2^64. + const bn = BigInt(n); + const bound = ((1n << 64n) / bn) * bn; + let r: bigint; + do { + r = this.next(); + } while (r >= bound); + return Number(r % bn); + } +} + +function seedPrng(key: Uint8Array, salt: Uint8Array): Xoshiro256ss { + // HKDF binds key + image salt into the PRNG seed, so the same key + // applied to two different images produces two different + // permutations — no leakage between pictures. + const seed = hkdf(sha256, key, salt, VISUAL_INFO, 32); + return new Xoshiro256ss(seed); +} + +// ─── permutation ─────────────────────────────────────────────────────────── + +/** Fisher-Yates: produces a uniformly random permutation of [0, n). */ +function buildPermutation(n: number, prng: Xoshiro256ss): Uint32Array { + const p = new Uint32Array(n); + for (let i = 0; i < n; i++) p[i] = i; + for (let i = n - 1; i > 0; i--) { + const j = prng.nextBelow(i + 1); + const tmp = p[i]; + p[i] = p[j]; + p[j] = tmp; + } + return p; +} + +/** Inverse permutation: `inv[p[i]] = i` so the descramble walk + * is symmetric to the scramble. */ +function invertPermutation(p: Uint32Array): Uint32Array { + const inv = new Uint32Array(p.length); + for (let i = 0; i < p.length; i++) inv[p[i]] = i; + return inv; +} + +// ─── image I/O ───────────────────────────────────────────────────────────── + +async function loadImage(dataUrl: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error("could not decode image")); + img.src = dataUrl; + }); +} + +function pixelsFromImage( + img: HTMLImageElement, +): { ctx: CanvasRenderingContext2D; data: ImageData } { + const canvas = document.createElement("canvas"); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("canvas 2d context unavailable"); + ctx.drawImage(img, 0, 0); + return { ctx, data: ctx.getImageData(0, 0, img.width, img.height) }; +} + +// ─── scramble + unscramble ───────────────────────────────────────────────── + +/** + * Scramble an image so the output is a valid PNG with the same + * dimensions but visually meaningless. `key` is 32 bytes; the same + * key reverses the operation via `unscrambleImage`. + * + * Output is always PNG — JPEG would re-quantize and destroy the + * pixel permutation, making decryption impossible. Caller should + * accept this as the price of survivability. + */ +export async function scrambleImage( + imageDataUrl: string, + key: Uint8Array, +): Promise { + const img = await loadImage(imageDataUrl); + const { ctx, data } = pixelsFromImage(img); + + // Salt the PRNG with the image content so re-scrambling the same + // picture twice (under the same key) still produces different + // outputs — and so a stranger can't compare "before vs after" + // shuffles to map permutations. + // + // Cast: canvas ImageData.data is Uint8ClampedArray; sha256 wants + // Uint8Array. They share the same underlying buffer so a view + // reinterpretation is free. + const salt = sha256(new Uint8Array(data.data.buffer, data.data.byteOffset, data.data.byteLength)); + const prng = seedPrng(key, salt); + + const nPixels = data.width * data.height; + const perm = buildPermutation(nPixels, prng); + + const src = new Uint8ClampedArray(data.data); + const dst = data.data; + for (let i = 0; i < nPixels; i++) { + const j = perm[i]; + // Move pixel j into slot i. RGBA = 4 bytes per pixel. + dst[i * 4 + 0] = src[j * 4 + 0]; + dst[i * 4 + 1] = src[j * 4 + 1]; + dst[i * 4 + 2] = src[j * 4 + 2]; + dst[i * 4 + 3] = src[j * 4 + 3]; + } + ctx.putImageData(data, 0, 0); + + // Embed the salt in the PNG via a trailing tEXt chunk would be + // ideal — but canvas.toDataURL doesn't expose that. Instead we + // prepend it as a tiny header URL fragment, decoded by + // unscrambleImage. + const pngBody = ctx.canvas.toDataURL("image/png"); + const saltHex = Array.from(salt, (b) => b.toString(16).padStart(2, "0")).join(""); + // Use a custom URL fragment after the data URL to ferry the salt + // out-of-band. Recipients use this when they unscramble; strangers + // ignore it (it's a valid PNG with or without the fragment). + return `${pngBody}#kez-visual-v1:${saltHex}`; +} + +/** + * Inverse of `scrambleImage`. Takes the (data-URL + salt fragment) + * produced by scrambleImage and the same key; returns a plain PNG + * data URL with the original pixels restored. + * + * Throws if the salt fragment is missing — meaning the input wasn't + * produced by our scrambler. + */ +export async function unscrambleImage( + scrambledDataUrl: string, + key: Uint8Array, +): Promise { + const fragIdx = scrambledDataUrl.indexOf("#kez-visual-v1:"); + if (fragIdx < 0) { + throw new Error("input is not a kez-visual-v1 scrambled image"); + } + const pureDataUrl = scrambledDataUrl.slice(0, fragIdx); + const saltHex = scrambledDataUrl.slice(fragIdx + "#kez-visual-v1:".length); + if (saltHex.length !== 64) { + throw new Error(`malformed salt (expected 64 hex chars, got ${saltHex.length})`); + } + const salt = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + salt[i] = parseInt(saltHex.slice(i * 2, i * 2 + 2), 16); + } + + const img = await loadImage(pureDataUrl); + const { ctx, data } = pixelsFromImage(img); + const prng = seedPrng(key, salt); + + const nPixels = data.width * data.height; + const perm = buildPermutation(nPixels, prng); + const inv = invertPermutation(perm); + + const src = new Uint8ClampedArray(data.data); + const dst = data.data; + for (let i = 0; i < nPixels; i++) { + const j = inv[i]; + dst[i * 4 + 0] = src[j * 4 + 0]; + dst[i * 4 + 1] = src[j * 4 + 1]; + dst[i * 4 + 2] = src[j * 4 + 2]; + dst[i * 4 + 3] = src[j * 4 + 3]; + } + ctx.putImageData(data, 0, 0); + return ctx.canvas.toDataURL("image/png"); +} + +// ─── tiny round-trip test (dev only) ─────────────────────────────────────── + +/** Quick sanity check — caller passes a known image data URL, + * this scrambles + unscrambles + compares the round-trip hash. + * Not exported as a unit test; called from the Settings page + * the first time the feature is enabled. */ +export async function visualSelfTest( + imageDataUrl: string, + key: Uint8Array, +): Promise { + try { + const scrambled = await scrambleImage(imageDataUrl, key); + const restored = await unscrambleImage(scrambled, key); + // Compare pixel hashes — base64 round-trip changes the encoding + // but pixel data should be byte-identical. + const a = await loadImage(imageDataUrl); + const b = await loadImage(restored); + if (a.width !== b.width || a.height !== b.height) return false; + const da = pixelsFromImage(a).data.data; + const db = pixelsFromImage(b).data.data; + const ha = sha256(new Uint8Array(da.buffer, da.byteOffset, da.byteLength)); + const hb = sha256(new Uint8Array(db.buffer, db.byteOffset, db.byteLength)); + for (let i = 0; i < 32; i++) if (ha[i] !== hb[i]) return false; + return true; + } catch (e) { + console.warn("visualSelfTest failed:", e); + return false; + } +} diff --git a/kez-chat/web/src/lib/webauthn.ts b/kez-chat/web/src/lib/webauthn.ts index b7d2688..ce09f19 100644 --- a/kez-chat/web/src/lib/webauthn.ts +++ b/kez-chat/web/src/lib/webauthn.ts @@ -118,7 +118,15 @@ export async function setupBiometricUnlock(opts: { // if the authenticator doesn't support it, registration succeeds // but getClientExtensionResults().prf.enabled will be false and we // bail. - const userId = new TextEncoder().encode(opts.primary); + // + // user.id is an opaque per-user identifier. WebAuthn spec caps it at + // 64 BYTES (not chars). An earlier version of this code used the full + // KEZ identity string ("ed25519:<64 hex>") which is 72 bytes — Android + // Chrome rejected it with "user handle exceeds 64 bytes". Use the + // 32 raw bytes of the ed25519 pubkey instead: well-defined, stable, + // and unique per account. + const pubkeyHex = opts.primary.replace(/^ed25519:/, ""); + const userId = hexToBytes(pubkeyHex); const challenge = crypto.getRandomValues(new Uint8Array(32)); const cred = (await navigator.credentials.create({ diff --git a/kez-chat/web/src/routes/AddClaim.svelte b/kez-chat/web/src/routes/AddClaim.svelte index 740b655..1084421 100644 --- a/kez-chat/web/src/routes/AddClaim.svelte +++ b/kez-chat/web/src/routes/AddClaim.svelte @@ -288,7 +288,7 @@ } -
+

Add a claim

+ {/if} + +
+
+ {/if} + +
+
+ + +{#if zoomedAvatarOpen && activeConv && peerProfiles.byPrimary[activeConv.peer_primary]?.picture} + {@const peerPic = peerProfiles.byPrimary[activeConv.peer_primary]?.picture} + +{/if} diff --git a/kez-chat/web/src/routes/Settings.svelte b/kez-chat/web/src/routes/Settings.svelte index 9f8b90a..150adc1 100644 --- a/kez-chat/web/src/routes/Settings.svelte +++ b/kez-chat/web/src/routes/Settings.svelte @@ -2,8 +2,16 @@ import { onMount } from "svelte"; import { push } from "svelte-spa-router"; import { bytesToHex } from "@noble/hashes/utils"; - import { session } from "../lib/store.svelte.js"; - import { hasStoredPhrase } from "../lib/identity-store.js"; + import { + session, + setMyProfile, + setPushAutoEnableDisabled, + } from "../lib/store.svelte.js"; + import { resizeToAvatarDataUrl, dataUrlBytes } from "../lib/image-utils.js"; + import { scrambleImage } from "../lib/visual-crypto.js"; + import { hexToBytes } from "@noble/hashes/utils"; + import Avatar from "../lib/Avatar.svelte"; + import { hasStoredPhrase, unlockIdentity } from "../lib/identity-store.js"; import { hasStoredBiometric, getStoredBiometricMeta, @@ -50,6 +58,79 @@ let notifPerm = $state("default"); let testNotifResult = $state<{ ok: boolean; reason?: string } | null>(null); + // ─── Profile picture ────────────────────────────────────────────── + let pictureBusy = $state(false); + let pictureError = $state(null); + let pictureFileInput = $state(null); + + // "What strangers see" preview of the encrypted picture. Computed + // lazily — whenever picture or picture_key changes (or encryption + // gets turned on), regenerate the scrambled thumbnail. Memoised + // by `previewForKey` so we don't re-scramble on every keystroke. + let scrambledPreview = $state(null); + let previewForKey = $state(null); + let previewBusy = $state(false); + + $effect(() => { + // Re-read into locals so Svelte tracks them as deps. + const enc = session.myProfile?.encrypted; + const pic = session.myProfile?.picture; + const key = session.myProfile?.picture_key; + if (!enc || !pic || !key) { + scrambledPreview = null; + previewForKey = null; + return; + } + // Cheap memo key: same picture+same key = same scramble (modulo + // the salt the scrambler picks per-call, but we want a STABLE + // preview here — so we only recompute when inputs change). + const cacheKey = `${key}|${pic.length}`; + if (cacheKey === previewForKey && scrambledPreview) return; + + previewBusy = true; + void (async () => { + try { + const scrambled = await scrambleImage(pic, hexToBytes(key)); + scrambledPreview = scrambled; + previewForKey = cacheKey; + } catch (e) { + // Non-fatal: just hide the preview. + console.warn("encrypted-preview failed:", e); + scrambledPreview = null; + } finally { + previewBusy = false; + } + })(); + }); + + async function onPicturePicked(file: File) { + pictureBusy = true; + pictureError = null; + try { + const dataUrl = await resizeToAvatarDataUrl(file); + await setMyProfile({ picture: dataUrl }); + } catch (e) { + pictureError = (e as Error).message; + } finally { + pictureBusy = false; + } + } + + async function removePicture() { + pictureBusy = true; + pictureError = null; + try { + // saveMyProfile keeps the picture field as undefined → Avatar + // falls back to the identicon. We also re-publish the kind:0 + // event WITHOUT the picture so peers stop seeing the old one. + await setMyProfile({ picture: undefined }); + } catch (e) { + pictureError = (e as Error).message; + } finally { + pictureBusy = false; + } + } + let webPushOk = $state(false); // browser supports it at all let webPushOn = $state(false); // currently subscribed let webPushBusy = $state(false); @@ -122,10 +203,15 @@ if (webPushOn) { await disablePush(session.unlocked.handle, session.unlocked.seed); webPushOn = false; + // Remember the explicit opt-out so we don't auto-enable on + // the next unlock — would be annoying right after the user + // turned it off. + setPushAutoEnableDisabled(true); } else { const ok = await enablePush(session.unlocked.handle, session.unlocked.seed); webPushOn = ok; if (!ok) webPushError = "Permission denied."; + else setPushAutoEnableDisabled(false); } } catch (e) { webPushError = (e as Error).message; @@ -134,36 +220,74 @@ } } - async function showSeed() { + // ─── Reveal recovery phrase: gated by fresh passphrase ────────────── + // 30 seconds of access to an unlocked phone shouldn't reveal the + // recovery phrase — yet that's exactly what the old `showSeed` + // alert() did (it just popped the in-session cached phrase, no + // re-auth). Now we gate behind a fresh passphrase prompt that + // verifies by attempting to decrypt the IDB-stored blob — same + // path the initial unlock uses, so we know the auth is real. + // See TODO.md Day 2 #4. + let revealPromptOpen = $state(false); + let revealPromptPassphrase = $state(""); + let revealPromptError = $state(null); + let revealPromptBusy = $state(false); + + function openRevealPrompt() { if (!session.unlocked) return; - const phrase = session.unlocked.phrase; - if (phrase) { - alert( - `Your 12-word recovery phrase (KEEP SECRET):\n\n${phrase}\n\n` + - `Write these 12 words down in order — they're the ONLY way to ` + - `recover this account on another device.`, - ); - return; + revealPromptPassphrase = ""; + revealPromptError = null; + revealPromptOpen = true; + } + + function closeRevealPrompt() { + // Zero out the buffer before nulling so the passphrase doesn't + // linger as a JS-engine intern. Probably overkill given JS string + // semantics, but it's free defense-in-depth. + revealPromptPassphrase = ""; + revealPromptError = null; + revealPromptOpen = false; + } + + async function confirmRevealPrompt() { + if (!session.unlocked || revealPromptBusy) return; + revealPromptBusy = true; + revealPromptError = null; + try { + // unlockIdentity throws "wrong passphrase" on failure — that's + // our verification. We don't keep the freshly-unlocked struct; + // we just use the SAME session that's already in memory. + const fresh = await unlockIdentity(revealPromptPassphrase); + revealPromptOpen = false; + revealPromptPassphrase = ""; + // Prefer the freshly-decrypted phrase (works even if this + // session was originally biometric-unlocked — the passphrase + // PRF key wasn't available before but is now). + const phrase = fresh.phrase ?? session.unlocked.phrase; + if (phrase) { + alert( + `Your 12-word recovery phrase (KEEP SECRET):\n\n${phrase}\n\n` + + `Write these 12 words down in order — they're the ONLY way to ` + + `recover this account on another device.`, + ); + } else if (await hasStoredPhrase()) { + alert( + `Your recovery phrase couldn't be decrypted in this session. ` + + `Lock and unlock again with your passphrase to reveal it.`, + ); + } else { + const hex = bytesToHex(fresh.seed); + alert( + `Your recovery seed — hex form (KEEP SECRET):\n\n${hex}\n\n` + + `This account was created before 12-word phrases were supported. ` + + `The 64-character hex above is still your full recovery.`, + ); + } + } catch (e) { + revealPromptError = (e as Error).message; + } finally { + revealPromptBusy = false; } - // Phrase not in this session — distinguish two cases: - // 1. Account HAS a stored phrase but this session unlocked via - // biometric (PRF key doesn't decrypt the passphrase-keyed blob). - // 2. Genuinely pre-mnemonic legacy account — show hex. - if (await hasStoredPhrase()) { - alert( - `Your recovery phrase isn't available in this session.\n\n` + - `Biometric unlock doesn't decrypt the phrase. Lock and unlock ` + - `again with your passphrase to reveal it.`, - ); - return; - } - const hex = bytesToHex(session.unlocked.seed); - alert( - `Your recovery seed — hex form (KEEP SECRET):\n\n${hex}\n\n` + - `This account was created before 12-word phrases were supported. ` + - `The 64-character hex above is still your full recovery — write ` + - `it down somewhere safe.`, - ); } function lock() { @@ -175,7 +299,170 @@ {#if session.unlocked} -
+
+ +
+

+ Profile +

+
+ +
+ + {#if session.myProfile?.encrypted && session.myProfile?.picture && scrambledPreview} + + {@const previewSrc = scrambledPreview.split("#")[0]} +
+ Encrypted preview — what strangers see +
+ {:else if session.myProfile?.encrypted && session.myProfile?.picture && previewBusy} +
+ … +
+ {/if} +
+
+

+ {session.unlocked.handle}@{session.unlocked.server} +

+ {#if session.myProfile?.picture} +

+ Custom picture · {Math.ceil( + dataUrlBytes(session.myProfile.picture) / 1024, + )} KB · stored locally + published to nostr +

+ {#if session.myProfile?.encrypted && scrambledPreview} +

+ The little box is what strangers see on public nostr — + visually-scrambled noise. Your contacts see the real + picture. +

+ {/if} + {:else} +

+ Showing your auto-generated identicon. Pick a picture + to replace it. +

+ {/if} +
+
+ + + { + const f = (e.currentTarget as HTMLInputElement).files?.[0]; + if (f) void onPicturePicked(f); + (e.currentTarget as HTMLInputElement).value = ""; + }} + /> + +
+ + {#if session.myProfile?.picture} + + {/if} +
+ {#if pictureError} +

{pictureError}

+ {/if} + + +
+ +
+ +

+ Pictures are resized to 256×256 and published as a NIP-01 + kind:0 event. {session.myProfile?.encrypted ?? true + ? "Visually-encrypted images survive any nostr client that renders a PNG; only kez-chat-aware clients with the right key descramble." + : "Stored locally too so it works offline."} +

+
+

Appearance

@@ -239,7 +526,7 @@ 12 words that recover this account anywhere. Write them down on paper — losing them means losing the account.

-
@@ -341,3 +628,65 @@
{/if} + + +{#if revealPromptOpen} + +{/if} diff --git a/kez-chat/web/src/routes/Welcome.svelte b/kez-chat/web/src/routes/Welcome.svelte index 6a77b30..488ae11 100644 --- a/kez-chat/web/src/routes/Welcome.svelte +++ b/kez-chat/web/src/routes/Welcome.svelte @@ -93,7 +93,12 @@ {#if session.unlocked} -
+ +

Welcome — let's get you set up

@@ -101,7 +106,12 @@ A couple of quick steps. You can skip and come back anytime from Settings.

- + {session.unlocked.handle}@{session.unlocked.server}

{done} of {total} essentials done

diff --git a/kez-chat/web/src/sw.ts b/kez-chat/web/src/sw.ts index 336b48d..6358ca5 100644 --- a/kez-chat/web/src/sw.ts +++ b/kez-chat/web/src/sw.ts @@ -55,28 +55,13 @@ self.addEventListener("activate", (event) => { // and decrypts there. This keeps the push provider (Apple/Google/Mozilla) // from ever seeing message content even theoretically. -interface PushPayload { - type?: string; - to?: string; - seq?: number; -} - self.addEventListener("push", (event: PushEvent) => { - let data: PushPayload = {}; - if (event.data) { - try { - data = event.data.json() as PushPayload; - } catch { - // Some providers send a wake-up "" payload — fall through and - // show a generic notification so the user knows to open the app. - } - } - + // Payload is intentionally empty (see TODO.md Day 1 #3 — we used + // to send {to, seq} but that leaked the social graph to the push + // provider). Notification text has to be content-free; the user + // opens the app to see who messaged them. const title = "New kez-chat message"; - const body = - data.to !== undefined - ? `You have a new message in @${data.to}` - : "Open kez-chat to view it."; + const body = "Open kez-chat to view it."; // `renotify` is widely supported but isn't in the baseline TS DOM lib; // build the options as a plain object and cast. @@ -84,11 +69,10 @@ self.addEventListener("push", (event: PushEvent) => { body, icon: "/pwa-192x192.png", badge: "/pwa-64x64.png", - // Group same-conversation pings — iOS especially gets spammy - // otherwise. `renotify` lets the next one still vibrate. - tag: data.to ? `kez-chat:${data.to}` : "kez-chat:new", + // Single tag so successive pushes collapse into one notification + // pill (no spam if a friend sends 5 quick messages). + tag: "kez-chat:new", renotify: true, - data, } as NotificationOptions; event.waitUntil(self.registration.showNotification(title, options)); @@ -96,8 +80,12 @@ self.addEventListener("push", (event: PushEvent) => { self.addEventListener("notificationclick", (event: NotificationEvent) => { event.notification.close(); - const data = event.notification.data as PushPayload | undefined; - const targetUrl = data?.to ? `/chats/${encodeURIComponent(data.to)}` : "/chats"; + // Push payload is empty (TODO.md Day 1 #3) so there's no per-peer + // deep link to honour. Land everyone on the conversation list and + // let them tap through. ?from=push lets App.svelte log a tiny + // breadcrumb for "tapped notification → wrong page" reports. + const hashTarget = "/chats"; + const fullUrl = `/?from=push#${hashTarget}`; event.waitUntil( (async () => { @@ -111,11 +99,11 @@ self.addEventListener("notificationclick", (event: NotificationEvent) => { // Found an already-open kez-chat tab — focus it and ask the // SPA to navigate; cheaper than spawning a fresh window. await client.focus(); - client.postMessage({ type: "kez-chat/navigate", to: targetUrl }); + client.postMessage({ type: "kez-chat/navigate", to: hashTarget }); return; } } - await self.clients.openWindow(targetUrl); + await self.clients.openWindow(fullUrl); })(), ); });