KEZ is a portable, decentralized identity graph: a person signs claims
linking their many accounts, publishes those claims in places only the
claimed account can publish to, and anyone can verify the connections
without trusting a central server.
Layout
------
- SPEC.md Language-agnostic protocol spec (v0.2)
- rust/ Rust implementation: kez-core, kez-channels, kez-cli
- nodejs/ TypeScript port at full parity
- rust-sig-server/ Optional axum + SQLite storage server for sigchains
- crosstest.sh Cross-implementation interop harness
Capabilities (both implementations, byte-compatible)
----------------------------------------------------
- Two primary-key algorithms: nostr/secp256k1 Schnorr (BIP-340) and
Ed25519 (RFC 8032). Identifiers: nostr:npub1... and ed25519:<hex>.
- JCS (RFC 8785) canonicalization for everything signed.
- Four proof encodings: JSON envelope, compact (kez:z1:<base64url(zstd(json))>),
Markdown fence, DNS TXT.
- Five channel plugins (no API keys, no auth needed for any of them):
dns: system resolver, _kez.<domain> TXT records
github: public gist scan + <user>/<user> profile README fallback
nostr: kind-30078 events from default relays
bluesky: public AppView author feed
ap: WebFinger + actor JSON (alias mastodon:)
- Identical CLI surface:
kez identity new [--key-type nostr|ed25519]
kez claim create <subject> (--nsec | --ed25519-seed) [--format ...] [--out ...]
kez claim dns <domain> (--nsec | --ed25519-seed)
kez verify file <path>
kez verify id <identifier>
kez sigchain add|revoke|show|export|publish
- Sigchains: append-only signed log per primary, hash-chained per spec §6,
stored locally at ~/.kez/sigchains/, exportable as JSONL or kez:zc1: bundle.
- Sigchain publish destinations: chain server, web (file dump), DNS (zone
record print), nostr (kind-30078 wrapping event).
kez-sig-server
--------------
Optional storage tier. Axum + SQLite, single binary, no external deps.
- No auth — the cryptography is the access control. The server validates
every signature, every seq, every prev hash before storing.
- REST API: POST /v1/sigchains/{scheme}/{id}/events (append signed event,
201 with new head hash or 4xx); GET /{scheme}/{id} (full chain as JSONL);
GET /head; GET /healthz.
- Designed for one central instance for now; the design doesn't preclude
running more later (clients gain a configurable list, verifiers
reconcile per spec §6.2).
- Channel-based publishing remains the always-available fallback if the
server is unavailable.
Tests
-----
- rust/ 99 tests
- rust-sig-server/ 10 integration tests (real HTTP, real SQLite)
- nodejs/ 91 tests (vitest)
- crosstest.sh 19 cross-impl scenarios — proves JCS bytes,
Schnorr + Ed25519 sigs, all four claim encodings,
and the sigchain JSONL bundle are byte-compatible
between Rust and Node in both directions.
What's not done yet
-------------------
- verify id consulting the sigchain for revocations (data path exists,
just not wired into the verifier output).
- rotate and add_device sigchain ops (types reserved).
- expires_at enforcement during claim verification.
- Typed VerificationStatus.status reflecting the five failure modes.
- Auth-required publishers (GitHub gist, Bluesky, ActivityPub).
18 KiB
kez-sig-server
A central HTTP server that stores KEZ sigchains.
A sigchain is a signed, append-only log of identity events for one KEZ primary key — "I added github:jason," "I revoked dns:old.example," "I rotated my key." Verifiers walk it to answer questions like "is this identity still active?"
For now, this is the central server for sigchain storage in the KEZ ecosystem. If you don't want to publish your sigchain anywhere else, this server is enough on its own.
If the server is unavailable (down, blocked, or you just don't want to
trust one operator), the protocol lets you publish the same sigchain
through any of the channel plugins instead — as a GitHub gist, a nostr
event, a /.well-known/kez-sigchain.jsonl on your own website, etc.
Verifiers know how to fetch from any of those. The server is the easy
default; the channels are the always-available fallback.
Why this exists
KEZ sigchains can be published to nostr relays, DNS TXT records,
/.well-known/kez-sigchain.jsonl on your own website, GitHub gists, IPFS,
ActivityPub profile fields, Bluesky posts, etc. Every one of those works,
and every one of those needs setup: domain + hosting, relay selection, a
gist you keep current, an IPFS pinning service.
This server exists to be the easy default:
- One binary, one SQLite file.
- POST your signed sigchain events to it.
- Anyone with the URL can fetch them.
The other protocol-level storage paths remain useful as backup or for users who want full self-hosting, but most users will just point at this server and be done.
Important framing
- The crypto is the auth (see next section). No accounts.
- Self-hostable. Single binary, SQLite file, any host with outbound TCP. No external services required. If you want to run your own instance for privacy or independence, you can.
- The spec leaves room for multiple instances if that ever becomes necessary. Today it's one.
No auth — why?
Most web services that accept user data have user accounts: passwords, API
keys, OAuth tokens. This server has none of those. There's nothing to log
into. Anyone on the internet can POST to /v1/sigchains/.../events —
and that's by design.
The reason it works: every sigchain event is signed with the user's private key. The server can verify those signatures itself. So:
- A malicious attacker who doesn't have your private key can POST garbage all
day, but every event will fail signature verification and be rejected
with
400 Bad Request. Nothing they send gets stored. - A user who does have their private key signs an event with it; the server's signature check passes; the event is stored.
The cryptography is the access control. No accounts, no tokens, no password resets, no email verification. The model is the same one git uses for signed commits: anyone can submit a signed object, but only the holder of the right key can produce a valid one.
What the server validates on every POST
Before accepting an event, the server (using kez-core's
Sigchain::append) checks:
- The envelope tag is
"sigchain_event". - The URL primary (
/v1/sigchains/<scheme>/<id>/events) matches theevent.payload.primaryfield. (Prevents POSTing valid chains for key A under key B's URL.) event.payload.seqequalshead.seq + 1for the existing chain (or0if no chain exists yet).event.payload.prevequalssha256:<hex>of the JCS-canonicalized envelope of the prior event.event.signature.sigverifies againstevent.payload.primaryusing the algorithm inevent.signature.alg(nostr-secp256k1-schnorr-sha256-jcsored25519-sha512-jcs).
If any check fails, the event is rejected and never stored.
Threat model
| Attacker | What they can do | Why it's fine |
|---|---|---|
| Has no private key | POST malformed events, hammer endpoints | All events rejected at signature check; rate-limit at proxy |
| Has someone else's private key | Append a revoke to that person's chain |
They've already compromised the identity — this is downstream of that breach, not caused by us |
| Runs a malicious server | Serve a fake chain over GET |
Clients are supposed to consult multiple sources; mismatch is detectable; signatures fail verification |
| Has the server admin login | Read/edit the SQLite file | Sigchains are public anyway; tampering breaks the hash chain, detectable on next fetch |
We don't protect against attackers who already have your private key — that's outside the protocol's scope. We do guarantee that an attacker without your key cannot publish a valid event to your chain on this server.
What we DON'T do for auth (and shouldn't)
- ❌ User accounts / passwords / email verification
- ❌ API keys / tokens / OAuth
- ❌ Rate limits per "user" (no user concept; rate-limit per IP at the proxy)
- ❌ Captchas
- ❌ TLS client certificates
- ❌ Mutual auth
Any of those would add complexity without adding security. The cryptography already does the job.
Quick start
# Build
cargo build --release
# Run with defaults — binds 0.0.0.0:7878, uses ./kez-sigchains.db
cargo run --release
# Or with explicit flags
cargo run --release -- --bind 127.0.0.1:8080 --db /var/lib/kez/chains.db
Configuration:
| Flag | Env var | Default | Meaning |
|---|---|---|---|
--bind |
KEZ_BIND |
0.0.0.0:7878 |
Address to listen on |
--db |
KEZ_DB |
kez-sigchains.db |
SQLite file path (created if missing) |
Logging via RUST_LOG (default info). Standard tracing filter syntax:
RUST_LOG=debug,hyper=info cargo run
Try it: end-to-end POST → GET
# 1. Start the server
cargo run --release &
# 2. Health check
curl -s http://localhost:7878/v1/healthz
# → {"status":"ok"}
# 3. Generate a key + sign a seq-0 sigchain event using the kez CLI
# (Assumes you've built the main rust workspace too.)
cd ../rust
SEED=$(cargo run -q -p kez-cli -- identity new --key-type ed25519 \
| awk -F': *' '/^Secret:/ {sub(/ \(.*$/, "", $2); print $2}')
echo "Seed: $SEED"
# 4. POST the event (today: hand-build via kez-core; a `kez sigchain` CLI
# is on the roadmap and will make this one-line)
# For now see the integration tests in tests/http.rs for a worked example.
# 5. Fetch the chain back
PRIMARY="ed25519:<your-pubkey-hex>"
SCHEME=$(echo "$PRIMARY" | cut -d: -f1)
ID=$(echo "$PRIMARY" | cut -d: -f2)
curl -s http://localhost:7878/v1/sigchains/$SCHEME/$ID
# → JSONL of every event in this chain
API reference
| Method | Path | Response |
|---|---|---|
GET |
/v1/healthz |
{"status":"ok"} |
GET |
/v1/sigchains/{scheme}/{id} |
application/jsonl — every event for this primary, in order |
GET |
/v1/sigchains/{scheme}/{id}/head |
application/json — the latest event envelope |
POST |
/v1/sigchains/{scheme}/{id}/events |
application/json — {"seq": N, "hash": "sha256:..."} on 201 |
The {scheme}/{id} path split represents a canonical KEZ identifier
(nostr:npub1abc... becomes /nostr/npub1abc.../). This keeps the colon
out of URL-encoded path segments.
POST request body
A SignedSigchainEvent envelope, exactly as kez-core produces:
{
"kez": "sigchain_event",
"payload": {
"type": "kez.sigchain.event",
"version": 1,
"primary": "ed25519:1bc0d49f0c5992961dec3f92afb55c06c93e91e392529417faac08cec9504ed8",
"seq": 0,
"created_at": "2026-05-24T19:00:00Z",
"op": "add",
"payload": { "subject": "github:jason" }
},
"signature": {
"alg": "ed25519-sha512-jcs",
"key": "ed25519:1bc0d49f0c5992961dec3f92afb55c06c93e91e392529417faac08cec9504ed8",
"sig": "<128-char-hex>"
}
}
Success response
201 Created:
{
"seq": 0,
"hash": "sha256:5e3a6f...the JCS sha256 of the envelope just stored"
}
The hash is what the next event's prev field must equal.
Status codes & error shape
| Code | When | Body |
|---|---|---|
200 OK |
Chain fetched successfully | JSONL (or JSON for /head) |
201 Created |
Event appended | {"seq", "hash"} |
400 Bad Request |
Bad signature, wrong primary, malformed JSON, invalid envelope | Error JSON |
404 Not Found |
No chain exists for this primary | Error JSON |
409 Conflict |
Event doesn't extend the existing chain (wrong seq, bad prev, duplicate seq) | Error JSON |
500 Internal Server Error |
DB or other server-side failure | Error JSON |
Error response body:
{
"error": {
"code": "conflict",
"message": "expected seq 3, got 5"
}
}
code is stable for programmatic handling; message is human-friendly and
may change.
Storage
SQLite, one table:
CREATE TABLE sigchain_events (
primary_scheme TEXT NOT NULL,
primary_id TEXT NOT NULL,
seq INTEGER NOT NULL,
envelope_json TEXT NOT NULL,
envelope_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (primary_scheme, primary_id, seq)
);
CREATE INDEX idx_primary ON sigchain_events (primary_scheme, primary_id);
The (primary_scheme, primary_id, seq) primary key prevents duplicate-seq
inserts at the database level — even racing writers can't both win.
Concurrency: a single tokio::sync::Mutex<Connection> serializes all
DB access. At single-instance scale (one server, low per-identity write
rate) this is fine. Reads are cheap; writes are rare (one per identity
change). For horizontal scaling, swap rusqlite for Postgres with row-level
locks — the Store API surface stays the same.
Backup: the SQLite file is the entire server state. cp kez-sigchains.db backup-$(date +%F).db while the server's stopped, or use SQLite's online
backup API while running. Sigchains are public, so unencrypted backups are
fine.
Vacuum / pruning: never needed for this workload. Sigchains are append-only; deletions aren't part of the protocol. If you really need to discard old data, drop the SQLite file and start fresh — clients that care will re-POST their chains.
Deployment
Bare metal / VPS
cargo build --release
scp target/release/kez-sig-server user@host:/usr/local/bin/
# systemd unit:
cat > /etc/systemd/system/kez-sig-server.service <<EOF
[Unit]
Description=KEZ sigchain server
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/kez-sig-server --db /var/lib/kez/chains.db
Restart=on-failure
User=kez
Group=kez
Environment=RUST_LOG=info
Environment=KEZ_BIND=127.0.0.1:7878
StateDirectory=kez
[Install]
WantedBy=multi-user.target
EOF
systemctl enable --now kez-sig-server
Put nginx or Caddy in front for TLS + rate limiting.
Docker
FROM rust:1.85-slim AS build
WORKDIR /src
COPY . .
RUN cargo build --release -p kez-sig-server
FROM debian:bookworm-slim
COPY --from=build /src/target/release/kez-sig-server /usr/local/bin/
RUN useradd -r kez && mkdir /data && chown kez:kez /data
USER kez
ENV KEZ_BIND=0.0.0.0:7878 KEZ_DB=/data/chains.db
VOLUME /data
EXPOSE 7878
ENTRYPOINT ["/usr/local/bin/kez-sig-server"]
docker build -t kez-sig-server .
docker run -d -p 7878:7878 -v kez-data:/data kez-sig-server
Fly.io / Render / single-container PaaS
Same Docker image. Mount a persistent volume at /data. That's it.
Reverse proxy notes
The server doesn't speak TLS itself — terminate at your reverse proxy (nginx, Caddy, Cloudflare, etc). Recommended proxy config:
- TLS — Let's Encrypt is fine.
- Per-IP rate limiting — e.g. nginx
limit_req_zone $binary_remote_addr zone=kez:10m rate=10r/s. The server itself has no rate limiting and doesn't need any beyond what your proxy provides. - Request size limit — cap POST body at ~64 KB. Sigchain events are tiny; anything larger is abuse.
- CORS — enabled wide-open in the binary (
Anyorigin). Override in your proxy if you want to restrict who can hit the API from a browser.
How clients use this server
A KEZ client (CLI, web app, library) treats this server as the sigchain store for now:
- After a
kez sigchain add/revoke, push the new event to the server. - During
kez verify id <identifier>, fetch the relevant chain from the server to check for revocations.
That's the happy path.
Fallback: publishing the sigchain through existing channels
If you don't want to depend on this server (operator went silent, region blocked, privacy preference, "I just don't trust any one place"), you can publish your sigchain via the same channel plugins that already exist for proofs. Same JSONL bundle, different transport. Verifiers fetch from whichever source they can reach.
Concrete options, in order of ease:
| Where | How to publish | How a verifier fetches |
|---|---|---|
| GitHub gist | Create a public gist with a kez-sigchain.jsonl file |
github: channel scans your gists, recognizes the filename, returns the chain |
| Your own website | Drop the file at https://<domain>/.well-known/kez-sigchain.jsonl |
web: channel does a single HTTPS fetch |
| DNS | Publish a compact-encoded sigchain URL hint in a _kez-chain.<domain> TXT record |
dns: channel reads the TXT, follows the URL |
| Nostr | Publish the chain as a kind-30078 event (one event per sigchain entry, or a single event holding a kez:zc1: compact bundle) |
nostr: channel queries relays by your pubkey |
| ActivityPub profile field | Tight: only fits a URL hint, not the chain itself. Point at where the real bundle lives. | ap: channel reads the field, follows the hint |
| Bluesky | Pin a post containing the compact kez:zc1: bundle |
bluesky: channel scans your feed |
A user who's worried about availability publishes to both: the server for
the easy path, plus a gist (or nostr event, or /.well-known URL) for the
"server is down" case. Verifiers consult both and take the longest valid
chain (spec §6.2).
The channel plugins that fetch proofs already exist
(kez-channels). Extending them to also
fetch sigchain bundles is straightforward implementation work in the
client; the server itself doesn't change to support any of this.
Future: multiple instances
(Distinct from the channel-fallback story above — this is about running several kez-sig-server instances, not about publishing through gists/nostr.)
We're starting with one server. The design doesn't preclude running more later: each event is signed and self-validating, so two instances can't meaningfully disagree about whether to accept an event. If we ever do spin up additional instances, clients gain a configurable list of server URLs and verifiers reconcile per spec §6.2's "longest valid chain wins" rule. Server code and wire format stay the same.
Not on the roadmap today.
Tests
cargo test
Spins up the real router on a random local port and drives it over real
HTTP with reqwest. No mocks. 10 integration scenarios:
| # | Test | Asserts |
|---|---|---|
| 1 | healthz_returns_ok |
Liveness endpoint works |
| 2 | empty_chain_returns_404 |
GET on unknown primary → 404 |
| 3 | post_then_get_round_trip |
POST event, GET returns it as JSONL |
| 4 | head_endpoint_returns_latest |
Two events posted; /head returns the second |
| 5 | rejects_event_for_wrong_primary_url |
Event signed for key A, POSTed under B's URL → 400 |
| 6 | rejects_bad_signature |
Tampered signature → 400 |
| 7 | rejects_seq_skip |
seq 0 then seq 2 (skipping 1) → 409 |
| 8 | rejects_bad_prev_hash |
Event with wrong prev → 409 |
| 9 | rejects_duplicate_seq |
POSTing same event twice → 409 |
| 10 | invalid_primary_in_url_is_bad_request |
Malformed identity in path → 400 |
The chain-validation logic lives in kez-core::Sigchain
and has its own unit tests (8 scenarios covering append/revoke/jsonl/
tamper-detection/wrong-primary/seq-skip/bad-prev/ed25519). The server is a
thin wrapper around that logic — most of the security comes from the
underlying type, not the HTTP layer.
What this server explicitly does NOT do
| Anti-feature | Why |
|---|---|
| User accounts / API keys / login | Keys are the auth |
| Encryption at rest | Sigchains are public data |
| Push notifications / WebSockets | Poll /head; sigchain writes are rare |
| Search / discovery / browse UI | Out of scope; clients know which primary they want |
| Multi-tenant features (orgs, teams) | Sigchains are per-primary already |
| Soft deletes / "archived" rows | Append-only is the whole point |
rotate / add_device op support |
Land those in kez-core first; this server validates them automatically via Sigchain::append |
| Fork resolution / merge logic | Per spec §6.2: report forks, don't pick one. Multiple chains for same (primary, seq) are caught by the PK; the second one is rejected with 409 |
| Server-to-server replication / peer gossip | Not needed for a single-instance deployment. If we ever want it, see Future: multiple instances |
Keep the server boring. The interesting stuff lives in the protocol.
Project layout
rust-sig-server/
├── Cargo.toml
├── README.md ← this file
├── src/
│ ├── main.rs binary: clap CLI, axum serve, graceful shutdown
│ ├── lib.rs re-exports (so tests can drive the router)
│ ├── api.rs HTTP routes + handlers
│ ├── store.rs SQLite-backed Store
│ └── error.rs typed ApiError → JSON response mapping
└── tests/
└── http.rs end-to-end integration tests (10 scenarios)
Total: ~600 lines of Rust including tests. The chain logic lives in
kez-core; this crate is a thin HTTP wrapper
around it.
License
Dual-licensed under MIT or Apache-2.0. See the parent repository's licence files.