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:
Tudisco 2026-05-24 23:36:53 -06:00
parent a1d1aa6983
commit 111b23b94b
17 changed files with 4086 additions and 0 deletions

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
View 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
View 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.

View 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"]

View 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"]

View 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
View 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
View 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/&lt;handle&gt;</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
View 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
View 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
View 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
View 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
View 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");
}

View 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
View 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(&registered_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(&registered_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
View 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);
}

View File

@ -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)?)?;