Jason Tudisco 5cb46e2aa1 feat(kez-chat): v0.1 chat — encrypted 1:1 messages (server + web client)
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>
2026-05-25 16:10:43 -06:00
..

KEZ — Rust Implementation

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

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

…without depending on any central authority to vouch for it. Every connection is proven by a signature against a key the user already controls — a nostr key, an Ed25519 key, a passkey, an Ethereum key, a GPG key, whatever they've got.

The protocol itself is specified in ../SPEC.md. This directory is the Rust implementation of that spec.

If you've used Keybase, the mental model is similar: you publish a signed "I control X" proof in a place only X can publish to (your gist, your DNS, your nostr key), and anyone can fetch + verify it. The difference: KEZ has no required central server. There is an optional chain server for sigchain storage, but using it is a convenience — the protocol works the same whether your sigchain lives there, in a gist, in DNS, in a nostr event, or on your own website. The proofs live wherever you publish them; the verifier just walks the links.


What's in this directory

rust/
├── Cargo.toml                      Workspace manifest
├── crates/
│   ├── kez-core/                   Types, signing, verification, JCS, all four encodings
│   ├── kez-channels/               One file per channel (github, dns, nostr, bluesky, ap)
│   └── kez-cli/                    Thin CLI that dispatches through the channel registry
└── README.md                       (this file)

Three crates, ~2,500 lines of Rust, 99 tests.


Quick start

# Build, test, and install the `kez` binary to ~/.cargo/bin (one time)
cargo build
cargo test
cargo install --path crates/kez-cli

After that, the examples below use bare kez. For dev iteration without installing, substitute cargo run -p kez-cli -- for kez in any command.

End-to-end walkthrough

1. Create a primary key.

kez identity new

Outputs:

Primary: nostr:npub1tkf...
Public:  npub1tkf...
Secret:  nsec1...

Save the nsec somewhere safe — it's the only thing that can sign as this identity.

2. Sign a claim that this primary key also controls your GitHub account. Pick the output format that fits where you'll publish:

# Markdown (for a GitHub gist or profile README)
kez claim create github:jason \
  --nsec nsec1... --format markdown --out github-jason.kez.md

# Compact (one-liner for QR codes, chat, DNS TXT)
kez claim create github:jason --nsec nsec1... --format compact

# JSON envelope (for /.well-known/kez.json)
kez claim create github:jason --nsec nsec1...

3. Publish the proof somewhere only the claimed account can publish to:

Channel Where to put the proof
github: A public gist whose filename includes kez, or your <user>/<user> profile README
dns: TXT record at _kez.<domain> (use kez claim dns ... to get the zone-file line)
nostr: A kind-30078 event published by the same key
bluesky: A public post containing the compact form or the Markdown fence
ap: / mastodon: Your profile metadata field (preferred) or anywhere in your bio

4. Verify it from anywhere:

kez verify id github:jason

Output:

Primary: nostr:npub1tkf...

Verified identities:
- github:jason

Status: valid
Confidence: strong

CLI reference

identity new

Generate a new primary key. Defaults to nostr/secp256k1 (prints nsec / npub); pass --key-type ed25519 to generate an Ed25519 key instead (prints the 32-byte seed and pubkey, both in hex). Stores nothing on disk.

claim create <subject> (--nsec <nsec> | --ed25519-seed <hex>) [--format json|markdown|compact] [--out <path>]

Sign a KEZ claim asserting that the supplied signing key also controls <subject>. Pass exactly one of --nsec (nostr) or --ed25519-seed (Ed25519). Defaults to JSON output. --out writes to a file; otherwise prints to stdout.

claim dns <domain> (--nsec <nsec> | --ed25519-seed <hex>)

Like claim create dns:<domain> but additionally prints a ready-to-paste zone-file line with the proof properly chunked into TXT segments.

verify file <path>

Parse and verify a local proof file (any encoding). Developer helper — not a real channel.

verify id <identifier>

Fetch the proof for <identifier> from its native channel and verify it. The identifier's system: prefix selects the channel plugin:

kez verify id dns:jason.example.com
kez verify id github:jason
kez verify id nostr:npub1...
kez verify id bluesky:jason.bsky.social
kez verify id ap:@jason@mastodon.social
kez verify id mastodon:@jason@mastodon.social

sigchain add <subject> --nsec | --ed25519-seed [--proof-url <url>]

Append an add event to the local sigchain for the signing key. Chain files live at ~/.kez/sigchains/<safe-primary>.jsonl.

sigchain revoke <subject> --nsec | --ed25519-seed

Append a revoke event for a previously added subject.

sigchain show [--primary <id>] | [--nsec | --ed25519-seed]

Print the chain: primary, file path, length, one line per event, head hash. Read-only — --primary works without a key.

sigchain export [--primary <id>] | [--nsec | --ed25519-seed] [--format jsonl|compact] [--out <path>]

Export the chain in a portable format (jsonl per spec §6, or compact = kez:zc1:<base64url(zstd(jsonl))>).

sigchain publish [--primary <id>] | [--nsec | --ed25519-seed] [destinations...]

Push the chain to one or more places. Destinations are flags and any combination can be passed:

  • --server <url> — POST every event to a kez-sig-server
  • --web --out <path> — write the JSONL bundle to a file (you upload it to https://<your-domain>/.well-known/kez-sigchain.jsonl)
  • --dns <domain> — print the TXT zone records for _kez-chain.<domain>
  • --nostr <relay> — publish the compact bundle as a kind-30078 event signed by your nostr key (requires --nsec)

Channels

Every channel lives in its own file under crates/kez-channels/src/ and implements one trait:

#[async_trait]
pub trait Channel: Send + Sync {
    fn system(&self) -> &'static str;
    async fn fetch_and_verify(&self, identity: &Identity) -> ChannelResult<ChannelHit>;
}
File System Fetches API key needed?
dns.rs dns: _kez.<domain> TXT via system resolver No
github.rs github: Public gists then <user>/<user> profile README No (60 req/hr anon, 5000 with GITHUB_TOKEN)
nostr.rs nostr: Kind-30078 events from damus / nos.lol / primal relays No
bluesky.rs bluesky: Author feed via the public Bluesky AppView No
activitypub.rs ap:, mastodon: WebFinger → actor JSON → profile fields + bio No

Each channel has a sibling test file in crates/kez-channels/tests/ using either wiremock (HTTP channels) or a fake fetcher trait (DNS, nostr).


Adding a new channel

The pattern is small and self-contained.

  1. Add the channel file. Create crates/kez-channels/src/<system>.rs. Implement Channel. Keep pure helpers (URL builders, parsers) as standalone pub fns so they can be unit-tested without I/O.

  2. Register it. In lib.rs, add pub mod <system>; and a line in Registry::with_defaults:

    r.register(Arc::new(my_channel::MyChannel::new().map_err(ChannelError::Other)?));
    

    If one adapter handles multiple identifier prefixes, use register_as:

    let adapter = Arc::new(...);
    r.register(adapter.clone());      // canonical
    r.register_as("alias", adapter);  // alias
    
  3. Add tests. Create crates/kez-channels/tests/<system>.rs. For HTTP channels, use wiremock with a constructor like MyChannel::with_base(client, mock_server.uri()). For network-protocol channels (DNS, nostr), abstract the fetcher behind a trait and inject a fake.

  4. Done. kez verify id <system>:... now works through the CLI without any CLI changes.


Library use

The crates are usable directly:

use kez_channels::Registry;
use kez_core::Identity;

let registry = Registry::with_defaults()?;
let identity = Identity::parse("github:jason")?;
let hit = registry.verify(&identity).await?;
println!("verified {} via {}", hit.proof.payload.subject, identity.scheme());

kez-core exports the claim/envelope types, signing primitives, and the four encoding round-trips. kez-channels exports the Channel trait, the ChannelError enum, the Registry, and one module per built-in channel.


Proof formats

A signed claim is one envelope, four wire forms.

Envelope shape:

{
  "kez": "claim",
  "payload": {
    "type": "kez.claim",
    "version": 1,
    "subject": "github:jason",
    "primary": "nostr:npub1...",
    "created_at": "2026-05-22T12:00:00Z"
  },
  "signature": {
    "alg": "nostr-secp256k1-schnorr-sha256-jcs",
    "key": "nostr:npub1...",
    "sig": "<hex>"
  }
}
Form Where Encoding
JSON /.well-known/kez.json, HTTP APIs Standard JSON of the envelope
Compact DNS TXT, QR codes, chat kez:z1:<base64url-no-pad(zstd(JSON))>
Markdown GitHub gist, README, bio Human prose + a ```kez fenced block
Legacy DNS (deprecated) kez1:<raw JSON> — parser still accepts it

Signatures are computed over JCS (RFC 8785) of the payload, not the envelope. That makes the bytes-being-signed deterministic across implementations.


Failure modes

A verifier returns one of five distinct statuses (mapped to ChannelError variants):

Variant Meaning
Unreachable(_) Channel couldn't be reached (DNS failure, HTTP 5xx, relay down)
NotFound(_) Channel reachable but no KEZ proof was found
Invalid(_) A proof was found but failed signature or format check
SubjectMismatch { expected, found } Signature valid, but the proof claims a different subject than what was requested
NoChannelForSystem(_) The identifier's system: has no registered channel

The CLI surfaces these as error messages today; the typed enum is in place for the verifier to expose them programmatically.


What's not done yet

This implementation covers the spec's v0.2 MVP plus four channels. Known gaps:

  • Sigchain walking during verify — the sigchain type and CLI commands exist (see kez sigchain ... above), and a separate chain server can store them, but verify id doesn't yet fetch a chain to check for revocations. Today every verify is a single one-shot proof check. rotate and add_device ops are also not implemented yet.
  • expires_at enforcement — the field exists on ClaimPayload and serializes correctly, but SignedClaim::verify doesn't reject expired proofs yet.
  • Typed VerificationStatus.status — currently hardcoded strings ("valid", "strong"). The ChannelError enum is ready to plumb the five failure modes through into the CLI output.
  • Nostr event signature verification — for the common case (subject == primary == the npub) the embedded KEZ proof's own signature is sufficient. Cross-key proofs (e.g. ed25519 primary claiming a nostr identity) need NIP-01 event-sig verification to be safe.
  • GitHub authentication — anonymous requests work but are limited to 60 req/hr per IP. A GITHUB_TOKEN env var read would raise this to 5,000/hr.

See ../SPEC.md for the full v0.2 spec these gaps reference.


Tests

cargo test                              # all 81 tests
cargo test -p kez-core                  # claim/envelope/encoding tests (15)
cargo test -p kez-channels              # channel logic + integration (61)
cargo test -p kez-channels --test github      # one channel's integration tests

No network is hit in the test suite — HTTP channels use wiremock, DNS uses a fake TxtResolver, nostr uses a fake NostrFetcher.


License

Dual-licensed under MIT or Apache-2.0 (see workspace Cargo.toml).