# kez-sig-server A central HTTP server that stores [KEZ](../SPEC.md) 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`](../rust/crates/kez-core)'s `Sigchain::append`) checks: 1. The envelope tag is `"sigchain_event"`. 2. The URL primary (`/v1/sigchains///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:` 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 ```sh # Build, then install the server binary to ~/.cargo/bin (one time) cargo build --release cargo install --path . # Run with defaults — binds 0.0.0.0:7878, uses ./kez-sigchains.db kez-sig-server # Or with explicit flags kez-sig-server --bind 127.0.0.1:8080 --db /var/lib/kez/chains.db ``` For dev iteration without installing, use `cargo run --release --` in place of `kez-sig-server`. 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: ```sh RUST_LOG=debug,hyper=info kez-sig-server ``` --- ## Try it: end-to-end POST → GET Assumes you've also installed the `kez` CLI from [`../rust/`](../rust/README.md#quick-start). ```sh # 1. Start the server kez-sig-server & # 2. Health check curl -s http://localhost:7878/v1/healthz # → {"status":"ok"} # 3. Generate a key, build a sigchain locally, push it to the server NSEC=$(kez identity new | awk -F': *' '/^Secret:/ {print $2; exit}') kez sigchain add github:jason --nsec "$NSEC" kez sigchain add dns:jason.example --nsec "$NSEC" kez sigchain publish --nsec "$NSEC" --server http://localhost:7878 # → server: posted 2 event(s), 0 already present # 4. Fetch the chain back as JSONL PRIMARY=$(kez sigchain show --nsec "$NSEC" | awk '/^Primary:/ {print $2; exit}') 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: ```json { "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`: ```json { "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: ```json { "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: ```sql 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` 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 ```sh cargo build --release scp target/release/kez-sig-server user@host:/usr/local/bin/ # systemd unit: cat > /etc/systemd/system/kez-sig-server.service <`, 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:///.well-known/kez-sigchain.jsonl` | `web:` channel does a single HTTPS fetch | | **DNS** | Publish a compact-encoded sigchain URL hint in a `_kez-chain.` 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`](../rust/crates/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 ```sh 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`](../rust/crates/kez-core/src/lib.rs) 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](#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`](../rust/crates/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.