Jason Tudisco c8370ffdf0 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>
2026-06-08 05:11:24 -06:00

KEZ

KEZ is a portable, decentralized identity graph. It lets a person say:

"These accounts, keys, domains, and identities are all me."

…without depending on any central authority. Every connection is proven by a cryptographic signature against a key the user already controls (a nostr key, an Ed25519 key, etc.), and the proofs are published in places only the claimed account itself can publish to (their gist, their DNS, their nostr relay event). Anyone can verify the graph without trusting a server.

Repository layout

.
├── SPEC.md              ← The protocol. Language-agnostic, normative.
├── rust/                ← Rust implementation (kez-core, kez-channels, kez-cli)
├── nodejs/              ← TypeScript/Node implementation (same shape, same CLI)
├── python/              ← Python implementation (same shape, same CLI)
├── rust-sig-server/     ← Optional HTTP store for sigchains (axum + SQLite)
├── crosstest.sh         ← Interop test: artifacts move between implementations
└── README.md            ← (this file)

Three parallel implementations. Wire-compatible: a claim signed in Rust verifies in Node and Python and vice versa, in every direction. The cross-test harness proves it.

A separate rust-sig-server/ crate provides an optional HTTP storage tier for sigchains — useful when a user doesn't want to set up DNS/hosting/nostr, but never required; the protocol stays decentralized.

Documentation

Start here:

  • SPEC.md — the language-agnostic protocol spec (v0.2). Normative for every implementation.
  • rust/README.md — Rust implementation guide: crate layout (kez-core / kez-channels / kez-cli), full CLI reference, channel plugin model, library examples, and the gap list.
  • nodejs/README.md — Node/TypeScript port: same shape as Rust, npm workspaces layout, crypto stack rationale, CLI reference.
  • python/README.md — Python port: single kez package, virtualenv setup, crypto stack rationale (pure-Python BIP-340 Schnorr + cryptography for Ed25519), CLI reference.
  • rust-sig-server/README.md — the optional storage server: API reference, no-auth design + threat model, deployment recipes (bare-metal, Docker, PaaS), and how channel-based publishing remains the fallback if the server is down.

Quick start

Rust

cd rust
cargo build
cargo test                                                # 99 tests
cargo install --path crates/kez-cli                       # → `kez` on PATH
kez verify id github:jason

Full guide: rust/README.md (reference) · rust/TUTORIAL.md (step-by-step, recommended for newcomers).

Node.js

cd nodejs
npm install
npm test                                                  # 91 tests
npm run cli -- verify id github:jason

Full guide: nodejs/README.md (reference) · nodejs/TUTORIAL.md (step-by-step).

Python

cd python
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python kez_cli.py identity new

Full guide: python/README.md (reference) · python/TUTORIAL.md (step-by-step).

Sigchain storage server (optional)

cd rust-sig-server
cargo build --release
./target/release/kez-sig-server                           # listens on :7878

Full guide: rust-sig-server/README.md.

Cross-testing

./crosstest.sh

Runs 55 scenarios that swap implementations at the artifact boundary:

# Scenarios
114 Rust ↔ Node: JSON / compact / markdown / DNS claims, nostr + ed25519
1520 Rust ↔ Node sigchains: build in one, parse + show in the other; JSONL byte parity
2144 Python ↔ Rust and Python ↔ Node claims: every format × key type, both directions
Python ↔ both peers DNS zone form, both directions
Python ↔ both peers sigchains: build/show both ways, JSONL byte parity, ed25519

If all 55 pass: JCS canonicalization, both signature suites (BIP-340 Schnorr and Ed25519), the compact kez:z1: zstd+base64url encoding, the Markdown fence, the DNS TXT shape, and the sigchain JSONL bundle format are all byte-compatible across all three implementations.

Pass -v for verbose output (echoes intermediate commands and proofs).

What ships in v0.2

  • Five channel plugins in each implementation: dns:, github:, nostr:, bluesky:, ap: (alias mastodon:).
  • Four wire encodings: JSON, compact, Markdown fence, DNS TXT.
  • Two primary-key algorithms: nostr/secp256k1 Schnorr (BIP-340) and Ed25519 (RFC 8032).
  • JCS (RFC 8785) canonicalization for everything signed.
  • No API keys required for any channel.

What's not done yet

Tracked in rust/README.md and the spec:

  • verify id consulting the sigchain. Sigchain types, CLI commands (kez sigchain add/revoke/show/export/publish), and the storage server all exist. But proof verification doesn't yet fetch the chain to check for revocations — every verify is still a single one-shot proof check.
  • rotate and add_device sigchain ops.
  • expires_at enforcement during claim verify.
  • Typed VerificationStatus.status reflecting the five failure modes (valid / revoked / expired / unreachable / fork).
  • Auth-required publishers (GitHub gist, Bluesky, ActivityPub).

License

Dual-licensed under MIT or Apache-2.0.

Description
No description provided
Readme 1.7 MiB
Languages
TypeScript 38.9%
Rust 31.8%
Svelte 18.2%
Python 5.4%
Shell 2.8%
Other 2.9%