8 commits landing from the mnemonics branch.
CORE FEATURE — BIP-39 recovery phrases for Ed25519 identities, across
all three implementations (Rust, Node, Python) plus the chat-app web
client. Bit-perfect interop verified by 27 new crosstest scenarios.
• 0058d9b feat(rust,nodejs): BIP-39 mnemonic phrases for Ed25519
identities (kez-core libs + CLI: identity new --mnemonic-words,
identity mnemonic, identity from-mnemonic; --mnemonic accepted
anywhere --ed25519-seed is). 24-word = bijection with the 32-byte
seed; 12-word = SHA-256("kez-bip39-12-v1" || entropy) → seed,
one-way KEZ-specific derivation. 9 rust + 8 node mnemonic tests.
• b0cc1a7 feat(python,crosstest): mirror to Python (kez/mnemonic.py
using Trezor's `mnemonic` lib; 19 pytest tests; CLI surface match)
+ crosstest.sh gains "BIP-39 mnemonic interop" section with three
canonical test vectors checked across all three impls and 18 cross-
impl claim round-trips via --mnemonic. crosstest now passes 84/84.
• 3fdbdc9 feat(kez-chat/web): 12-word phrase replaces hex seed in
the chat-app account flow (browser-native lib/mnemonic.ts with the
same domain tag; numbered-grid display on create + onboarding;
restore accepts phrase or legacy hex; Settings reveals phrase when
available; identity-store gains optional encrypted phrase field,
backwards-compatible with pre-mnemonic accounts).
CHAT APP — chain-service mirror
• 5ad47a9 feat(kez-chat/web): when the user adds a claim, append an
`add` event to their sigchain on the chain service (rust-sig-server);
revoke on delete. Implements SPEC.md §8. Per-row "Sync to chain"
retry; ask-before-drop if the chain service is unreachable.
USER COMMITS (carried in this merge)
• 8789659 Add nostr chat notes, update favicon, add test.txt
• b1240c1 Add Python implementation and cross-test interop
DOCS
• aeba28d docs(rust,nodejs): expand TUTORIAL.md with the full
"Recovery phrases" mini-chapter — 12 vs 24 entropy table, picking
guide, hardware-wallet-incompatibility callout, backup hygiene
advice, "working with phrases later" examples.
• d0e96c1 docs(python): add python/TUTORIAL.md mirroring rust+nodejs
(was missing). All three impl tutorials are now in parity. Root
README points at each impl's README (reference) and TUTORIAL
(step-by-step) side-by-side.
Test totals across the repo after this merge:
Rust: 114 (was 99)
Node: 99 (was 91)
Python: 19 (new)
Crosstest: 84 scenarios (was 55)
Completes the parallel tutorial set across all three implementations.
Python now has the same friendly step-by-step walkthrough that the
Rust and Node sides have had since the original tutorial commits.
Python tutorial content mirrors the others 1:1, adapted for the
Python invocation style (.venv/bin/python kez_cli.py …), plus:
• Programmatic section uses Python imports (NostrSecret.from_nsec,
sign_claim, default_registry, etc.) instead of the TS imports
from the Node tutorial.
• Same "Recovery phrases" mini-chapter as rust/nodejs — both 12-word
AND 24-word are explained, with the entropy table, picking guide,
hardware-wallet-incompatibility callout, concrete backup advice
("pencil + paper, numbered words, fireproof, don't split,
don't permute"), and "Working with phrases later" examples
(`identity mnemonic`, `identity from-mnemonic`).
• Notes that `sigchain publish` isn't in the Python CLI yet (only
add/revoke/show/export) — match the actual current surface; the
JSONL the Python CLI produces is byte-compatible with Rust/Node,
so users can build the chain in Python and publish via either
of the other CLIs in the meantime.
• Troubleshooting includes ModuleNotFoundError: kez (a Python-
specific footgun when running outside the venv).
• Links to ../rust/TUTORIAL.md and ../nodejs/TUTORIAL.md as parallel
references throughout.
python/README.md now opens with the same "New to KEZ? Read TUTORIAL.md"
callout as the rust and nodejs READMEs do.
Root README's quick-start blocks for each implementation now reference
BOTH the impl README (reference) AND the impl TUTORIAL (step-by-step,
on-ramp) instead of just the README.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reworks the "Pick your primary key" → Option B block in both tutorials
into a proper "Recovery phrases" mini-chapter:
• Table comparing 24-word (256 bits, bijection) vs 12-word (128 bits,
one-way SHA-256 derivation).
• Decision guide — why someone would actually pick 12 over 24 (and
vice versa). Explicitly: "save the phrase, not just the seed" for
the 12-word case.
• Wallet-incompatibility callout — KEZ phrases don't produce the
same key as the same phrase in Ledger / MetaMask / Bitcoin
wallets. Explains the two deliberate reasons (no BIP-39 PBKDF2,
no BIP-32 derivation tree), and the inverse — KEZ phrases can't be
used to extract funds from a hardware-wallet recovery so a
malicious importer can't phish that direction either.
• Concrete backup advice — pencil on paper, numbered words, fireproof
storage, don't photograph it, don't cloud-sync it, don't split it,
don't permute it. Calls out which password-manager patterns are
OK vs not.
• "Working with phrases later" — clean examples of `identity mnemonic`
(no key derived) and `identity from-mnemonic` (recover an existing
key), with the note that the recovered output is byte-for-byte
identical to what `identity new` originally printed.
Same content in both the Rust and Node tutorials, command examples
adapted to each CLI invocation style.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When the user adds a claim, append an `add` event to their sigchain
on the chain service (rust-sig-server); when they remove a claim,
append a `revoke`. Implements SPEC.md §8 — the sigchain is now the
canonical, verifiable record of what the user currently claims, not
a per-claim field.
• lib/sigchain-service.ts: new module that fetches the current chain
to compute the next seq + prev hash, signs the event locally (the
chain service never sees the seed), and POSTs. Returns a typed
SigchainSyncResult so the caller can record the seq + status.
• lib/kez.ts: sigchain event types + helpers (nextChainCursor,
sigchainEventHash, signSigchainEvent, SignedSigchainEvent /
SigchainOp types). Mirrors the Rust + Node + Python core surface.
• lib/api.ts: getSigchain (GET full chain) + postSigchainEvent
(POST one signed event) wrappers against the chain service.
• lib/claims-store: StoredClaim gains chain_service, sigchain_seq,
sigchain_status ("synced" | "error"), sigchain_error fields +
setSigchainSync helper.
• routes/AddClaim: on successful claim creation, fires an `add` to
the chain service in the background; surfaces sync errors with a
"Retry sync" button.
• routes/Claims: a `revoke` is posted to the chain service first
when the user removes a claim. Best-effort — if the service is
unreachable, asks before dropping the local copy so the chain
doesn't silently drift. Per-row "Sync to chain" button retries
failed adds.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Brings the BIP-39 mnemonic surface (CLI + libs landed in 0058d9b /
b0cc1a7) into the chat app's user-facing account flow. Match the same
SHA-256 domain-tag derivation as Rust / Node / Python — a phrase
generated in the browser verifies against the spec vectors in
python/MNEMONIC-TEST-VECTORS.md byte-for-byte.
• New lib/mnemonic.ts: browser-native helpers (generateMnemonic12,
seedFromMnemonic, mnemonicFromSeed24, ed25519FromMnemonic,
generateIdentityWithMnemonic, isValidMnemonic). Uses @scure/bip39
(same lib as Node impl) + the same domain tag "kez-bip39-12-v1".
12-word phrases by default; restore accepts 24-word too for parity
with the CLI.
• lib/identity-store.ts: StoredIdentity gains optional
phrase_nonce + phrase_ciphertext, encrypted under the SAME
PBKDF2-derived key as the seed (fresh nonce — AES-GCM reuse is
fatal). unlockIdentity returns the phrase when present. New
hasStoredPhrase() helper distinguishes "phrase exists but not
accessible in this session" (biometric unlock) from "truly legacy
hex-only account".
• CreateAccount: generates via generateIdentityWithMnemonic. Step 2
now shows the 12 words in a numbered grid with a "copy all" button
and a real ack checkbox before continuing. Step indicator updated
to "2. Back up phrase".
• Restore: was previously a stub that always threw "v0.1 limitation".
Now actually works — accepts either a 12/24-word phrase OR a
legacy 64-char hex seed (auto-detected), looks up the handle via
/v1/by-primary, derives the seed, saves identity, unlocks, routes
to /welcome.
• Settings: "Reveal seed" → "Reveal phrase". Three-state output:
- phrase in session → show 12 words
- phrase stored but biometric session → tell user to passphrase-
unlock to reveal
- truly legacy → show hex seed with explanation
• Welcome (onboarding): "Back up your recovery seed" step renders the
phrase as a numbered grid when available, falls back to the hex
block with a "Legacy 64-char hex" caption for pre-mnemonic accounts.
Biometric unlock continues to surface only the seed (the phrase blob is
encrypted under the passphrase-derived key, not the PRF-derived key) —
documented in the Settings UX. Encrypting under PRF too is a v0.3
follow-up.
Backwards compatible: existing accounts (which have only the
seed-ciphertext) unlock fine; their phrase fields stay undefined; the
UI falls back to the hex flow throughout.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Completes the three-way BIP-39 mnemonic surface (Rust + Node landed in
0058d9b) and pins down byte-for-byte agreement with crosstest scenarios.
Python (mirrors rust/crates/kez-core/src/mnemonic.rs + nodejs's mnemonic.ts):
• python/kez/mnemonic.py — generate_mnemonic, seed_from_mnemonic,
mnemonic_from_seed_24, ed25519_from_mnemonic,
generate_ed25519_with_mnemonic. Same 24-word-bijection / 12-word-
SHA-256-domain-tagged semantics. Uses Trezor's `mnemonic` library
(v0.21) for the BIP-39 wordlist + entropy parsing; deliberately does
NOT use BIP-39's PBKDF2 to_seed function.
• python/kez/keys.py — Ed25519Secret.from_mnemonic() +
generate_with_mnemonic() classmethods; signer_from_flags widened to
accept --mnemonic.
• python/kez/cli.py — identity new --mnemonic-words, identity
mnemonic [--words], identity from-mnemonic; --mnemonic flag on
claim create/dns and sigchain add/revoke/show/export. Output format
matches Rust + Node verbatim so the crosstest harness can grep
Primary/Public/Secret/Mnemonic lines.
• python/tests/test_mnemonic.py — 19 tests covering all three
canonical vectors (exact-match Secret + Public hex), round-trip,
determinism, whitespace tolerance, bad-checksum, bad-word-count,
the literal domain-tag bytes, and the 12-vs-24 entropy-overlap
non-collision case.
Note: --mnemonic is NOT added to `sigchain publish` because that
subcommand doesn't exist in the Python CLI yet (rust + node only). When
the publish surface is ported, --mnemonic should follow it the same way.
Ground truth — python/MNEMONIC-TEST-VECTORS.md:
V1: 24-word zero-entropy phrase ("abandon… art")
seed = 0000…0000
pubkey = 3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29
V2: 12-word zero-entropy phrase ("abandon… about")
seed = 09451c0f06588db78205e32a793536e15ae263c8f9ee6d14f5c6fd82b8bd20da
pubkey = 9403c32e0d3b4ce51105c0bcac09a0d73be0cca98a6bf7b3cd434651be866d70
V3: 12-word "legal winner thank year wave sausage worth useful legal winner thank yellow"
seed = 9df434a2bd5dc767ee949d8ab95ca09c4ebbb88cefc3d0b1523f6b2a744ca824
pubkey = cc99d06b15ccb83a5ca43f25dd3d27f50638c1c6fbe3a822352da3e07156ce03
The domain tag for the 12-word derivation is exactly the 15 ASCII
bytes of "kez-bip39-12-v1", documented in the spec doc.
crosstest.sh — new "BIP-39 mnemonic interop" section:
• Vector match: each impl × each vector × Public hex == expected (9
scenarios). Catches any silent derivation drift.
• Cross-impl claim signing via --mnemonic: every signer ↔ verifier
pair (rust↔node, rust↔py, node↔py), every format (json/compact/
markdown). 6 pairings × 3 formats = 18 scenarios.
• Bijection sanity: the 24-word phrase printed by `identity from-
mnemonic` round-trips to itself byte-for-byte (rust + node).
• Python-involving scenarios auto-skip if `python/.venv/bin/python
kez_cli.py identity from-mnemonic` returns non-zero, so the harness
stays runnable on machines where Python isn't set up.
Verified end-to-end: `bash crosstest.sh` reports
"All 84 scenarios passed."
Test totals across implementations:
Rust: 114 (9 mnemonic-specific in kez-core)
Node: 99 (8 mnemonic-specific in @kez/core)
Python: 19 (mnemonic only; was no test suite before)
Crosstest: 84 scenarios end-to-end
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the canonical wallet-style backup form (12 or 24 BIP-39 English
words) to both implementations. Wire-compatible — bit-identical seed
derivation across Rust and Node.
Semantics:
• 24 words ↔ 32 bytes of entropy ↔ Ed25519 seed (bijection).
Phrase ↔ seed round-trips exactly.
• 12 words → 16 bytes of entropy → seed via
SHA-256("kez-bip39-12-v1" || entropy). Deterministic but one-way;
you can't recover a 12-word phrase from a seed.
The 12-word case is KEZ-specific (not interoperable with hardware-
wallet BIP-32 derivations). The 24-word case is. Both use the BIP-39
English wordlist so users can paper-back-up alongside other wallets.
We deliberately do NOT use BIP-39's PBKDF2 to_seed(passphrase) — that
produces a 64-byte seed for BIP-32 hierarchical derivation, which is
the wrong primitive for KEZ's single-identity-per-phrase model.
Rust (kez-core):
• New mod mnemonic with MnemonicWords, generate_mnemonic,
seed_from_mnemonic, mnemonic_from_seed_24.
• Ed25519Secret::{from_mnemonic, generate_with_mnemonic}.
• Dep: bip39 v2.0 with the `rand` feature for OS-RNG generation.
• 9 unit tests, all green.
Rust (kez-cli):
• `identity new --key-type ed25519` now also prints a 24-word phrase
(default), with --mnemonic-words 12 to use 12 instead.
• `identity mnemonic [--words 12|24]` — print a fresh phrase only.
• `identity from-mnemonic "<phrase>"` — derive the key from a phrase.
• `--mnemonic <phrase>` is now accepted everywhere `--ed25519-seed
<hex>` was (claim create/dns, sigchain add/revoke/show/export/
publish), mutually exclusive with --ed25519-seed and --nsec via
clap conflicts_with_all.
Node (@kez/core):
• New mnemonic.ts with the parallel API:
generateMnemonic, seedFromMnemonic, mnemonicFromSeed24,
ed25519FromMnemonic, generateEd25519WithMnemonic.
• Dep: @scure/bip39 v2.x (note: import path is
"@scure/bip39/wordlists/english.js" with the .js suffix in v2).
• 8 vitest cases mirroring the Rust tests, all green.
Node (@kez/cli):
• Same CLI surface added: identity new --mnemonic-words 12|24,
identity mnemonic --words 12|24, identity from-mnemonic "<phrase>".
• --mnemonic flag accepted alongside --nsec / --ed25519-seed in the
flag parser, with mutex enforcement; loadSigner dispatches it.
Verified cross-implementation interop:
• Same 24-word phrase → identical Ed25519 pubkey in Rust and Node.
• Same 12-word phrase → identical pubkey (proves the SHA-256
domain-tagged derivation matches byte-for-byte).
• A claim signed in Rust with --mnemonic verifies in Node (Status:
valid).
Tests: 114 Rust + 99 Node total, zero regressions.
TUTORIAL.md updated in both rust/ and nodejs/ with the new section in
"Pick your primary key" plus a callout that --mnemonic can substitute
for --ed25519-seed throughout the rest of the tutorial.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add a Python port of the KEZ CLI under python/, mirroring the Rust and
Node implementations command-for-command and byte-for-byte:
- Pure-Python JCS (RFC 8785), BIP-340 Schnorr, and Bech32; cryptography
for Ed25519 and zstandard for the compact zstd framing.
- Full CLI: identity new, claim create/dns, verify file, and
sigchain add/revoke/show/export.
Wire Python into crosstest.sh with 35 new scenarios covering Python
against both Rust and Node, in every direction, across all wire formats,
both key types, DNS proofs, and sigchains (incl. JSONL byte parity).
All 55 scenarios pass.
Update root README and .gitignore for the new implementation.
13 commits landing from the nostr branch. The big ones:
DESIGN (kez-chat/web)
• 60ff82b foundation — tactical-terminal theme + tokens (cyan #28C8E8
on near-black, Inter + JetBrains Mono, identicon avatars, kez▌
wordmark cursor, DESIGN.md as source of truth)
• 40ebd63 new IA + nav shell — dashboard-as-home killed; lands on
Chats; left rail (desktop) / bottom tabs (mobile); Identity +
Settings split out from the old Dashboard
• a9ef611 auth + claims pages restyled to the dark theme (phase 6)
• 7bbe336 @theme block was being dropped — fixed the misplaced
google-fonts @import + unicode-box-drawing comment that killed
Tailwind's @theme transform
• 0d7e48b login no longer blanks — redirects to /chats not /dashboard
• fc75b27 light theme + Light/Dark/System toggle (deeper cyan accent
for light, no-flash via inline pre-paint script)
FEATURES (kez-chat)
• a2538b2 verified-user badge in chat (X-style, but the client
actually verifies a peer's published proofs against the channel;
24h cache; server stores subjects for discovery; PUT /v1/profile/
:handle/proofs authed with X-KEZ-Auth)
• dac9848 / e1f2514 first-run onboarding — Getting Started checklist
routed to after CreateAccount; resumable, skippable
• 41f9442 Nostr-relay chat transport behind VITE_TRANSPORT
• 7bbf8ba verified badge tightened (require 2+ proofs)
DOCS
• d10dfb9 rust/TUTORIAL.md — friendly step-by-step for first-time
users (zero to verified published sigchain in ~15 min)
• b1f8b3a nodejs/TUTORIAL.md — same tutorial mirrored for the Node
implementation
All server changes ship with passing tests. Wire-compatible across
both implementations.
Parallel of rust/TUTORIAL.md (d10dfb9), adapted for the Node.js
implementation. Same end-state for the reader: from "I have a nostr
nsec" to "I have a verified, published sigchain" in ~15 minutes.
Node-specific adaptations:
• Install (Node 22+ note for the built-in WebSocket the nostr
channel needs, npm 9+ workspaces, optional `npm link` for global
`kez` instead of `npm run cli --`).
• Every command uses `npm run cli --` to match the README's
existing convention; explicit "-- swallowed flags" callout.
• New section 8 "Programmatic use" — short snippet showing how to
sign + verify via @kez/core + @kez/channels for embedding in a
Node app. Cross-checked against the real exports
(newClaimPayload(subject, primary, date), signClaim(payload,
signer), await defaultRegistry(), registry.verify(...)).
• Cross-implementation interop callout: sign in Node, verify in
Rust (wire-compatible by design).
• Common-confusions FAQ gets one extra entry — "Is the Node version
slower than Rust?" (answer: I/O-bound on channels, both fine for
interactive use; Rust faster only for batch sigchain work).
• Troubleshooting adds "WebSocket is not defined → upgrade Node" for
the nostr channel.
README now points to TUTORIAL.md as the on-ramp, matching the Rust
README's structure.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The existing README is a solid reference but assumes you already know
what KEZ is and what each subcommand does. Add a parallel TUTORIAL.md
that takes a complete newcomer from "I have a nostr nsec" to "I have
a published, verified sigchain" in ~15 minutes.
Sections (~500 lines):
0. Install (incl. cargo-run alternative + GITHUB_TOKEN tip)
1. Pick your primary key — use your existing nsec (recommended) OR
generate a fresh ed25519. Concrete warnings about nsec handling.
2. Sign your first claim — full markdown/compact/json walkthrough
with a real github:tudisco example.
3. Publish the proof — separate concrete how-tos per channel:
github (gist + profile README), DNS (zone-file output), nostr
(3 places it can live), bluesky, ActivityPub, your own website.
4. Verify it — `kez verify id` + a full "if verification fails"
troubleshooting block (not_found, subject_mismatch, bad sig,
github rate limit).
5. Sigchain basics — when you actually need one, add/show/revoke,
where chain files live on disk.
6. Publish your sigchain — server, web (.well-known), DNS,
nostr (kind-30078), and how to combine destinations.
7. Verify someone else — the reverse direction (verify id, walk
a chain by --primary, verify a chain bundle from disk).
8. Quick-reference command card.
9. Common confusions FAQ — sigchain optional? two key types?
nsec leakage? proof copying? key rotation?
10. Where to go next — kez.lat, SPEC.md, sig-server, channel plugin
trait.
All commands cross-checked against crates/kez-cli/src/main.rs (every
flag and output format quoted in the tutorial actually exists in the
binary).
README now points to TUTORIAL.md as the on-ramp; the existing reference
content stays put.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The verified checkmark only appeared on the profile, never in chat, even
for clearly-verified peers. Three gaps fixed:
- Chat verified only the open conversation, so list items never showed a
badge. Verify all conversations on load (24h per-peer cache).
- A peer's proofs were only published to the server on a manual "reverify
all", so verified users were invisible to peers. Auto-publish verified
subjects when the Identity page loads.
- Unify the threshold: a badge now requires >=2 independently-verified
proofs, in both chat (VERIFY_MIN_PROOFS) and the profile (isVerified),
so "verified" means the same thing everywhere.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Swap the chat transport from the kez-chat server inbox to Nostr relays
without touching the identity model or the E2E crypto. The existing
SealedEnvelope (ed25519/x25519 + AES-GCM, our own key) is unchanged and
becomes the content of a Nostr event — Nostr only moves the bytes.
- nostr-id.ts: derive a secp256k1 signing key from the ed25519 seed
(HKDF, domain-separated — internal transport credential, never the
user's real Nostr account); route by a hash of the recipient's public
ed25519 primary since the curves can't be cross-derived.
- nostr-transport.ts: send/poll/stream mirroring messages.ts, via
SimplePool; per-handle time cursor + seen-id dedupe in localStorage.
- transport.ts: facade selecting server vs nostr via VITE_TRANSPORT
(code default stays "server"; this branch's .env flips it to nostr).
- inbox-service + Messages import from the facade.
Directory lookup (handle->primary) still runs on the kez-chat server;
identity stays internal. Metadata privacy is at parity with the server
transport (relay sees the from/to graph, body stays confidential).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New-account success screen sent users straight to /chats; first-run users
never saw the Getting Started checklist. Route to /welcome instead.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New accounts land on /welcome instead of an empty Chats. A resumable,
skippable checklist (not a forced wizard) orients them and drives the
key first-run tasks. Essentials-only scope.
• Welcome.svelte: progress checklist —
1. Account created (auto ✓)
2. Back up recovery seed — highlighted as critical (warning border),
inline reveal + copy + "I've saved it safely". SKIPPABLE per
decision; no hard gate.
3. Add your first proof → /claims/add (✓ once a proof verifies)
4. Enable app lock (optional, only if a platform authenticator exists)
5. Turn on notifications (optional)
Steps derive from real state (claims, biometric, notif permission),
so checkmarks are truthful. "Skip for now" + "Enter kez-chat →"
both finish onboarding.
• lib/onboarding.svelte.ts: $state-backed flags (onboarded, seedAcked)
persisted to localStorage; reopen() for Settings.
• CreateAccount → /welcome after registration (was → /chats).
• Chats: dismissible "Finish setting up your account" nudge shown
until onboarding is completed/skipped.
• Settings → Account → "Getting started" reopens the checklist.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A green check next to any KEZ that controls a proven account. Unlike
Twitter's "we say so," the badge means YOUR browser independently
verified ≥1 of the peer's published proofs against the channel.
Server:
• handles.proofs column (JSON array of claim subjects) + ALTER for
existing DBs. Returned in /v1/u/:handle and /v1/by-primary as
`proofs` — pure discovery; peers verify each themselves.
• PUT /v1/profile/:handle/proofs (authed X-KEZ-Auth, signed over
"PUT\n/v1/profile/<h>/proofs\n<ts>", distinct line from inbox/stream
so sigs can't cross-replay; 60s skew; max 64 subjects).
• All 20 existing http tests still pass.
Client:
• api.ts: HandleResponse.proofs + setProofs() (signs + PUTs).
• verify.ts: verifySubject(subject, primary) — runs the real channel
verifier given just subject+primary (no local envelope needed).
• conversations-store: cache verified + verified_checked_at per peer.
• Messages: on conversation open, fetch the peer's proof subjects and
verify them in the background (24h cache → snappy, rate-limit
friendly). VerifiedBadge in the conversation row + thread header.
• Identity: reverify now publishes your verified subjects to your
profile (so peers can discover them) + shows the badge on your own
card.
• VerifiedBadge.svelte: scalloped-seal check in verified-green
(distinct from the cyan brand accent).
Flow: you reverify your proofs on Identity → they publish to your
profile → when someone opens a chat with you, their client fetches +
verifies them → you get the check on their screen.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Some users want light. Dark stays the default/brand; light is a
first-class option.
• app.css: :root[data-theme="light"] overrides the @theme token values
(deeper cyan accent so it's legible as text on white; light elevation
ramp + text tiers + semantic colors). Every utility is var(--color-*),
so flipping the vars flips the whole app — no per-component work.
• lib/theme.svelte.ts: choice (light/dark/system) persisted to
localStorage; "system" follows prefers-color-scheme live; sets
<html data-theme> + syncs the mobile theme-color meta.
• index.html: inline pre-paint script resolves the theme before first
render to avoid a flash of the wrong palette.
• Settings: new Appearance section with a Light/Dark/System segmented
control + a hint line ("Following your device (dark)").
• EmojiButton: picker now follows the app theme (was hardcoded white).
• main.ts: side-effect import so the system-theme listener is always
live.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Unlock (passphrase + biometric) and CreateAccount still pushed to
/dashboard, which the redesign removed from the routes map. svelte-spa-
router matched nothing and rendered a blank page. Point them at /chats
(the new home).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The deployed redesign rendered dark-bg but unstyled, low-contrast text
because none of the --color-* tokens or token utilities were in the
output CSS. Two compounding causes, both fixed:
1. A CSS @import url(google-fonts) AFTER @import "tailwindcss" becomes a
misplaced import once Tailwind inlines itself; Lightning CSS drops it
and everything after — including @theme. Fonts now load via <link> in
index.html.
2. A box-drawing-unicode comment immediately before @theme stopped
Tailwind v4 from transforming the block. Replaced with plain ASCII.
CSS 21.8KB → 26.3KB; tokens + utilities now present; theme applies.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Completes visual consistency across the whole app — every surface now
uses the tactical-terminal token set, so the redesign can ship without
a light-on-dark login screen.
• app.css: dark defaults for input/textarea/select (bg-elevated, token
text/border/placeholder, accent focus) so forms that don't set an
explicit bg still read correctly.
• Landing / CreateAccount / Restore / Unlock: light utility classes →
tokens (bg-white→surface, text-gray-*→text tiers, gray-900 buttons→
accent, red/green/amber→danger/verified/warning).
• Claims / AddClaim: same swap, plus the nostr publish panel + format
toggle + status badges remapped (purple→accent, blue→accent,
yellow→warning).
Now consistent end to end. Remaining polish (message ticks, day
separators, contact preview-card, skeletons, emoji-picker dark theme)
tracked for a follow-up; ready to deploy for a look.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Kills the dashboard-as-home. Logged-in users now land on Chats like a
real messenger. Implements the design-team's IA recommendation.
Navigation:
• Desktop: slim left icon rail (Chats / Identity / Settings) with the
cyan key-cursor logo, active-state accent bar, unread badge.
• Mobile: fixed bottom tab bar, same 3 destinations, safe-area inset.
• Unauthenticated flow renders full-bleed with a wordmark header.
• Legacy /dashboard + /messages redirect to /identity + /chats.
Chats (restyled Messages):
• Two-pane on desktop; on mobile the list is full-screen and the
thread pushes over it with a back chevron.
• Conversation rows get identicon avatars, active accent bar, truncated
previews. Thread header shows avatar + handle + key.
• Bubbles: sent = cyan accent fill / near-black text / tail; received =
dark bubble-recv + hairline border. Emoji-only boost retained.
• Compose + "start a chat" inputs use the dark token styling; live/
reconnecting status moved into the list header.
• All functionality preserved: SSE, emoji picker, auto-scroll,
notifications, unread badge.
Identity (new — was Dashboard's identity + claims):
• Identity card: identicon avatar (ring), copyable handle@server chip,
key fingerprint, registration date.
• Proofs grouped verified / failed / pending with verified-green
badges; Add proof + Manage links.
Settings (new — was Dashboard's remainder):
• Security (app lock / biometric, reveal seed), Notifications (perm +
test), Account (lock + build sha + source).
Dashboard.svelte is now unused (left in tree, removed from routes;
cleanup later). Claims/AddClaim + auth pages (Landing/Create/Restore/
Unlock) still use the old light classes — restyle is the next phase.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 0 of the redesign (see DESIGN.md). Establishes the visual
foundation; route restyling + IA reorg follow in subsequent commits.
Design direction (decided with a 3-agent design-team debate):
• Audience: hackers, privacy absolutists, anti-surveillance, Meshtastic
/ off-grid, journalists in hostile environments.
• Aesthetic: "muted tactical terminal" — Mullvad-calm restraint, not
neon cyberpunk cosplay. Monospace as identity. Hard-ish edges.
• Signature color: electric cyan #28C8E8 on neutral near-black #0B0C0E
(chosen over signal-amber and phosphor-green — ages better, reads
"serious infrastructure" without shouting). Verified-green reserved
for proofs only.
Changes:
• app.css: full Tailwind v4 @theme token set — elevation ramp, text
tiers, accent + dim + contrast, semantic colors, Inter + JetBrains
Mono via Google Fonts, tactical radius scale, accent glow, dark
color-scheme, cyan text-selection, thin dark scrollbars, and the
kez-cursor blink keyframe (respects prefers-reduced-motion).
• Wordmark.svelte: `kez▌` mono wordmark with blinking cyan block
cursor — the cursor is the brand mark.
• Avatar.svelte: deterministic 5×5 symmetric identicon from the
ed25519 key, cyan-arc hue. Every KEZ gets a stable face.
• kez-icon.svg: amber key → cyan key-meets-cursor glyph; regenerated
the full PWA icon set + apple-touch-icon from it.
• manifest + index.html theme/background color → #0B0C0E.
• DESIGN.md: the full system + IA plan as source of truth.
Note: existing route components still use light-theme utility classes
and will look inconsistent until restyled in the next phases — that
work lands next (shell/nav → Chats → Identity → Settings → Contacts).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The bug you hit: minimize Chrome → SSE stream gets throttled by the
background-tab policy → eventually disconnects → next message arrives
via the 30s heartbeat poll instead of SSE push → my code skipped the
notification because of a `viaPush` guard.
Removed the guard. Now notifications fire for ANY incoming message
the user hasn't seen yet, regardless of transport (SSE vs poll). To
avoid notification-storm on startup catch-up:
• inbox-service now tracks #notifiedThroughSeq, seeded from the
persisted global cursor at start().
• #ingest only fires badge++ + system notification when m.seq is
strictly greater than the watermark — startup re-reading the
cache doesn't blow up the UI.
Also added a "Send test notification" button on Dashboard (visible
once permission is granted). Lets you sanity-check OS + browser
settings without needing a second device:
• Fires regardless of visibilityState
• Reports failure reason if Notification() throws
• Auto-clears after 5s so the panel doesn't grow stale
If the test fires successfully but real chat notifications still
don't appear when minimized, the fault is probably OS-level
(System Settings → Notifications → Chrome on macOS) — the success
message now tells the user where to look.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two rules, picked to feel natural:
1. Conversation just opened (peer_primary changed) → always scroll to
bottom. You expect to see the latest exchange first.
2. New message landed in the current conversation → scroll to bottom
IF you were already within 120px of the bottom. If you're scrolled
up reading older history, the auto-scroll doesn't yank you back
down. (Slack / Telegram / iMessage all do this; getting yanked out
of history when you're searching for something is infuriating.)
Implementation: a single $effect tracks activeConv.messages.length +
activeConv.peer_primary. Compares against two cursor vars (prevPrimary,
prevMessageCount) to distinguish "opened a new conversation" from
"new message in current one". queueMicrotask after the DOM updates so
scrollHeight reflects the just-rendered message.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The footer was showing "dev" instead of the commit sha because vite
runs inside the Docker build context, which doesn't have .git (only
kez-chat/ gets copied in, not the parent .git/). git rev-parse failed
in the try/catch and fell back to the "dev" sentinel.
Fix: vite.config now resolves the sha from, in order:
1. process.env.BUILD_SHA — set by deploy script
2. ./BUILD_SHA file in web/ — set by deploy script
3. git rev-parse --short HEAD — local dev
4. "dev" — give up
Deploy script writes the file before rsync; .gitignored so it doesn't
accidentally get committed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously the SSE stream only ran while the Messages component was
mounted. Navigate to Dashboard or Claims and new messages just piled
up server-side until you came back. Now the stream runs for the whole
session, drives an unread badge in the nav, and (with permission)
fires a system notification when a message lands while you're in
another tab.
inboxService (lib/inbox-service.svelte.ts):
• Singleton Svelte 5 $state class. session.setUnlocked() starts it,
session.lock() stops it. Holds the SSE stream + the 30s heartbeat
poll for the entire session lifetime.
• Reactive state read by anyone: status (off/connecting/live/
reconnecting), unreadCount (since last visit to /messages), and
lastError (surfaced in the Messages footer).
• onMessage(fn) lets components subscribe to repaint when ingest
succeeds — Messages page uses this instead of owning its own
stream.
• #fireSystemNotification fires Notification API on inbound when
Notification.permission === "granted" AND document.visibilityState
!== "visible". Silent while you're actively looking at the tab.
Uses tag="kez-chat-inbox" so multiple notifications collapse.
Messages.svelte:
• Stripped its own stream/poll. Now just subscribes to inboxService.
onMount also calls markAllRead() — landing on /messages = you've
seen the new stuff.
• Footer status indicator reads from inboxService instead of local
state.
App.svelte nav:
• Messages link grows a red unread-count badge (1, 2, …, 9+) when
inboxService.unreadCount > 0 and the user isn't already on the
Messages route.
Dashboard:
• New "Notifications" section between Quick unlock and Backup with
the standard 3-state UX: granted (green confirm), denied (amber
"fix in site settings"), default (button to request).
• Helpers in inbox-service.ts wrap the Notification API so non-
supporting browsers (older Safari, Firefox in some configs) get
graceful "not supported" copy.
Caveat (for v0.3): notifications only fire while the tab is open in
SOME state (background-but-not-closed). Closing the tab kills the
SSE stream so nothing arrives at the page to notify about. True
background push (Web Push API + VAPID + server-side push) is a
separate piece of work.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bug: I was appending the <emoji-picker> custom element to the OUTER
wrapper div (which is part of the flex compose row), so when the picker
opened it became a flex sibling of the button and pushed the text
input + Send button down to a tiny strip.
Fix: append into the absolute-positioned popover container (new
popoverEl ref) instead of the wrapper. The popover is taken out of
flow so the compose row stays put and the picker floats above it.
Also:
- Outer wrapper gets shrink-0 so it doesn't expand even if the picker
somehow leaks.
- Click-outside check now looks at both wrapEl AND popoverEl (since
the picker is no longer a descendant of the wrapper).
- Popover anchors bottom-full left-0 — picker grows up and to the
right of the 😀 button.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two related changes — both aimed at "can you tell what's deployed?"
1. SW auto-update (no more "refresh twice")
The default vite-plugin-pwa autoUpdate behavior was: new SW
downloads on first reload, activates on second reload. Users
refresh after a deploy, still see old bundle, get confused.
Now:
• workbox: skipWaiting + clientsClaim → new SW activates and
takes control of existing pages immediately on install.
• main.ts listens for `controllerchange` and calls reload() once.
New SW takes over → page reloads → new bundle loads.
Net: deploys land on the FIRST refresh after the new bundle is
reachable. (Caveat: the SW that's currently running has to
download the new SW first, so the very first refresh after a
deploy may serve stale + then auto-reload a beat later.)
2. Visible build sha in the footer
vite.config.ts now runs `git rev-parse --short HEAD` at build
time and injects __BUILD_SHA__ + __BUILD_TIME__ via Vite's
`define`. App.svelte's footer renders the sha as a small monospace
chip linking to the commit on gitea, with the build time on
hover.
"kez-chat web v0.1" → "kez-chat [abc1234] · source"
So when you refresh and the chip changes value, you know the new
build landed. When it doesn't, you know the SW is still serving
the old bundle.
3. Killed the `apple-mobile-web-app-capable` deprecation warning by
adding the standard `mobile-web-app-capable` next to it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Touch ID / Face ID / Windows Hello / Android fingerprint / YubiKey
(PRF-capable) can now unlock the local seed without typing the
passphrase. Fully client-side — the server has zero visibility into
the credential or the derived key.
How it works (WebAuthn PRF extension):
1. Setup (Dashboard → "Quick unlock" → "Set up biometric unlock"):
• Register a platform credential with prf:{} in extensions.
• If the authenticator returns prf.enabled, immediately
getAssertion() with a random 32-byte salt to retrieve a
deterministic 32-byte secret (the "PRF output").
• AES-GCM(seed) under that secret → store the blob, salt, nonce,
and credentialId in a separate IDB entry from the passphrase
blob.
2. Unlock (Unlock page → big "Unlock with Touch ID" button):
• getAssertion() with the stored credentialId + salt → same
32-byte secret → AES-GCM decrypt → seed.
• unlockWithSeed() (new helper in identity-store) merges the
seed with handle/server/primary metadata to rebuild the
UnlockedIdentity session shape.
Trust properties (intentional):
• Passphrase blob stays in place as the authoritative backup.
Biometric is purely additive — wipe your browser profile or lose
the device, passphrase still works on any device where you
re-import the seed.
• PRF output never leaves the browser. The authenticator is the
only thing that can produce it, and only with the matching salt
+ credentialId we stored.
• Disable → just deletes the IDB entry; the registered credential
on the device still exists but is unused. (User can also clear
it from their OS / passkey manager.)
Browser support gating:
• Dashboard panel renders "no platform authenticator detected" if
isUserVerifyingPlatformAuthenticatorAvailable() returns false.
• Setup fails with a clear error if PRF isn't supported by the
authenticator (older YubiKeys, some password managers).
• Unlock page falls back to passphrase form automatically if
biometric fails (cancelled, sensor error, etc.).
Live at https://kez.lat (asset index-Df_F5lEP.js).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Compose bar gets a 😀 button. Click → emoji-picker-element (the
canonical web component, ~140 KB) lazy-loads on first open and stays
cached after. Pick → inserts at cursor position, focus returns to
input. Click-outside closes the popover.
Bonus: emoji-only messages render iMessage-style — no bubble, larger
text. Uses Intl.Segmenter to count grapheme clusters so 👨👩👧 reads
as 1 not 5 code points.
• 1 emoji → text-5xl
• 2-3 → text-4xl
• 4-6 → text-3xl
• 7+ or any letters/digits/punct → normal bubble
Bundle: emoji picker chunked separately via dynamic import (38 KB
gzipped). Initial Messages-page JS only nudged ~159→162 KB.
Native emoji input (macOS ⌃⌘Space, iOS keyboard, Android long-press)
still works — the picker is just for discoverability.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Chat was polling every 5s, which felt sluggish with two users online.
Switched to Server-Sent Events for push delivery. Polling now runs as
a 30s heartbeat just to catch anything missed during reconnect windows.
NATS is still bundled in docker-compose but no Rust code talks to it
yet — that lands in v0.2 for cross-instance fanout. The migration is
"swap the in-process broker for nats.publish/subscribe against
kez.chat.inbox.<handle>"; SSE subscribers don't notice.
Server (kez-chat-server):
• New broker module: per-recipient tokio::sync::broadcast channels,
in-process pub/sub. 64-slot buffer per channel; lagging subscribers
drop on the floor and resync via the polling heartbeat. 4 unit
tests cover subscribe/publish, multi-subscriber fanout, per-handle
isolation, no-op on no-subscribers.
• POST /v1/messages now publishes to broker after persisting → any
open SSE stream for the recipient gets the envelope immediately.
• New GET /v1/inbox/:handle/stream — SSE endpoint, ?auth=<ts>:<sig>
query param (EventSource can't set headers). Signed message is
distinct from the polling header ("GET\n/v1/inbox/<h>/stream\n<ts>"
vs "GET\n/v1/inbox/<h>\nsince=<n>\n<ts>") so a captured poll sig
can't be replayed as a stream sig and vice versa.
• 15s SSE keep-alive ping so Cloudflare/NAT/load balancers don't
drop idle connections.
• 3 new stream-auth unit tests, including the cross-endpoint replay
rejection. 19 unit + 20 integration tests all green.
• New deps: tokio-stream (sync feature for BroadcastStream),
futures (for the Stream trait the Sse handler returns).
Browser (kez-chat/web):
• streamInbox() in lib/messages.ts: long-lived EventSource,
auto-reconnects on error with fresh auth (tears down on `error`,
re-opens after 3s — EventSource's native retry uses the stale URL).
Exposes onMessage + onStatus callbacks.
• Messages.svelte: opens SSE on mount, decrypts pushed envelopes
inline via the new shared ingest() helper. Polling dropped from
5s → 30s heartbeat.
• Sidebar footer shows live status:
● live (green)
● reconnecting… (amber)
○ connecting… (gray)
Verified live: /v1/inbox/<registered>/stream?auth=bad returns 401,
no-auth returns 400. Asset index-C1ogRtUG.js serving.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Now installable on iOS (Safari → Share → Add to Home Screen) and
Android/desktop Chrome (install prompt or Settings → Install app).
Launches in standalone mode with a dark theme color matching the
lock-icon palette.
Stack:
• vite-plugin-pwa with workbox in generateSW mode, registerType
'autoUpdate' — new SW activates on next page load, no upgrade prompt
(chat needs to stay fresh).
• @vite-pwa/assets-generator for icon variants from a single SVG.
Source kez-icon.svg = dark squircle (#111827) + amber key glyph,
drawn inside the 80% maskable safe zone.
Caching:
• Precaches the SPA shell (~635 KB inc. the zstd WASM, well under
the 5 MB per-file cap).
• runtimeCaching 'NetworkOnly' for /v1/* — never cache authenticated
chat data; every poll must hit the network.
• navigateFallback to index.html so /messages, /claims, /dashboard
survive a refresh while offline. The /v1/, /internal/, /.well-known/
paths are explicitly denylisted from this fallback.
Meta tags (index.html):
• <link rel="manifest"> + theme-color for Android Chrome.
• apple-touch-icon-180x180 + apple-mobile-web-app-* meta for iOS,
including status-bar-style=black-translucent so the dark header
flows into the notch area in standalone.
• viewport-fit=cover so safe-area-inset works on notched devices.
Generated artifacts committed under web/public/:
kez-icon.svg, pwa-{64,192,512}.png, maskable-icon-512x512.png,
apple-touch-icon-180x180.png, favicon.ico.
Verified live: /manifest.webmanifest serves application/manifest+json,
/sw.js serves text/javascript, all icons return 200.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previous Messages page assumed you knew what a "handle" was and showed
truncated ed25519 hex everywhere. Reframed it so a newcomer can figure
out what to do without having read the spec.
Server:
• GET /v1/by-primary/:primary — reverse lookup, ed25519:<hex> →
handle record. Used by the SPA to render @alice instead of the
truncated hex when an inbound envelope arrives from a peer we
haven't chatted with yet. 3 new integration tests cover round-trip,
NotFound, BadRequest-on-garbage.
Web — sidebar:
• "Your KEZ" panel at top — handle@server with a copy button. The
whole point: someone needs your KEZ to message you, so make
sharing it one click.
• "Start a chat" input accepts `alice` or `alice@kez.lat`. Resolves
via /v1/u/:handle before adding — explicit error if unregistered,
friendly "that's you" guard for self.
• Conversation rows show resolved handles, not hex blobs.
Web — empty state:
• 🔒 + "End-to-end encrypted chat" headline + plain-English paragraph
explaining that even the server can't read messages.
• Concrete starter hint: "open kez.lat in a second browser, create
another account, message yourself between the two."
Conversation cache redesign:
• Now keyed by peer_primary (canonical KEZ identity) with peer_handle
as display metadata. Resolves the same-person-as-two-threads bug
you'd hit when you sent to "alice" then alice replied (her primary
didn't match the "alice" key).
• IDB key bumped to :v2 — old shape abandoned (was placeholder data).
• On inbound, ensureConversation refreshes the cached handle if we
just resolved a fresher one.
Followups still queued: cross-server lookups, NATS push, group chats,
"find someone by their published claim" (paste their gist / dns proof
to discover their handle).
Live at https://kez.lat.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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.
The auth_callout block required a real account nkey for the issuer
field and we don't have one yet — chat-server's callout endpoint is
a 501 stub for v0.1 anyway. NATS was crash-looping on startup
rejecting the placeholder nkey:
Expected callout user to be a valid public account nkey,
got "ABACVOI4POPS3SBFLDQYTQHHHACRVMCM2HK7PXX4UTI7XYWQHQGOA3PX"
Commented the block out with clear notes on how to re-enable in
v0.2 once we run `nsc generate nkey` for real issuer + user keys.
In v0.1 NATS runs with no auth, which is fine because:
- the deployment is behind a Cloudflare tunnel (not directly
internet-exposed)
- no KEZ client exists yet to connect
- even if one did, the chat-server's callout endpoint is a stub
Deployment verified live at tudisco@10.5.2.5:
chat-server :6969 → {"server":"kez.lat","status":"ok","version":"0.1.0"}
sig-server :7878 → {"status":"ok"}
nats :4222 → INFO frame, v2.14.1, JetStream on
:8222 → /varz monitoring
:8443 → WebSocket transport for browser SPA
deploy.sh has tudisco@10.5.2.5 + /home/tudisco/kez-chat baked into
its defaults — it's a personal deploy script, not a generic project
artifact. Same goes for any future *.local.sh / .env / .env.local
files in kez-chat/deploy/.
What stays in git:
- Dockerfile / Dockerfile.sig-server (project infrastructure)
- docker-compose.yml (project infrastructure)
- nats.conf (project infrastructure)
- install-docker.sh (generic Ubuntu setup, no
host-specific info)
What's now gitignored:
- deploy.sh (personal — kept locally)
- *.local.sh (any other personal scripts)
- .env / .env.local (any local config)
Two helper scripts in kez-chat/deploy/ so deployment is one command
once SSH access is set up:
- install-docker.sh — run once on a fresh Ubuntu host. Installs
Docker Engine + Compose plugin from Docker's apt repo, adds the
current user to the docker group, enables the systemd unit.
Idempotent (safe to re-run).
- deploy.sh — run from a workstation. Rsyncs the three subdirs we
need (rust/, kez-chat/, rust-sig-server/) to the target host,
excludes build artifacts (target/, node_modules/, *.db), then
SSHes in to run docker compose up -d --build, waits for the
chat-server healthcheck.
Defaults match what we agreed:
host = tudisco@10.5.2.5
path = /home/tudisco/kez-chat
server domain = kez.lat
Overridable via flags or env vars.
First runnable kez-chat-server binary plus its docker-compose deploy
recipe. Implements steps 2-3 of the document.md sequenced plan; the
rust-lib refactor (step 1) is deferred — chat-server path-deps on
rust/crates/kez-core for now, which works and matches what
rust-sig-server already does.
What's in this commit:
kez-core (1-line change)
- New public `verify_envelope<T>(payload, signature)` helper that
dispatches Schnorr / Ed25519 / future suites by signature.alg.
Used by chat-server's registration verifier; downstream value
beyond chat-server too.
kez-chat-server (new crate)
- src/main.rs: tokio + axum + tracing entry; clap config; graceful
Ctrl-C shutdown.
- src/lib.rs: re-exports so tests can drive the same router.
- src/config.rs: env/flag config (bind, db, server, sig_server_url,
web_dir) with defaults sane for both dev and prod.
- src/error.rs: typed ApiError → structured JSON responses with
stable error codes.
- src/store.rs: SQLite-backed handle registry, UNIQUE on both
(handle) and (primary_id); race-safe via SQL primary key.
- src/handles.rs: username validation (length, charset, reserved
list, must start with letter/digit).
- src/registration.rs: SignedRegistration envelope sharing KEZ's
JCS canonical-bytes pattern; signature verification via the new
kez-core helper; replay protection via ±5-minute clock skew check.
- src/api.rs: all six routes in one file —
GET /v1/healthz
GET /v1/u/:handle
POST /v1/register
GET /.well-known/webfinger
POST /internal/nats/auth (501 stub for v0.1; wired up in v0.2)
GET / (placeholder HTML; ServeDir when web/dist exists)
tests/http.rs — 13 integration tests
- Stands up the real router on a random port; uses reqwest.
- Coverage: healthz, lookup-404, full register→lookup round-trip,
duplicate-handle conflict, wrong-server rejection, reserved-name
rejection, tampered-signature rejection, stale-timestamp rejection,
WebFinger success + wrong-server-404, placeholder SPA renders,
NATS callout 501, JCS determinism sanity.
deploy/
- Dockerfile: multi-stage build (rust:1.86-slim → debian:bookworm-slim).
Build context is repo root so the path dep on kez-core resolves.
Runtime image ~50 MB; runs as non-root uid 10001.
- Dockerfile.sig-server: same pattern for the existing
rust-sig-server, so the stack builds from one git pull.
- docker-compose.yml: three services (chat-server + nats + sig-server)
with named volumes for persistence. Ports: 6969 (chat HTTP),
4222/8443/8222 (NATS native/ws/monitoring), 7878 (sig-server).
- nats.conf: WebSocket on 8443 for the browser SPA, JetStream
enabled, auth_callout pointing at chat-server's
/internal/nats/auth endpoint (issuer nkey is a placeholder — must
be replaced with a real one before going live).
README.md
- Documents all endpoints with example bodies.
- Quick-start for both local dev and full Docker compose.
- Honest list of what's in v0.1 vs what's still stubbed.
Smoke-tested running on 127.0.0.1:6969:
GET /v1/healthz → {"server":"kez.lat","status":"ok","version":"0.1.0"}
GET / → placeholder HTML rendering
GET /v1/u/ghost → 404
POST /internal/nats/auth → 501 with "wired up in v0.2"
cargo test → 13 passed.
cargo build --release → 19.6s, clean.
The test UI is a Svelte 5 + TypeScript + Vite + Tailwind single-page
app served as static files by kez-chat-server. The web app uses the
exact same HTTP API a native client would use, so every action in the
UI dogfoods the API contract.
Architecture changes:
- kez-chat-server now serves `/` as the SPA (tower-http ServeDir)
alongside the existing /v1 API
- Web app talks NATS over WebSocket (nats.ws + nats-server's
built-in websocket transport — same auth callout, same nkey auth,
same JetStream durable consumers)
- Web app cannot do Iroh: browsers can't open raw UDP sockets and
Iroh's WebTransport story isn't ready in 2026. Web shows manifests
and prompts "Download requires CLI" for actual file transfer.
- Key storage in browser: passphrase-encrypted IndexedDB (documented
limitation — native clients use OS keychain)
New / updated sections in document.md:
- §1: opening pitch mentions the web app + that it dogfoods the API
- §4.1: responsibilities table adds "serves the test web app"
- §4.4 NEW: full design of the web app — stack, capabilities, what
it can't do in v0, deployment model
- §4.5: endpoint list now includes / (the SPA) and /assets/*
- §4.3: nats.conf snippet enables WebSocket transport alongside the
existing native NATS port; both transports hit the same auth
callout
- §5.4: file-sharing flow notes the web app caveat (visible manifest,
CLI required for actual download)
- §6.1: folder layout adds web/ subdirectory with Svelte/Vite/Tailwind
scaffolding and an updated Dockerfile (multi-stage: build web →
build rust → ship)
- §6.3: dependencies split into Rust server vs Web app sections.
Web app pulls in svelte, typescript, vite, nats.ws, @noble/curves,
@scure/base, canonicalize, svelte-spa-router, tailwindcss,
idb-keyval.
- §7 MVP scope: full Web app checklist added; CLI section renamed
and clarified ("same Rust core powers CLI and future native GUI")
- §8 out-of-scope: "file transfer from the browser" added
- §11 sequenced plan: split into 12 steps; new phases 7-10 are the
web app build (scaffold → account/contacts → chat → manifest);
step 12 deferred native GUI
- §12 summary: rewritten to reflect "two Rust services + a Svelte
web app + a CLI"
- Decisions-locked table: added rows for test UI choice, browser
file transfer, manifest format, frontend framework, in-browser
key storage
Fix the doc: a kez-chat handle looks like an email address —
local@server — with NO leading @. The leading @ is mention syntax
in chat ("hey @tudisco look at this"), the same convention Slack /
Twitter / Discord use. It's not part of the handle.
Three forms now spelled out in §3.1:
Storage / wire tudisco@kez.lat (always fully qualified)
Display (UI) tudisco (when default server; full when cross-server)
Mention (chat) @tudisco (in-message convention; UI resolves)
Specifically updated:
- §1 opener mentions the email-style form + note about mention syntax
- §3.1 fully qualified form, no leading @, with the three-forms table
- §5.1 account creation heading and step 12 now use tudisco@kez.lat
- §5.2 local cache key is "chris@kez.lat" not "@chris@kez.lat"
- §12 summary updated
ActivityPub identifiers in SPEC.md (ap:@jason@mastodon.social) are
unchanged — that's the ActivityPub convention for a different
addressing system.
In-text narrative mentions like "@tudisco shares a file with @chris"
and CLI examples like `kez-chat add @chris` are intentionally
preserved — those use the mention syntax, which the CLI resolves
to the full handle.