feat(kez-chat): scaffold the home server (v0.1)
First runnable kez-chat-server binary plus its docker-compose deploy
recipe. Implements steps 2-3 of the document.md sequenced plan; the
rust-lib refactor (step 1) is deferred — chat-server path-deps on
rust/crates/kez-core for now, which works and matches what
rust-sig-server already does.
What's in this commit:
kez-core (1-line change)
- New public `verify_envelope<T>(payload, signature)` helper that
dispatches Schnorr / Ed25519 / future suites by signature.alg.
Used by chat-server's registration verifier; downstream value
beyond chat-server too.
kez-chat-server (new crate)
- src/main.rs: tokio + axum + tracing entry; clap config; graceful
Ctrl-C shutdown.
- src/lib.rs: re-exports so tests can drive the same router.
- src/config.rs: env/flag config (bind, db, server, sig_server_url,
web_dir) with defaults sane for both dev and prod.
- src/error.rs: typed ApiError → structured JSON responses with
stable error codes.
- src/store.rs: SQLite-backed handle registry, UNIQUE on both
(handle) and (primary_id); race-safe via SQL primary key.
- src/handles.rs: username validation (length, charset, reserved
list, must start with letter/digit).
- src/registration.rs: SignedRegistration envelope sharing KEZ's
JCS canonical-bytes pattern; signature verification via the new
kez-core helper; replay protection via ±5-minute clock skew check.
- src/api.rs: all six routes in one file —
GET /v1/healthz
GET /v1/u/:handle
POST /v1/register
GET /.well-known/webfinger
POST /internal/nats/auth (501 stub for v0.1; wired up in v0.2)
GET / (placeholder HTML; ServeDir when web/dist exists)
tests/http.rs — 13 integration tests
- Stands up the real router on a random port; uses reqwest.
- Coverage: healthz, lookup-404, full register→lookup round-trip,
duplicate-handle conflict, wrong-server rejection, reserved-name
rejection, tampered-signature rejection, stale-timestamp rejection,
WebFinger success + wrong-server-404, placeholder SPA renders,
NATS callout 501, JCS determinism sanity.
deploy/
- Dockerfile: multi-stage build (rust:1.86-slim → debian:bookworm-slim).
Build context is repo root so the path dep on kez-core resolves.
Runtime image ~50 MB; runs as non-root uid 10001.
- Dockerfile.sig-server: same pattern for the existing
rust-sig-server, so the stack builds from one git pull.
- docker-compose.yml: three services (chat-server + nats + sig-server)
with named volumes for persistence. Ports: 6969 (chat HTTP),
4222/8443/8222 (NATS native/ws/monitoring), 7878 (sig-server).
- nats.conf: WebSocket on 8443 for the browser SPA, JetStream
enabled, auth_callout pointing at chat-server's
/internal/nats/auth endpoint (issuer nkey is a placeholder — must
be replaced with a real one before going live).
README.md
- Documents all endpoints with example bodies.
- Quick-start for both local dev and full Docker compose.
- Honest list of what's in v0.1 vs what's still stubbed.
Smoke-tested running on 127.0.0.1:6969:
GET /v1/healthz → {"server":"kez.lat","status":"ok","version":"0.1.0"}
GET / → placeholder HTML rendering
GET /v1/u/ghost → 404
POST /internal/nats/auth → 501 with "wired up in v0.2"
cargo test → 13 passed.
cargo build --release → 19.6s, clean.
This commit is contained in:
parent
a1d1aa6983
commit
111b23b94b
2582
kez-chat/Cargo.lock
generated
Normal file
2582
kez-chat/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
kez-chat/Cargo.toml
Normal file
27
kez-chat/Cargo.toml
Normal file
@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "kez-chat-server"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "Home server for kez-chat: handle registry + NATS auth callout + WebFinger + static SPA host. Designed in kez-chat/document.md."
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
axum = "0.7"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
hex = "0.4"
|
||||
kez-core = { path = "../rust/crates/kez-core" }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "sync", "signal"] }
|
||||
tower-http = { version = "0.6", features = ["trace", "cors", "fs"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[dev-dependencies]
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||
sha2 = "0.10"
|
||||
tempfile = "3"
|
||||
169
kez-chat/README.md
Normal file
169
kez-chat/README.md
Normal file
@ -0,0 +1,169 @@
|
||||
# kez-chat-server
|
||||
|
||||
Home server for the kez-chat application. One Rust binary that hosts:
|
||||
|
||||
- Handle registry (`POST /v1/register`, `GET /v1/u/:handle`)
|
||||
- WebFinger discovery (`GET /.well-known/webfinger`)
|
||||
- NATS auth callout endpoint (`POST /internal/nats/auth`) — stub in v0.1
|
||||
- Static SPA serving (`GET /`) — placeholder until the Svelte build lands
|
||||
- Healthz (`GET /v1/healthz`)
|
||||
|
||||
Designed in [`document.md`](document.md). Spec for the underlying KEZ
|
||||
identity layer in [`../SPEC.md`](../SPEC.md).
|
||||
|
||||
## What's in v0.1 (this is the scaffold)
|
||||
|
||||
✅ HTTP API end-to-end
|
||||
✅ Ed25519-signed handle registration with replay protection
|
||||
✅ SQLite-backed registry with uniqueness on both handle and primary key
|
||||
✅ WebFinger endpoint
|
||||
✅ Placeholder SPA at `/`
|
||||
✅ docker-compose for full stack (chat + nats + sig-server)
|
||||
✅ Multi-stage Dockerfiles
|
||||
✅ 13 integration tests against a live router
|
||||
|
||||
⚠️ NATS auth callout returns 501 — wired up in v0.2
|
||||
⚠️ Svelte SPA build pipeline not yet in place — placeholder HTML for now
|
||||
⚠️ TLS terminated upstream (no cert handling in this binary)
|
||||
|
||||
## Quick start (local development)
|
||||
|
||||
```sh
|
||||
# Run from source
|
||||
cargo run -- --bind 127.0.0.1:6969 --db ./kez-chat.db --server kez.lat
|
||||
|
||||
# Or install once
|
||||
cargo install --path .
|
||||
kez-chat-server --bind 127.0.0.1:6969 --server kez.lat
|
||||
```
|
||||
|
||||
Configuration via flags or env vars:
|
||||
|
||||
| Flag | Env | Default |
|
||||
|---|---|---|
|
||||
| `--bind` | `KEZ_CHAT_BIND` | `0.0.0.0:6969` |
|
||||
| `--db` | `KEZ_CHAT_DB` | `kez-chat.db` |
|
||||
| `--server` | `KEZ_CHAT_SERVER` | `kez.lat` |
|
||||
| `--sig-server-url` | `KEZ_CHAT_SIG_SERVER_URL` | `http://localhost:7878` |
|
||||
| `--web-dir` | `KEZ_CHAT_WEB_DIR` | _(unset → placeholder page)_ |
|
||||
|
||||
Logging: `RUST_LOG=debug,hyper=info` etc.
|
||||
|
||||
## Quick start (Docker compose, full stack)
|
||||
|
||||
```sh
|
||||
cd deploy
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Brings up three services:
|
||||
|
||||
| Service | Port(s) | What it does |
|
||||
|---|---|---|
|
||||
| `chat-server` | 6969 | HTTP API + SPA |
|
||||
| `nats` | 4222 (native), 8443 (WebSocket), 8222 (monitoring) | Dumb broker, JetStream enabled |
|
||||
| `sig-server` | 7878 | Sigchain storage (the existing `rust-sig-server`) |
|
||||
|
||||
Then point a reverse proxy / Cloudflare tunnel at `localhost:6969`.
|
||||
|
||||
## Testing
|
||||
|
||||
```sh
|
||||
cargo test # 13 integration tests (real server, real HTTP)
|
||||
```
|
||||
|
||||
The tests stand up the router on a random local port and exercise it
|
||||
via `reqwest`. No mocks. They cover: healthz, lookup, registration
|
||||
(success + duplicate + wrong-server + reserved-name + tampered-sig +
|
||||
stale-timestamp), WebFinger, the placeholder SPA, and the NATS auth
|
||||
callout stub.
|
||||
|
||||
## Endpoints in detail
|
||||
|
||||
### `GET /v1/healthz`
|
||||
|
||||
```json
|
||||
{ "status": "ok", "server": "kez.lat", "version": "0.1.0" }
|
||||
```
|
||||
|
||||
### `GET /v1/u/:handle`
|
||||
|
||||
Returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"handle": "tudisco",
|
||||
"fqhn": "tudisco@kez.lat",
|
||||
"primary": "ed25519:2152f8d19b...",
|
||||
"sigchain_url": "https://sig.kez.lat/v1/sigchains/ed25519/2152f8d19b...",
|
||||
"registered_at": "2026-05-25T03:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Returns 404 if the handle isn't registered.
|
||||
|
||||
### `POST /v1/register`
|
||||
|
||||
Request body — a signed registration envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"kez": "handle_registration",
|
||||
"payload": {
|
||||
"type": "kez.chat.handle_registration",
|
||||
"version": 1,
|
||||
"handle": "tudisco",
|
||||
"primary": "ed25519:2152f8d19b...",
|
||||
"server": "kez.lat",
|
||||
"created_at": "2026-05-25T03:00:00Z"
|
||||
},
|
||||
"signature": {
|
||||
"alg": "ed25519-sha512-jcs",
|
||||
"key": "ed25519:2152f8d19b...",
|
||||
"sig": "<128-char-hex>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Server validates:
|
||||
1. Envelope tag is `"handle_registration"`
|
||||
2. Payload type is `"kez.chat.handle_registration"`, version 1
|
||||
3. `signature.key` equals `payload.primary`
|
||||
4. Signature verifies against the primary key (Ed25519 only for chat)
|
||||
5. `payload.server` matches this server's configured domain
|
||||
6. `payload.handle` passes validation (length 3-32, `a-z0-9_-`,
|
||||
starts with letter/digit, not in reserved list)
|
||||
7. `payload.created_at` is within 5 minutes of server time
|
||||
|
||||
On success: `201 Created` with the same body as `GET /v1/u/:handle`.
|
||||
|
||||
### `GET /.well-known/webfinger?resource=acct:user@server`
|
||||
|
||||
Standard fediverse-style discovery. Returns the user's KEZ identity
|
||||
info as a WebFinger JRD. Used by other servers (federated lookup,
|
||||
future) and by tools like fediverse browsers.
|
||||
|
||||
### `POST /internal/nats/auth`
|
||||
|
||||
NATS auth callout endpoint. **Stub in v0.1** — returns 501. The real
|
||||
implementation (v0.2) will: parse the NATS auth request JWT, extract
|
||||
the connecting client's nkey, look up the corresponding handle, sign
|
||||
a response permitting `kez.inbox.<pubkey>.>` subjects.
|
||||
|
||||
## Deployment notes
|
||||
|
||||
- The `chat-server` Docker image is built from the **repo root** as
|
||||
context (so it can copy `rust/crates/kez-core` for the path dep).
|
||||
`docker-compose.yml` sets this correctly.
|
||||
- The `sig-server` is the existing [`../rust-sig-server`](../rust-sig-server/)
|
||||
binary, built into a separate image via `Dockerfile.sig-server`.
|
||||
- NATS config (`nats.conf`) has WebSocket enabled on port 8443 so the
|
||||
browser SPA can connect via `nats.ws`. The `issuer` field in
|
||||
`auth_callout` is a placeholder — generate a real nkey and replace
|
||||
before going to production.
|
||||
- TLS is **not** handled by this binary. Put a reverse proxy (Caddy,
|
||||
nginx, Cloudflare tunnel) in front for HTTPS.
|
||||
|
||||
## License
|
||||
|
||||
Dual-licensed under MIT or Apache-2.0.
|
||||
42
kez-chat/deploy/Dockerfile
Normal file
42
kez-chat/deploy/Dockerfile
Normal file
@ -0,0 +1,42 @@
|
||||
# Multi-stage build for kez-chat-server.
|
||||
#
|
||||
# Stage 1: build the Rust binary against kez-core (path dep). The build
|
||||
# context must be the *repository root* (the dir that contains both
|
||||
# `kez-chat/` and `rust/`), not `kez-chat/` itself — see the
|
||||
# `docker-compose.yml` which sets `context: ..`.
|
||||
|
||||
FROM rust:1.86-slim AS build
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config libssl-dev ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /src
|
||||
|
||||
# Copy what we need:
|
||||
# - rust/crates/kez-core (path dep)
|
||||
# - kez-chat (this project)
|
||||
COPY rust/ /src/rust/
|
||||
COPY kez-chat/ /src/kez-chat/
|
||||
|
||||
WORKDIR /src/kez-chat
|
||||
RUN cargo build --release --bin kez-chat-server
|
||||
|
||||
# Stage 2: minimal runtime image
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd -r -u 10001 -m kez
|
||||
|
||||
COPY --from=build /src/kez-chat/target/release/kez-chat-server /usr/local/bin/kez-chat-server
|
||||
|
||||
USER kez
|
||||
WORKDIR /data
|
||||
|
||||
ENV KEZ_CHAT_BIND=0.0.0.0:6969 \
|
||||
KEZ_CHAT_DB=/data/kez-chat.db \
|
||||
KEZ_CHAT_SERVER=kez.lat \
|
||||
KEZ_CHAT_SIG_SERVER_URL=http://sig-server:7878 \
|
||||
RUST_LOG=info
|
||||
|
||||
EXPOSE 6969
|
||||
ENTRYPOINT ["/usr/local/bin/kez-chat-server"]
|
||||
33
kez-chat/deploy/Dockerfile.sig-server
Normal file
33
kez-chat/deploy/Dockerfile.sig-server
Normal file
@ -0,0 +1,33 @@
|
||||
# Sibling Dockerfile that builds rust-sig-server out of the same repo
|
||||
# checkout. Compose uses this for the `sig-server` service so the whole
|
||||
# stack comes from one git pull. Context must be the repository root.
|
||||
|
||||
FROM rust:1.86-slim AS build
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config libssl-dev ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /src
|
||||
|
||||
COPY rust/ /src/rust/
|
||||
COPY rust-sig-server/ /src/rust-sig-server/
|
||||
|
||||
WORKDIR /src/rust-sig-server
|
||||
RUN cargo build --release --bin kez-sig-server
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd -r -u 10002 -m kez
|
||||
|
||||
COPY --from=build /src/rust-sig-server/target/release/kez-sig-server /usr/local/bin/kez-sig-server
|
||||
|
||||
USER kez
|
||||
WORKDIR /data
|
||||
|
||||
ENV KEZ_BIND=0.0.0.0:7878 \
|
||||
KEZ_DB=/data/sigchains.db \
|
||||
RUST_LOG=info
|
||||
|
||||
EXPOSE 7878
|
||||
ENTRYPOINT ["/usr/local/bin/kez-sig-server"]
|
||||
66
kez-chat/deploy/docker-compose.yml
Normal file
66
kez-chat/deploy/docker-compose.yml
Normal file
@ -0,0 +1,66 @@
|
||||
# kez-chat home server stack.
|
||||
#
|
||||
# Three services:
|
||||
# - nats dumb broker, JetStream enabled, WebSocket on 8443
|
||||
# - chat-server handle registry + NATS auth callout + serves the SPA
|
||||
# - sig-server sigchain HTTP store (existing rust-sig-server)
|
||||
#
|
||||
# Run from this dir: docker compose up -d --build
|
||||
# Build context for the Rust services is `..` (the repo root) so they
|
||||
# can pull in `rust/crates/kez-core` as a path dep.
|
||||
#
|
||||
# In production you'll terminate TLS at a reverse proxy (Caddy, nginx,
|
||||
# or a Cloudflare tunnel) in front of port 6969 (HTTP) and the NATS
|
||||
# listeners. The compose file itself binds to plain HTTP for simplicity.
|
||||
|
||||
services:
|
||||
nats:
|
||||
image: nats:latest
|
||||
command:
|
||||
- "-c"
|
||||
- "/etc/nats/nats.conf"
|
||||
- "--jetstream"
|
||||
volumes:
|
||||
- ./nats.conf:/etc/nats/nats.conf:ro
|
||||
- nats-data:/data
|
||||
ports:
|
||||
- "4222:4222" # native NATS (CLI clients)
|
||||
- "8443:8443" # WebSocket (browser SPA)
|
||||
- "8222:8222" # monitoring
|
||||
restart: unless-stopped
|
||||
|
||||
chat-server:
|
||||
build:
|
||||
context: .. # repo root, so Dockerfile sees rust/ and kez-chat/
|
||||
dockerfile: kez-chat/deploy/Dockerfile
|
||||
environment:
|
||||
KEZ_CHAT_BIND: 0.0.0.0:6969
|
||||
KEZ_CHAT_DB: /data/kez-chat.db
|
||||
KEZ_CHAT_SERVER: kez.lat
|
||||
KEZ_CHAT_SIG_SERVER_URL: http://sig-server:7878
|
||||
RUST_LOG: info
|
||||
volumes:
|
||||
- chat-data:/data
|
||||
ports:
|
||||
- "6969:6969" # HTTP API + SPA (Cloudflare tunnel terminates here)
|
||||
depends_on: [sig-server]
|
||||
restart: unless-stopped
|
||||
|
||||
sig-server:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: kez-chat/deploy/Dockerfile.sig-server
|
||||
environment:
|
||||
KEZ_BIND: 0.0.0.0:7878
|
||||
KEZ_DB: /data/sigchains.db
|
||||
RUST_LOG: info
|
||||
volumes:
|
||||
- sig-data:/data
|
||||
ports:
|
||||
- "7878:7878" # exposed for direct client fetches of sigchains
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
nats-data:
|
||||
chat-data:
|
||||
sig-data:
|
||||
51
kez-chat/deploy/nats.conf
Normal file
51
kez-chat/deploy/nats.conf
Normal file
@ -0,0 +1,51 @@
|
||||
# NATS config for kez-chat home server.
|
||||
#
|
||||
# - Native NATS protocol on 4222 for CLI clients (TLS terminated by your
|
||||
# reverse proxy in production).
|
||||
# - WebSocket on 8443 for the browser SPA. Also TLS-terminated upstream.
|
||||
# - JetStream on for offline message buffering (durable consumers).
|
||||
# - auth_callout points at our chat-server's /internal/nats/auth endpoint.
|
||||
# The chat-server is the source of truth for which nkeys are allowed
|
||||
# to connect and what subjects they can publish/subscribe to.
|
||||
|
||||
# Standard NATS listener (CLI clients use this).
|
||||
listen: 0.0.0.0:4222
|
||||
|
||||
# WebSocket listener (browser SPA uses this via nats.ws).
|
||||
websocket {
|
||||
port: 8443
|
||||
no_tls: true # TLS terminated by Cloudflare tunnel / reverse proxy
|
||||
}
|
||||
|
||||
# Persistent storage for durable consumers (offline buffering).
|
||||
jetstream {
|
||||
store_dir: /data/jetstream
|
||||
max_mem: 1G
|
||||
max_file: 10G
|
||||
}
|
||||
|
||||
# Monitoring / healthcheck.
|
||||
http_port: 8222
|
||||
|
||||
# Auth callout: every connection's auth request is forwarded to our
|
||||
# chat-server, which checks the handle registry and signs a response.
|
||||
# Until we ship the v0.2 auth callout, the chat-server returns 501 and
|
||||
# all connections are rejected. That's intentional — fail closed.
|
||||
authorization {
|
||||
auth_callout {
|
||||
# The chat-server signs its callout responses with this nkey; NATS
|
||||
# accepts responses signed by this key only. Generated once via
|
||||
# `nsc generate nkey -o` (operator-level) and embedded in the
|
||||
# chat-server's deployment secrets.
|
||||
#
|
||||
# PLACEHOLDER — replace before going live.
|
||||
issuer: "ABACVOI4POPS3SBFLDQYTQHHHACRVMCM2HK7PXX4UTI7XYWQHQGOA3PX"
|
||||
|
||||
# NATS uses this user identity when invoking the callout endpoint.
|
||||
# Distinct from real users; it's just an internal protocol marker.
|
||||
auth_users: ["AUTHUSER"]
|
||||
|
||||
# The account real users land in once the callout approves them.
|
||||
account: "DEFAULT"
|
||||
}
|
||||
}
|
||||
269
kez-chat/src/api.rs
Normal file
269
kez-chat/src/api.rs
Normal file
@ -0,0 +1,269 @@
|
||||
//! HTTP API routes — all in one file for v0.1 since each route is small.
|
||||
//!
|
||||
//! GET / placeholder SPA (or web_dir)
|
||||
//! GET /v1/healthz liveness
|
||||
//! GET /v1/u/:handle handle → primary + sigchain pointer + endpoints
|
||||
//! POST /v1/register claim a handle (signed body)
|
||||
//! GET /.well-known/webfinger?resource=... fediverse-style discovery
|
||||
//! POST /internal/nats/auth NATS auth callout (stub in v0.1)
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{StatusCode, header};
|
||||
use axum::response::{Html, IntoResponse};
|
||||
use axum::routing::{get, post};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::error::ApiError;
|
||||
use crate::handles::validate_handle;
|
||||
use crate::registration::SignedRegistration;
|
||||
use crate::store::{HandleRecord, Store};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub store: Store,
|
||||
pub config: Config,
|
||||
}
|
||||
|
||||
pub fn router(state: AppState) -> axum::Router {
|
||||
let web_dir = state.config.web_dir.clone();
|
||||
|
||||
// Build the router with all API routes first, attach the SPA fallback,
|
||||
// then apply state at the end (axum requires all routes to be added
|
||||
// before `with_state` is called).
|
||||
let mut router = axum::Router::new()
|
||||
.route("/v1/healthz", get(healthz))
|
||||
.route("/v1/u/:handle", get(lookup))
|
||||
.route("/v1/register", post(register))
|
||||
.route("/.well-known/webfinger", get(webfinger))
|
||||
.route("/internal/nats/auth", post(nats_auth_callout));
|
||||
|
||||
router = if let Some(dir) = web_dir {
|
||||
// Real SPA build dir provided; ServeDir handles index.html + assets.
|
||||
router.fallback_service(ServeDir::new(dir))
|
||||
} else {
|
||||
// No SPA dir; serve a built-in placeholder page at `/`.
|
||||
router.route("/", get(placeholder_index))
|
||||
};
|
||||
|
||||
router.with_state(state)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// healthz
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn healthz(State(state): State<AppState>) -> Json<Value> {
|
||||
Json(json!({
|
||||
"status": "ok",
|
||||
"server": state.config.server,
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
}))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GET /v1/u/:handle — handle lookup
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct HandleResponse {
|
||||
pub handle: String, // bare local-part: "tudisco"
|
||||
pub fqhn: String, // fully qualified: "tudisco@kez.lat"
|
||||
pub primary: String, // e.g. "ed25519:abc..."
|
||||
pub sigchain_url: String, // where the sigchain lives
|
||||
pub registered_at: String,
|
||||
}
|
||||
|
||||
async fn lookup(
|
||||
State(state): State<AppState>,
|
||||
Path(handle): Path<String>,
|
||||
) -> Result<Json<HandleResponse>, ApiError> {
|
||||
let record = state
|
||||
.store
|
||||
.lookup(&handle)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
Ok(Json(handle_response(&state.config, &record)))
|
||||
}
|
||||
|
||||
fn handle_response(config: &Config, record: &HandleRecord) -> HandleResponse {
|
||||
let scheme = record.primary.scheme();
|
||||
let id = record.primary.value();
|
||||
HandleResponse {
|
||||
handle: record.handle.clone(),
|
||||
fqhn: format!("{}@{}", record.handle, config.server),
|
||||
primary: record.primary.to_string(),
|
||||
sigchain_url: format!(
|
||||
"{}/v1/sigchains/{}/{}",
|
||||
config.sig_server_url.trim_end_matches('/'),
|
||||
scheme,
|
||||
id
|
||||
),
|
||||
registered_at: record.registered_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// POST /v1/register — claim a handle
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn register(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<SignedRegistration>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
// Format-level validation (envelope, signature)
|
||||
req.verify_format()?;
|
||||
|
||||
// Semantic checks
|
||||
if req.payload.server != state.config.server {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"registration server {:?} does not match this server {:?}",
|
||||
req.payload.server, state.config.server
|
||||
)));
|
||||
}
|
||||
validate_handle(&req.payload.handle)?;
|
||||
req.check_timestamp(Utc::now())?;
|
||||
|
||||
let record = HandleRecord {
|
||||
handle: req.payload.handle.clone(),
|
||||
primary: req.payload.primary.clone(),
|
||||
registered_at: Utc::now(),
|
||||
};
|
||||
state.store.register(&record).await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(handle_response(&state.config, &record)),
|
||||
))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GET /.well-known/webfinger — fediverse-style discovery
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WebfingerQuery {
|
||||
resource: String,
|
||||
}
|
||||
|
||||
async fn webfinger(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<WebfingerQuery>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
// Accept `acct:user@server` per RFC 7565.
|
||||
let resource = q
|
||||
.resource
|
||||
.strip_prefix("acct:")
|
||||
.ok_or_else(|| ApiError::BadRequest("resource must start with `acct:`".into()))?;
|
||||
let (handle, server) = resource
|
||||
.split_once('@')
|
||||
.ok_or_else(|| ApiError::BadRequest("resource must be `acct:user@server`".into()))?;
|
||||
if server != state.config.server {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
let record = state.store.lookup(handle).await?.ok_or(ApiError::NotFound)?;
|
||||
let resp = handle_response(&state.config, &record);
|
||||
|
||||
let body = json!({
|
||||
"subject": format!("acct:{}", resp.fqhn),
|
||||
"links": [
|
||||
{
|
||||
"rel": "https://kez.example/spec/v1/handle",
|
||||
"type": "application/json",
|
||||
"href": format!("https://{}/v1/u/{}", state.config.server, handle),
|
||||
},
|
||||
{
|
||||
"rel": "https://kez.example/spec/v1/sigchain",
|
||||
"type": "application/jsonl",
|
||||
"href": resp.sigchain_url,
|
||||
}
|
||||
]
|
||||
});
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "application/jrd+json")],
|
||||
Json(body),
|
||||
))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// POST /internal/nats/auth — NATS auth callout (stub for v0.1)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// In v0.2 this will: parse the NATS auth request JWT, extract the
|
||||
// client's nkey, look it up in the handle registry, sign a response
|
||||
// JWT granting permissions to `kez.inbox.<pubkey>.>` and reject if
|
||||
// not found. For now it returns 501 so misconfigured NATS deployments
|
||||
// fail loudly instead of silently allowing everyone.
|
||||
|
||||
async fn nats_auth_callout(
|
||||
State(_state): State<AppState>,
|
||||
Json(_body): Json<Value>,
|
||||
) -> impl IntoResponse {
|
||||
(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
Json(json!({
|
||||
"error": {
|
||||
"code": "not_implemented",
|
||||
"message": "NATS auth callout will be wired up in v0.2"
|
||||
}
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Placeholder SPA — until we ship the real Svelte build
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn placeholder_index(State(state): State<AppState>) -> Html<String> {
|
||||
Html(format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>kez-chat — {server}</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||
max-width: 640px;
|
||||
margin: 4rem auto;
|
||||
padding: 0 1rem;
|
||||
color: #222;
|
||||
line-height: 1.5;
|
||||
}}
|
||||
h1 {{ margin-bottom: 0.5rem; }}
|
||||
code {{ background: #f3f3f3; padding: 0.15rem 0.3rem; border-radius: 3px; }}
|
||||
.api {{ font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 0.9rem; }}
|
||||
.api li {{ margin: 0.3rem 0; }}
|
||||
footer {{ margin-top: 3rem; color: #888; font-size: 0.85rem; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>kez-chat</h1>
|
||||
<p>Home server for <code>username@{server}</code>.</p>
|
||||
|
||||
<p>The Svelte web app isn't built yet — this is the placeholder. The HTTP API
|
||||
is up though:</p>
|
||||
|
||||
<ul class="api">
|
||||
<li><code>GET /v1/healthz</code></li>
|
||||
<li><code>GET /v1/u/<handle></code></li>
|
||||
<li><code>POST /v1/register</code></li>
|
||||
<li><code>GET /.well-known/webfinger?resource=acct:user@{server}</code></li>
|
||||
</ul>
|
||||
|
||||
<p>See <a href="https://git.ptud.biz/DukeInc/Kez">the project repo</a>
|
||||
for the design doc and progress.</p>
|
||||
|
||||
<footer>kez-chat-server v{version} — server: <code>{server}</code></footer>
|
||||
</body>
|
||||
</html>"#,
|
||||
server = state.config.server,
|
||||
version = env!("CARGO_PKG_VERSION"),
|
||||
))
|
||||
}
|
||||
|
||||
40
kez-chat/src/config.rs
Normal file
40
kez-chat/src/config.rs
Normal file
@ -0,0 +1,40 @@
|
||||
//! Runtime configuration: HTTP bind, DB path, server domain, sig-server
|
||||
//! URL. Read from CLI flags and/or environment variables.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Debug, Parser, Clone)]
|
||||
#[command(name = "kez-chat-server")]
|
||||
#[command(about = "KEZ chat home server — handle registry + NATS auth + static SPA")]
|
||||
pub struct Config {
|
||||
/// HTTP bind address.
|
||||
#[arg(long, env = "KEZ_CHAT_BIND", default_value = "0.0.0.0:6969")]
|
||||
pub bind: SocketAddr,
|
||||
|
||||
/// SQLite database file for the handle registry.
|
||||
#[arg(long, env = "KEZ_CHAT_DB", default_value = "kez-chat.db")]
|
||||
pub db: PathBuf,
|
||||
|
||||
/// This server's domain. Handles registered here belong to
|
||||
/// `<handle>@<server>`. Used to validate registrations and
|
||||
/// answer WebFinger queries.
|
||||
#[arg(long, env = "KEZ_CHAT_SERVER", default_value = "kez.lat")]
|
||||
pub server: String,
|
||||
|
||||
/// Base URL of the sig-server users should publish their sigchains to.
|
||||
/// Returned in handle-lookup responses so clients know where to fetch.
|
||||
#[arg(
|
||||
long,
|
||||
env = "KEZ_CHAT_SIG_SERVER_URL",
|
||||
default_value = "http://localhost:7878"
|
||||
)]
|
||||
pub sig_server_url: String,
|
||||
|
||||
/// Optional directory of static files to serve at `/` (the SPA build
|
||||
/// output). If unset, `/` serves a built-in placeholder page.
|
||||
#[arg(long, env = "KEZ_CHAT_WEB_DIR")]
|
||||
pub web_dir: Option<PathBuf>,
|
||||
}
|
||||
75
kez-chat/src/error.rs
Normal file
75
kez-chat/src/error.rs
Normal file
@ -0,0 +1,75 @@
|
||||
//! Structured API errors → JSON responses.
|
||||
|
||||
use axum::Json;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use kez_core::KezError;
|
||||
use serde_json::json;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ApiError {
|
||||
#[error("not found")]
|
||||
NotFound,
|
||||
#[error("bad request: {0}")]
|
||||
BadRequest(String),
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
#[error("forbidden: {0}")]
|
||||
Forbidden(String),
|
||||
#[error("internal: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
fn status(&self) -> StatusCode {
|
||||
match self {
|
||||
ApiError::NotFound => StatusCode::NOT_FOUND,
|
||||
ApiError::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||
ApiError::Conflict(_) => StatusCode::CONFLICT,
|
||||
ApiError::Forbidden(_) => StatusCode::FORBIDDEN,
|
||||
ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
fn code(&self) -> &'static str {
|
||||
match self {
|
||||
ApiError::NotFound => "not_found",
|
||||
ApiError::BadRequest(_) => "bad_request",
|
||||
ApiError::Conflict(_) => "conflict",
|
||||
ApiError::Forbidden(_) => "forbidden",
|
||||
ApiError::Internal(_) => "internal",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = self.status();
|
||||
let body = Json(json!({
|
||||
"error": {
|
||||
"code": self.code(),
|
||||
"message": self.to_string(),
|
||||
}
|
||||
}));
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<KezError> for ApiError {
|
||||
fn from(e: KezError) -> Self {
|
||||
ApiError::BadRequest(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for ApiError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
ApiError::Internal(format!("db: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for ApiError {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
ApiError::BadRequest(format!("json: {e}"))
|
||||
}
|
||||
}
|
||||
85
kez-chat/src/handles.rs
Normal file
85
kez-chat/src/handles.rs
Normal file
@ -0,0 +1,85 @@
|
||||
//! Handle validation. Handles look like email local-parts: short,
|
||||
//! lowercase, restricted charset, must not collide with reserved names.
|
||||
|
||||
use crate::error::ApiError;
|
||||
|
||||
/// Names we never let users register (system / role / well-known).
|
||||
/// Conservative starter list; operators can extend.
|
||||
const RESERVED: &[&str] = &[
|
||||
"admin", "administrator", "root", "system", "api", "internal",
|
||||
"kez", "support", "help", "abuse", "postmaster", "noreply",
|
||||
"no-reply", "mailer-daemon", "webmaster", "hostmaster",
|
||||
"www", "ftp", "mail", "smtp", "imap", "pop3",
|
||||
"everyone", "all", "anyone", "nobody",
|
||||
];
|
||||
|
||||
pub fn validate_handle(handle: &str) -> Result<(), ApiError> {
|
||||
if handle.len() < 3 {
|
||||
return Err(ApiError::BadRequest("handle must be at least 3 chars".into()));
|
||||
}
|
||||
if handle.len() > 32 {
|
||||
return Err(ApiError::BadRequest("handle must be at most 32 chars".into()));
|
||||
}
|
||||
let bytes = handle.as_bytes();
|
||||
let first = bytes[0];
|
||||
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
|
||||
return Err(ApiError::BadRequest(
|
||||
"handle must start with a lowercase letter or digit".into(),
|
||||
));
|
||||
}
|
||||
for &b in bytes {
|
||||
let ok = b.is_ascii_lowercase()
|
||||
|| b.is_ascii_digit()
|
||||
|| b == b'-'
|
||||
|| b == b'_';
|
||||
if !ok {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"handle contains invalid character: {:?}",
|
||||
b as char
|
||||
)));
|
||||
}
|
||||
}
|
||||
if RESERVED.contains(&handle) {
|
||||
return Err(ApiError::Forbidden(format!("handle is reserved: {handle}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn accepts_normal_handles() {
|
||||
for h in &["tudisco", "chris", "alice", "user_123", "ab-cd", "a1b2c3"] {
|
||||
assert!(validate_handle(h).is_ok(), "expected ok: {h}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_short_or_long() {
|
||||
assert!(validate_handle("ab").is_err());
|
||||
assert!(validate_handle(&"a".repeat(33)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_chars() {
|
||||
for h in &["Tudisco", "ali.ce", "user@name", "name space", "emo😀"] {
|
||||
assert!(validate_handle(h).is_err(), "expected err: {h}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_first_char() {
|
||||
for h in &["-name", "_name"] {
|
||||
assert!(validate_handle(h).is_err(), "expected err: {h}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_reserved() {
|
||||
for h in &["admin", "root", "kez", "noreply"] {
|
||||
assert!(matches!(validate_handle(h), Err(ApiError::Forbidden(_))));
|
||||
}
|
||||
}
|
||||
}
|
||||
14
kez-chat/src/lib.rs
Normal file
14
kez-chat/src/lib.rs
Normal file
@ -0,0 +1,14 @@
|
||||
//! Library crate so integration tests can drive the same router the
|
||||
//! binary serves.
|
||||
|
||||
pub mod api;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod handles;
|
||||
pub mod registration;
|
||||
pub mod store;
|
||||
|
||||
pub use api::{AppState, router};
|
||||
pub use config::Config;
|
||||
pub use error::ApiError;
|
||||
pub use store::Store;
|
||||
51
kez-chat/src/main.rs
Normal file
51
kez-chat/src/main.rs
Normal file
@ -0,0 +1,51 @@
|
||||
//! Binary entry: parse config, open DB, build router, serve.
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use kez_chat_server::{AppState, Config, Store, router};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let config = Config::parse();
|
||||
tracing::info!(
|
||||
bind = %config.bind,
|
||||
db = ?config.db,
|
||||
server = %config.server,
|
||||
sig_server_url = %config.sig_server_url,
|
||||
web_dir = ?config.web_dir,
|
||||
"starting kez-chat-server"
|
||||
);
|
||||
|
||||
let store = Store::open(&config.db)?;
|
||||
let state = AppState { store, config: config.clone() };
|
||||
|
||||
let app = router(state)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any),
|
||||
);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(config.bind).await?;
|
||||
tracing::info!(addr = %config.bind, "kez-chat-server listening");
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
tracing::info!("shutdown signal received");
|
||||
}
|
||||
89
kez-chat/src/registration.rs
Normal file
89
kez-chat/src/registration.rs
Normal file
@ -0,0 +1,89 @@
|
||||
//! Handle-registration request shape.
|
||||
//!
|
||||
//! A client requesting a handle constructs a [`RegistrationPayload`],
|
||||
//! signs it with their KEZ primary key using the same JCS-canonical
|
||||
//! envelope KEZ uses everywhere else, and POSTs the [`SignedRegistration`]
|
||||
//! to `/v1/register`. The server validates the signature with
|
||||
//! `kez_core::verify_envelope`, then checks the rest of the request
|
||||
//! semantically (server matches, handle is allowed, timestamp window).
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use kez_core::{Identity, SignatureBlock, verify_envelope};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::ApiError;
|
||||
|
||||
pub const REGISTRATION_TYPE: &str = "kez.chat.handle_registration";
|
||||
pub const ENVELOPE_TAG: &str = "handle_registration";
|
||||
pub const FORMAT_VERSION: u8 = 1;
|
||||
|
||||
/// Max allowed clock skew between client and server for a registration
|
||||
/// timestamp. Prevents replay of stale signed requests.
|
||||
pub const MAX_CLOCK_SKEW: Duration = Duration::minutes(5);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RegistrationPayload {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub version: u8,
|
||||
pub handle: String,
|
||||
pub primary: Identity,
|
||||
pub server: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SignedRegistration {
|
||||
pub kez: String,
|
||||
pub payload: RegistrationPayload,
|
||||
pub signature: SignatureBlock,
|
||||
}
|
||||
|
||||
impl SignedRegistration {
|
||||
/// Run the format-level checks: envelope tag, payload type, version,
|
||||
/// signature.key matches payload.primary, signature verifies.
|
||||
/// Semantic checks (server match, handle allowed, clock skew) happen
|
||||
/// in the route handler with access to `Config` and the registry.
|
||||
pub fn verify_format(&self) -> Result<(), ApiError> {
|
||||
if self.kez != ENVELOPE_TAG {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"envelope tag must be \"{ENVELOPE_TAG}\", got: {:?}",
|
||||
self.kez
|
||||
)));
|
||||
}
|
||||
if self.payload.kind != REGISTRATION_TYPE {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"payload type must be \"{REGISTRATION_TYPE}\", got: {:?}",
|
||||
self.payload.kind
|
||||
)));
|
||||
}
|
||||
if self.payload.version != FORMAT_VERSION {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"unsupported payload version: {}",
|
||||
self.payload.version
|
||||
)));
|
||||
}
|
||||
if self.signature.key != self.payload.primary {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"signature.key ({}) does not match payload.primary ({})",
|
||||
self.signature.key, self.payload.primary
|
||||
)));
|
||||
}
|
||||
verify_envelope(&self.payload, &self.signature)
|
||||
.map_err(|e| ApiError::BadRequest(format!("signature: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Confirm the timestamp is fresh enough — guards against replay of
|
||||
/// old signed requests.
|
||||
pub fn check_timestamp(&self, now: DateTime<Utc>) -> Result<(), ApiError> {
|
||||
let drift = (now - self.payload.created_at).num_seconds().abs();
|
||||
if drift > MAX_CLOCK_SKEW.num_seconds() {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"created_at is {drift}s from server time; must be within {}s",
|
||||
MAX_CLOCK_SKEW.num_seconds()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
157
kez-chat/src/store.rs
Normal file
157
kez-chat/src/store.rs
Normal file
@ -0,0 +1,157 @@
|
||||
//! SQLite-backed handle registry.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use kez_core::Identity;
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::error::ApiError;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HandleRecord {
|
||||
pub handle: String,
|
||||
pub primary: Identity,
|
||||
pub registered_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Store {
|
||||
inner: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn open(path: &Path) -> Result<Self, rusqlite::Error> {
|
||||
let conn = Connection::open(path)?;
|
||||
init_schema(&conn)?;
|
||||
Ok(Self {
|
||||
inner: Arc::new(Mutex::new(conn)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn open_in_memory() -> Result<Self, rusqlite::Error> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
init_schema(&conn)?;
|
||||
Ok(Self {
|
||||
inner: Arc::new(Mutex::new(conn)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Reserve a handle for a primary key. Fails with Conflict if the
|
||||
/// handle is already taken, or if this primary key has already
|
||||
/// registered a (different) handle.
|
||||
pub async fn register(&self, record: &HandleRecord) -> Result<(), ApiError> {
|
||||
let conn = self.inner.lock().await;
|
||||
conn.execute(
|
||||
"INSERT INTO handles (handle, primary_id, registered_at)
|
||||
VALUES (?1, ?2, ?3)",
|
||||
params![
|
||||
record.handle,
|
||||
record.primary.to_string(),
|
||||
record.registered_at.to_rfc3339(),
|
||||
],
|
||||
)
|
||||
.map_err(|e| match e {
|
||||
rusqlite::Error::SqliteFailure(err, _)
|
||||
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
|
||||
{
|
||||
ApiError::Conflict("handle is already taken".into())
|
||||
}
|
||||
other => ApiError::Internal(format!("db: {other}")),
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Look up the record for `handle`. Returns None if not registered.
|
||||
pub async fn lookup(&self, handle: &str) -> Result<Option<HandleRecord>, ApiError> {
|
||||
let conn = self.inner.lock().await;
|
||||
let row = conn
|
||||
.query_row(
|
||||
"SELECT handle, primary_id, registered_at
|
||||
FROM handles WHERE handle = ?1",
|
||||
params![handle],
|
||||
|row| {
|
||||
let handle: String = row.get(0)?;
|
||||
let primary_id: String = row.get(1)?;
|
||||
let registered_at: String = row.get(2)?;
|
||||
Ok((handle, primary_id, registered_at))
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
match row {
|
||||
None => Ok(None),
|
||||
Some((handle, primary_id, registered_at)) => {
|
||||
let primary = Identity::parse(primary_id).map_err(|e| {
|
||||
ApiError::Internal(format!("stored primary not parseable: {e}"))
|
||||
})?;
|
||||
let registered_at = DateTime::parse_from_rfc3339(®istered_at)
|
||||
.map_err(|e| {
|
||||
ApiError::Internal(format!("stored timestamp not parseable: {e}"))
|
||||
})?
|
||||
.with_timezone(&Utc);
|
||||
Ok(Some(HandleRecord {
|
||||
handle,
|
||||
primary,
|
||||
registered_at,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up the record for a primary key — used by the NATS auth
|
||||
/// callout: NATS sends us a connecting client's nkey, we figure out
|
||||
/// which handle (if any) owns it.
|
||||
pub async fn lookup_by_primary(
|
||||
&self,
|
||||
primary: &Identity,
|
||||
) -> Result<Option<HandleRecord>, ApiError> {
|
||||
let conn = self.inner.lock().await;
|
||||
let row = conn
|
||||
.query_row(
|
||||
"SELECT handle, primary_id, registered_at
|
||||
FROM handles WHERE primary_id = ?1",
|
||||
params![primary.to_string()],
|
||||
|row| {
|
||||
let handle: String = row.get(0)?;
|
||||
let primary_id: String = row.get(1)?;
|
||||
let registered_at: String = row.get(2)?;
|
||||
Ok((handle, primary_id, registered_at))
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
match row {
|
||||
None => Ok(None),
|
||||
Some((handle, primary_id, registered_at)) => {
|
||||
let primary = Identity::parse(primary_id).map_err(|e| {
|
||||
ApiError::Internal(format!("stored primary not parseable: {e}"))
|
||||
})?;
|
||||
let registered_at = DateTime::parse_from_rfc3339(®istered_at)
|
||||
.map_err(|e| {
|
||||
ApiError::Internal(format!("stored timestamp not parseable: {e}"))
|
||||
})?
|
||||
.with_timezone(&Utc);
|
||||
Ok(Some(HandleRecord {
|
||||
handle,
|
||||
primary,
|
||||
registered_at,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn init_schema(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS handles (
|
||||
handle TEXT NOT NULL PRIMARY KEY,
|
||||
primary_id TEXT NOT NULL UNIQUE,
|
||||
registered_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_handles_primary
|
||||
ON handles (primary_id);",
|
||||
)
|
||||
}
|
||||
315
kez-chat/tests/http.rs
Normal file
315
kez-chat/tests/http.rs
Normal file
@ -0,0 +1,315 @@
|
||||
//! Integration tests: stand up the real router on a random local port,
|
||||
//! drive it with `reqwest`. No mocks — exercises the full HTTP + SQLite +
|
||||
//! kez-core signature path.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use kez_chat_server::{AppState, Config, Store, router};
|
||||
use kez_chat_server::registration::{
|
||||
ENVELOPE_TAG, FORMAT_VERSION, REGISTRATION_TYPE, RegistrationPayload, SignedRegistration,
|
||||
};
|
||||
use kez_core::{
|
||||
Ed25519Secret, Identity, SignatureBlock, ED25519_SHA512_ALG, canonical_bytes,
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
struct TestServer {
|
||||
base: String,
|
||||
#[allow(dead_code)]
|
||||
handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
async fn spawn_server() -> TestServer {
|
||||
spawn_server_with_config(default_config()).await
|
||||
}
|
||||
|
||||
fn default_config() -> Config {
|
||||
Config {
|
||||
bind: SocketAddr::from(([127, 0, 0, 1], 0)),
|
||||
db: PathBuf::from(":memory:"), // unused (we open in-memory below)
|
||||
server: "kez.test".to_owned(),
|
||||
sig_server_url: "http://sig.test".to_owned(),
|
||||
web_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn spawn_server_with_config(config: Config) -> TestServer {
|
||||
let store = Store::open_in_memory().unwrap();
|
||||
let state = AppState { store, config };
|
||||
let app = router(state);
|
||||
let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))
|
||||
.await
|
||||
.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let handle = tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
TestServer {
|
||||
base: format!("http://{addr}"),
|
||||
handle,
|
||||
}
|
||||
}
|
||||
|
||||
fn sign_registration(
|
||||
secret: &Ed25519Secret,
|
||||
handle: &str,
|
||||
server: &str,
|
||||
created_at: DateTime<Utc>,
|
||||
) -> SignedRegistration {
|
||||
let primary = secret.identity().unwrap();
|
||||
let payload = RegistrationPayload {
|
||||
kind: REGISTRATION_TYPE.to_owned(),
|
||||
version: FORMAT_VERSION,
|
||||
handle: handle.to_owned(),
|
||||
primary: primary.clone(),
|
||||
server: server.to_owned(),
|
||||
created_at,
|
||||
};
|
||||
let jcs = canonical_bytes(&payload).unwrap();
|
||||
let sig = secret.sign(&jcs);
|
||||
SignedRegistration {
|
||||
kez: ENVELOPE_TAG.to_owned(),
|
||||
payload,
|
||||
signature: SignatureBlock {
|
||||
alg: ED25519_SHA512_ALG.to_owned(),
|
||||
key: primary,
|
||||
sig: hex::encode(sig),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn healthz_returns_ok() {
|
||||
let server = spawn_server().await;
|
||||
let resp = reqwest::get(format!("{}/v1/healthz", server.base))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body: Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["status"], "ok");
|
||||
assert_eq!(body["server"], "kez.test");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_handle_returns_404() {
|
||||
let server = spawn_server().await;
|
||||
let resp = reqwest::get(format!("{}/v1/u/ghost", server.base))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_then_lookup_round_trip() {
|
||||
let server = spawn_server().await;
|
||||
let secret = Ed25519Secret::generate();
|
||||
let req = sign_registration(&secret, "tudisco", "kez.test", Utc::now());
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let post = client
|
||||
.post(format!("{}/v1/register", server.base))
|
||||
.json(&req)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(post.status(), StatusCode::CREATED);
|
||||
let posted: Value = post.json().await.unwrap();
|
||||
assert_eq!(posted["handle"], "tudisco");
|
||||
assert_eq!(posted["fqhn"], "tudisco@kez.test");
|
||||
|
||||
let get = reqwest::get(format!("{}/v1/u/tudisco", server.base))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(get.status(), StatusCode::OK);
|
||||
let looked: Value = get.json().await.unwrap();
|
||||
assert_eq!(looked["handle"], "tudisco");
|
||||
assert_eq!(looked["primary"], secret.identity().unwrap().to_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_duplicate_handle() {
|
||||
let server = spawn_server().await;
|
||||
let a = Ed25519Secret::generate();
|
||||
let b = Ed25519Secret::generate();
|
||||
|
||||
let req_a = sign_registration(&a, "tudisco", "kez.test", Utc::now());
|
||||
let req_b = sign_registration(&b, "tudisco", "kez.test", Utc::now());
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let r1 = client
|
||||
.post(format!("{}/v1/register", server.base))
|
||||
.json(&req_a)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r1.status(), StatusCode::CREATED);
|
||||
|
||||
let r2 = client
|
||||
.post(format!("{}/v1/register", server.base))
|
||||
.json(&req_b)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r2.status(), StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_wrong_server() {
|
||||
let server = spawn_server().await;
|
||||
let secret = Ed25519Secret::generate();
|
||||
let req = sign_registration(&secret, "tudisco", "other.example", Utc::now());
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(format!("{}/v1/register", server.base))
|
||||
.json(&req)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_reserved_handle() {
|
||||
let server = spawn_server().await;
|
||||
let secret = Ed25519Secret::generate();
|
||||
let req = sign_registration(&secret, "admin", "kez.test", Utc::now());
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(format!("{}/v1/register", server.base))
|
||||
.json(&req)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_tampered_signature() {
|
||||
let server = spawn_server().await;
|
||||
let secret = Ed25519Secret::generate();
|
||||
let mut req = sign_registration(&secret, "tudisco", "kez.test", Utc::now());
|
||||
// Tamper: flip the handle after signing. Signature still references
|
||||
// the original handle, but payload now claims a different one.
|
||||
req.payload.handle = "imposter".to_owned();
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(format!("{}/v1/register", server.base))
|
||||
.json(&req)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_stale_timestamp() {
|
||||
let server = spawn_server().await;
|
||||
let secret = Ed25519Secret::generate();
|
||||
let stale = Utc::now() - chrono::Duration::hours(1);
|
||||
let req = sign_registration(&secret, "tudisco", "kez.test", stale);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(format!("{}/v1/register", server.base))
|
||||
.json(&req)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
let body: Value = resp.json().await.unwrap();
|
||||
let msg = body["error"]["message"].as_str().unwrap();
|
||||
assert!(msg.contains("created_at"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webfinger_finds_registered_user() {
|
||||
let server = spawn_server().await;
|
||||
let secret = Ed25519Secret::generate();
|
||||
let req = sign_registration(&secret, "tudisco", "kez.test", Utc::now());
|
||||
let client = reqwest::Client::new();
|
||||
client
|
||||
.post(format!("{}/v1/register", server.base))
|
||||
.json(&req)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let url = format!(
|
||||
"{}/.well-known/webfinger?resource=acct:tudisco@kez.test",
|
||||
server.base
|
||||
);
|
||||
let resp = reqwest::get(&url).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body: Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["subject"], "acct:tudisco@kez.test");
|
||||
assert!(body["links"].is_array());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webfinger_rejects_wrong_server() {
|
||||
let server = spawn_server().await;
|
||||
let resp = reqwest::get(format!(
|
||||
"{}/.well-known/webfinger?resource=acct:tudisco@other.example",
|
||||
server.base
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn placeholder_index_renders() {
|
||||
let server = spawn_server().await;
|
||||
let resp = reqwest::get(format!("{}/", server.base)).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let text = resp.text().await.unwrap();
|
||||
assert!(text.contains("kez-chat"));
|
||||
assert!(text.contains("kez.test"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nats_auth_callout_stub_returns_not_implemented() {
|
||||
let server = spawn_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(format!("{}/internal/nats/auth", server.base))
|
||||
.json(&serde_json::json!({}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_IMPLEMENTED);
|
||||
}
|
||||
|
||||
// Sanity: signing the same payload twice with the same Ed25519 key
|
||||
// gives the same signature. Catches any accidental non-determinism in
|
||||
// the JCS pipeline.
|
||||
#[tokio::test]
|
||||
async fn registration_signing_is_deterministic() {
|
||||
let seed = "4242424242424242424242424242424242424242424242424242424242424242";
|
||||
let secret = Ed25519Secret::from_seed_hex(seed).unwrap();
|
||||
let payload = RegistrationPayload {
|
||||
kind: REGISTRATION_TYPE.to_owned(),
|
||||
version: FORMAT_VERSION,
|
||||
handle: "tudisco".to_owned(),
|
||||
primary: secret.identity().unwrap(),
|
||||
server: "kez.lat".to_owned(),
|
||||
created_at: DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc),
|
||||
};
|
||||
let jcs1 = canonical_bytes(&payload).unwrap();
|
||||
let jcs2 = canonical_bytes(&payload).unwrap();
|
||||
assert_eq!(jcs1, jcs2);
|
||||
|
||||
let sig1 = secret.sign(&jcs1);
|
||||
let sig2 = secret.sign(&jcs2);
|
||||
assert_eq!(sig1, sig2);
|
||||
|
||||
// Hash for human eyeballing in CI logs.
|
||||
let _ = Sha256::digest(&jcs1);
|
||||
}
|
||||
@ -853,6 +853,27 @@ fn sign_jcs_schnorr_hex<T: Serialize>(payload: &T, signer: &NostrSecret) -> Resu
|
||||
Ok(hex::encode(signature.as_ref()))
|
||||
}
|
||||
|
||||
/// Verify a `SignatureBlock` against an arbitrary payload. Dispatches on
|
||||
/// `signature.alg`. Used by `SignedClaim::verify` and
|
||||
/// `SignedSigchainEvent::verify` internally; downstream crates (e.g. the
|
||||
/// chat-server's handle-registration verifier) call it for non-claim
|
||||
/// payloads that share the envelope shape.
|
||||
pub fn verify_envelope<T: Serialize>(
|
||||
payload: &T,
|
||||
signature: &SignatureBlock,
|
||||
) -> Result<()> {
|
||||
match signature.alg.as_str() {
|
||||
NOSTR_SCHNORR_ALG => {
|
||||
verify_jcs_schnorr_hex(payload, signature.key.value(), &signature.sig)
|
||||
}
|
||||
ED25519_SHA512_ALG => {
|
||||
let jcs = canonical_bytes(payload)?;
|
||||
verify_ed25519_hex(signature.key.value(), &jcs, &signature.sig)
|
||||
}
|
||||
other => Err(KezError::UnsupportedAlgorithm(other.to_owned())),
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_jcs_schnorr_hex<T: Serialize>(payload: &T, npub: &str, sig: &str) -> Result<()> {
|
||||
let public_key = decode_npub(npub)?;
|
||||
let signature = Signature::from_slice(&hex::decode(sig)?)?;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user