feat(kez-chat): three days of security + UX + protocol work

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:<hex>
    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:<hex>" 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) <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-06-08 05:11:24 -06:00
parent f2970955dd
commit c8370ffdf0
43 changed files with 5609 additions and 208 deletions

37
.dockerignore Normal file
View File

@ -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

14
.gitignore vendored
View File

@ -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/

522
kez-chat/Cargo.lock generated
View File

@ -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"

View File

@ -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"

336
kez-chat/TODO.md Normal file
View File

@ -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: <handle>, seq}` to FCM/APNs/Mozilla
on every fanout. RFC 8291 encrypts the payload so the push provider can't
read the bytes, but the provider already knows the endpoint's owner — the
`to` field adds no information for the recipient and *does* give Google a
clear "message arrived for alice at T" timeline.
**Files:**
- `kez-chat/src/messages.rs:123-127` (the payload we hand to `push.fanout`)
- `kez-chat/web/src/sw.ts:78-99` (the `push` handler)
**Fix:** send `{}` as the payload. The service worker shows a generic
"New kez-chat message" notification — it can still focus an existing tab,
which navigates to the conversation list. Deep-linking to a specific peer
goes away on cold-open (acceptable trade-off — one extra tap to open the
right thread is the price of *not* exporting metadata to FCM).
### #5. Rename the routing tag from `h` to something less claimed [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=<plain handle> sub_count=N` at INFO level. Operator-side log
retention turns this into a permanent "who's chatting" ledger. Even if
we trust the operator (it's us), forensics on a stolen log file leaks the
social graph in plaintext.
**Files:**
- `kez-chat/src/push.rs:259-262, 275-281` (fanout + send logging)
- `kez-chat/src/api.rs:387-393` (subscribe registration)
**Fix:** demote the handle-bearing INFO lines to DEBUG. Replace the visible
field with a short HMAC of the handle under a server-instance secret so we
can still group "all sends for X" in logs without exposing X. Set log level
in production to INFO, so DEBUG lines are off by default.
---
## Day 2 — another half-day
### #2. Replay protection — bound + timestamp freshness [DONE]
**Why it matters:** `SEEN_CAP=500` evicts oldest event ids once we've seen
500 messages. An active user rolls past that in days, then a malicious
relay can re-broadcast any old event and we accept it as a fresh message —
the decrypted `sent_at` is never compared to wall-clock.
**Files:**
- `kez-chat/web/src/lib/nostr-transport.ts:107` (`SEEN_CAP = 500`)
- `kez-chat/web/src/lib/nostr-transport.ts:142` (`slice(-SEEN_CAP)`)
- `kez-chat/web/src/lib/crypto.ts:161-205` (`openMessage` — no freshness check)
**Fix:**
1. Bump `SEEN_CAP` to 10_000 and move from localStorage to IndexedDB so the
set isn't capped by the 5MB localStorage quota.
2. In `openMessage`, reject envelopes where `|now sent_at| > 7 days`.
3. Also clamp `ev.created_at` to `[now 7d, now + 5min]` before using it
as a seq generator — otherwise a relay can backdate or future-date
events and either replay or skip-ahead `bumpSince`.
### #4. Reveal-recovery-phrase requires fresh auth [DONE]
**Why it matters:** 30 seconds of access to an unlocked phone = full
identity exfil. The Settings → Reveal Phrase button decrypts straight from
the persistent-session blob with no re-prompt.
**Files:**
- `kez-chat/web/src/routes/Settings.svelte` (the Reveal flow)
**Fix:** gate Reveal Phrase + Lock + biometric setup behind a fresh
passphrase prompt OR a WebAuthn assertion. Same model Apple/1Password use:
"this action requires your password again".
### #15. Rate-limit `POST /v1/messages` [DONE]
**Why it matters:** the endpoint currently accepts anonymous posts (no
auth on send) capped at 256KB per envelope. A bot can fill any mailbox
until disk fills. Acknowledged in `messages.rs:18-20` ("Spam: v0.1 doesn't
gate POST").
**Files:**
- `kez-chat/src/messages.rs:70-133`
**Fix (v0.1):** per-IP token bucket — 60 messages/min per source IP. Drop
overflow with 429.
**Fix (v0.2):** require the sender to sign with their KEZ primary; chat-
server verifies. Becomes useless for cross-server v0.2 unless the sender's
server vouches.
---
## Roadmap — multi-day, needs design pass
### #6. Forward secrecy (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 `<all_urls>`, any XSS, any compromised npm dep can call
`restoreSession()` and lift the seed. My comment in
`persistent-session.ts:18-23` overstates the actual protection.
**Fix:** gate `restoreSession()` on a user-gesture WebAuthn assertion
(touchID / passkey). Background scripts can't fake a user gesture, so the
seed never gets decrypted unattended. Falls back to passphrase on devices
without WebAuthn.
### #8. Rotate addr daily (`info = "v1|YYYYMMDD"`) [ROADMAP]
**Why it matters:** a relay scrapes `#h` filter values + the public KEZ
directory + builds a rainbow table mapping `addr → primary → handle`. The
hash buys little when the input space is enumerable. Per-day addr
rotation forces the rainbow table to be rebuilt daily and stops long-term
correlation.
**Trade-off:** receivers need to subscribe to multiple addrs during the
boundary day (yesterday's + today's). Listener server-side needs the same.
Migration logic isn't hard but isn't free.
### #9. Unforgeable delivery acks [DONE — Day 3 Option A]
**Why it matters:** anyone who saw an event id can publish a fake kind-4244
ack. Sender's UI shows false "delivered". Cosmetic-only today; will be a
real problem when someone builds a tracker bot.
**Fix:** ack payload = recipient's ed25519 signature over the acked event
id. Sender verifies against the recipient's known KEZ primary. Free —
already have ed25519 plumbing.
### #10. NIP-65 outbox model [PARTIAL — Day 3 Option B]
Publish-side only. We now emit a `kind:10002` event on first session
alongside the kind:0 baseline, listing our 3 default relays as
read+write. NIP-65-aware clients can discover where to reach us.
What's still missing: when SENDING to a peer, we should fetch their
`kind:10002` and union their read-relays with ours. v0.2 — needs a
deeper transport refactor (per-message relay set).
We hardcode 3 relays for every user. Real nostr clients publish
`kind:10002` listing their preferred read+write relays; senders publish to
each recipient's published read-relays. Without this, isolated networks
of users on different relay sets can't reach each other.
### #11. NIP-42 AUTH support [DONE — Day 3 Option B]
damus.io regularly requires NIP-42 AUTH for DM-kind reads. Without it our
subscriptions get rejected silently. Add the client AUTH handshake +
support being prompted by the relay.
### #12. Publish a minimal kind-0 profile on first use [DONE — Day 3 Option B]
Some relays silently drop writes from "unknown" pubkeys (no kind-0). A
single minimal `kind:0` per derived nostr pubkey (just `{"name":"kez-chat
user"}`) unblocks this without revealing anything.
### #13. NIP-25 ack shape with `["p", senderNostrPubkey]` [DONE — Day 3 Option B]
Our kind-4244 ack is custom. Adopting the NIP-25 shape gets free interop
with nostr clients that already render reactions — handy if we ever expose
the underlying events.
### #14. Shorten `since=` default cursor [DONE — Day 3 Option A]
Default 7-day cursor exceeds most relay retention windows (often 13
days). Fresh devices on quiet conversations silently miss messages.
Shorten to 48h + augment with explicit "fetch full history" UI for the
rare resurrect case.
### #16. Bounded concurrency on push fanout [DONE — Day 3 Option A]
**Why it matters:** every send spawns an unbounded `tokio::spawn` to fan
out push. Under flood, OOM.
**Files:**
- `kez-chat/src/messages.rs:128`
**Fix:** semaphore-bound to ~32 concurrent fanouts. Excess queues; under
extreme flood we drop with a warn-log rather than swap-thrash.
---
## Visually-encrypted profile pictures (new feature, in progress)
### Phase 1A — local scramble + per-contact key wrap [DONE this commit]
- `kez-chat/web/src/lib/visual-crypto.ts` — keyed Fisher-Yates pixel
permutation + xoshiro256** PRNG. Output is a valid PNG with same
dimensions, scrambled content. Salt embedded as `#kez-visual-v1:<hex>`
URL fragment so descramble doesn't need out-of-band metadata.
- `profile-store.ts` — profile gains `encrypted: boolean` (default
true) + `picture_key` (local-only). On publish: scramble the picture,
wrap the visual key for each contact via the existing
`sealMessage()` envelope, embed as `kez_visual_keys` map in the
kind:0 content.
- Settings — "Visually encrypt picture (recommended)" toggle, default ON.
### Phase 1B — peer descramble [DONE this commit]
- `peer-profile-store.ts` (new): IDB cache + one-shot `pool.querySync`
fetch of the peer's kind:0 metadata event. On hit, looks up our
primary in `kez_visual_keys`, opens the SealedEnvelope wrap to
recover the visual key, descrambles `metadata.picture`, caches the
rendered data URL.
- `peer-profile-cell.svelte.ts` (new): reactive Svelte 5 mirror over
the IDB cache so component re-renders are automatic on fetch.
- `nostr-transport.ts`: surfaces `sender_nostr_pubkey` on every
inbound DM. `conversations-store.ts` persists it on the conversation
row so we can locate the peer's kind:0 later.
- `inbox-service.svelte.ts`: on every fresh DM, fires off a profile
fetch for the sender — first DM lights up their avatar.
- `Messages.svelte`: hydrates the cache on mount, kicks off refreshes
for every visible conversation, threads cached pictures through
both Avatar usages (conversation list + thread header).
- Conversation list re-renders on cache update; staleness window 24h.
Edges noted for later: peers we've only *sent* to (never received from)
have no `peer_nostr_pubkey` until they reply, so they don't get a
picture lookup yet. Easy follow-up: backfill pubkey from a NIP-05 or
WebFinger lookup, or proactively probe relays for `kind:0` events whose
content tags match a known primary.
### Phase 1C — UX polish [TODO]
- "X contacts can see your real picture" hint in Settings.
- Re-publish kind:0 automatically when a new conversation is created
(so the new contact gets key-wrapped without the user re-saving).
- Optional: per-image AES-CTR mode for uniform-noise output (stronger,
less "visually meaningful").
---
## Acknowledged trade-offs (won't fix in v0.1)
### Persistent-session is no stronger than the biometric path
The `non-extractable AES key` story stops `exportKey`, NOT
`decrypt`+`read`. Anyone with origin-execution access (XSS, malicious
extension) can lift the seed. Document this honestly in the README and the
file header. Real fix is #7 above.
### 30-day TTL is client-only
`expiresAt` in localStorage is editable by anyone with file-system access.
Server-side device binding (issue a signed nonce on unlock, expire at the
server) would help but adds round-trips. v0.2 candidate.
### Identity-key reuse is safe under current crypto
ed25519 seed → ed25519 (sigchain, envelope sig) + x25519 (ECDH) + HKDF →
secp256k1 (nostr signer). The auditor confirmed: no cross-curve
chosen-message attack path. Standard libsodium pattern.
---
## Tracking + cross-references
- Nostr-protocol review: see commit message of this commit; full report in
the audit-trail.
- Security audit: ditto.
- Owner: tudisco
- Last updated: 2026-06-08

View File

@ -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/

View File

@ -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 812 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"]

View File

@ -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

View File

@ -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<String>,
}
async fn push_list_subscriptions(
State(state): State<AppState>,
Path(handle): Path<String>,
headers: axum::http::HeaderMap,
) -> Result<Json<PushSubscriptionsResponse>, 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 <ts>:<sig>".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<String> = 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<AppState>) -> Html<String> {
Html(format!(
r#"<!DOCTYPE html>

View File

@ -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<String>,
}

View File

@ -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",
}
}

View File

@ -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;

View File

@ -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<String> = 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)

View File

@ -69,8 +69,19 @@ pub struct SendMessageResponse {
pub async fn send_message(
State(state): State<AppState>,
headers: HeaderMap,
Json(req): Json<SendMessageRequest>,
) -> Result<Json<SendMessageResponse>, 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;
});

View File

@ -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::<Sha256>::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<Vec<(String, Identity)>, 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::<Result<Vec<_>, _>>()?;
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<String>) {
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<String> = HashSet::new();
let mut subscription: Option<SubscriptionId> = 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<String, String> =
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<String> =
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<String>,
members: HashSet<String>,
}
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
}
}

View File

@ -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<tokio::sync::Semaphore>,
}
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<u64> = 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()
}

206
kez-chat/src/rate_limit.rs Normal file
View File

@ -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<Mutex<HashMap<IpAddr, Bucket>>>,
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::<IpAddr>() {
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::<IpAddr>() {
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");
}
}

View File

@ -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");
}
});

View File

@ -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 @@
}
</script>
<svg
width={size}
height={size}
viewBox="0 0 5 5"
class="shrink-0"
style="border-radius: {Math.max(4, size * 0.2)}px; {ring ? `box-shadow: 0 0 0 2px var(--color-accent);` : ''}"
role="img"
aria-label="identity avatar"
>
<rect width="5" height="5" fill={tile} />
{#each [0, 1, 2, 3, 4] as col}
{#each [0, 1, 2, 3, 4] as row}
{#if isOn(col, row)}
<rect x={col} y={row} width="1" height="1" fill={fg} />
{/if}
{#if picture}
<img
src={picture}
width={size}
height={size}
alt="profile picture"
class="shrink-0 object-cover"
style="border-radius: {Math.max(4, size * 0.2)}px; {ring
? 'box-shadow: 0 0 0 2px var(--color-accent);'
: ''}"
/>
{:else}
<svg
width={size}
height={size}
viewBox="0 0 5 5"
class="shrink-0"
style="border-radius: {Math.max(4, size * 0.2)}px; {ring ? `box-shadow: 0 0 0 2px var(--color-accent);` : ''}"
role="img"
aria-label="identity avatar"
>
<rect width="5" height="5" fill={tile} />
{#each [0, 1, 2, 3, 4] as col}
{#each [0, 1, 2, 3, 4] as row}
{#if isOn(col, row)}
<rect x={col} y={row} width="1" height="1" fill={fg} />
{/if}
{/each}
{/each}
{/each}
</svg>
</svg>
{/if}

View File

@ -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<void> {
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<void> {
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<void> {
status?: MessageStatus;
}): Promise<number> {
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<void> {
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<boolean> {
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;
}
}

View File

@ -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<CryptoKey> {
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<SealedEnvelope> {
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<SealedEnvelopeV2> {
// ─── 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<MessagePlaintext> {
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<MessagePlaintext> {
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<MessagePlaintext> {
// 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;
}

View File

@ -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 35 MB; a 256×256
// crop at 0.85 quality lands at 1020 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<string> {
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<HTMLImageElement> {
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;
}

View File

@ -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 {

View File

@ -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<void> {
/* server transport: receipts not implemented in v0.1 */
}
export async function flushPendingAcks(_seed: Uint8Array): Promise<void> {
/* 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<Map<string, string | undefined>> {
/* 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;
}

View File

@ -24,8 +24,14 @@ import type { Identity } from "./kez.js";
/** Regular event kind (10009999 → 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");

View File

@ -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 httpws, 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<string, boolean>();
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<Event>);
}).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<string, Set<{ url?: string }>> };
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 13 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: <recipient's nostr-derived pubkey>,
// content: "", // no body — id-only ack
// tags: [
// ["h", <original sender's addr>], // so sender can subscribe + filter
// ["e", <acked event id>], // 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<void> {
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<void> {
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<Map<string, string | undefined>> {
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<string, string | undefined>();
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() {

View File

@ -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<Record<string, CachedPeerProfile>>({});
/** 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<CachedPeerProfile | undefined> {
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();

View File

@ -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[<my_primary>]` 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<string, CachedPeerProfile>;
async function readCache(): Promise<Cache> {
return (await get<Cache>(CACHE_KEY)) ?? {};
}
async function writeCache(c: Cache): Promise<void> {
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<void> {
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<CachedPeerProfile | undefined> {
// 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<never>((_, 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<string, unknown>;
try {
metadata = JSON.parse(latest.content);
} catch (e) {
console.warn(`peer-profile: bad kind:0 content for ${opts.peer_primary}`, e);
return cached;
}
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<string, unknown>;
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);
}
}

View File

@ -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<IDBDatabase> {
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<T>(key: string): Promise<T | undefined> {
return openDb().then(
(db) =>
new Promise<T | undefined>((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<void> {
return openDb().then(
(db) =>
new Promise<void>((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<void> {
return openDb().then(
(db) =>
new Promise<void>((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<CryptoKey> {
const existing = await idbGet<CryptoKey>(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<void> {
try {
const key = await getOrCreateKey();
const iv = crypto.getRandomValues(new Uint8Array(12));
// Casts side-step a TS DOM-lib quirk where the strict
// ArrayBufferView<ArrayBuffer> type doesn't accept our generic
// Uint8Array<ArrayBufferLike>; 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<UnlockedIdentity | null> {
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<CryptoKey>(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<void> {
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;
}
}

View File

@ -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<StoredProfile | null> {
return (await get<StoredProfile>(PROFILE_KEY)) ?? null;
}
export async function saveMyProfile(profile: StoredProfile): Promise<void> {
await set(PROFILE_KEY, profile);
}
export async function clearMyProfile(): Promise<void> {
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<void> {
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<StoredProfile | null> {
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<never>((_, 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<string, unknown>;
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<string, unknown>;
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<string | null> {
try {
const sk = nostrSecretFromSeed(seed);
const metadata: Record<string, unknown> = {};
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: { <contact_primary>: <sealed
// envelope containing { key } > }.
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<string, unknown> = {};
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;
}
}

View File

@ -204,6 +204,65 @@ export async function isPushSubscribed(): Promise<boolean> {
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

View File

@ -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<void> {
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<UnlockedIdentity | null>(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<StoredProfile | null>(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<boolean> {
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<StoredProfile>,
): Promise<string | null> {
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,
);
}

View File

@ -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;

View File

@ -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<HTMLImageElement> {
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<string> {
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<string> {
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<boolean> {
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;
}
}

View File

@ -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({

View File

@ -288,7 +288,7 @@
}
</script>
<div class="space-y-6">
<div class="max-w-2xl mx-auto px-4 py-6 space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-text">Add a claim</h1>
<button

View File

@ -156,7 +156,7 @@
}
</script>
<div class="space-y-6">
<div class="max-w-2xl mx-auto px-4 py-6 space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-text">Claims</h1>
<div class="flex gap-2">

View File

@ -154,7 +154,7 @@
</script>
{#if session.unlocked}
<div class="space-y-8">
<div class="max-w-2xl mx-auto px-4 py-6 space-y-8">
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
<section class="border border-gray-200 rounded-lg p-6 bg-white">

View File

@ -113,11 +113,16 @@
</script>
{#if session.unlocked}
<div class="max-w-2xl mx-auto space-y-6">
<div class="max-w-2xl mx-auto px-4 py-6 space-y-6">
<!-- Identity card -->
<section class="bg-surface border border-border rounded-xl p-6">
<div class="flex items-start gap-4">
<Avatar seed={session.unlocked.primary} size={64} ring />
<Avatar
seed={session.unlocked.primary}
size={64}
ring
picture={session.myProfile?.picture}
/>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-mono text-lg font-semibold text-text truncate inline-flex items-center gap-1">

View File

@ -2,7 +2,13 @@
import { onMount, onDestroy } from "svelte";
import { push } from "svelte-spa-router";
import { session } from "../lib/store.svelte.js";
import { sendMessage } from "../lib/transport.js";
import {
sendMessage,
getRelayStatuses,
activeTransport,
fetchAcksForEventIds,
type RelayStatus,
} from "../lib/transport.js";
import { lookup, lookupByPrimary, ApiError } from "../lib/api.js";
import { inboxService } from "../lib/inbox-service.svelte.js";
import { verifySubject } from "../lib/verify.js";
@ -10,10 +16,21 @@
import EmojiButton from "../lib/EmojiButton.svelte";
import Avatar from "../lib/Avatar.svelte";
import VerifiedBadge from "../lib/VerifiedBadge.svelte";
import { peerProfiles } from "../lib/peer-profile-cell.svelte.js";
import {
pushSupported,
isPushSubscribed,
enablePush,
isStandalonePwa,
isIos,
} from "../lib/push.js";
import {
appendOutbound,
ensureConversation,
listConversations,
markConversationRead,
markDeliveredByEventId,
markOutboundStatus,
setVerified,
type Conversation,
} from "../lib/conversations-store.js";
@ -26,6 +43,41 @@
? conversations.find((c) => c.peer_primary === activePrimary) ?? null
: null,
);
// Force-refresh the active peer's profile when the user opens
// their thread. The bulk-scan refresh on /chats mount honours a
// 6h staleness gate (cheap), but clicking into someone's thread
// is a strong "I care right now" signal — skip the cache and
// refetch their kind:0. Catches "they just updated their picture"
// immediately on the surface that matters most.
//
// Track only activePrimary (not activeConv) so this fires once
// per thread open, not every time conversations[] re-paints —
// otherwise every inbound message would trigger a force-refresh.
$effect(() => {
const pk = activePrimary;
if (!pk || !session.unlocked) return;
// Untrack `conversations` reads with $state.snapshot wrapped in
// queueMicrotask so we don't add it as a dependency. Cheaper:
// just look the conversation up directly from the IDB-backed
// store on the next microtask.
queueMicrotask(async () => {
if (!session.unlocked) return;
// Opening a thread = "I've seen it" — clear the unread badge
// for THIS conversation. Other conversations keep their counts.
await markConversationRead(pk);
await refresh();
const conv = conversations.find((c) => c.peer_primary === pk);
if (!conv?.peer_nostr_pubkey) return;
void peerProfiles.refresh({
peer_primary: conv.peer_primary,
peer_nostr_pubkey: conv.peer_nostr_pubkey,
my_handle: session.unlocked.handle,
my_seed: session.unlocked.seed,
forceRefresh: true,
});
});
});
let composeText = $state("");
let composing = $state(false);
let composeEl: HTMLInputElement | null = $state(null);
@ -134,11 +186,131 @@
// Toast for the share-link copy action.
let copied = $state(false);
// Tap-to-zoom for the thread-header avatar. Renders a fullscreen
// overlay with the picture upscaled. Click anywhere outside (or
// Escape) to dismiss.
let zoomedAvatarOpen = $state(false);
function closeZoomedAvatar() {
zoomedAvatarOpen = false;
}
function onZoomKeydown(e: KeyboardEvent) {
if (e.key === "Escape") closeZoomedAvatar();
}
// ─── Relay state ──────────────────────────────────────────────────────
// The "● live (N)" header is a tiny live view onto the transport's
// relay pool. We poll every 2s rather than subscribing because (a)
// nostr-tools' SimplePool doesn't fire a "connectionstate" event and
// (b) at 3 relays this costs nothing. The popover renders the same
// RelayStatus[] array on demand.
let relayStatuses = $state<RelayStatus[]>(getRelayStatuses());
let relayPopoverOpen = $state(false);
let relayPopoverEl = $state<HTMLDivElement | null>(null);
let relayButtonEl = $state<HTMLButtonElement | null>(null);
let relayPollTimer: ReturnType<typeof setInterval> | null = null;
let liveRelayCount = $derived(relayStatuses.filter((r) => r.connected).length);
let totalRelayCount = $derived(relayStatuses.length);
function toggleRelayPopover() {
relayPopoverOpen = !relayPopoverOpen;
}
function onDocumentClick(e: MouseEvent) {
if (!relayPopoverOpen) return;
const t = e.target as Node;
if (relayPopoverEl?.contains(t)) return;
if (relayButtonEl?.contains(t)) return;
relayPopoverOpen = false;
}
// ─── Push-notification nudge ──────────────────────────────────────────
// The previous default was "off until the user finds Settings and
// toggles it" — almost nobody did, so people thought push was broken.
// Now we show a friendly banner at the top of /chats whenever push is
// supported AND the user hasn't subscribed yet. Dismissals are sticky
// for 7 days so we don't nag, and explicit Enable shows the system
// prompt right there.
const PUSH_NUDGE_DISMISS_KEY = "kez-chat:push-nudge-dismissed-until";
let pushNudgeVisible = $state(false);
let pushNudgeBusy = $state(false);
let pushNudgeError = $state<string | null>(null);
let pushNudgeNeedsPwa = $state(false);
async function evaluatePushNudge() {
pushNudgeVisible = false;
pushNudgeError = null;
pushNudgeNeedsPwa = false;
if (!session.unlocked) return;
// Suppressed within the 7-day "maybe later" window?
try {
const until = parseInt(
localStorage.getItem(PUSH_NUDGE_DISMISS_KEY) ?? "0",
10,
);
if (until > Date.now()) return;
} catch {
/* private mode */
}
// iOS-not-installed: banner mentions the install step instead of
// pretending the user can enable from here (they can't).
if (isIos() && !isStandalonePwa()) {
pushNudgeNeedsPwa = true;
pushNudgeVisible = true;
return;
}
if (!pushSupported()) return;
// Already subscribed? Nothing to nudge about.
const subscribed = await isPushSubscribed();
if (subscribed) return;
pushNudgeVisible = true;
}
async function enablePushFromNudge() {
if (!session.unlocked) return;
pushNudgeBusy = true;
pushNudgeError = null;
try {
const ok = await enablePush(session.unlocked.handle, session.unlocked.seed);
if (ok) {
pushNudgeVisible = false;
} else {
// User clicked "Block" in the system permission prompt — no
// way to recover without browser-settings intervention. Don't
// hide the banner so they see a hint about it.
pushNudgeError =
"Permission blocked. Re-enable in your browser's site settings, then refresh.";
}
} catch (e) {
pushNudgeError = (e as Error).message;
} finally {
pushNudgeBusy = false;
}
}
function dismissPushNudge() {
try {
const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
localStorage.setItem(
PUSH_NUDGE_DISMISS_KEY,
String(Date.now() + sevenDaysMs),
);
} catch {
/* private mode */
}
pushNudgeVisible = false;
}
onMount(async () => {
if (!session.unlocked) {
push("/unlock");
return;
}
// Hydrate the in-memory mirror of peer profiles before the first
// refresh() so the conversation rows have avatars from disk on
// first paint (avoids a "identicon → real picture" flash).
await peerProfiles.hydrate();
await refresh();
// Kick off verification for every existing conversation (24h cache per
// peer), so the verified badge shows in the list without opening each
@ -151,14 +323,77 @@
unsubscribe = inboxService.onMessage(() => void refresh());
// Landing here = the user has seen new messages; reset the badge.
inboxService.markAllRead();
// Relay state poll.
relayPollTimer = setInterval(() => {
relayStatuses = getRelayStatuses();
}, 2_000);
document.addEventListener("click", onDocumentClick);
void evaluatePushNudge();
});
onDestroy(() => {
unsubscribe?.();
if (relayPollTimer) clearInterval(relayPollTimer);
document.removeEventListener("click", onDocumentClick);
});
async function refresh() {
conversations = await listConversations();
// Kick off peer-profile fetches for any conversation whose peer
// nostr pubkey we know — most-recent-first so the visible rows
// light up fastest. Fire-and-forget; the reactive cell repaints
// when each fetch returns. Cached entries inside the staleness
// window short-circuit at the peer-profile-store layer.
if (session.unlocked) {
const seed = session.unlocked.seed;
const myHandle = session.unlocked.handle;
for (const c of conversations) {
if (!c.peer_nostr_pubkey) continue;
void peerProfiles.refresh({
peer_primary: c.peer_primary,
peer_nostr_pubkey: c.peer_nostr_pubkey,
my_handle: myHandle,
my_seed: seed,
});
}
}
// Catch-up ack scan: any outbound bubble still showing "sent"
// (single check, no circle) might have already been acked by
// the recipient — we just missed the ack event in the live
// stream (offline window, relay flap, etc.). Query relays for
// any kind-4244 events that reference our pending event ids
// and mark the matching bubbles as delivered.
//
// Cap at 200 recent ids to keep the filter small; older ones
// would be slow to query and unlikely to still be in any relay's
// cache anyway.
const pending: string[] = [];
for (const c of conversations) {
for (const m of c.messages) {
if (m.direction === "out" && m.status === "sent" && m.event_id) {
pending.push(m.event_id);
}
}
}
if (pending.length > 0) {
const idsToCheck = pending.slice(-200);
try {
const acked = await fetchAcksForEventIds(idsToCheck);
if (acked.size > 0) {
// markDeliveredByEventId verifies the sig (if any) against
// the conversation peer's KEZ primary. Unsigned acks from
// pre-Day-3 clients still flip the bubble (graceful
// degradation); signed-but-forged acks are dropped silently.
for (const [id, sigHex] of acked.entries()) {
await markDeliveredByEventId(id, sigHex);
}
conversations = await listConversations(); // repaint
}
} catch (e) {
console.warn("catch-up ack scan failed:", e);
}
}
}
// Verify the active peer whenever a conversation is opened (covers
@ -236,28 +471,63 @@
async function send() {
if (!session.unlocked || !activeConv || !composeText.trim()) return;
composing = true;
const body = composeText;
composeText = "";
// 1. Optimistic local echo — the bubble appears INSTANTLY in
// "sending" state. No matter how slow the relay handshake is,
// the user sees their own message immediately. Status icon
// flips to "sent" once at least one relay accepts, then to
// "delivered" when the recipient's client publishes an ack.
const peer_primary = activeConv.peer_primary;
const peer_handle = activeConv.peer_handle;
let localSeq: number;
try {
const body = composeText;
composeText = "";
await sendMessage({
senderHandle: session.unlocked.handle,
senderSeed: session.unlocked.seed,
senderPrimary: session.unlocked.primary,
recipient: activeConv.peer_handle || activeConv.peer_primary,
body,
});
await appendOutbound({
peer_primary: activeConv.peer_primary,
peer_handle: activeConv.peer_handle,
localSeq = await appendOutbound({
peer_primary,
peer_handle,
from: session.unlocked.primary,
body,
status: "sending",
});
await refresh();
} catch (e) {
alert(`Send failed: ${(e as Error).message}`);
composeText = composeText; // no-op, keep linter happy
} finally {
alert(`Local append failed: ${(e as Error).message}`);
composing = false;
return;
}
// 2. Fire the actual publish in the background. The compose
// field is already free so the user can keep typing.
composing = false;
try {
const result = await sendMessage({
senderHandle: session.unlocked.handle,
senderSeed: session.unlocked.seed,
senderPrimary: session.unlocked.primary,
recipient: peer_handle || peer_primary,
body,
// We already have the recipient's primary from the
// conversation row — pass it so the nostr transport can skip
// the /v1/u/:handle lookup. Chat over nostr should NOT
// depend on the chat-server; if the server's down, we still
// publish to relays and the recipient still gets the message.
recipientPrimary: peer_primary,
// Reply over the same relay that delivered the recipient's
// most recent message to us — usually the lowest-latency
// path for the round-trip. Falls back to our default set if
// unset.
preferRelay: activeConv?.peer_via_relay,
});
await markOutboundStatus(peer_primary, localSeq, "sent", {
event_id: result.event_id,
accepted_by: result.accepted_by,
});
await refresh();
} catch (e) {
console.error("sendMessage failed:", e);
await markOutboundStatus(peer_primary, localSeq, "failed");
await refresh();
}
}
@ -297,25 +567,144 @@
}
</script>
<div class="flex h-full bg-bg">
<!-- Sidebar (conversation list). On mobile it's full-width and hides
when a conversation is open. -->
<div class="flex flex-col h-full bg-bg">
<!--
Push-enable nudge. Sits above both sidebar + thread so users get
the prompt regardless of which view they're in. Silent + skipped
entirely when push is already on, was dismissed in the last
7 days, or isn't supported.
-->
{#if pushNudgeVisible}
<div class="shrink-0 px-3 py-2 sm:py-2.5 bg-accent/10 border-b border-accent/30 flex items-start sm:items-center gap-3 text-sm">
<span class="text-base sm:text-lg shrink-0 leading-none" aria-hidden="true">🔔</span>
<div class="flex-1 min-w-0">
{#if pushNudgeNeedsPwa}
<p class="text-text">
<strong class="font-semibold">Want notifications?</strong>
Tap <strong>Share</strong> in Safari, then
<strong>Add to Home Screen</strong> — iOS only delivers
push to installed apps.
</p>
{:else}
<p class="text-text">
<strong class="font-semibold">Get notified about new messages</strong>
— even when kez-chat is closed.
</p>
{/if}
{#if pushNudgeError}
<p class="text-xs text-danger mt-1">{pushNudgeError}</p>
{/if}
</div>
<div class="flex items-center gap-2 shrink-0">
{#if !pushNudgeNeedsPwa}
<button
type="button"
class="px-3 py-1.5 text-xs font-semibold bg-accent text-accent-contrast rounded-md hover:bg-accent-dim disabled:opacity-50"
disabled={pushNudgeBusy}
onclick={enablePushFromNudge}
>
{pushNudgeBusy ? "…" : "Enable"}
</button>
{/if}
<button
type="button"
class="px-2 py-1.5 text-xs text-text-muted hover:text-text"
onclick={dismissPushNudge}
aria-label="Dismiss notification prompt"
title="Dismiss for 7 days"
>
{pushNudgeNeedsPwa ? "Got it" : "Later"}
</button>
</div>
</div>
{/if}
<div class="flex flex-1 min-h-0">
<!-- Sidebar (conversation list). On mobile it's full-width and hides
when a conversation is open. -->
<aside class={`${activeConv ? "hidden" : "flex"} sm:flex w-full sm:w-80 shrink-0 border-r border-border bg-surface flex-col`}>
<!-- Header: your KEZ + status -->
<div class="p-3 border-b border-border">
<div class="flex items-center justify-between gap-2">
<h1 class="text-sm font-semibold text-text uppercase tracking-wider">Chats</h1>
<span class="text-xs">
{#if inboxService.status === "live"}
<span class="text-accent">● live</span>
{:else if inboxService.status === "reconnecting"}
<span class="text-warning">● reconnecting</span>
{:else if inboxService.status === "connecting"}
<span class="text-text-muted">○ connecting</span>
{:else}
<span class="text-text-muted">○ off</span>
<!--
Live indicator — also a button that pops a small panel
listing every configured relay (or the single chat-server
on server transport) and whether its socket is currently
open. The count in parens is the *connected* relay count,
not the configured total: 0 → red, partial → yellow,
full → green.
-->
<div class="relative">
<button
bind:this={relayButtonEl}
type="button"
onclick={toggleRelayPopover}
class="text-xs font-mono px-1.5 py-0.5 rounded hover:bg-elevated transition-colors"
aria-haspopup="true"
aria-expanded={relayPopoverOpen}
aria-label="{liveRelayCount} of {totalRelayCount} {activeTransport === 'nostr' ? 'relays' : 'servers'} connected — click for details"
title="{activeTransport === 'nostr' ? 'Nostr relays' : 'Chat server'}: {liveRelayCount}/{totalRelayCount} connected"
>
{#if inboxService.status === "live"}
<span class="text-accent">● live</span>
{:else if inboxService.status === "reconnecting"}
<span class="text-warning">● reconnecting</span>
{:else if inboxService.status === "connecting"}
<span class="text-text-muted">○ connecting</span>
{:else}
<span class="text-text-muted">○ off</span>
{/if}
<!-- "(7)" — only show the count when the transport actually
has multiple relays, OR when nostr is active (since the
user explicitly asked for it). Hiding "(1)" on server
transport keeps the header uncluttered. -->
{#if activeTransport === "nostr" || totalRelayCount > 1}
<span class="text-text-muted">({liveRelayCount})</span>
{/if}
</button>
{#if relayPopoverOpen}
<div
bind:this={relayPopoverEl}
class="absolute right-0 top-full mt-1 z-30 w-72 bg-surface border border-border rounded-lg shadow-lg p-3"
role="dialog"
aria-label="Relay status"
>
<div class="flex items-center justify-between mb-2">
<p class="text-[10px] uppercase tracking-wider text-text-muted font-semibold">
{activeTransport === "nostr" ? "Nostr relays" : "Chat server"}
</p>
<p class="text-[10px] text-text-muted font-mono">
{liveRelayCount}/{totalRelayCount} up
</p>
</div>
<ul class="space-y-1">
{#each relayStatuses as r (r.url)}
<li class="flex items-center gap-2 text-xs">
<span
class={r.connected
? "text-accent shrink-0"
: "text-text-muted shrink-0"}
aria-hidden="true"
>{r.connected ? "●" : "○"}</span>
<span class="font-mono text-text truncate" title={r.url}>
{r.url.replace(/^wss?:\/\//, "")}
</span>
</li>
{/each}
{#if relayStatuses.length === 0}
<li class="text-xs text-text-muted italic">
No relays configured.
</li>
{/if}
</ul>
<p class="mt-3 pt-2 border-t border-border text-[10px] text-text-muted">
Transport: <span class="font-mono text-text">{activeTransport}</span>
</p>
</div>
{/if}
</span>
</div>
</div>
{#if session.unlocked}
<button
@ -378,7 +767,11 @@
onclick={() => (activePrimary = c.peer_primary)}
>
{#if active}<span class="absolute left-0 top-0 bottom-0 w-0.5 bg-accent"></span>{/if}
<Avatar seed={c.peer_primary} size={40} />
<Avatar
seed={c.peer_primary}
size={40}
picture={peerProfiles.byPrimary[c.peer_primary]?.picture}
/>
<div class="min-w-0 flex-1">
<p class="font-mono text-sm font-semibold text-text truncate flex items-center gap-1">
<span class="truncate">{displayName(c)}</span>
@ -392,7 +785,21 @@
<p class="text-xs text-text-muted italic">No messages yet</p>
{/if}
</div>
{#if last}<span class="text-[10px] text-text-muted shrink-0 self-start mt-0.5">{formatTime(last.ts)}</span>{/if}
<div class="flex flex-col items-end shrink-0 self-start gap-1 mt-0.5">
{#if last}
<span class="text-[10px] text-text-muted">{formatTime(last.ts)}</span>
{/if}
{#if (c.unread_count ?? 0) > 0}
<!-- Unread badge — accent dot with the count.
Sized like a chip; rounds gracefully past 99. -->
<span
class="min-w-[18px] h-[18px] px-1.5 inline-flex items-center justify-center rounded-full bg-accent text-accent-contrast text-[10px] font-semibold leading-none"
aria-label="{c.unread_count} unread message{c.unread_count === 1 ? '' : 's'}"
>
{c.unread_count > 99 ? "99+" : c.unread_count}
</span>
{/if}
</div>
</button>
</li>
{/each}
@ -424,7 +831,29 @@
<button class="sm:hidden text-text-secondary hover:text-text -ml-1" onclick={() => (activePrimary = null)} aria-label="Back">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
</button>
<Avatar seed={activeConv.peer_primary} size={36} />
<!--
Tap-to-zoom: clicking the header avatar opens a fullscreen
overlay with the picture upscaled. Only interactive when
the peer actually has a picture; the identicon falls back
to a plain Avatar render (nothing useful to zoom to).
-->
{#if peerProfiles.byPrimary[activeConv.peer_primary]?.picture}
<button
type="button"
class="shrink-0 rounded-md focus:outline-none focus:ring-2 focus:ring-accent"
onclick={() => (zoomedAvatarOpen = true)}
aria-label="View {displayName(activeConv)}'s profile picture"
title="View profile picture"
>
<Avatar
seed={activeConv.peer_primary}
size={36}
picture={peerProfiles.byPrimary[activeConv.peer_primary]?.picture}
/>
</button>
{:else}
<Avatar seed={activeConv.peer_primary} size={36} />
{/if}
<div class="min-w-0">
<p class="font-mono text-sm font-semibold text-text truncate flex items-center gap-1">
<span class="truncate">{displayName(activeConv)}</span>
@ -463,21 +892,54 @@
>
<span class="whitespace-pre-wrap break-words align-middle">{m.body}</span>
<!--
Inline timestamp. `float-right` + a leading non-
breaking space pulls the time onto the same baseline
as the last line of text when there's room, and
drops to its own line when the text wraps right up
to it. Lower opacity so it doesn't compete.
Inline timestamp + delivery status. `float-right` +
a leading non-breaking space pulls the cluster onto
the same baseline as the last line of text when
there's room, and drops to its own line when the
text wraps right up to it. Lower opacity so it
doesn't compete.
-->
<span
class={[
"float-right ml-2 mt-1 text-[10px] leading-none select-none",
"float-right ml-2 mt-1 text-[10px] leading-none select-none flex items-center gap-1",
out ? "text-accent-contrast/70" : "text-text-muted",
].join(" ")}
aria-hidden="true"
>{formatTime(m.ts)}</span>
>
<span>{formatTime(m.ts)}</span>
{#if out}
{#if m.status === "sending"}
<!-- Hollow circle: publish in flight. -->
<svg viewBox="0 0 16 16" class="w-3 h-3 inline opacity-80" fill="none" stroke="currentColor" stroke-width="1.5" aria-label="Sending"><circle cx="8" cy="8" r="6"/></svg>
{:else if m.status === "failed"}
<!-- Red exclamation in circle. -->
<svg viewBox="0 0 16 16" class="w-3 h-3 inline" fill="none" stroke="#ff6b6b" stroke-width="1.5" aria-label="Failed to send"><circle cx="8" cy="8" r="6"/><line x1="8" y1="5" x2="8" y2="9" stroke-linecap="round"/><circle cx="8" cy="11.5" r="0.6" fill="#ff6b6b" stroke="none"/></svg>
{:else if m.status === "delivered"}
<!-- Check inside a circle = received by recipient. -->
<svg viewBox="0 0 16 16" class="w-3.5 h-3.5 inline" fill="none" stroke="currentColor" stroke-width="1.5" aria-label="Delivered"><circle cx="8" cy="8" r="6.5"/><path d="M4.8 8.4 L7 10.5 L11.2 6" stroke-linecap="round" stroke-linejoin="round"/></svg>
{:else}
<!-- Single check = sent to nostr (at least 1 relay). -->
<svg viewBox="0 0 16 16" class="w-3 h-3 inline" fill="none" stroke="currentColor" stroke-width="1.8" aria-label="Sent"><path d="M3 8.5 L6.5 12 L13 5" stroke-linecap="round" stroke-linejoin="round"/></svg>
{/if}
{/if}
</span>
<!-- Screen-reader timestamp (the visual one is decorative). -->
<span class="sr-only">{formatTime(m.ts)}</span>
<span class="sr-only">{formatTime(m.ts)}{#if out && m.status}{m.status}{/if}</span>
{#if out && m.accepted_by}
<!-- "via X" — tiny hint that surfaces which relay
carried this message. Float-right clears the
float'd timestamp so it lands on its own line
beneath, in a quieter color. -->
<span
class="block float-right clear-right mt-0.5 text-[9px] leading-none select-none {out
? 'text-accent-contrast/60'
: 'text-text-muted'}"
title="Relay this message was published through"
aria-hidden="true"
>
via {m.accepted_by.replace(/^wss?:\/\//, "")}
</span>
{/if}
</div>
{/if}
</div>
@ -511,4 +973,46 @@
</form>
{/if}
</main>
</div>
</div>
<!--
Avatar zoom overlay. Only renders when the user tapped the thread
header avatar. Black backdrop fills the viewport; the picture
scales to ~70% of the shorter viewport edge so it has breathing
room. Click anywhere (backdrop OR picture) to dismiss — easier
than hunting for an X button on mobile.
-->
{#if zoomedAvatarOpen && activeConv && peerProfiles.byPrimary[activeConv.peer_primary]?.picture}
{@const peerPic = peerProfiles.byPrimary[activeConv.peer_primary]?.picture}
<button
type="button"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm cursor-zoom-out"
onclick={closeZoomedAvatar}
onkeydown={onZoomKeydown}
aria-label="Close profile picture"
>
<div class="relative flex flex-col items-center gap-3 max-w-[90vw]">
<!-- The picture itself. We render inline here rather than
reusing the Avatar component because Avatar bakes size
into the img's width attribute, which clashes with the
responsive CSS sizing we want at zoom time. min(70vw,
70vh) keeps it comfortably inside the viewport in both
orientations. -->
<img
src={peerPic}
alt="profile picture"
class="object-cover rounded-2xl shadow-2xl"
style="width: min(70vw, 70vh); height: min(70vw, 70vh);"
/>
<p class="font-mono text-sm text-white/90 truncate max-w-full">
{displayName(activeConv)}
</p>
{#if peerProfiles.byPrimary[activeConv.peer_primary]?.picture_was_encrypted}
<p class="text-[10px] text-white/60">
🔒 visually-encrypted on nostr — descrambled for you
</p>
{/if}
</div>
</button>
{/if}

View File

@ -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<NotificationPermission | "unsupported">("default");
let testNotifResult = $state<{ ok: boolean; reason?: string } | null>(null);
// ─── Profile picture ──────────────────────────────────────────────
let pictureBusy = $state(false);
let pictureError = $state<string | null>(null);
let pictureFileInput = $state<HTMLInputElement | null>(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<string | null>(null);
let previewForKey = $state<string | null>(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<string | null>(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 @@
</script>
{#if session.unlocked}
<div class="max-w-2xl mx-auto space-y-6">
<div class="max-w-2xl mx-auto px-4 py-6 space-y-6">
<!--
Profile picture. Renders the user's own Avatar (which now
honours `picture` if set) next to a file picker that resizes
and stores the image both locally and as a nostr kind:0
event so peers can fetch it later. Falls back to the
identicon when no picture is set.
-->
<section class="bg-surface border border-border rounded-xl p-6">
<h2 class="text-sm font-semibold text-text uppercase tracking-wider mb-3">
Profile
</h2>
<div class="flex items-center gap-4">
<!-- Stacked: real avatar (big) + "strangers see this" thumb
tucked into the bottom-right corner. Only renders when
encryption is on AND we have a picture. -->
<div class="relative shrink-0">
<Avatar
seed={session.unlocked.primary}
size={80}
ring
picture={session.myProfile?.picture}
/>
{#if session.myProfile?.encrypted && session.myProfile?.picture && scrambledPreview}
<!-- Strip the URL-fragment salt so the <img> doesn't
trigger Chrome's "weird URL" warning. The salt isn't
needed for rendering, only for descrambling. -->
{@const previewSrc = scrambledPreview.split("#")[0]}
<div
class="absolute -bottom-1 -right-1 w-9 h-9 rounded-md overflow-hidden border-2 border-surface shadow-md"
title="What strangers see on public nostr clients"
>
<img
src={previewSrc}
width="36"
height="36"
alt="Encrypted preview — what strangers see"
class="w-full h-full object-cover"
/>
</div>
{:else if session.myProfile?.encrypted && session.myProfile?.picture && previewBusy}
<div
class="absolute -bottom-1 -right-1 w-9 h-9 rounded-md border-2 border-surface bg-elevated flex items-center justify-center text-[10px] text-text-muted"
title="Rendering preview…"
>
</div>
{/if}
</div>
<div class="min-w-0 flex-1">
<p class="font-mono text-sm font-semibold text-text truncate">
{session.unlocked.handle}@{session.unlocked.server}
</p>
{#if session.myProfile?.picture}
<p class="text-xs text-text-muted mt-1">
Custom picture · {Math.ceil(
dataUrlBytes(session.myProfile.picture) / 1024,
)} KB · stored locally + published to nostr
</p>
{#if session.myProfile?.encrypted && scrambledPreview}
<p class="text-[10px] text-text-muted mt-1">
The little box is what strangers see on public nostr —
visually-scrambled noise. Your contacts see the real
picture.
</p>
{/if}
{:else}
<p class="text-xs text-text-muted mt-1">
Showing your auto-generated identicon. Pick a picture
to replace it.
</p>
{/if}
</div>
</div>
<!-- Hidden file input; buttons drive it so we can style freely. -->
<input
bind:this={pictureFileInput}
type="file"
accept="image/*"
class="hidden"
onchange={(e) => {
const f = (e.currentTarget as HTMLInputElement).files?.[0];
if (f) void onPicturePicked(f);
(e.currentTarget as HTMLInputElement).value = "";
}}
/>
<div class="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
class="px-3 py-2 text-sm bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim disabled:opacity-50"
disabled={pictureBusy}
onclick={() => pictureFileInput?.click()}
>
{pictureBusy
? "Working…"
: session.myProfile?.picture
? "Replace picture"
: "Choose picture"}
</button>
{#if session.myProfile?.picture}
<button
type="button"
class="px-3 py-2 text-sm border border-border rounded-md text-text-secondary hover:text-danger hover:border-danger disabled:opacity-50"
disabled={pictureBusy}
onclick={removePicture}
>
Remove
</button>
{/if}
</div>
{#if pictureError}
<p class="mt-2 text-xs text-danger">{pictureError}</p>
{/if}
<!--
Privacy toggle. When ON (the default), the picture published
to nostr is visually scrambled — strangers see colored noise,
contacts you've messaged can descramble. When OFF, the
picture goes out in cleartext (any nostr client can render).
-->
<div class="mt-4 pt-4 border-t border-border space-y-3">
<label class="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
class="mt-0.5 shrink-0"
checked={session.myProfile?.encrypted ?? true}
disabled={pictureBusy}
onchange={async (e) => {
const checked = (e.currentTarget as HTMLInputElement).checked;
pictureBusy = true;
pictureError = null;
try {
await setMyProfile({ encrypted: checked });
} catch (err) {
pictureError = (err as Error).message;
} finally {
pictureBusy = false;
}
}}
/>
<div class="text-sm">
<p class="font-semibold text-text">
Visually encrypt picture
<span class="text-xs text-text-muted font-normal">(recommended)</span>
</p>
<p class="text-xs text-text-secondary mt-0.5">
Strangers see colored noise. People you've messaged can
descramble and see your real face. Your face is private
by default — your contacts are not.
</p>
</div>
</label>
</div>
<p class="mt-3 text-[10px] text-text-muted">
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."}
</p>
</section>
<!-- Appearance -->
<section class="bg-surface border border-border rounded-xl p-6">
<h2 class="text-sm font-semibold text-text uppercase tracking-wider mb-3">Appearance</h2>
@ -239,7 +526,7 @@
12 words that recover this account anywhere. Write them down on
paper — losing them means losing the account.
</p>
<button class="mt-2 px-3 py-2 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text" onclick={showSeed}>
<button class="mt-2 px-3 py-2 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text" onclick={openRevealPrompt}>
Reveal phrase
</button>
</div>
@ -341,3 +628,65 @@
</section>
</div>
{/if}
<!--
Fresh-passphrase prompt for Reveal Phrase. Same threat-model
argument the OS uses for showing iCloud-stored passwords or 1Password
vault items: "you were already unlocked, but this is sensitive enough
that we want fresh proof you're really you." Closing the modal
without confirming wipes the in-memory passphrase buffer.
-->
{#if revealPromptOpen}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
aria-label="Confirm passphrase"
>
<form
class="w-full max-w-sm bg-surface border border-border rounded-xl p-5 space-y-4 shadow-2xl"
onsubmit={(e) => {
e.preventDefault();
void confirmRevealPrompt();
}}
>
<div class="space-y-1">
<h3 class="text-base font-semibold text-text">Confirm passphrase</h3>
<p class="text-xs text-text-secondary">
Showing your recovery phrase reveals enough to take over the
account. Type your passphrase to continue — even though
you're already signed in.
</p>
</div>
<input
type="password"
autocomplete="current-password"
class="w-full px-3 py-2 text-sm bg-elevated border border-border rounded-md text-text placeholder:text-text-muted focus:border-accent focus:outline-none font-mono"
placeholder="Your passphrase"
bind:value={revealPromptPassphrase}
disabled={revealPromptBusy}
autofocus
/>
{#if revealPromptError}
<p class="text-xs text-danger">{revealPromptError}</p>
{/if}
<div class="flex items-center justify-end gap-2 pt-1">
<button
type="button"
class="px-3 py-2 text-sm border border-border rounded-md text-text-secondary hover:bg-elevated hover:text-text disabled:opacity-50"
disabled={revealPromptBusy}
onclick={closeRevealPrompt}
>
Cancel
</button>
<button
type="submit"
class="px-3 py-2 text-sm bg-accent text-accent-contrast font-semibold rounded-md hover:bg-accent-dim disabled:opacity-50"
disabled={revealPromptBusy || !revealPromptPassphrase}
>
{revealPromptBusy ? "Checking…" : "Reveal"}
</button>
</div>
</form>
</div>
{/if}

View File

@ -93,7 +93,12 @@
</script>
{#if session.unlocked}
<div class="max-w-xl mx-auto py-6 space-y-6">
<!--
px-4 keeps the cards breathing on mobile (viewport < max-w-xl).
On larger screens the mx-auto centering takes over and the
horizontal padding is essentially invisible.
-->
<div class="max-w-xl mx-auto px-4 py-6 space-y-6">
<div class="text-center space-y-2">
<div class="flex justify-center"><Wordmark size={28} /></div>
<h1 class="text-xl font-semibold text-text">Welcome — let's get you set up</h1>
@ -101,7 +106,12 @@
A couple of quick steps. You can skip and come back anytime from Settings.
</p>
<div class="flex items-center justify-center gap-3 pt-1">
<Avatar seed={session.unlocked.primary} size={44} ring />
<Avatar
seed={session.unlocked.primary}
size={44}
ring
picture={session.myProfile?.picture}
/>
<span class="font-mono text-sm text-accent">{session.unlocked.handle}@{session.unlocked.server}</span>
</div>
<p class="text-xs text-text-muted">{done} of {total} essentials done</p>

View File

@ -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);
})(),
);
});