Tudisco eae98fead0 docs: prefer cargo install + bare kez binary in examples
Rename the CLI binary from `kez-cli` to `kez` (via a [[bin]] section in
the package's Cargo.toml; package name and `-p kez-cli` invocations stay
the same so the workspace build, tests, and the cross-test harness are
unaffected).

Then update the READMEs to recommend `cargo install --path` once at the
top of Quick Start, after which every example is the much shorter
`kez ...` form. Mention `cargo run -p kez-cli --` as the dev iteration
alternative for anyone who doesn't want to install.

- rust/README.md: 11 `cargo run -p kez-cli --` → `kez` substitutions,
  plus a stale "81 tests" → "99 tests" fix.
- README.md (root): Quick start gains a `cargo install` line.
- rust-sig-server/README.md: Quick start uses `kez-sig-server`
  (post-install) with `cargo run` as the dev alternative; "Try it"
  section rewritten to use the actual `kez sigchain` CLI (which now
  exists) instead of the stale "hand-build via kez-core" workaround.
2026-05-24 15:29:32 -06:00

502 lines
18 KiB
Markdown

# 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/<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
```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<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
```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 <<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
```dockerfile
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"]
```
```sh
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`](../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.