Kez/rust-sig-server
Tudisco d0db6f00f1 Initial implementation of KEZ — protocol, two impls, and storage server
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).
2026-05-24 14:41:00 -06:00
..

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:

  1. The envelope tag is "sigchain_event".
  2. The URL primary (/v1/sigchains/<scheme>/<id>/events) matches the event.payload.primary field. (Prevents POSTing valid chains for key A under key B's URL.)
  3. event.payload.seq equals head.seq + 1 for the existing chain (or 0 if no chain exists yet).
  4. event.payload.prev equals sha256:<hex> of the JCS-canonicalized envelope of the prior event.
  5. event.signature.sig verifies against event.payload.primary using the algorithm in event.signature.alg (nostr-secp256k1-schnorr-sha256-jcs or ed25519-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 (Any origin). 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:

  1. After a kez sigchain add/revoke, push the new event to the server.
  2. 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.