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:
parent
f2970955dd
commit
c8370ffdf0
37
.dockerignore
Normal file
37
.dockerignore
Normal 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
14
.gitignore
vendored
@ -34,6 +34,9 @@ kez-sigchains.db
|
|||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
|
||||||
|
# Claude Code harness state (per-session scratch, not project code)
|
||||||
|
.claude/
|
||||||
|
|
||||||
# Cross-test artifacts
|
# Cross-test artifacts
|
||||||
/tmp/
|
/tmp/
|
||||||
|
|
||||||
@ -43,3 +46,14 @@ kez-chat/deploy/deploy.local.sh
|
|||||||
kez-chat/deploy/*.local.sh
|
kez-chat/deploy/*.local.sh
|
||||||
kez-chat/deploy/.env
|
kez-chat/deploy/.env
|
||||||
kez-chat/deploy/.env.local
|
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
522
kez-chat/Cargo.lock
generated
@ -2,6 +2,27 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
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]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.8.12"
|
version = "0.8.12"
|
||||||
@ -23,6 +44,12 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "allocator-api2"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android_system_properties"
|
name = "android_system_properties"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@ -117,6 +144,46 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "atomic-waker"
|
name = "atomic-waker"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
@ -190,6 +257,15 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
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]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
@ -220,6 +296,12 @@ version = "0.9.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445"
|
checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bech32"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "binstring"
|
name = "binstring"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@ -239,13 +321,47 @@ dependencies = [
|
|||||||
"unicode-normalization",
|
"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]]
|
[[package]]
|
||||||
name = "bitcoin_hashes"
|
name = "bitcoin_hashes"
|
||||||
version = "0.14.100"
|
version = "0.14.100"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c9901a56e133a1fc86eeb1113e2591f45f4682451ca893bff494d2f88918e3f"
|
checksum = "0c9901a56e133a1fc86eeb1113e2591f45f4682451ca893bff494d2f88918e3f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bitcoin-io",
|
||||||
"hex-conservative",
|
"hex-conservative",
|
||||||
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -263,6 +379,15 @@ dependencies = [
|
|||||||
"generic-array",
|
"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]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.3"
|
version = "3.20.3"
|
||||||
@ -290,6 +415,15 @@ dependencies = [
|
|||||||
"rustversion",
|
"rustversion",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cbc"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.62"
|
version = "1.2.62"
|
||||||
@ -314,6 +448,30 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
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]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.44"
|
version = "0.4.44"
|
||||||
@ -328,6 +486,17 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.6.1"
|
version = "4.6.1"
|
||||||
@ -446,6 +615,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
|
"rand_core 0.6.4",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -513,6 +683,12 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.4.5"
|
version = "0.4.5"
|
||||||
@ -647,6 +823,12 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "either"
|
||||||
|
version = "1.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "elliptic-curve"
|
name = "elliptic-curve"
|
||||||
version = "0.13.8"
|
version = "0.13.8"
|
||||||
@ -944,6 +1126,18 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "group"
|
name = "group"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
@ -970,6 +1164,8 @@ version = "0.15.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"allocator-api2",
|
||||||
|
"equivalent",
|
||||||
"foldhash",
|
"foldhash",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1015,6 +1211,12 @@ dependencies = [
|
|||||||
"arrayvec",
|
"arrayvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hex_lit"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hkdf"
|
name = "hkdf"
|
||||||
version = "0.12.4"
|
version = "0.12.4"
|
||||||
@ -1153,7 +1355,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"webpki-roots",
|
"webpki-roots 1.0.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1324,6 +1526,28 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.0"
|
version = "2.12.0"
|
||||||
@ -1441,7 +1665,9 @@ dependencies = [
|
|||||||
"clap",
|
"clap",
|
||||||
"futures",
|
"futures",
|
||||||
"hex",
|
"hex",
|
||||||
|
"hkdf",
|
||||||
"kez-core",
|
"kez-core",
|
||||||
|
"nostr-sdk",
|
||||||
"p256",
|
"p256",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@ -1464,7 +1690,7 @@ name = "kez-core"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bech32",
|
"bech32 0.9.1",
|
||||||
"bip39",
|
"bip39",
|
||||||
"chrono",
|
"chrono",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
@ -1551,12 +1777,33 @@ version = "0.8.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
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]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.29"
|
version = "0.4.29"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
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]]
|
[[package]]
|
||||||
name = "lru-slab"
|
name = "lru-slab"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@ -1611,6 +1858,113 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@ -1666,6 +2020,21 @@ dependencies = [
|
|||||||
"libm",
|
"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]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@ -1678,6 +2047,12 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opaque-debug"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.80"
|
version = "0.10.80"
|
||||||
@ -1751,6 +2126,27 @@ version = "2.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
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]]
|
[[package]]
|
||||||
name = "pem"
|
name = "pem"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@ -1874,6 +2270,17 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@ -2119,7 +2526,7 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"webpki-roots",
|
"webpki-roots 1.0.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2262,6 +2669,15 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6518fc26bced4d53678a22d6e423e9d8716377def84545fe328236e3af070e7f"
|
checksum = "6518fc26bced4d53678a22d6e423e9d8716377def84545fe328236e3af070e7f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "salsa20"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schannel"
|
name = "schannel"
|
||||||
version = "0.1.29"
|
version = "0.1.29"
|
||||||
@ -2271,6 +2687,18 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "sec1"
|
name = "sec1"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
@ -2302,8 +2730,10 @@ version = "0.29.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
|
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bitcoin_hashes",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"secp256k1-sys",
|
"secp256k1-sys",
|
||||||
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2368,6 +2798,7 @@ version = "1.0.150"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
"itoa",
|
"itoa",
|
||||||
"memchr",
|
"memchr",
|
||||||
"serde",
|
"serde",
|
||||||
@ -2398,6 +2829,17 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@ -2709,6 +3151,18 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-stream"
|
name = "tokio-stream"
|
||||||
version = "0.1.18"
|
version = "0.1.18"
|
||||||
@ -2721,6 +3175,22 @@ dependencies = [
|
|||||||
"tokio-util",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
@ -2868,6 +3338,26 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
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]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.0"
|
version = "1.20.0"
|
||||||
@ -2901,6 +3391,16 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
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]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@ -2917,8 +3417,15 @@ dependencies = [
|
|||||||
"idna",
|
"idna",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf-8"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@ -3128,6 +3635,15 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
|
|||||||
@ -23,11 +23,18 @@ web-push = "0.10"
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
p256 = { version = "0.13", features = ["pem"] }
|
p256 = { version = "0.13", features = ["pem"] }
|
||||||
rand = "0.8"
|
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"] }
|
tower-http = { version = "0.6", features = ["trace", "cors", "fs"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||||
sha2 = "0.10"
|
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
336
kez-chat/TODO.md
Normal file
336
kez-chat/TODO.md
Normal 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 1–3
|
||||||
|
days). Fresh devices on quiet conversations silently miss messages.
|
||||||
|
Shorten to 48h + augment with explicit "fetch full history" UI for the
|
||||||
|
rare resurrect case.
|
||||||
|
|
||||||
|
### #16. Bounded concurrency on push fanout [DONE — Day 3 Option A]
|
||||||
|
|
||||||
|
**Why it matters:** every send spawns an unbounded `tokio::spawn` to fan
|
||||||
|
out push. Under flood, OOM.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `kez-chat/src/messages.rs:128`
|
||||||
|
|
||||||
|
**Fix:** semaphore-bound to ~32 concurrent fanouts. Excess queues; under
|
||||||
|
extreme flood we drop with a warn-log rather than swap-thrash.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visually-encrypted profile pictures (new feature, in progress)
|
||||||
|
|
||||||
|
### Phase 1A — local scramble + per-contact key wrap [DONE this commit]
|
||||||
|
|
||||||
|
- `kez-chat/web/src/lib/visual-crypto.ts` — keyed Fisher-Yates pixel
|
||||||
|
permutation + xoshiro256** PRNG. Output is a valid PNG with same
|
||||||
|
dimensions, scrambled content. Salt embedded as `#kez-visual-v1:<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
|
||||||
@ -50,3 +50,21 @@ ENV KEZ_CHAT_BIND=0.0.0.0:6969 \
|
|||||||
|
|
||||||
EXPOSE 6969
|
EXPOSE 6969
|
||||||
ENTRYPOINT ["/usr/local/bin/kez-chat-server"]
|
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/
|
||||||
|
|||||||
42
kez-chat/deploy/Dockerfile.runtime
Normal file
42
kez-chat/deploy/Dockerfile.runtime
Normal 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 8–12 minutes a full Rust rebuild
|
||||||
|
# takes on the (slower) production box.
|
||||||
|
#
|
||||||
|
# Build context = kez-chat/deploy/ (the directory holding this file
|
||||||
|
# and the prebuilt/ subdirectory). Pinned by docker-compose.fast.yml.
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& useradd -r -u 10001 -m kez
|
||||||
|
|
||||||
|
# Rust binary — must already be built for linux/amd64.
|
||||||
|
COPY prebuilt/kez-chat-server /usr/local/bin/kez-chat-server
|
||||||
|
RUN chmod +x /usr/local/bin/kez-chat-server
|
||||||
|
|
||||||
|
# SPA static files.
|
||||||
|
COPY prebuilt/web/ /app/web/
|
||||||
|
|
||||||
|
USER kez
|
||||||
|
WORKDIR /data
|
||||||
|
|
||||||
|
ENV KEZ_CHAT_BIND=0.0.0.0:6969 \
|
||||||
|
KEZ_CHAT_DB=/data/kez-chat.db \
|
||||||
|
KEZ_CHAT_SERVER=kez.lat \
|
||||||
|
KEZ_CHAT_SIG_SERVER_URL=http://sig-server:7878 \
|
||||||
|
KEZ_CHAT_WEB_DIR=/app/web \
|
||||||
|
RUST_LOG=info
|
||||||
|
|
||||||
|
EXPOSE 6969
|
||||||
|
ENTRYPOINT ["/usr/local/bin/kez-chat-server"]
|
||||||
23
kez-chat/deploy/docker-compose.fast.yml
Normal file
23
kez-chat/deploy/docker-compose.fast.yml
Normal 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
|
||||||
@ -30,6 +30,8 @@ pub struct AppState {
|
|||||||
pub broker: crate::broker::Broker,
|
pub broker: crate::broker::Broker,
|
||||||
pub vapid: crate::push::VapidKeys,
|
pub vapid: crate::push::VapidKeys,
|
||||||
pub push: crate::push::PushSender,
|
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 {
|
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/vapid-public-key", get(push_vapid_key))
|
||||||
.route("/v1/push/subscribe/:handle", post(push_subscribe))
|
.route("/v1/push/subscribe/:handle", post(push_subscribe))
|
||||||
.route("/v1/push/unsubscribe/:handle", post(push_unsubscribe))
|
.route("/v1/push/unsubscribe/:handle", post(push_unsubscribe))
|
||||||
|
.route("/v1/push/subscriptions/:handle", get(push_list_subscriptions))
|
||||||
.route("/.well-known/webfinger", get(webfinger))
|
.route("/.well-known/webfinger", get(webfinger))
|
||||||
.route("/internal/nats/auth", post(nats_auth_callout));
|
.route("/internal/nats/auth", post(nats_auth_callout));
|
||||||
|
|
||||||
router = if let Some(dir) = web_dir {
|
router = if let Some(dir) = web_dir {
|
||||||
// Real SPA build dir provided; ServeDir handles index.html + assets.
|
// Explicit no-cache for the files that gate everything else:
|
||||||
router.fallback_service(ServeDir::new(dir))
|
// • /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 {
|
} else {
|
||||||
// No SPA dir; serve a built-in placeholder page at `/`.
|
// No SPA dir; serve a built-in placeholder page at `/`.
|
||||||
router.route("/", get(placeholder_index))
|
router.route("/", get(placeholder_index))
|
||||||
@ -332,6 +353,7 @@ async fn push_subscribe(
|
|||||||
Utc::now().timestamp(),
|
Utc::now().timestamp(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
let endpoint_for_log = req.endpoint.clone();
|
||||||
state
|
state
|
||||||
.store
|
.store
|
||||||
.upsert_push_subscription(
|
.upsert_push_subscription(
|
||||||
@ -343,6 +365,15 @@ async fn push_subscribe(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.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)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -383,6 +414,77 @@ async fn push_unsubscribe(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
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
|
// 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
|
// 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> {
|
async fn placeholder_index(State(state): State<AppState>) -> Html<String> {
|
||||||
Html(format!(
|
Html(format!(
|
||||||
r#"<!DOCTYPE html>
|
r#"<!DOCTYPE html>
|
||||||
|
|||||||
@ -54,4 +54,17 @@ pub struct Config {
|
|||||||
default_value = "mailto:admin@kez.lat"
|
default_value = "mailto:admin@kez.lat"
|
||||||
)]
|
)]
|
||||||
pub vapid_subject: String,
|
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>,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,8 @@ pub enum ApiError {
|
|||||||
Forbidden(String),
|
Forbidden(String),
|
||||||
#[error("unauthorized: {0}")]
|
#[error("unauthorized: {0}")]
|
||||||
Unauthorized(String),
|
Unauthorized(String),
|
||||||
|
#[error("rate limited: {0}")]
|
||||||
|
RateLimited(String),
|
||||||
#[error("internal: {0}")]
|
#[error("internal: {0}")]
|
||||||
Internal(String),
|
Internal(String),
|
||||||
}
|
}
|
||||||
@ -31,6 +33,7 @@ impl ApiError {
|
|||||||
ApiError::Conflict(_) => StatusCode::CONFLICT,
|
ApiError::Conflict(_) => StatusCode::CONFLICT,
|
||||||
ApiError::Forbidden(_) => StatusCode::FORBIDDEN,
|
ApiError::Forbidden(_) => StatusCode::FORBIDDEN,
|
||||||
ApiError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
|
ApiError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
|
||||||
|
ApiError::RateLimited(_) => StatusCode::TOO_MANY_REQUESTS,
|
||||||
ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -42,6 +45,7 @@ impl ApiError {
|
|||||||
ApiError::Conflict(_) => "conflict",
|
ApiError::Conflict(_) => "conflict",
|
||||||
ApiError::Forbidden(_) => "forbidden",
|
ApiError::Forbidden(_) => "forbidden",
|
||||||
ApiError::Unauthorized(_) => "unauthorized",
|
ApiError::Unauthorized(_) => "unauthorized",
|
||||||
|
ApiError::RateLimited(_) => "rate_limited",
|
||||||
ApiError::Internal(_) => "internal",
|
ApiError::Internal(_) => "internal",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,9 @@ pub mod config;
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod handles;
|
pub mod handles;
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
|
pub mod nostr_listener;
|
||||||
pub mod push;
|
pub mod push;
|
||||||
|
pub mod rate_limit;
|
||||||
pub mod registration;
|
pub mod registration;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
|
||||||
|
|||||||
@ -29,12 +29,63 @@ async fn main() -> Result<()> {
|
|||||||
let vapid =
|
let vapid =
|
||||||
kez_chat_server::push::load_or_generate_vapid(&config.vapid_key_path)?;
|
kez_chat_server::push::load_or_generate_vapid(&config.vapid_key_path)?;
|
||||||
let push = kez_chat_server::push::PushSender::new(&vapid, &config.vapid_subject)?;
|
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 {
|
let state = AppState {
|
||||||
store,
|
store,
|
||||||
config: config.clone(),
|
config: config.clone(),
|
||||||
broker: kez_chat_server::broker::Broker::new(),
|
broker: kez_chat_server::broker::Broker::new(),
|
||||||
vapid,
|
vapid,
|
||||||
push,
|
push,
|
||||||
|
send_rate_limit,
|
||||||
};
|
};
|
||||||
|
|
||||||
let app = router(state)
|
let app = router(state)
|
||||||
|
|||||||
@ -69,8 +69,19 @@ pub struct SendMessageResponse {
|
|||||||
|
|
||||||
pub async fn send_message(
|
pub async fn send_message(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
Json(req): Json<SendMessageRequest>,
|
Json(req): Json<SendMessageRequest>,
|
||||||
) -> Result<Json<SendMessageResponse>, ApiError> {
|
) -> 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)
|
validate_handle(&req.to)
|
||||||
.map_err(|e| ApiError::BadRequest(format!("invalid 'to' handle: {e}")))?;
|
.map_err(|e| ApiError::BadRequest(format!("invalid 'to' handle: {e}")))?;
|
||||||
|
|
||||||
@ -114,17 +125,19 @@ pub async fn send_message(
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Web Push fanout — fire-and-forget so the HTTP request still
|
// Web Push fanout — fire-and-forget so the HTTP request still
|
||||||
// returns fast. The push payload is intentionally tiny and
|
// returns fast. The push payload is INTENTIONALLY EMPTY: RFC 8291
|
||||||
// contains only metadata: the recipient's own client will pull
|
// encrypts the payload to the device's p256dh+auth so providers
|
||||||
// the real (encrypted) envelope from the inbox/SSE on wake-up.
|
// 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 push = state.push.clone();
|
||||||
let store = state.store.clone();
|
let store = state.store.clone();
|
||||||
let recipient_handle = recipient.handle.clone();
|
let recipient_handle = recipient.handle.clone();
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({});
|
||||||
"type": "kez-chat/new-message",
|
|
||||||
"to": recipient_handle,
|
|
||||||
"seq": seq,
|
|
||||||
});
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
push.fanout(&store, &recipient_handle, &payload).await;
|
push.fanout(&store, &recipient_handle, &payload).await;
|
||||||
});
|
});
|
||||||
|
|||||||
300
kez-chat/src/nostr_listener.rs
Normal file
300
kez-chat/src/nostr_listener.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -32,7 +32,7 @@ use p256::elliptic_curve::sec1::ToEncodedPoint;
|
|||||||
use p256::pkcs8::EncodePrivateKey;
|
use p256::pkcs8::EncodePrivateKey;
|
||||||
use rusqlite::params;
|
use rusqlite::params;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::sync::Mutex;
|
use std::sync::Arc;
|
||||||
// Note: WebPushClient is a trait that provides the .send() method on
|
// Note: WebPushClient is a trait that provides the .send() method on
|
||||||
// IsahcWebPushClient — keep it in scope even though it looks "unused".
|
// IsahcWebPushClient — keep it in scope even though it looks "unused".
|
||||||
use web_push::{
|
use web_push::{
|
||||||
@ -222,8 +222,13 @@ struct PushInner {
|
|||||||
client: IsahcWebPushClient,
|
client: IsahcWebPushClient,
|
||||||
vapid_private_pem: String,
|
vapid_private_pem: String,
|
||||||
vapid_subject: String,
|
vapid_subject: String,
|
||||||
// Reserved for future provider tweaks without re-plumbing.
|
/// Bounded concurrency on fanout — caps the number of in-flight
|
||||||
_lock: Mutex<()>,
|
/// 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 {
|
impl PushSender {
|
||||||
@ -233,7 +238,7 @@ impl PushSender {
|
|||||||
client: IsahcWebPushClient::new()?,
|
client: IsahcWebPushClient::new()?,
|
||||||
vapid_private_pem: vapid.private_pem.clone(),
|
vapid_private_pem: vapid.private_pem.clone(),
|
||||||
vapid_subject: vapid_subject.to_owned(),
|
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,
|
recipient_handle: &str,
|
||||||
payload: &serde_json::Value,
|
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 {
|
let subs = match store.list_push_subscriptions(recipient_handle).await {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = %e, "push: list_subscriptions failed");
|
tracing::warn!(error = %e, handle = %recipient_handle, "push: list_subscriptions failed");
|
||||||
return;
|
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() {
|
if subs.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -268,17 +292,29 @@ impl PushSender {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
for sub in subs {
|
for sub in subs {
|
||||||
if let Err(e) = self.send_one(&sub, &body).await {
|
match self.send_one(&sub, &body).await {
|
||||||
match e {
|
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 => {
|
WebPushError::EndpointNotValid | WebPushError::EndpointNotFound => {
|
||||||
// 410 Gone / 404 → subscription is dead; drop it.
|
// 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;
|
let _ = store.delete_push_subscription(&sub.endpoint).await;
|
||||||
}
|
}
|
||||||
other => {
|
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
|
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
206
kez-chat/src/rate_limit.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -35,6 +35,14 @@
|
|||||||
const showNav = $derived(!!session.unlocked && APP_ROUTES.includes($location));
|
const showNav = $derived(!!session.unlocked && APP_ROUTES.includes($location));
|
||||||
|
|
||||||
onMount(async () => {
|
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();
|
const stored = await hasStoredIdentity();
|
||||||
// Redirect legacy paths.
|
// Redirect legacy paths.
|
||||||
if ($location === "/dashboard") return push(session.unlocked ? "/identity" : "/unlock");
|
if ($location === "/dashboard") return push(session.unlocked ? "/identity" : "/unlock");
|
||||||
@ -43,6 +51,10 @@
|
|||||||
push("/");
|
push("/");
|
||||||
} else if (stored && !session.unlocked && APP_ROUTES.includes($location)) {
|
} else if (stored && !session.unlocked && APP_ROUTES.includes($location)) {
|
||||||
push("/unlock");
|
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");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -13,8 +13,17 @@
|
|||||||
size?: number;
|
size?: number;
|
||||||
/** Optional ring (e.g. for the active/own avatar). */
|
/** Optional ring (e.g. for the active/own avatar). */
|
||||||
ring?: boolean;
|
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.
|
// Cheap, stable 32-bit FNV-1a hash — no crypto needed, just spreading.
|
||||||
function hash(str: string): number {
|
function hash(str: string): number {
|
||||||
@ -53,21 +62,34 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svg
|
{#if picture}
|
||||||
width={size}
|
<img
|
||||||
height={size}
|
src={picture}
|
||||||
viewBox="0 0 5 5"
|
width={size}
|
||||||
class="shrink-0"
|
height={size}
|
||||||
style="border-radius: {Math.max(4, size * 0.2)}px; {ring ? `box-shadow: 0 0 0 2px var(--color-accent);` : ''}"
|
alt="profile picture"
|
||||||
role="img"
|
class="shrink-0 object-cover"
|
||||||
aria-label="identity avatar"
|
style="border-radius: {Math.max(4, size * 0.2)}px; {ring
|
||||||
>
|
? 'box-shadow: 0 0 0 2px var(--color-accent);'
|
||||||
<rect width="5" height="5" fill={tile} />
|
: ''}"
|
||||||
{#each [0, 1, 2, 3, 4] as col}
|
/>
|
||||||
{#each [0, 1, 2, 3, 4] as row}
|
{:else}
|
||||||
{#if isOn(col, row)}
|
<svg
|
||||||
<rect x={col} y={row} width="1" height="1" fill={fg} />
|
width={size}
|
||||||
{/if}
|
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}
|
||||||
{/each}
|
</svg>
|
||||||
</svg>
|
{/if}
|
||||||
|
|||||||
@ -6,6 +6,8 @@
|
|||||||
// anyone with this browser profile already has the user's seed (the real
|
// anyone with this browser profile already has the user's seed (the real
|
||||||
// secret), so encrypting the message log adds little practical security.
|
// 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 { get, set } from "idb-keyval";
|
||||||
import type { Identity } from "./kez.js";
|
import type { Identity } from "./kez.js";
|
||||||
|
|
||||||
@ -15,6 +17,21 @@ import type { Identity } from "./kez.js";
|
|||||||
// placeholders anyway.
|
// placeholders anyway.
|
||||||
const KEY = "kez-chat:conversations:v2";
|
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 {
|
export interface ConversationMessage {
|
||||||
/** Server seq for inbound, Date.now() for outbound. Only used for ordering + dedupe. */
|
/** Server seq for inbound, Date.now() for outbound. Only used for ordering + dedupe. */
|
||||||
seq: number;
|
seq: number;
|
||||||
@ -24,6 +41,20 @@ export interface ConversationMessage {
|
|||||||
from: Identity;
|
from: Identity;
|
||||||
/** ISO timestamp (sender's clock for `in`, click-time for `out`). */
|
/** ISO timestamp (sender's clock for `in`, click-time for `out`). */
|
||||||
ts: string;
|
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 {
|
export interface Conversation {
|
||||||
@ -38,6 +69,27 @@ export interface Conversation {
|
|||||||
verified?: boolean;
|
verified?: boolean;
|
||||||
/** ISO timestamp of the last verification check (24h cache window). */
|
/** ISO timestamp of the last verification check (24h cache window). */
|
||||||
verified_checked_at?: string;
|
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 {
|
interface Store {
|
||||||
@ -122,6 +174,13 @@ export async function appendInbound(opts: {
|
|||||||
seq: number;
|
seq: number;
|
||||||
body: string;
|
body: string;
|
||||||
ts: 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> {
|
}): Promise<void> {
|
||||||
const s = await read();
|
const s = await read();
|
||||||
const conv = s.by_peer[opts.peer_primary] ?? {
|
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.
|
// Refresh display name in case we just resolved it.
|
||||||
if (opts.peer_handle) conv.peer_handle = opts.peer_handle;
|
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({
|
conv.messages.push({
|
||||||
seq: opts.seq,
|
seq: opts.seq,
|
||||||
direction: "in",
|
direction: "in",
|
||||||
@ -140,6 +203,10 @@ export async function appendInbound(opts: {
|
|||||||
from: opts.peer_primary,
|
from: opts.peer_primary,
|
||||||
ts: opts.ts,
|
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);
|
conv.last_seq = Math.max(conv.last_seq, opts.seq);
|
||||||
s.by_peer[opts.peer_primary] = conv;
|
s.by_peer[opts.peer_primary] = conv;
|
||||||
@ -147,12 +214,37 @@ export async function appendInbound(opts: {
|
|||||||
await write(s);
|
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: {
|
export async function appendOutbound(opts: {
|
||||||
peer_primary: Identity;
|
peer_primary: Identity;
|
||||||
peer_handle: string;
|
peer_handle: string;
|
||||||
from: Identity;
|
from: Identity;
|
||||||
body: string;
|
body: string;
|
||||||
}): Promise<void> {
|
status?: MessageStatus;
|
||||||
|
}): Promise<number> {
|
||||||
const s = await read();
|
const s = await read();
|
||||||
const conv =
|
const conv =
|
||||||
s.by_peer[opts.peer_primary] ?? {
|
s.by_peer[opts.peer_primary] ?? {
|
||||||
@ -162,13 +254,114 @@ export async function appendOutbound(opts: {
|
|||||||
last_seq: 0,
|
last_seq: 0,
|
||||||
};
|
};
|
||||||
if (opts.peer_handle) conv.peer_handle = opts.peer_handle;
|
if (opts.peer_handle) conv.peer_handle = opts.peer_handle;
|
||||||
|
const seq = Date.now();
|
||||||
conv.messages.push({
|
conv.messages.push({
|
||||||
seq: Date.now(),
|
seq,
|
||||||
direction: "out",
|
direction: "out",
|
||||||
body: opts.body,
|
body: opts.body,
|
||||||
from: opts.from,
|
from: opts.from,
|
||||||
ts: new Date().toISOString(),
|
ts: new Date().toISOString(),
|
||||||
|
status: opts.status ?? "sending",
|
||||||
});
|
});
|
||||||
s.by_peer[opts.peer_primary] = conv;
|
s.by_peer[opts.peer_primary] = conv;
|
||||||
await write(s);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,8 +25,8 @@ import { hkdf } from "@noble/hashes/hkdf";
|
|||||||
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
|
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
|
||||||
import { canonicalBytes, type Identity } from "./kez.js";
|
import { canonicalBytes, type Identity } from "./kez.js";
|
||||||
|
|
||||||
const ENVELOPE_VERSION = 1;
|
const HKDF_INFO_V1 = new TextEncoder().encode("kez-chat-msg-v1");
|
||||||
const HKDF_INFO = 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. */
|
/** What the sender stores in the encrypted blob. */
|
||||||
export interface MessagePlaintext {
|
export interface MessagePlaintext {
|
||||||
@ -39,17 +39,52 @@ export interface MessagePlaintext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** What goes on the wire to POST /v1/messages. */
|
/** 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;
|
v: 1;
|
||||||
/** Sender's primary — recipient uses this to derive x25519 pub for ECDH. */
|
|
||||||
from: Identity;
|
from: Identity;
|
||||||
/** Recipient handle, e.g. "alice". */
|
|
||||||
to: string;
|
to: string;
|
||||||
/** 12-byte AES-GCM nonce, hex. Also seeds HKDF salt → key. */
|
|
||||||
nonce: string;
|
nonce: string;
|
||||||
/** AES-256-GCM(plaintext_json), hex. */
|
|
||||||
ciphertext: string;
|
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;
|
sender_sig: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,11 +124,14 @@ async function deriveAesKey(
|
|||||||
myPriv: Uint8Array,
|
myPriv: Uint8Array,
|
||||||
theirPub: Uint8Array,
|
theirPub: Uint8Array,
|
||||||
nonce: Uint8Array,
|
nonce: Uint8Array,
|
||||||
|
info: Uint8Array,
|
||||||
): Promise<CryptoKey> {
|
): Promise<CryptoKey> {
|
||||||
const shared = x25519.getSharedSecret(myPriv, theirPub);
|
const shared = x25519.getSharedSecret(myPriv, theirPub);
|
||||||
// HKDF-SHA256 with the nonce as salt — different nonce per message →
|
// HKDF-SHA256 with the nonce as salt — different nonce per message →
|
||||||
// different AES key, even if shared secret stays the same.
|
// different AES key, even if shared secret stays the same. `info`
|
||||||
const keyBytes = hkdf(sha256, shared, nonce, HKDF_INFO, 32);
|
// 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, [
|
return crypto.subtle.importKey("raw", asBuffer(keyBytes), "AES-GCM", false, [
|
||||||
"encrypt",
|
"encrypt",
|
||||||
"decrypt",
|
"decrypt",
|
||||||
@ -118,41 +156,79 @@ export async function sealMessage(opts: {
|
|||||||
recipientHandle: string;
|
recipientHandle: string;
|
||||||
recipientPrimary: Identity;
|
recipientPrimary: Identity;
|
||||||
body: string;
|
body: string;
|
||||||
}): Promise<SealedEnvelope> {
|
}): Promise<SealedEnvelopeV2> {
|
||||||
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
// ─── ephemeral x25519 keypair, used once and destroyed ─────────
|
||||||
const senderX25519Priv = x25519PrivFromEd25519Seed(opts.senderSeed);
|
// The whole point of this dance vs. the legacy v1 path is forward
|
||||||
const recipientX25519Pub = x25519PubFromPrimary(opts.recipientPrimary);
|
// secrecy: even if `senderSeed` is compromised later, the captured
|
||||||
const aesKey = await deriveAesKey(senderX25519Priv, recipientX25519Pub, nonce);
|
// 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 = {
|
const plaintext: MessagePlaintext = {
|
||||||
from: opts.senderPrimary,
|
from: opts.senderPrimary,
|
||||||
body: opts.body,
|
body: opts.body,
|
||||||
sent_at: new Date().toISOString(),
|
sent_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
const ptBytes = new TextEncoder().encode(JSON.stringify(plaintext));
|
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(
|
const ctBytes = new Uint8Array(
|
||||||
await crypto.subtle.encrypt(
|
await crypto.subtle.encrypt(
|
||||||
{ name: "AES-GCM", iv: asBuffer(nonce) },
|
{
|
||||||
|
name: "AES-GCM",
|
||||||
|
iv: asBuffer(nonce),
|
||||||
|
additionalData: asBuffer(aad),
|
||||||
|
},
|
||||||
aesKey,
|
aesKey,
|
||||||
asBuffer(ptBytes),
|
asBuffer(ptBytes),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sign the envelope-minus-sig so the recipient can confirm the
|
// Sign envelope minus the sig. The recipient verifies this AFTER
|
||||||
// sender's primary key authored this ciphertext (and no one swapped
|
// they've decrypted and read `plaintext.from` — so the sig binds
|
||||||
// the nonce or recipient post-hoc).
|
// the sender's KEZ identity to this exact envelope without ever
|
||||||
|
// exposing the identity to the relay.
|
||||||
const partial = {
|
const partial = {
|
||||||
v: ENVELOPE_VERSION,
|
v: 2 as const,
|
||||||
from: opts.senderPrimary,
|
eph_pub: bytesToHex(ephPub),
|
||||||
to: opts.recipientHandle,
|
|
||||||
nonce: bytesToHex(nonce),
|
nonce: bytesToHex(nonce),
|
||||||
ciphertext: bytesToHex(ctBytes),
|
ciphertext: bytesToHex(ctBytes),
|
||||||
};
|
};
|
||||||
const sig = ed25519.sign(canonicalBytes(partial), opts.senderSeed);
|
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
|
* Verify + decrypt an envelope addressed to me. Returns the plaintext
|
||||||
* fields or throws on any failure (bad sig, primary mismatch, AES tag
|
* fields or throws on any failure (bad sig, primary mismatch, AES tag
|
||||||
@ -164,12 +240,42 @@ export async function openMessage(opts: {
|
|||||||
mySeed: Uint8Array;
|
mySeed: Uint8Array;
|
||||||
}): Promise<MessagePlaintext> {
|
}): Promise<MessagePlaintext> {
|
||||||
const env = opts.envelope;
|
const env = opts.envelope;
|
||||||
if (env.v !== 1) throw new Error(`unsupported envelope version: ${env.v}`);
|
let plaintext: MessagePlaintext;
|
||||||
if (env.to !== opts.myHandle) {
|
if (env.v === 1) plaintext = await openMessageV1(env, opts.myHandle, opts.mySeed);
|
||||||
throw new Error(`envelope addressed to ${env.to}, not ${opts.myHandle}`);
|
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 = {
|
const partial = {
|
||||||
v: env.v,
|
v: env.v,
|
||||||
from: env.from,
|
from: env.from,
|
||||||
@ -187,13 +293,15 @@ export async function openMessage(opts: {
|
|||||||
senderPubKey,
|
senderPubKey,
|
||||||
);
|
);
|
||||||
if (!sigOk) throw new Error("envelope signature did not verify");
|
if (!sigOk) throw new Error("envelope signature did not verify");
|
||||||
|
|
||||||
// 2. ECDH → key → AES-GCM decrypt.
|
|
||||||
const nonce = hexToBytes(env.nonce);
|
const nonce = hexToBytes(env.nonce);
|
||||||
const myX25519Priv = x25519PrivFromEd25519Seed(opts.mySeed);
|
const myX25519Priv = x25519PrivFromEd25519Seed(mySeed);
|
||||||
const senderX25519Pub = x25519PubFromEd25519Pub(senderPubKey);
|
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(
|
const ptBytes = new Uint8Array(
|
||||||
await crypto.subtle.decrypt(
|
await crypto.subtle.decrypt(
|
||||||
{ name: "AES-GCM", iv: asBuffer(nonce) },
|
{ name: "AES-GCM", iv: asBuffer(nonce) },
|
||||||
@ -203,3 +311,64 @@ export async function openMessage(opts: {
|
|||||||
);
|
);
|
||||||
return JSON.parse(new TextDecoder().decode(ptBytes)) as MessagePlaintext;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
86
kez-chat/web/src/lib/image-utils.ts
Normal file
86
kez-chat/web/src/lib/image-utils.ts
Normal 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 3–5 MB; a 256×256
|
||||||
|
// crop at 0.85 quality lands at 10–20 KB which fits comfortably in
|
||||||
|
// IndexedDB AND in a single nostr kind:0 event without bumping into
|
||||||
|
// relay size limits.
|
||||||
|
|
||||||
|
const AVATAR_SIZE = 256;
|
||||||
|
const AVATAR_QUALITY = 0.85;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take a File from a file input / camera capture, return a data URL
|
||||||
|
* containing a square cover-cropped JPEG. Throws on decode failure
|
||||||
|
* (corrupt image, unsupported format) or canvas errors (rare on
|
||||||
|
* desktop, more common on iOS Safari).
|
||||||
|
*/
|
||||||
|
export async function resizeToAvatarDataUrl(file: File): Promise<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;
|
||||||
|
}
|
||||||
@ -22,8 +22,12 @@
|
|||||||
// • In-page UX (toast / banner) lives in the component layer.
|
// • In-page UX (toast / banner) lives in the component layer.
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
attachSigner,
|
||||||
decrypt,
|
decrypt,
|
||||||
|
detachSigner,
|
||||||
|
flushPendingAcks,
|
||||||
pollInbox,
|
pollInbox,
|
||||||
|
sendAck,
|
||||||
streamInbox,
|
streamInbox,
|
||||||
type InboxMessage,
|
type InboxMessage,
|
||||||
type StreamHandle,
|
type StreamHandle,
|
||||||
@ -33,8 +37,10 @@ import {
|
|||||||
appendInbound,
|
appendInbound,
|
||||||
getConversation,
|
getConversation,
|
||||||
getGlobalCursor,
|
getGlobalCursor,
|
||||||
|
markDeliveredByEventId,
|
||||||
} from "./conversations-store.js";
|
} from "./conversations-store.js";
|
||||||
import type { Identity } from "./kez.js";
|
import type { Identity } from "./kez.js";
|
||||||
|
import { peerProfiles } from "./peer-profile-cell.svelte.js";
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 30_000;
|
const POLL_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
@ -72,15 +78,26 @@ class InboxService {
|
|||||||
// (which the user has either seen on this device or another).
|
// (which the user has either seen on this device or another).
|
||||||
this.#notifiedThroughSeq = await getGlobalCursor();
|
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({
|
this.#stream = streamInbox({
|
||||||
handle,
|
handle,
|
||||||
seed,
|
seed,
|
||||||
onMessage: (m) => void this.#ingest(m),
|
onMessage: (m) => void this.#ingest(m),
|
||||||
|
onAck: (eventId, sigHex) => void this.#ingestAck(eventId, sigHex),
|
||||||
onStatus: (s) => (this.status = s),
|
onStatus: (s) => (this.status = s),
|
||||||
});
|
});
|
||||||
this.#pollTimer = setInterval(() => void this.#heartbeat(), POLL_INTERVAL_MS);
|
this.#pollTimer = setInterval(() => void this.#heartbeat(), POLL_INTERVAL_MS);
|
||||||
// Eager first poll so we catch up anything queued before this session.
|
// Eager first poll so we catch up anything queued before this session.
|
||||||
void this.#heartbeat();
|
void this.#heartbeat();
|
||||||
|
// 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. */
|
/** Stop everything. Called on lock + on tab close. */
|
||||||
@ -92,6 +109,9 @@ class InboxService {
|
|||||||
this.#handle = null;
|
this.#handle = null;
|
||||||
this.#seed = null;
|
this.#seed = null;
|
||||||
this.status = "off";
|
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. */
|
/** Messages page calls this when the user lands on /messages. */
|
||||||
@ -145,7 +165,45 @@ class InboxService {
|
|||||||
seq: m.seq,
|
seq: m.seq,
|
||||||
body: pt.body,
|
body: pt.body,
|
||||||
ts: pt.sent_at,
|
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
|
// Only fire UI side-effects (badge + system notification) for
|
||||||
// messages we haven't already notified about. This guards both:
|
// messages we haven't already notified about. This guards both:
|
||||||
// • SSE+poll race: same seq comes in twice via different paths
|
// • 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() {
|
#notifyListeners() {
|
||||||
for (const fn of this.#listeners) {
|
for (const fn of this.#listeners) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -18,6 +18,20 @@ export interface InboxMessage {
|
|||||||
seq: number;
|
seq: number;
|
||||||
envelope: SealedEnvelope;
|
envelope: SealedEnvelope;
|
||||||
created_at: string;
|
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. */
|
/** Canonical bytes the inbox poller signs. Mirrors the rust constant. */
|
||||||
@ -72,14 +86,28 @@ export async function sendMessage(opts: {
|
|||||||
senderPrimary: Identity;
|
senderPrimary: Identity;
|
||||||
recipient: string;
|
recipient: string;
|
||||||
body: 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 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({
|
const envelope = await sealMessage({
|
||||||
senderSeed: opts.senderSeed,
|
senderSeed: opts.senderSeed,
|
||||||
senderPrimary: opts.senderPrimary,
|
senderPrimary: opts.senderPrimary,
|
||||||
recipientHandle,
|
recipientHandle,
|
||||||
recipientPrimary: record.primary as Identity,
|
recipientPrimary: resolvedPrimary,
|
||||||
body: opts.body,
|
body: opts.body,
|
||||||
});
|
});
|
||||||
const resp = await fetch(`${base()}/v1/messages`, {
|
const resp = await fetch(`${base()}/v1/messages`, {
|
||||||
@ -90,7 +118,12 @@ export async function sendMessage(opts: {
|
|||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
throw new Error(`POST /v1/messages → ${resp.status}: ${await resp.text()}`);
|
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;
|
handle: string;
|
||||||
seed: Uint8Array;
|
seed: Uint8Array;
|
||||||
onMessage: (msg: InboxMessage) => void;
|
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;
|
onStatus?: (status: "connecting" | "live" | "reconnecting") => void;
|
||||||
}): StreamHandle {
|
}): StreamHandle {
|
||||||
let es: EventSource | null = null;
|
let es: EventSource | null = null;
|
||||||
@ -178,7 +215,10 @@ export function streamInbox(opts: {
|
|||||||
const auth = streamAuthQueryParam({ handle: opts.handle, seed: opts.seed });
|
const auth = streamAuthQueryParam({ handle: opts.handle, seed: opts.seed });
|
||||||
const url = `${base()}/v1/inbox/${opts.handle}/stream?auth=${encodeURIComponent(auth)}`;
|
const url = `${base()}/v1/inbox/${opts.handle}/stream?auth=${encodeURIComponent(auth)}`;
|
||||||
es = new EventSource(url);
|
es = new EventSource(url);
|
||||||
es.addEventListener("open", () => opts.onStatus?.("live"));
|
es.addEventListener("open", () => {
|
||||||
|
_setServerConnected(true);
|
||||||
|
opts.onStatus?.("live");
|
||||||
|
});
|
||||||
es.addEventListener("message", (ev) => {
|
es.addEventListener("message", (ev) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(ev.data) as InboxMessage;
|
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
|
// (avoid hot-loop if the server is rejecting). EventSource also
|
||||||
// auto-reconnects but with the same (now possibly stale) URL.
|
// auto-reconnects but with the same (now possibly stale) URL.
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
|
_setServerConnected(false);
|
||||||
es?.close();
|
es?.close();
|
||||||
es = null;
|
es = null;
|
||||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
@ -204,6 +245,7 @@ export function streamInbox(opts: {
|
|||||||
return {
|
return {
|
||||||
close() {
|
close() {
|
||||||
closed = true;
|
closed = true;
|
||||||
|
_setServerConnected(false);
|
||||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
es?.close();
|
es?.close();
|
||||||
},
|
},
|
||||||
@ -214,3 +256,67 @@ export function streamInbox(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type { SealedEnvelope, MessagePlaintext };
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -24,8 +24,14 @@ import type { Identity } from "./kez.js";
|
|||||||
/** Regular event kind (1000–9999 → relays persist it, which the inbox needs). */
|
/** Regular event kind (1000–9999 → relays persist it, which the inbox needs). */
|
||||||
export const KEZ_DM_KIND = 4242;
|
export const KEZ_DM_KIND = 4242;
|
||||||
|
|
||||||
/** Tag name carrying the recipient address. `#h` filter on the relay side. */
|
/** Tag name carrying the recipient address. `#q` filter on the relay
|
||||||
export const ADDR_TAG = "h";
|
* 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_SALT = new TextEncoder().encode("kez-chat:nostr-signkey");
|
||||||
const SIGNKEY_INFO = new TextEncoder().encode("v1");
|
const SIGNKEY_INFO = new TextEncoder().encode("v1");
|
||||||
|
|||||||
@ -15,6 +15,8 @@
|
|||||||
// secp256k1 key derived from our ed25519 seed (see nostr-id.ts) purely so
|
// secp256k1 key derived from our ed25519 seed (see nostr-id.ts) purely so
|
||||||
// relays accept them — that key is never surfaced to the user.
|
// 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 { SimplePool, finalizeEvent, type Event, type EventTemplate } from "nostr-tools";
|
||||||
import { sealMessage, type SealedEnvelope } from "./crypto.js";
|
import { sealMessage, type SealedEnvelope } from "./crypto.js";
|
||||||
import { lookup } from "./api.js";
|
import { lookup } from "./api.js";
|
||||||
@ -26,41 +28,224 @@ import { decrypt, type InboxMessage, type StreamHandle } from "./messages.js";
|
|||||||
export { decrypt };
|
export { decrypt };
|
||||||
export type { InboxMessage, StreamHandle };
|
export type { InboxMessage, StreamHandle };
|
||||||
|
|
||||||
/** Relays to publish to / read from. Override with VITE_NOSTR_RELAYS (csv). */
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Connection-state surface — drives the "● live (N)" indicator and its
|
||||||
|
// "show me which relays" popover in the chat UI.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface RelayStatus {
|
||||||
|
url: string;
|
||||||
|
/** WebSocket is in OPEN state right now. */
|
||||||
|
connected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a relay URL the same way nostr-tools does internally
|
||||||
|
* (see abstract-pool.js normalizeURL): coerce http→ws, strip default
|
||||||
|
* ports, trim duplicate + trailing slashes from the path. We need this
|
||||||
|
* because the pool's internal Map is keyed by the normalized form, so
|
||||||
|
* a lookup by the raw configured URL silently misses ("wss://nos.lol"
|
||||||
|
* vs the stored "wss://nos.lol/").
|
||||||
|
*/
|
||||||
|
function normalizeRelayUrl(raw: string): string {
|
||||||
|
try {
|
||||||
|
let s = raw;
|
||||||
|
if (!s.includes("://")) s = "wss://" + s;
|
||||||
|
const u = new URL(s);
|
||||||
|
if (u.protocol === "http:") u.protocol = "ws:";
|
||||||
|
else if (u.protocol === "https:") u.protocol = "wss:";
|
||||||
|
u.pathname = u.pathname.replace(/\/+/g, "/");
|
||||||
|
if (u.pathname.endsWith("/")) u.pathname = u.pathname.slice(0, -1);
|
||||||
|
if (
|
||||||
|
(u.port === "80" && u.protocol === "ws:") ||
|
||||||
|
(u.port === "443" && u.protocol === "wss:")
|
||||||
|
) {
|
||||||
|
u.port = "";
|
||||||
|
}
|
||||||
|
return u.toString();
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snapshot of every configured relay + whether its socket is currently
|
||||||
|
* open. Cheap to call — just reads from SimplePool's internal map. */
|
||||||
|
export function getRelayStatuses(): RelayStatus[] {
|
||||||
|
// listConnectionStatus only returns URLs the pool has touched, so seed
|
||||||
|
// the map with our configured set first to show unconnected relays too.
|
||||||
|
const live = _pool ? _pool.listConnectionStatus() : new Map<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[] = (
|
const RELAYS: string[] = (
|
||||||
import.meta.env.VITE_NOSTR_RELAYS ??
|
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(",")
|
.split(",")
|
||||||
.map((r: string) => r.trim())
|
.map((r: string) => r.trim())
|
||||||
.filter(Boolean);
|
.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 _pool: SimplePool | null = null;
|
||||||
|
let _authSeed: Uint8Array | null = null;
|
||||||
function pool(): SimplePool {
|
function pool(): SimplePool {
|
||||||
if (!_pool) _pool = new SimplePool();
|
if (!_pool) _pool = buildPool();
|
||||||
return _pool;
|
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)
|
// Per-handle cursor + dedupe (localStorage, survives reloads)
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const SINCE_KEY = (h: string) => `kez-chat:nostr:since:${h}`;
|
const SINCE_KEY = (h: string) => `kez-chat:nostr:since:${h}`;
|
||||||
const SEEN_KEY = (h: string) => `kez-chat:nostr:seen:${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
|
// Clock-skew tolerance against ev.created_at. A relay or a malicious
|
||||||
* fresh device still catches very recent messages. */
|
// publisher can backdate or future-date events to game our `since`
|
||||||
|
// cursor (jumping forward → skipping legit events; jumping back →
|
||||||
|
// reordering UI). Clamp to a reasonable window.
|
||||||
|
const CREATED_AT_MAX_PAST_SECS = 7 * 24 * 3600; // 7 days
|
||||||
|
const CREATED_AT_MAX_FUTURE_SECS = 5 * 60; // 5 minutes
|
||||||
|
|
||||||
|
/** Drop events whose nostr-claimed timestamp is impossibly old or
|
||||||
|
* impossibly future. Caller treats `false` as "ignore this event". */
|
||||||
|
function isCreatedAtSane(createdAt: number): boolean {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (createdAt < now - CREATED_AT_MAX_PAST_SECS) return false;
|
||||||
|
if (createdAt > now + CREATED_AT_MAX_FUTURE_SECS) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Relay `since` filter (unix seconds). Default = 48h back on first
|
||||||
|
* use of a handle — chosen because most public relays only retain
|
||||||
|
* events for 1–3 days; a longer cursor silently misses anything
|
||||||
|
* the relay has already evicted. Returning user sessions use the
|
||||||
|
* persisted cursor (last seen `created_at`) and so are unaffected.
|
||||||
|
* TODO.md Day 3 #14. */
|
||||||
|
const DEFAULT_SINCE_LOOKBACK_SECS = 48 * 3600;
|
||||||
function readSince(handle: string): number {
|
function readSince(handle: string): number {
|
||||||
try {
|
try {
|
||||||
const v = localStorage.getItem(SINCE_KEY(handle));
|
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 {
|
} catch {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function bumpSince(handle: string, createdAt: number) {
|
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 {
|
try {
|
||||||
if (createdAt > readSince(handle)) {
|
if (createdAt > readSince(handle)) {
|
||||||
localStorage.setItem(SINCE_KEY(handle), String(createdAt));
|
localStorage.setItem(SINCE_KEY(handle), String(createdAt));
|
||||||
@ -118,16 +303,52 @@ function toInboxMessage(ev: Event): InboxMessage | null {
|
|||||||
// Send
|
// 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: {
|
export async function sendMessage(opts: {
|
||||||
senderHandle: string;
|
senderHandle: string;
|
||||||
senderSeed: Uint8Array;
|
senderSeed: Uint8Array;
|
||||||
senderPrimary: Identity;
|
senderPrimary: Identity;
|
||||||
recipient: string;
|
recipient: string;
|
||||||
body: 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 recipientHandle = opts.recipient.split("@")[0];
|
||||||
const record = await lookup(recipientHandle); // throws on 404
|
// Skip the server lookup if the caller already has the primary
|
||||||
const recipientPrimary = record.primary as Identity;
|
// 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.
|
// Our own encryption layer — identical to the server transport.
|
||||||
const envelope = await sealMessage({
|
const envelope = await sealMessage({
|
||||||
@ -147,8 +368,25 @@ export async function sendMessage(opts: {
|
|||||||
};
|
};
|
||||||
const signed = finalizeEvent(tmpl, sk);
|
const signed = finalizeEvent(tmpl, sk);
|
||||||
|
|
||||||
// Succeed if at least one relay accepts.
|
// Publish to all relays in order; succeed if any accepts. We also
|
||||||
const results = await Promise.allSettled(pool().publish(RELAYS, signed));
|
// 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")) {
|
if (!results.some((r) => r.status === "fulfilled")) {
|
||||||
const why = results
|
const why = results
|
||||||
.map((r) => (r.status === "rejected" ? String(r.reason) : ""))
|
.map((r) => (r.status === "rejected" ? String(r.reason) : ""))
|
||||||
@ -156,7 +394,231 @@ export async function sendMessage(opts: {
|
|||||||
.join("; ");
|
.join("; ");
|
||||||
throw new Error(`no relay accepted the message${why ? `: ${why}` : ""}`);
|
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[] = [];
|
const messages: InboxMessage[] = [];
|
||||||
let maxSeq = 0;
|
let maxSeq = 0;
|
||||||
for (const ev of events.sort((a, b) => a.created_at - b.created_at)) {
|
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;
|
if (!markSeen(opts.handle, ev.id)) continue;
|
||||||
const m = toInboxMessage(ev);
|
const m = toInboxMessage(ev);
|
||||||
if (!m) continue;
|
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);
|
messages.push(m);
|
||||||
bumpSince(opts.handle, ev.created_at);
|
bumpSince(opts.handle, ev.created_at);
|
||||||
if (m.seq > maxSeq) maxSeq = m.seq;
|
if (m.seq > maxSeq) maxSeq = m.seq;
|
||||||
@ -201,6 +673,13 @@ export function streamInbox(opts: {
|
|||||||
handle: string;
|
handle: string;
|
||||||
seed: Uint8Array;
|
seed: Uint8Array;
|
||||||
onMessage: (msg: InboxMessage) => void;
|
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;
|
onStatus?: (status: "connecting" | "live" | "reconnecting") => void;
|
||||||
}): StreamHandle {
|
}): StreamHandle {
|
||||||
const myPrimary = identityFromSeed(opts.seed).identity;
|
const myPrimary = identityFromSeed(opts.seed).identity;
|
||||||
@ -210,14 +689,50 @@ export function streamInbox(opts: {
|
|||||||
opts.onStatus?.("connecting");
|
opts.onStatus?.("connecting");
|
||||||
const sub = pool().subscribeMany(
|
const sub = pool().subscribeMany(
|
||||||
RELAYS,
|
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) {
|
onevent(ev: Event) {
|
||||||
if (closed) return;
|
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;
|
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);
|
const m = toInboxMessage(ev);
|
||||||
if (!m) return;
|
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);
|
opts.onMessage(m);
|
||||||
},
|
},
|
||||||
oneose() {
|
oneose() {
|
||||||
|
|||||||
59
kez-chat/web/src/lib/peer-profile-cell.svelte.ts
Normal file
59
kez-chat/web/src/lib/peer-profile-cell.svelte.ts
Normal 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();
|
||||||
222
kez-chat/web/src/lib/peer-profile-store.ts
Normal file
222
kez-chat/web/src/lib/peer-profile-store.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
306
kez-chat/web/src/lib/persistent-session.ts
Normal file
306
kez-chat/web/src/lib/persistent-session.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
387
kez-chat/web/src/lib/profile-store.ts
Normal file
387
kez-chat/web/src/lib/profile-store.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -204,6 +204,65 @@ export async function isPushSubscribed(): Promise<boolean> {
|
|||||||
return sub !== null;
|
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
|
* iOS PWA detection — Safari only exposes PushManager once installed
|
||||||
* to the home screen. We use this to render a "Tap Share → Add to Home
|
* to the home screen. We use this to render a "Tap Share → Add to Home
|
||||||
|
|||||||
@ -7,19 +7,246 @@
|
|||||||
|
|
||||||
import type { UnlockedIdentity } from "./identity-store.js";
|
import type { UnlockedIdentity } from "./identity-store.js";
|
||||||
import { inboxService } from "./inbox-service.svelte.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 {
|
class Session {
|
||||||
unlocked = $state<UnlockedIdentity | null>(null);
|
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) {
|
setUnlocked(id: UnlockedIdentity) {
|
||||||
this.unlocked = id;
|
this.unlocked = id;
|
||||||
inboxService.start(id.handle, id.seed);
|
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() {
|
lock() {
|
||||||
inboxService.stop();
|
inboxService.stop();
|
||||||
this.unlocked = null;
|
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();
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -21,8 +21,30 @@ export const sendMessage = impl.sendMessage;
|
|||||||
export const pollInbox = impl.pollInbox;
|
export const pollInbox = impl.pollInbox;
|
||||||
export const streamInbox = impl.streamInbox;
|
export const streamInbox = impl.streamInbox;
|
||||||
export const decrypt = impl.decrypt;
|
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 { 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. */
|
/** Which transport this build is using — handy for a debug line in the UI. */
|
||||||
export const activeTransport = TRANSPORT;
|
export const activeTransport = TRANSPORT;
|
||||||
|
|||||||
282
kez-chat/web/src/lib/visual-crypto.ts
Normal file
282
kez-chat/web/src/lib/visual-crypto.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -118,7 +118,15 @@ export async function setupBiometricUnlock(opts: {
|
|||||||
// if the authenticator doesn't support it, registration succeeds
|
// if the authenticator doesn't support it, registration succeeds
|
||||||
// but getClientExtensionResults().prf.enabled will be false and we
|
// but getClientExtensionResults().prf.enabled will be false and we
|
||||||
// bail.
|
// 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 challenge = crypto.getRandomValues(new Uint8Array(32));
|
||||||
|
|
||||||
const cred = (await navigator.credentials.create({
|
const cred = (await navigator.credentials.create({
|
||||||
|
|||||||
@ -288,7 +288,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-2xl font-bold text-text">Add a claim</h1>
|
<h1 class="text-2xl font-bold text-text">Add a claim</h1>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -156,7 +156,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-2xl font-bold text-text">Claims</h1>
|
<h1 class="text-2xl font-bold text-text">Claims</h1>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
|
|||||||
@ -154,7 +154,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if session.unlocked}
|
{#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>
|
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||||
|
|
||||||
<section class="border border-gray-200 rounded-lg p-6 bg-white">
|
<section class="border border-gray-200 rounded-lg p-6 bg-white">
|
||||||
|
|||||||
@ -113,11 +113,16 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if session.unlocked}
|
{#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 -->
|
<!-- Identity card -->
|
||||||
<section class="bg-surface border border-border rounded-xl p-6">
|
<section class="bg-surface border border-border rounded-xl p-6">
|
||||||
<div class="flex items-start gap-4">
|
<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="min-w-0 flex-1">
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<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">
|
<span class="font-mono text-lg font-semibold text-text truncate inline-flex items-center gap-1">
|
||||||
|
|||||||
@ -2,7 +2,13 @@
|
|||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { push } from "svelte-spa-router";
|
import { push } from "svelte-spa-router";
|
||||||
import { session } from "../lib/store.svelte.js";
|
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 { lookup, lookupByPrimary, ApiError } from "../lib/api.js";
|
||||||
import { inboxService } from "../lib/inbox-service.svelte.js";
|
import { inboxService } from "../lib/inbox-service.svelte.js";
|
||||||
import { verifySubject } from "../lib/verify.js";
|
import { verifySubject } from "../lib/verify.js";
|
||||||
@ -10,10 +16,21 @@
|
|||||||
import EmojiButton from "../lib/EmojiButton.svelte";
|
import EmojiButton from "../lib/EmojiButton.svelte";
|
||||||
import Avatar from "../lib/Avatar.svelte";
|
import Avatar from "../lib/Avatar.svelte";
|
||||||
import VerifiedBadge from "../lib/VerifiedBadge.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 {
|
import {
|
||||||
appendOutbound,
|
appendOutbound,
|
||||||
ensureConversation,
|
ensureConversation,
|
||||||
listConversations,
|
listConversations,
|
||||||
|
markConversationRead,
|
||||||
|
markDeliveredByEventId,
|
||||||
|
markOutboundStatus,
|
||||||
setVerified,
|
setVerified,
|
||||||
type Conversation,
|
type Conversation,
|
||||||
} from "../lib/conversations-store.js";
|
} from "../lib/conversations-store.js";
|
||||||
@ -26,6 +43,41 @@
|
|||||||
? conversations.find((c) => c.peer_primary === activePrimary) ?? null
|
? conversations.find((c) => c.peer_primary === activePrimary) ?? null
|
||||||
: 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 composeText = $state("");
|
||||||
let composing = $state(false);
|
let composing = $state(false);
|
||||||
let composeEl: HTMLInputElement | null = $state(null);
|
let composeEl: HTMLInputElement | null = $state(null);
|
||||||
@ -134,11 +186,131 @@
|
|||||||
// Toast for the share-link copy action.
|
// Toast for the share-link copy action.
|
||||||
let copied = $state(false);
|
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 () => {
|
onMount(async () => {
|
||||||
if (!session.unlocked) {
|
if (!session.unlocked) {
|
||||||
push("/unlock");
|
push("/unlock");
|
||||||
return;
|
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();
|
await refresh();
|
||||||
// Kick off verification for every existing conversation (24h cache per
|
// Kick off verification for every existing conversation (24h cache per
|
||||||
// peer), so the verified badge shows in the list without opening each
|
// peer), so the verified badge shows in the list without opening each
|
||||||
@ -151,14 +323,77 @@
|
|||||||
unsubscribe = inboxService.onMessage(() => void refresh());
|
unsubscribe = inboxService.onMessage(() => void refresh());
|
||||||
// Landing here = the user has seen new messages; reset the badge.
|
// Landing here = the user has seen new messages; reset the badge.
|
||||||
inboxService.markAllRead();
|
inboxService.markAllRead();
|
||||||
|
// Relay state poll.
|
||||||
|
relayPollTimer = setInterval(() => {
|
||||||
|
relayStatuses = getRelayStatuses();
|
||||||
|
}, 2_000);
|
||||||
|
document.addEventListener("click", onDocumentClick);
|
||||||
|
void evaluatePushNudge();
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
unsubscribe?.();
|
unsubscribe?.();
|
||||||
|
if (relayPollTimer) clearInterval(relayPollTimer);
|
||||||
|
document.removeEventListener("click", onDocumentClick);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
conversations = await listConversations();
|
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
|
// Verify the active peer whenever a conversation is opened (covers
|
||||||
@ -236,28 +471,63 @@
|
|||||||
async function send() {
|
async function send() {
|
||||||
if (!session.unlocked || !activeConv || !composeText.trim()) return;
|
if (!session.unlocked || !activeConv || !composeText.trim()) return;
|
||||||
composing = true;
|
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 {
|
try {
|
||||||
const body = composeText;
|
localSeq = await appendOutbound({
|
||||||
composeText = "";
|
peer_primary,
|
||||||
await sendMessage({
|
peer_handle,
|
||||||
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,
|
|
||||||
from: session.unlocked.primary,
|
from: session.unlocked.primary,
|
||||||
body,
|
body,
|
||||||
|
status: "sending",
|
||||||
});
|
});
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(`Send failed: ${(e as Error).message}`);
|
alert(`Local append failed: ${(e as Error).message}`);
|
||||||
composeText = composeText; // no-op, keep linter happy
|
|
||||||
} finally {
|
|
||||||
composing = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full bg-bg">
|
<div class="flex flex-col h-full bg-bg">
|
||||||
<!-- Sidebar (conversation list). On mobile it's full-width and hides
|
<!--
|
||||||
when a conversation is open. -->
|
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`}>
|
<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 -->
|
<!-- Header: your KEZ + status -->
|
||||||
<div class="p-3 border-b border-border">
|
<div class="p-3 border-b border-border">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<h1 class="text-sm font-semibold text-text uppercase tracking-wider">Chats</h1>
|
<h1 class="text-sm font-semibold text-text uppercase tracking-wider">Chats</h1>
|
||||||
<span class="text-xs">
|
<!--
|
||||||
{#if inboxService.status === "live"}
|
Live indicator — also a button that pops a small panel
|
||||||
<span class="text-accent">● live</span>
|
listing every configured relay (or the single chat-server
|
||||||
{:else if inboxService.status === "reconnecting"}
|
on server transport) and whether its socket is currently
|
||||||
<span class="text-warning">● reconnecting</span>
|
open. The count in parens is the *connected* relay count,
|
||||||
{:else if inboxService.status === "connecting"}
|
not the configured total: 0 → red, partial → yellow,
|
||||||
<span class="text-text-muted">○ connecting</span>
|
full → green.
|
||||||
{:else}
|
-->
|
||||||
<span class="text-text-muted">○ off</span>
|
<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}
|
{/if}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if session.unlocked}
|
{#if session.unlocked}
|
||||||
<button
|
<button
|
||||||
@ -378,7 +767,11 @@
|
|||||||
onclick={() => (activePrimary = c.peer_primary)}
|
onclick={() => (activePrimary = c.peer_primary)}
|
||||||
>
|
>
|
||||||
{#if active}<span class="absolute left-0 top-0 bottom-0 w-0.5 bg-accent"></span>{/if}
|
{#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">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="font-mono text-sm font-semibold text-text truncate flex items-center gap-1">
|
<p class="font-mono text-sm font-semibold text-text truncate flex items-center gap-1">
|
||||||
<span class="truncate">{displayName(c)}</span>
|
<span class="truncate">{displayName(c)}</span>
|
||||||
@ -392,7 +785,21 @@
|
|||||||
<p class="text-xs text-text-muted italic">No messages yet</p>
|
<p class="text-xs text-text-muted italic">No messages yet</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
@ -424,7 +831,29 @@
|
|||||||
<button class="sm:hidden text-text-secondary hover:text-text -ml-1" onclick={() => (activePrimary = null)} aria-label="Back">
|
<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>
|
<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>
|
</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">
|
<div class="min-w-0">
|
||||||
<p class="font-mono text-sm font-semibold text-text truncate flex items-center gap-1">
|
<p class="font-mono text-sm font-semibold text-text truncate flex items-center gap-1">
|
||||||
<span class="truncate">{displayName(activeConv)}</span>
|
<span class="truncate">{displayName(activeConv)}</span>
|
||||||
@ -463,21 +892,54 @@
|
|||||||
>
|
>
|
||||||
<span class="whitespace-pre-wrap break-words align-middle">{m.body}</span>
|
<span class="whitespace-pre-wrap break-words align-middle">{m.body}</span>
|
||||||
<!--
|
<!--
|
||||||
Inline timestamp. `float-right` + a leading non-
|
Inline timestamp + delivery status. `float-right` +
|
||||||
breaking space pulls the time onto the same baseline
|
a leading non-breaking space pulls the cluster onto
|
||||||
as the last line of text when there's room, and
|
the same baseline as the last line of text when
|
||||||
drops to its own line when the text wraps right up
|
there's room, and drops to its own line when the
|
||||||
to it. Lower opacity so it doesn't compete.
|
text wraps right up to it. Lower opacity so it
|
||||||
|
doesn't compete.
|
||||||
-->
|
-->
|
||||||
<span
|
<span
|
||||||
class={[
|
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",
|
out ? "text-accent-contrast/70" : "text-text-muted",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
aria-hidden="true"
|
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). -->
|
<!-- 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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@ -511,4 +973,46 @@
|
|||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
</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}
|
||||||
|
|||||||
@ -2,8 +2,16 @@
|
|||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { push } from "svelte-spa-router";
|
import { push } from "svelte-spa-router";
|
||||||
import { bytesToHex } from "@noble/hashes/utils";
|
import { bytesToHex } from "@noble/hashes/utils";
|
||||||
import { session } from "../lib/store.svelte.js";
|
import {
|
||||||
import { hasStoredPhrase } from "../lib/identity-store.js";
|
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 {
|
import {
|
||||||
hasStoredBiometric,
|
hasStoredBiometric,
|
||||||
getStoredBiometricMeta,
|
getStoredBiometricMeta,
|
||||||
@ -50,6 +58,79 @@
|
|||||||
let notifPerm = $state<NotificationPermission | "unsupported">("default");
|
let notifPerm = $state<NotificationPermission | "unsupported">("default");
|
||||||
let testNotifResult = $state<{ ok: boolean; reason?: string } | null>(null);
|
let testNotifResult = $state<{ ok: boolean; reason?: string } | null>(null);
|
||||||
|
|
||||||
|
// ─── 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 webPushOk = $state(false); // browser supports it at all
|
||||||
let webPushOn = $state(false); // currently subscribed
|
let webPushOn = $state(false); // currently subscribed
|
||||||
let webPushBusy = $state(false);
|
let webPushBusy = $state(false);
|
||||||
@ -122,10 +203,15 @@
|
|||||||
if (webPushOn) {
|
if (webPushOn) {
|
||||||
await disablePush(session.unlocked.handle, session.unlocked.seed);
|
await disablePush(session.unlocked.handle, session.unlocked.seed);
|
||||||
webPushOn = false;
|
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 {
|
} else {
|
||||||
const ok = await enablePush(session.unlocked.handle, session.unlocked.seed);
|
const ok = await enablePush(session.unlocked.handle, session.unlocked.seed);
|
||||||
webPushOn = ok;
|
webPushOn = ok;
|
||||||
if (!ok) webPushError = "Permission denied.";
|
if (!ok) webPushError = "Permission denied.";
|
||||||
|
else setPushAutoEnableDisabled(false);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
webPushError = (e as Error).message;
|
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;
|
if (!session.unlocked) return;
|
||||||
const phrase = session.unlocked.phrase;
|
revealPromptPassphrase = "";
|
||||||
if (phrase) {
|
revealPromptError = null;
|
||||||
alert(
|
revealPromptOpen = true;
|
||||||
`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.`,
|
function closeRevealPrompt() {
|
||||||
);
|
// Zero out the buffer before nulling so the passphrase doesn't
|
||||||
return;
|
// 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() {
|
function lock() {
|
||||||
@ -175,7 +299,170 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if session.unlocked}
|
{#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 -->
|
<!-- Appearance -->
|
||||||
<section class="bg-surface border border-border rounded-xl p-6">
|
<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>
|
<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
|
12 words that recover this account anywhere. Write them down on
|
||||||
paper — losing them means losing the account.
|
paper — losing them means losing the account.
|
||||||
</p>
|
</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
|
Reveal phrase
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -341,3 +628,65 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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}
|
||||||
|
|||||||
@ -93,7 +93,12 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if session.unlocked}
|
{#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="text-center space-y-2">
|
||||||
<div class="flex justify-center"><Wordmark size={28} /></div>
|
<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>
|
<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.
|
A couple of quick steps. You can skip and come back anytime from Settings.
|
||||||
</p>
|
</p>
|
||||||
<div class="flex items-center justify-center gap-3 pt-1">
|
<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>
|
<span class="font-mono text-sm text-accent">{session.unlocked.handle}@{session.unlocked.server}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-text-muted">{done} of {total} essentials done</p>
|
<p class="text-xs text-text-muted">{done} of {total} essentials done</p>
|
||||||
|
|||||||
@ -55,28 +55,13 @@ self.addEventListener("activate", (event) => {
|
|||||||
// and decrypts there. This keeps the push provider (Apple/Google/Mozilla)
|
// and decrypts there. This keeps the push provider (Apple/Google/Mozilla)
|
||||||
// from ever seeing message content even theoretically.
|
// from ever seeing message content even theoretically.
|
||||||
|
|
||||||
interface PushPayload {
|
|
||||||
type?: string;
|
|
||||||
to?: string;
|
|
||||||
seq?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.addEventListener("push", (event: PushEvent) => {
|
self.addEventListener("push", (event: PushEvent) => {
|
||||||
let data: PushPayload = {};
|
// Payload is intentionally empty (see TODO.md Day 1 #3 — we used
|
||||||
if (event.data) {
|
// to send {to, seq} but that leaked the social graph to the push
|
||||||
try {
|
// provider). Notification text has to be content-free; the user
|
||||||
data = event.data.json() as PushPayload;
|
// opens the app to see who messaged them.
|
||||||
} catch {
|
|
||||||
// Some providers send a wake-up "" payload — fall through and
|
|
||||||
// show a generic notification so the user knows to open the app.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = "New kez-chat message";
|
const title = "New kez-chat message";
|
||||||
const body =
|
const body = "Open kez-chat to view it.";
|
||||||
data.to !== undefined
|
|
||||||
? `You have a new message in @${data.to}`
|
|
||||||
: "Open kez-chat to view it.";
|
|
||||||
|
|
||||||
// `renotify` is widely supported but isn't in the baseline TS DOM lib;
|
// `renotify` is widely supported but isn't in the baseline TS DOM lib;
|
||||||
// build the options as a plain object and cast.
|
// build the options as a plain object and cast.
|
||||||
@ -84,11 +69,10 @@ self.addEventListener("push", (event: PushEvent) => {
|
|||||||
body,
|
body,
|
||||||
icon: "/pwa-192x192.png",
|
icon: "/pwa-192x192.png",
|
||||||
badge: "/pwa-64x64.png",
|
badge: "/pwa-64x64.png",
|
||||||
// Group same-conversation pings — iOS especially gets spammy
|
// Single tag so successive pushes collapse into one notification
|
||||||
// otherwise. `renotify` lets the next one still vibrate.
|
// pill (no spam if a friend sends 5 quick messages).
|
||||||
tag: data.to ? `kez-chat:${data.to}` : "kez-chat:new",
|
tag: "kez-chat:new",
|
||||||
renotify: true,
|
renotify: true,
|
||||||
data,
|
|
||||||
} as NotificationOptions;
|
} as NotificationOptions;
|
||||||
|
|
||||||
event.waitUntil(self.registration.showNotification(title, options));
|
event.waitUntil(self.registration.showNotification(title, options));
|
||||||
@ -96,8 +80,12 @@ self.addEventListener("push", (event: PushEvent) => {
|
|||||||
|
|
||||||
self.addEventListener("notificationclick", (event: NotificationEvent) => {
|
self.addEventListener("notificationclick", (event: NotificationEvent) => {
|
||||||
event.notification.close();
|
event.notification.close();
|
||||||
const data = event.notification.data as PushPayload | undefined;
|
// Push payload is empty (TODO.md Day 1 #3) so there's no per-peer
|
||||||
const targetUrl = data?.to ? `/chats/${encodeURIComponent(data.to)}` : "/chats";
|
// 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(
|
event.waitUntil(
|
||||||
(async () => {
|
(async () => {
|
||||||
@ -111,11 +99,11 @@ self.addEventListener("notificationclick", (event: NotificationEvent) => {
|
|||||||
// Found an already-open kez-chat tab — focus it and ask the
|
// Found an already-open kez-chat tab — focus it and ask the
|
||||||
// SPA to navigate; cheaper than spawning a fresh window.
|
// SPA to navigate; cheaper than spawning a fresh window.
|
||||||
await client.focus();
|
await client.focus();
|
||||||
client.postMessage({ type: "kez-chat/navigate", to: targetUrl });
|
client.postMessage({ type: "kez-chat/navigate", to: hashTarget });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await self.clients.openWindow(targetUrl);
|
await self.clients.openWindow(fullUrl);
|
||||||
})(),
|
})(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user