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.
502 lines
18 KiB
Markdown
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.
|