Time to actually chat. Server is a dumb relay storing opaque envelopes;
recipients decrypt client-side. Everything below is end-to-end encrypted,
the server can't read anything it stores.
Server (kez-chat-server):
• New messages table (seq autoinc, recipient_handle, envelope blob,
created_at). Indexed by (recipient, seq) for cursor paging.
• POST /v1/messages
body: { to: handle, envelope: <opaque JSON> }
validates recipient exists; rejects > 256 KB envelopes.
• GET /v1/inbox/:handle?since=<seq>&limit=<n>
auth: X-KEZ-Auth: <unix_ts>:<sig_hex>
sig = ed25519(handle's primary,
"GET\n/v1/inbox/<handle>\nsince=<n>\n<ts>")
60s clock-skew tolerance; signed message includes cursor so a
captured header can't page through history.
• New ApiError::Unauthorized → 401.
• kez-core: verify_ed25519_hex is now pub so the auth handler can
use it for arbitrary-message verification (outside JCS envelopes).
Crypto (browser):
• ed25519 seed → x25519 priv via Montgomery conversion
(ed25519.utils.toMontgomerySecret).
• ed25519 pubkey → x25519 pubkey for the recipient (toMontgomery).
• ECDH → 32-byte shared secret → HKDF-SHA256(salt=nonce, info=
"kez-chat-msg-v1") → AES-256-GCM key.
• Per-message random 12-byte nonce; each message gets a unique AES key.
• Sender signs envelope-minus-sig with their ed25519 primary so the
recipient can confirm the sender authored the ciphertext + binding.
SPA UI:
• /messages route, two-pane layout (sidebar conversations, thread view,
compose box).
• 5-second poller against /v1/inbox using the global cursor; new
messages get decrypted + appended to the right thread.
• Local IDB cache (lib/conversations-store.ts) so decrypted history
survives reloads. Dedupes by seq+direction.
• Page-specific max-w-6xl so the two-pane layout has room.
Tests:
• 6 new unit tests in messages.rs covering auth header verification
(stale ts, wrong handle, wrong cursor, malformed).
• 4 new integration tests in tests/http.rs: full send + inbox round-
trip, wrong-signer rejected, missing header rejected, unknown
recipient → 404.
• All 17 chat-server tests pass.
Followups (deferred):
• NATS WebSocket push (live messages without 5s poll lag).
• Group chats with proper member-key rotation.
• Reverse handle resolution (/v1/by-primary) so the UI can show
"@alice" instead of the truncated ed25519 hex.
• At-rest encryption for the IDB conversations cache.
• Sender spam mitigation on POST /v1/messages.
Live at https://kez.lat — try /messages with two browsers.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The dashboard's Claims section used to be a "here's what claims are"
teaser with a link to /claims. Now it shows the actual verified rows:
• Empty state → "+ Add your first claim" CTA
• Claims exist, none verified → amber callout pointing at Re-verify
• Verified claims → green rows, one per verified claim:
[GitHub] github:tudisco
✓ Verified via public gist proof ↗
• Trailing summary if relevant: "1 failed verification. 2 not yet
checked. See the claims page for details."
A "Re-verify all" button at the section header re-runs every verifier
in place, so the dashboard stays fresh without a round-trip to /claims.
Uses $derived to bucket claims by verification status. $state.snapshot
before passing each claim to the verifier (same proxy/structuredClone
gotcha as the Claims page).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
If a NIP-07 nostr extension (Alby, nos2x, Flamingo, …) is loaded into
the page, the Add Claim flow detects it and offers two shortcuts:
• Identifier step (nostr): "⚡ Use my nostr extension" button reads
window.nostr.getPublicKey() and fills the npub via nip19.npubEncode
— no copy/paste from Damus/primal needed.
• Publish step (nostr): "⚡ One-click publish via your nostr extension"
wraps the kez markdown block in a kind-1 nostr post, asks the
extension to sign it (the user's nostr key never enters this app —
the extension gates each signature behind its own UX), and
broadcasts to the same 5-relay pool the verifier reads from.
Result UI shows ✓ N relays ok + an njump.me evidence link, and
lists any relay that didn't ack.
New module lib/nip07.ts holds the wrapper — hasNip07, getNostrNpub,
publishKezClaimToNostr — so future flows (Dashboard, sigchain push)
can reuse the same plumbing.
Initial JS: 130→134 KB. nostr-tools stays in its own dynamic-import
chunk so the extension code only loads when the user clicks one of
these buttons.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Most users won't manually craft a NIP-78 kind-30078 event with a d=kez
tag — that needed a nostr client most folks don't have. So verifiers
now look in all three sensible spots and the user picks whichever is
easiest to publish:
1. Kind 0 (profile metadata) — kez fence in the `about` field
2. Kind 1 (text note) — kez fence in the post body
3. Kind 30078 (NIP-78) — envelope as event content (advanced)
Web (kez-chat/web):
• New verifier implementation (replaces the v0.1 stub). Adds nostr-
tools (~108 KB) under dynamic import so it lands in its own chunk
— initial JS only grew 128→130 KB.
• SimplePool.querySync against five public relays (Damus, nos.lol,
primal, snort, nostr.wine), 4s timeout, kinds [0,1,30078] in one
REQ. Returns ✓ on first match, with an evidence_url to njump.me.
• AddClaim instructions for nostr rewritten — "pick whichever is
easiest" with concrete steps for each.
Rust (kez-channels):
• Filter now includes kinds [0, 1, 30078], limit bumped to 200.
• extract_proof_body() pulls the right candidate out of each event:
- kind 0 → JSON-decode content, return `about`
- kind 1 / 30078 → return content as-is
• 4 new unit tests (extract_proof_body for each kind incl. malformed
profile) + 2 new integration tests:
- verifies_proof_from_profile_about_field
- verifies_proof_from_kind_1_post
• Updated existing integration tests for the new filter shape.
All 11 unit + 7 integration nostr tests pass. Live at https://kez.lat.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
DNS verifier used to say "no envelope found" even when a kez:z1: TXT
was sitting there but failed to parse (DNS providers can mangle bytes
at 255-char segment boundaries). GitHub verifier said "no proof found"
even when the gists API returned 403 — rate-limited from the browser
(unauthenticated GitHub allows only 60 req/hr/IP).
Now:
- DNS: distinguishes "found a kez record but it's corrupted" from
"no kez record exists." Calls out provider-side segment mangling
and tells the user to re-publish.
- GitHub: returns the actual HTTP status and rate-limit reset time
when the gists API rejects the request.
- Both: when an envelope's primary doesn't match the local key, the
error explicitly notes "probably signed with an older build — re-sign
and re-publish" (relevant to anything created before cd8dda6).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
AddClaim.svelte passed session.unlocked (an UnlockedIdentity, shape
{handle, server, primary, seed}) to signClaim, which expects an
Ed25519Identity ({seed, publicKey, identity}). Different fields:
session.unlocked.identity is undefined.
Result: payload.primary was undefined → JCS omits it → signature was
valid over a payload-without-primary, and signature.key was also
undefined. Verifiers correctly rejected these envelopes — and the
markdown header read "Primary: undefined".
Fix:
- AddClaim: derive a real Ed25519Identity via identityFromSeed(session.
unlocked.seed) before calling signClaim. The seed is the canonical
source of truth — publicKey + identity are derived deterministically.
- signWith: throw if signer.identity is missing or seed is malformed.
Belt-and-suspenders so a future caller passing the wrong shape gets
a loud error instead of producing silently unverifiable envelopes.
Note: any claims signed before this fix have invalid signatures and
must be re-created. Remove them on the Claims page and re-add.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Plugin layout (one file per channel — easy to extend):
lib/verifiers/{dns,web,github,nostr,bluesky,ap}.ts
lib/verifiers/types.ts — VerifyResult + ok/fail/skipped builders
lib/verify.ts — dispatcher routing on claim.channel
Live verifiers (browser-native, no CORS proxy):
• DNS — Cloudflare DoH /dns-query, TXT at _kez.<domain>
• web — fetch <base>/.well-known/kez.json
• github — public gists API for kez.md + <user>/<user> README
Deferred to v0.2 (stubs return "skipped" with a hint):
• nostr — needs ws relay pool + NIP-19
• bluesky — needs AT-Proto client
• ap — WebFinger CORS hostile from browsers
Verification flow (all channels):
1. Fetch the published artifact via the channel's transport
2. parseAnyEnvelope() handles kez:z1: compact, ```kez fences, or raw
3. Check subject + primary against the stored claim
4. Re-canonicalize payload (JCS) and verify ed25519 signature
UI changes on /claims:
• Status badge per claim: ✓ Verified / ✗ Failed / — Skipped / Not verified
• Per-claim "Verify" button + a "Verify all" button at the top
• Expandable details panel showing the evidence URL and any error info
• Latest result persists in IndexedDB (with $state.snapshot for cloning)
kez.ts gains verifyEnvelope() and parseAnyEnvelope() — also useful to
any future verifier (CLI, sig-server, third-party).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Save claim was silently failing — button click did nothing. Cause:
\`envelope\` lives in \$state, which wraps the value in a deep Proxy;
idb-keyval calls structuredClone internally, which can't clone proxies
and throws DataCloneError. Without a try/catch the error vanished into
the console and the step transition never ran.
- Pass \$state.snapshot(envelope) to addClaim so IDB sees a plain object.
- Wrap in try/catch + alert so future IDB failures surface to the user
instead of dying silently.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Add @bokuweb/zstd-wasm; replace the kez:zd1: deflate-raw placeholder
with spec-compliant kez:z1: zstd(JSON envelope) compact form.
- Dynamic import keeps the WASM (~348 KB) in its own Vite chunk so the
initial bundle only grows from 113 KB to 116 KB; the WASM is fetched
the first time a user picks compact format.
- AddClaim.svelte: 3-way format toggle (compact / markdown / JSON).
DNS defaults to compact since TXT records want the shortest payload.
- Drop the v0.1 apology in DNS instructions — kez:z1: is the spec form
and verifiers can decompress it directly.
- Cross-impl interop verified: browser-generated kez:z1: decompresses
cleanly in the Rust CLI and the Node port, byte-for-byte modulo
JSON key-order whitespace.
Deployed live to https://kez.lat.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
First real UI for kez-chat. Served by the chat-server as static
files; uses the same HTTP API a native client would (dogfoods the
contract).
Stack: Svelte 5 + TypeScript + Vite + Tailwind 4 + @noble/curves +
@scure/base + canonicalize + idb-keyval + svelte-spa-router.
Bundle: 113 KB JS / 14 KB CSS (gzip: 42 KB / 4 KB).
Pages (all behind hash routing):
/ Landing — sign up or restore from seed
/create Account creation flow:
1. Pick handle, set passphrase
2. Show seed for paper backup, require ack
3. Confirm
4. POST /v1/register, save passphrase-encrypted seed
to IndexedDB
/restore Stub for restore-from-seed (v0.2: needs
GET /v1/by-primary endpoint on the server)
/unlock Enter passphrase to derive the AES-GCM key,
decrypt the seed, populate session state
/dashboard Show handle, primary, registered_at, sigchain URL
/claims List locally-cached claims (with publication status)
/claims/add Add-a-claim wizard:
1. Pick channel (github/dns/web/nostr/bluesky/ap)
2. Enter identifier
3. SignedClaimEnvelope built + signed in-browser
using Ed25519 + JCS, matching the spec exactly
4. Show channel-appropriate publish instructions +
copyable markdown or JSON artifact
5. User marks it published (purely a local note —
actual verification is the verifier's job)
Crypto / KEZ helpers (src/lib/kez.ts):
- generateIdentity / identityFromSeed (32-byte Ed25519)
- canonicalBytes (RFC 8785 JCS via the `canonicalize` package — same
one our Node port uses; produces byte-identical output to Rust)
- signClaim, signRegistration (build envelopes; sign with
ed25519-sha512-jcs; same alg / key / sig shape as kez-core)
- toPrettyJson, toMarkdown (the same wire encodings the CLI emits)
Key storage (src/lib/identity-store.ts):
- IndexedDB via idb-keyval
- Seed encrypted under user passphrase: PBKDF2-SHA256
(600,000 iterations, OWASP 2024 guidance) → AES-GCM-256
- Documented limitation: browsers don't have an OS-keychain
equivalent. Native clients (future CLI/Tauri) will use the OS
keychain for better protection.
Bundle includes:
- Workaround for TS 5.6+ Uint8Array<ArrayBufferLike> vs ArrayBuffer
strictness (small asBuffer() helper that copies into a plain
ArrayBuffer for WebCrypto + Response calls).
Dockerfile updated: now multi-stage with a Node `webbuild` stage
that runs `npm run build` before the Rust binary stage. SPA dist
is copied into the runtime image at /app/web; chat-server's
KEZ_CHAT_WEB_DIR points at it so the SPA is served at /.
What works against the LIVE deployment right now (https://kez.lat):
- Open https://kez.lat → SPA loads (113 KB JS, 14 KB CSS)
- Create account → key gen happens in browser, seed shown for
backup, encrypted under passphrase, POSTed to /v1/register
- Dashboard → shows registered handle + primary + sigchain URL
- Claims wizard → sign for any of the 6 channels, get publish
instructions + the right wire format to copy
- Lock / unlock — passphrase-derived AES-GCM, no roundtrips
What's still TODO (v0.2):
- Restore-from-seed: needs GET /v1/by-primary on the server so the
SPA can discover the handle from a seed
- Actual NATS chat: needs server's auth callout (currently 501) +
nats.ws client (browser side; package is in deps but not used yet)
- Sigchain integration: append `add` event when user publishes a
claim, upload to sig-server (needs sig.kez.lat tunnel)
- Verification: in-browser channel fetches (some channels are
CORS-friendly, others need a server-side proxy)
- Compact (kez:z1:) form: the spec uses zstd, browsers don't have
native zstd CompressionStream support yet. Workaround in code
uses deflate-raw with a `kez:zd1:` prefix to make it obvious the
output isn't spec-compliant; replace with @bokuweb/zstd-wasm or
similar when we need true compact form in the SPA.