diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 46c0e979..a10c10ac 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,16 +11,22 @@ updates: directory: "/capsule-web" schedule: interval: "weekly" + # The workspace root: `Cargo.toml` and `Cargo.lock` live here and cover every member. This + # pointed at `/capsule-api` until the Salvo tree moved to `legacy-review/` in `S-C59`, so it + # had been watching a directory that no longer exists — and therefore watching nothing. - package-ecosystem: "cargo" - directory: "/capsule-api" - schedule: - interval: "weekly" - - package-ecosystem: "docker" - directory: "/capsule-api" + directory: "/" schedule: interval: "weekly" + # The server's local service images (Postgres, Valkey). Same story: `/capsule-api/compose.yaml` + # went with the Salvo tree, and `capsule-server/compose.yaml` is the live file (issue #401). + # + # There is no `docker` entry any more. It watched `/capsule-api/Containerfile`, and no + # Containerfile exists anywhere in the active tree — an OCI image for the rebuilt server is + # not written yet, and an ecosystem pointed at an absent file is a permanent dashboard error + # rather than a dependency update. Add it back in the change that adds the Containerfile. - package-ecosystem: "docker-compose" - directory: "/capsule-api" + directory: "/capsule-server" schedule: interval: "weekly" - package-ecosystem: "github-actions" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa3d3865..2ce7108d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,16 @@ name: Release # Fires when a release commit (`chore(release): vX.Y.Z`, produced by prepare-release.yml -# and merged via its PR) lands on master. It builds the `capsule` CLI for each target and -# publishes a GitHub Release. `gh release create` also creates the tag, so the whole -# build+publish happens in this one run — no PAT or tag-push re-trigger needed. +# and merged via its PR) lands on master. It builds the `capsule` CLI and the `capsule-server` +# binary for each target and publishes a GitHub Release. `gh release create` also creates the +# tag, so the whole build+publish happens in this one run — no PAT or tag-push re-trigger +# needed. +# +# Both binaries ride in the one per-target archive rather than two: an operator running a +# self-hosted deployment wants the server and the CLI that talks to it at the same version, and +# two downloads is two chances to mix versions. The server is Unix-only here — Windows is +# already best-effort for the CLI, and adding a server build to a job that is allowed to fail +# would make "did the Windows CLI ship" harder to answer, not easier. on: push: branches: [master] @@ -41,7 +48,7 @@ jobs: fi build: - name: Build capsule (${{ matrix.target }}) + name: Build binaries (${{ matrix.target }}) needs: detect if: ${{ needs.detect.outputs.release == 'true' }} runs-on: ${{ matrix.os }} @@ -74,8 +81,11 @@ jobs: uses: Swatinem/rust-cache@v2 with: key: release-${{ matrix.target }} - - name: Build release binary + - name: Build the CLI run: cargo build -p capsule-cli --release --target ${{ matrix.target }} + - name: Build the server + if: runner.os != 'Windows' + run: cargo build -p capsule-server --release --target ${{ matrix.target }} - name: Package (unix) if: runner.os != 'Windows' shell: bash @@ -84,6 +94,11 @@ jobs: dist="capsule-v${{ needs.detect.outputs.version }}-${{ matrix.target }}" mkdir -p "$dist" cp "target/${{ matrix.target }}/release/capsule" "$dist/" + cp "target/${{ matrix.target }}/release/capsule-server" "$dist/" + # The operator's starting point: every setting the server reads, with what it defaults + # to and why. A release without it is a binary that refuses to start and an operator + # reading GitHub to find out which variables it wanted. + cp capsule-server/.env.example "$dist/" cp README.md LICENSE NOTICE CHANGELOG.md "$dist/" tar -czf "${dist}.tar.gz" "$dist" echo "ASSET=${dist}.tar.gz" >> "$GITHUB_ENV" diff --git a/Cargo.lock b/Cargo.lock index af416a67..575bda5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -748,6 +748,7 @@ dependencies = [ name = "capsule-server" version = "0.1.0" dependencies = [ + "argon2", "base64", "bytes", "capsule-core", @@ -765,10 +766,12 @@ dependencies = [ "serde", "serde_json", "subtle", + "tempfile", "thiserror 2.0.20", "tokio", "totp-rs", "tracing", + "tracing-subscriber", "uuid", ] diff --git a/capsule-cli/src/remote.rs b/capsule-cli/src/remote.rs index 0fde67ca..89954e44 100644 --- a/capsule-cli/src/remote.rs +++ b/capsule-cli/src/remote.rs @@ -53,7 +53,7 @@ pub struct RemoteConfig { pub protocol_version: String, } -/// The default server origin — one host, one port, matching `mise run serve-api`. +/// The default server origin — one host, one port, matching `mise run serve-memory`. pub const DEFAULT_ENDPOINT: &str = "http://127.0.0.1:3000"; impl RemoteConfig { diff --git a/capsule-docs/src/content/docs/development/local-development.md b/capsule-docs/src/content/docs/development/local-development.md index 68850524..ddcecf48 100644 --- a/capsule-docs/src/content/docs/development/local-development.md +++ b/capsule-docs/src/content/docs/development/local-development.md @@ -52,17 +52,140 @@ mise run hooks-install # installs the git hooks (hk) ## Running a server locally -**There is no local server today, and that is a known gap rather than a missing instruction.** +`capsule-server` is one binary with subcommands: + +```text +capsule-server [--config PATH] + serve [--listen HOST:PORT] [--memory] [--blob-root PATH] + gc [--apply] [--grace-window-hours N] --memory --blob-root PATH + purge [--apply] [--limit N] --memory --blob-root PATH + scrub [--deep] [--budget BYTES] --memory --blob-root PATH + gen-openapi [FILE] [--check] + +`--memory` is written as required on the three operator commands because today it is: they +compare the index against the blob store, and the only index adapter written is the in-memory +one. Without it they refuse and say so. It becomes optional when #402 lands. +``` + +### The development profile + +```bash +mise run serve-memory +``` + +That is a server you can point a client at: it binds, prints the address it bound, and answers +every operation. An account registers and signs in — the credential is checked with Argon2id +against a real in-memory account directory, so a wrong password is refused rather than accepted. + +What it is missing is durability. The blob store is a **real** filesystem store under +`target/capsule-server-blobs`; everything else — the index, sessions, albums, the device +directory, quota, the collector's marks — lives in the process and is gone when it exits. That +is not a gap to route around, it is the shape of a profile whose durable half is exactly the one +adapter that has been written. Two consequences worth knowing before they surprise you: + +- After a restart, `capsule-server scrub` will honestly report every blob still on disk as an + orphan, because the index that referenced them is gone. +- `capsule-server gc` can only ever **mark** in this profile. Collection is two passes by + design — a blob that reaches zero references is marked, and swept on a later pass once the + grace window has passed — and the mark store does not outlive the process. + +The signing key `serve-memory` falls back to is the published example in +`capsule-server/.env.example` — commented out there, so a `cp .env.example .env` cannot silently +produce a forgeable deployment. Every token the task mints under it is forgeable by anyone who has +read this repository, which is why it is `serve-memory` and not `serve`, and why it binds +`127.0.0.1` rather than every interface. Set `JWT_ED25519_DER` yourself and it is used instead: + +```bash +JWT_ED25519_DER="$(openssl genpkey -algorithm ed25519 -outform DER | base64 | tr -d '\n')" \ + mise run serve-memory +``` + +### A configured server + +```bash +cp capsule-server/.env.example capsule-server/.env # then edit it +mise run serve-deps # Postgres 18 + Valkey 9, on loopback +mise run serve +``` + +The template ships with **both secrets commented out** — `JWT_ED25519_DER` and +`ATTESTATION_KEY_SEED` — so a copy you have not finished editing produces a server that refuses +and names what it wants, rather than one that starts under a published key. Uncomment each and +put your own value in. Every other setting is either a working default or optional. + +Nothing in the template is a shell expression, deliberately: the file is read by more than a +shell — `podman --env-file`, compose's `env_file:`, systemd's `EnvironmentFile=` — and those take +a line literally, so a placeholder shaped like `$(...)` would be stored as the value rather than +replaced. + +`serve-deps` and `serve` are separate tasks on purpose: a task that silently starts containers is +a task that leaks them. Bring them down with +`podman compose -f capsule-server/compose.yaml down` (`docker compose` accepts the same file). + +**`mise run serve` does not work yet, and refuses rather than pretending.** The Postgres and +Valkey adapters are not written. Without `VALKEY_URL` and without `--memory` it exits 2 naming +the variable — the refusal `capsule-server/src/store/mod.rs` has documented since `S-C29` and +nothing could enforce until there was a boot path; with `VALKEY_URL` set it exits non-zero naming +the issue that will honour it. Neither ever silently falls back to the in-memory adapters, which +is the whole point: a deployment that forgot a variable must fail closed. + +A configured server also has to supply `ATTESTATION_KEY_SEED`. It is **not** derived from +`JWT_ED25519_DER`, and that is deliberate: the attestation key signs custody receipts and has to +be distinct from the key that signs session tokens, or anyone holding the operational key could +manufacture custody evidence — see +[Cryptography — Failure Modes](/design/cryptography/failure-modes/). A different HKDF label over +the same input is not a separation. `serve --memory` derives it, because a development server's +whole state is discarded when it exits. + +Every configuration fault is reported in **one** message with exit code 2, so bringing a +deployment up is one read of one log line rather than one restart per variable. + +`capsule-server/.env.example` is the full list of settings. The precedence is command-line flag, +then the environment, then the built-in default; there is no configuration file, and `--config +PATH` is accepted and refused with a sentence saying so. + +### TLS + +The server does not terminate it. HTTPS is the ingress or reverse proxy's job — see +[Cryptography — Failure Modes](/design/cryptography/failure-modes/) — so there is no certificate +setting and Kynos's `tls` feature is off. + +### Logs and reports + +Every log line goes to **stderr**; stdout is a data channel. `serve` writes one +`listening on ` line there (which is how a `--listen 127.0.0.1:0` caller learns its port), +`gen-openapi` writes the path it wrote, and the operator commands write their report. `LOG_FORMAT` +is `pretty` in a debug build and `json` in a release one; `RUST_LOG` is the usual filter. + +### The operator commands + +`gc`, `purge` and `scrub` are the three jobs +[Filesystem — Maintenance](/design/filesystem/maintenance/) describes. They need a blob root and +deliberately **no key material**: a maintenance host that had to hold the production +token-signing key to sweep a directory would be a reason to put the key on a maintenance host. + +They do need `--memory` today, and they say so rather than naming a variable that would not have +helped: all three compare the index against the blob store, and the in-memory one is the only +index adapter written. + +Dry run is the default for the two that write; `--apply` opts in, and the report says which +posture produced it. `scrub` mutates nothing at all and exits non-zero on a non-empty report, +which is what makes it usable as a monitoring probe — and a `--deep` pass that ran out of budget +says so, because a clean report from a pass that stopped early is not a clean store. -`mise run serve-api` and the compose stack behind it went with the Salvo tree in slice `S-C59`. The Kynos server that replaces it is complete as a *surface* — fifty-nine operations, a committed OpenAPI 3.2 document, and a test suite that drives the real router — and it has **no binary, no configuration loading and no Postgres or Valkey adapter**. Nothing reads `JWT_ED25519_DER`, `SYNC_CURSOR_MAC_KEY` or `ATTESTATION_KEY_SEED` yet. +### Without running anything -That ordering is deliberate: every port in `capsule-server` has a deterministic in-memory adapter and a conformance suite, because the suite is what a real adapter is written *against*, and a port with two implementations before it has one suite is a port whose implementations will disagree. Until those adapters land, the way to exercise the server is the way its own tests do — in process, with no container: +To exercise the server the way its own tests do — in process, no socket, no container: ```bash cargo nextest run -p capsule-server ``` -`kynos::test::TestClient` drives a built `Service` directly: no socket, no port, no runtime flavour. One test (`tests/sdk_client.rs`) does bind an ephemeral port, because the property it proves — that the **generated** SDK client round-trips the real router over TCP — is the one an in-process client cannot. +`kynos::test::TestClient` drives a built `Service` directly. Two test files do use a socket: +`capsule-server/tests/sdk_client.rs`, because the property it proves is that the **generated** +SDK client round-trips the real router over TCP, and `capsule-server/tests/binary.rs`, because +the properties it proves — that the binary binds, reports its port, and drains to exit 0 on +SIGTERM — belong to a process rather than to a router. To read the served contract without running anything: @@ -70,9 +193,13 @@ To read the served contract without running anything: mise run openapi-kynos # regenerate capsule-server/openapi.json ``` -### Nothing here needs a container any more +### Nothing here needs a container -The testcontainers section this page used to carry is gone with the crate that needed it. No test in the workspace starts a container, so `mise run test-rust` has no podman prerequisite and cannot leak one. (The `containers` nextest group is kept, empty, for the first real adapter — the one-thread rule it encodes was learned by watching CI flake, and that is the expensive way to learn it.) +No test in the workspace starts a container, so `mise run test-rust` has no podman prerequisite +and cannot leak one. `mise run serve-deps` is the only task that starts anything, and it is never +a dependency of another task. (The `containers` nextest group is kept, empty, for the first real +adapter — the one-thread rule it encodes was learned by watching CI flake, and that is the +expensive way to learn it.) ## Git hooks diff --git a/capsule-server/.env.example b/capsule-server/.env.example new file mode 100644 index 00000000..8fa7239c --- /dev/null +++ b/capsule-server/.env.example @@ -0,0 +1,153 @@ +# Every setting `capsule-server` reads. Copy to `.env` and export it, or set these in your +# process manager — there is no configuration file: `--config PATH` is accepted and refused, and +# `capsule-server/src/config.rs` records why. +# +# Precedence, highest first: command-line flag, then the environment, then the built-in default. +# `FOO=` with an empty value counts as unset. +# +# A missing or malformed setting is reported with **every** other fault in one message and exit +# code 2, so bringing a deployment up is one read of one log line rather than one restart per +# variable. + +# ── Required to serve ──────────────────────────────────────────────────────────────────────── +# +# PKCS#8 v1 DER-encoded Ed25519 private key, base64. Access and refresh tokens are signed with +# it, and `.well-known/capsule/server-info` publishes the public half **derived from it** — so +# there is no second copy to paste wrongly. +# +# openssl genpkey -algorithm ed25519 -outform DER | base64 -w 0 +# +# **Deliberately left commented out.** The value below is the retired deployment's own example: +# it is public, anyone who has read this repository can forge tokens under it, and a +# `cp .env.example .env` that silently produced a forgeable deployment is exactly the accident +# worth preventing. Commented, the server refuses and names the variable, which is the right +# outcome for a configuration you have not finished writing. +# +# `mise run serve-memory` falls back to this same key for development, and binds loopback only. +# JWT_ED25519_DER=MC4CAQAwBQYDK2VwBCIEIN6eTvXEL7xMZWHY8rTk7VbQSGSuRkle5MVfiiYUStLF + +# Where ciphertext blobs are written. There is no object store: this filesystem path *is* the +# blob backend, and `capsule-server gc|purge|scrub` read the same tree. +# +# Under `target/` so it is already git-ignored and `cargo clean` takes it with it. That is the +# right trade for the development profile, whose index does not survive a restart either — a +# blob whose index row is gone is an orphan, which is exactly what `scrub` will tell you. +# A real deployment points this at durable storage. +# +# There is deliberately **no default**: a server that silently wrote blobs into the current +# directory would be worse than one that refuses to start. `UPLOAD_DIR` is the retired name and +# is still honoured, with a warning. +BLOB_ROOT=./target/capsule-server-blobs + +# ── The listener ───────────────────────────────────────────────────────────────────────────── +# +# `SERVER_HOST` must be an IP address to bind. `--listen HOST:PORT` overrides both. +# Port 0 asks the operating system to choose, and the chosen address is written to stdout. +SERVER_HOST=127.0.0.1 +SERVER_PORT=3000 + +# This deployment's canonical origin — the `server_id` every published record carries, and the +# issuer an authenticator app shows beside a second-factor code. In production, your public +# domain (e.g. api.capsule.example). +SERVER_DOMAIN=localhost + +# The absolute base URL clients reach the versioned API at. `server-info` derives the published +# auth endpoints by appending to it, so the `/v1` prefix is not optional. +# Default: http://{SERVER_DOMAIN}:{SERVER_PORT}/v1 +# API_BASE_URL=https://api.capsule.example/v1 + +# TLS is **not** terminated here. `design/cryptography/failure-modes.md` puts HTTPS on the +# ingress or reverse proxy; there is no certificate setting and Kynos's `tls` feature is off. + +# ── Backends ───────────────────────────────────────────────────────────────────────────────── +# +# Postgres and Valkey are required for a deployment, and **no adapter reads either URL yet** +# (#402, #403). Until they land: +# +# - `capsule-server serve` with `VALKEY_URL` set refuses to boot and names the issue; +# - `capsule-server serve` with neither `VALKEY_URL` nor `--memory` refuses and names the +# variable, which is the refusal `store/mod.rs` has always documented; +# - `capsule-server serve --memory` (`mise run serve-memory`) runs on the in-crate in-memory +# adapters over a real filesystem blob store. An explicit development act, never a fallback. +# +# `mise run serve-deps` brings both services up from capsule-server/compose.yaml. +DATABASE_URL=postgresql://capsule:capsule@localhost:5432/capsule +VALKEY_URL=redis://127.0.0.1:6379 + +# `memory` selects the in-memory adapters, exactly as `--memory` does. The flag is the primary +# spelling; this exists for a process manager that cannot add an argument. +# CAPSULE_PROFILE=memory + +# ── The rest of the key material ───────────────────────────────────────────────────────────── +# +# The sync-cursor MAC key is HKDF-SHA256-derived from JWT_ED25519_DER when unset, which is the +# right default for a single-server deployment: a cursor MAC and a session token are the same +# trust domain — both are operational secrets this server holds to authenticate its own output — +# so deriving one from the other gives nobody anything they did not already have. Set it +# explicitly only to rotate it independently, or to share it across replicas. +# +# SYNC_CURSOR_MAC_KEY= # base64, exactly 32 bytes +# +# **ATTESTATION_KEY_SEED is required for a real deployment, and is deliberately not derived.** +# The attestation key signs custody receipts and has to be *distinct* from the token-signing key: +# a receipt that verified under the operational key would let anything holding that key +# manufacture custody evidence. Deriving this seed from JWT_ED25519_DER would collapse exactly +# that distinction — a different HKDF label over the same input is not a separation — so `serve` +# without `--memory` refuses until it is set. `serve --memory` derives it, because a development +# server's whole state is discarded when it exits. +# +# openssl rand -base64 64 | tr -d '\n' +# +# **Deliberately left commented out**, exactly as JWT_ED25519_DER above is, and for a second +# reason as well. This file is read by more than a shell: `podman --env-file`, compose's +# `env_file:`, systemd's `EnvironmentFile=` and a Kubernetes ConfigMap all take a line +# literally — no expansion, no command substitution — so a placeholder shaped like a shell +# expression would be *stored* as the value. `capsule-server gc|purge|scrub` would then refuse a +# malformed seed, even though those commands are built to need no key material at all. +# +# The placeholder is plain text for the same reason: sourced by bash, `$(...)` would run and +# print `command not found`, which is noise at best and an execution seam at worst. +# +# base64, 32 or 64 bytes; 32 is expanded to 64, domain-separated. +# ATTESTATION_KEY_SEED=replace-with-your-own-base64-seed + +# ── The protocol window ────────────────────────────────────────────────────────────────────── +# +# Both ends inclusive, both published, and both default to the version `capsule-core` speaks. +# Widen `PROTOCOL_MIN` only with a deprecation announcement behind it. +# PROTOCOL_MIN=2026-05-31 +# PROTOCOL_MAX=2026-05-31 + +# ── Operational knobs ──────────────────────────────────────────────────────────────────────── +# +# How long a blob sits at zero references before `gc` may sweep it. The design's range is 24-72 +# hours: long enough for an in-flight finalization retry to re-reference it, short enough that an +# orphan is not held forever. `gc --grace-window-hours` overrides it. +# GC_GRACE_WINDOW_HOURS=24 + +# How long a shutdown may take to drain. 25 seconds by default, under the usual 30-second +# orchestrator termination window. +# SHUTDOWN_TIMEOUT_SECONDS=25 + +# The accepted-connection ceiling, across every listener. +# MAX_CONNECTIONS=10000 + +# The account lockout, which is two numbers: how many consecutive failures inside the window lock +# an account, and how long it then stays locked, measured from the last counted failure. +# +# It decays because nothing else can clear it: there is no unlock operation on any surface, and +# `login`, `reauthenticate` and `password` each refuse a locked account before verifying +# anything — so a lockout that never expired would be a permanently lost account. An attempt made +# *during* a lockout is refused without extending it, so nobody can hold somebody else's account +# shut by hammering the endpoint. A threshold of zero is refused rather than clamped. +# LOCKOUT_MAX_ATTEMPTS=10 +# LOCKOUT_WINDOW_SECONDS=900 + +# `json` (one object per event, for a log shipper) or `pretty` (for a person). Defaults to +# `pretty` in a debug build and `json` in a release one. Everything is written to **stderr**, so +# stdout stays a data channel: `gen-openapi` writes a path there and the operator commands write +# their report. +# LOG_FORMAT=json + +# The usual `tracing` filter. Defaults to `debug` in a debug build and `info` in a release one. +# RUST_LOG=info diff --git a/capsule-server/Cargo.toml b/capsule-server/Cargo.toml index a79b4def..c60c08da 100644 --- a/capsule-server/Cargo.toml +++ b/capsule-server/Cargo.toml @@ -73,7 +73,18 @@ jsonwebtoken = { workspace = true } # the three features a ranged read and a durable append need, and nothing more. Already the # workspace's async runtime (design/dependencies.md, "Async runtime") and already in this # crate's tree through kynos — this promotes it from a dev-dependency, it does not add a crate. -tokio = { workspace = true, features = ["fs", "io-util", "rt"] } +# `rt-multi-thread` and `macros` on top of those three for the binary: `serve` runs an accept +# loop that has to make progress while a request is awaiting the disk, and `#[tokio::main]` is +# the attribute that starts it. Kynos's `server` feature already unifies `net`/`signal`/`rt` in; +# these two are named here rather than relied on through it, so a Kynos feature change cannot +# silently take the binary's runtime away. +tokio = { workspace = true, features = [ + "fs", + "io-util", + "rt", + "rt-multi-thread", + "macros", +] } # The `.reason.json` a quarantined blob keeps beside it (design/filesystem/server.md). The # encoding belongs to the filesystem *adapter*, not to the port's record — which derives no # serde traits, exactly as the state ports' records do not — so this is used in one file. @@ -106,13 +117,32 @@ totp-rs = { workspace = true } # design/dependencies.md. subtle = { workspace = true } -# `gen_openapi` only. Both are already in the lock file via `capsule-api`'s equivalent binary, -# and this mirrors it deliberately: two committed documents, two identical drift guards, so the -# changeover at parity is a re-point rather than a new mechanism. +# Argon2id for the development profile's account adapter (`auth::credential`, +# `auth::accounts_memory`). Already a workspace dependency (consumed by `capsule-core` for the +# escrow key wrap) and already pinned by the Argon2id row in design/cryptography/primitives.md, +# so this adds no crate and opens no new domain. It is the *server-side password verification* +# parameter set rather than the escrow KDF's tiered one — see `auth::credential` for why those +# are unrelated numbers. The Postgres adapter (#402) uses the same helper. +argon2 = { workspace = true } +# The `capsule-server` binary: subcommand parsing, and the error report a startup failure prints. +# `clap` and `color-eyre` were already here for the `gen_openapi` binary this replaces; the +# binary is now one `capsule-server` with `serve | gc | purge | scrub | gen-openapi` +# subcommands, for the reason `capsule-cli` is one binary — four executables would each carry +# their own copy of the config loader and the adapter seam. clap = { version = "4.6.1", features = ["derive"] } color-eyre = "0.6.5" +# The log stream the binary installs (`cli::install_tracing`). `env-filter` for `RUST_LOG` and +# `json` for the one-object-per-event rendering a log shipper wants; both are on in the workspace +# pin, and the crate already has a row in design/dependencies.md. Written to **stderr**, so +# `gen-openapi` and the operator commands keep a parseable stdout. +tracing-subscriber = { workspace = true } [dev-dependencies] +# `tests/binary.rs` gives the spawned server a blob root of its own, and deletes it afterwards. +# Already the workspace's scratch-directory crate (`capsule-core`, `capsule-sdk`, +# `capsule-core-ffi` all dev-depend on it) and already in the lock file. +tempfile = "3" + # `test-util` carries `kynos::test::TestClient`, which drives a built `Service` in-process — # no socket, no port, no runtime flavour — and the two conformance assertions the suite is # built around. `server` is on top of it for exactly one test: `tests/sdk_client.rs` binds the diff --git a/capsule-server/README.md b/capsule-server/README.md index 0d443cc8..7d7f373d 100644 --- a/capsule-server/README.md +++ b/capsule-server/README.md @@ -38,6 +38,16 @@ with two implementations before it has one suite is a port whose implementations It is also why this crate's whole test suite runs without a container. +Two of those adapters live beside the ports rather than in `tests/support/`: `auth::accounts_memory` +and `auth::totp`'s `InMemoryTotp`. The account ports' docs say a double in `src/` would be "a fake +credential directory shipped inside the server binary", and that reasoning is about a **double** — +`tests/support/mod.rs`'s, which accepts whatever password it was told to accept. These verify with +the same Argon2id helper (`auth::credential`) a Postgres adapter will, store PHC strings and no +plaintext, take the timing-equalized miss, and lock an account out after enough failures — for a +window, because no route and no operator command can clear a lockout, so one that never expired +would be a permanently lost account. What they lack is durability, which is what makes them a +development profile rather than a deployment. + ## Running the tests ```bash @@ -60,8 +70,56 @@ mise run openapi-kynos # regenerate mise run openapi-check-kynos # verify no drift ``` +## Running it + +```bash +mise run serve-memory # a server you can point a client at +``` + +One binary, several subcommands: + +```text +capsule-server [--config PATH] + serve [--listen HOST:PORT] [--memory] [--blob-root PATH] + gc [--apply] [--grace-window-hours N] --memory --blob-root PATH + purge [--apply] [--limit N] --memory --blob-root PATH + scrub [--deep] [--budget BYTES] --memory --blob-root PATH + gen-openapi [FILE] [--check] + +`--memory` is written as required on the three operator commands because today it is: they +compare the index against the blob store, and the only index adapter written is the in-memory +one. Without it they refuse and say so. It becomes optional when #402 lands. +``` + +`config` reads every setting an operator decides — command-line flag over environment over +default — and reports **every** fault in one message, because an operator otherwise restarts the +process once per variable. `capsule-server/.env.example` is the full list. There is no +configuration file; `--config PATH` is accepted and refused with a sentence saying why. + +A real deployment supplies **two** independent secrets: `JWT_ED25519_DER` signs session tokens, +and `ATTESTATION_KEY_SEED` signs custody receipts. The second is deliberately not derived from +the first — a receipt that verified under the operational key would let anything holding that key +manufacture custody evidence, and a different HKDF label over the same input is not a separation. +`serve --memory` derives it, because a development server's whole state is discarded on exit. + +`boot::assemble` is the one composition root. `--memory` takes every in-crate adapter over a real +filesystem blob store; anything else refuses, so a deployment that forgot `VALKEY_URL` fails +closed rather than coming up on state it loses at the next restart. + +Logs go to stderr. stdout is a data channel: `serve` writes one `listening on ` line there, +which is how a `--listen 127.0.0.1:0` caller learns its port. + +`gc`, `purge` and `scrub` need a blob root and no key material at all. Dry run is the default for +the two that write; `scrub` mutates nothing and exits non-zero on a non-empty report. + ## What is owed -There is **no binary, no configuration loading, and no Postgres or Valkey adapter.** Nothing reads -`JWT_ED25519_DER`, `SYNC_CURSOR_MAC_KEY` or `ATTESTATION_KEY_SEED` yet, so there is no way to run -this server outside its own tests. See `SLICES.md`, lane C. +**No Postgres or Valkey adapter.** `DATABASE_URL` and `VALKEY_URL` are read into the +configuration and no adapter consumes either, so `serve` without `--memory` refuses and names +the issue that will honour it: the account, album and index adapters are one issue and the +session and upload-session adapters another. + +That ordering is deliberate rather than unfinished, for the reason above: the contract and its +conformance suite are what a real adapter is written *against*. What `--memory` therefore buys is +not durability — the blob store is the only durable half — but a running surface to write those +adapters against and to point a client at. diff --git a/capsule-server/compose.yaml b/capsule-server/compose.yaml new file mode 100644 index 00000000..60013ef1 --- /dev/null +++ b/capsule-server/compose.yaml @@ -0,0 +1,76 @@ +# The two external services a Capsule deployment needs, for local development. +# +# Bring them up with `mise run serve-deps` and the server up with `mise run serve`. The two are +# deliberately separate tasks: a task that silently starts containers is a task that leaks them. +# +# **Nothing reads these yet.** The Postgres adapter is issue #402 and the Valkey adapter is #403; +# until they land, `capsule-server serve` refuses to boot with `VALKEY_URL` set and the way to +# run a server is `mise run serve-memory`. This file exists now because the compose stack went +# with the Salvo tree in `S-C59` and re-deriving it later is re-doing work — and because +# dependabot needs a live file to track the images against. +# +# There is deliberately **no object store**. Blobs are written to the filesystem `BLOB_ROOT`, +# which is the blob backend and not a cache in front of one (design/filesystem/server.md); a +# MinIO service used to sit here and was never wired to anything. +# +# Podman-first: the `:Z,U` volume labels relabel for SELinux and chown to the container user, and +# `docker compose` accepts both. Every image is fully qualified, because podman prompts for a +# registry otherwise. + +services: + postgres: + image: docker.io/library/postgres:18 + ports: + # Loopback only. `"5432:5432"` publishes on every interface, and these are development + # credentials in a checked-in file — a database anyone on the network can open with + # capsule/capsule. + - "127.0.0.1:5432:5432" + environment: + # Development credentials, matching capsule-server/.env.example's DATABASE_URL. Override + # them in the environment or a .env file; nothing here is a secret worth keeping. + POSTGRES_USER: capsule + POSTGRES_PASSWORD: capsule + POSTGRES_DB: capsule + healthcheck: + # `serve-deps` returns as soon as compose has started the containers, so a developer who + # runs `mise run serve` immediately afterwards would otherwise race the database's own + # startup. `pg_isready` is what makes "up" mean "accepting connections". + test: ["CMD-SHELL", "pg_isready -U capsule -d capsule"] + interval: 5s + timeout: 3s + retries: 12 + volumes: + - postgres_data:/var/lib/postgresql/data:Z,U + + valkey: + image: docker.io/valkey/valkey:9.0.4 + ports: + # Loopback only, and here it is load-bearing rather than tidy: `--protected-mode no` below + # is safe *because* nothing outside this machine can reach the port, and the two have to + # agree. + - "127.0.0.1:6379:6379" + environment: + # Carried over from the retired deployment verbatim, because these are the flags the + # session and upload-session stores were sized against: `volatile-lru` so a key with a TTL + # is what gets evicted under pressure and a key without one never is, `appendonly` with + # `everysec` so a restart loses at most a second of session state rather than all of it, + # and the `lazyfree-*` set so an eviction does not block the command that triggered it. + # `protected-mode no` is a development-only concession, and it is only a concession because + # the port above is published to loopback rather than to every interface. + # + # These flags do reach the server. `VALKEY_EXTRA_FLAGS` is often described as a Bitnami + # convention, but the official image honours it too: `valkey/valkey:9.0.4`'s own + # `/usr/local/bin/docker-entrypoint.sh` ends with `exec "$@" $VALKEY_EXTRA_FLAGS`, + # unquoted, so the variable word-splits into arguments of `valkey-server`. + VALKEY_EXTRA_FLAGS: "--maxmemory 4G --maxmemory-policy volatile-lru --save 900 1 300 10 --appendonly yes --appendfsync everysec --no-appendfsync-on-rewrite yes --auto-aof-rewrite-percentage 100 --auto-aof-rewrite-min-size 64mb --lazyfree-lazy-eviction yes --lazyfree-lazy-expire yes --lazyfree-lazy-server-del yes --replica-lazy-flush yes --protected-mode no --tcp-keepalive 60 --loglevel notice --slowlog-log-slower-than 10000 --slowlog-max-len 128 --io-threads 4" + healthcheck: + test: ["CMD-SHELL", "valkey-cli ping | grep -q PONG"] + interval: 5s + timeout: 3s + retries: 12 + volumes: + - valkey_data:/data:Z,U + +volumes: + postgres_data: + valkey_data: diff --git a/capsule-server/src/auth/accounts_memory.rs b/capsule-server/src/auth/accounts_memory.rs new file mode 100644 index 00000000..3d148b23 --- /dev/null +++ b/capsule-server/src/auth/accounts_memory.rs @@ -0,0 +1,778 @@ +//! [`InMemoryAccounts`] — the deterministic account store the development profile runs on. +//! +//! # Why this exists in `src/` when three port modules say it would not +//! +//! [`directory`](super::directory), [`registry`](super::registry) and +//! [`profile`](super::profile) each record the same reason for having no adapter: *"the real one +//! is Postgres, the test one is a double, and a double in `src/` is a fake credential directory +//! shipped inside the server binary."* That reasoning is about a **double** — specifically +//! `tests/support/mod.rs`'s, which "accepts whatever password it was told to accept" and +//! therefore must never be linkable by a server. +//! +//! This is not that. It verifies with the same Argon2id helper the Postgres adapter will use +//! ([`credential`](super::credential)), it stores PHC strings and no plaintext, it takes the +//! timing-equalized miss, and it locks an account out after enough failures. What it is missing +//! is **durability**, which is what makes it a development profile rather than a deployment: +//! every account registered against it is gone when the process exits. `capsule-server serve` +//! reaches it only through `--memory`, which is an explicit operator act +//! ([`Backends::Memory`](crate::config::Backends)). +//! +//! The alternative was a fail-closed stub: four ports that answer `Unavailable`, so +//! `POST /v1/auth/register` and `POST /v1/auth/login` return their declared refusal until #402 +//! lands. That was rejected once the cost was measured — `argon2` is already a workspace +//! dependency with a design-doc row, and the credential helper this needs is the one #402 +//! reuses, so nothing is written twice — and the gain is large: a `mise run serve-memory` you +//! can actually sign in to is the difference between a server a client developer can point at +//! and a surface they can only read. +//! +//! # Where the hashing happens relative to the lock +//! +//! Argon2id is deliberately expensive — tens of milliseconds — and this adapter holds a +//! `Mutex`. Every operation therefore computes or checks its hash **outside** the critical +//! section and touches the map only to read a snapshot or to write a result. Holding the lock +//! across a hash would serialize every account operation in the process behind the slowest +//! primitive in it. +//! +//! The cost of that is a read-modify-write gap in the failed-attempt counter, so two +//! simultaneous wrong passwords can be recorded as one. That is the right trade for a +//! development adapter and it is *not* the trade a Postgres adapter should make: there the +//! increment is one statement, which is why the port asks the adapter for the bookkeeping rather +//! than describing how to do it. + +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use jiff::{SignedDuration, Timestamp}; + +use super::credential::{CredentialError, Credentials}; +use super::directory::{AccountDirectory, Authentication, DirectoryError, DirectoryFuture}; +use super::profile::{ + AccountProfiles, PasswordChange, PasswordChanged, ProfileRecord, ProfileUpdate, +}; +use super::registry::{AccountRegistry, Registration}; +use crate::store::{Clock, UserId}; + +/// How many consecutive failures put an account into [`Authentication::Locked`]. +/// +/// Ten, and it is a **lockout** rather than a rate limit: the port is explicit that `Locked` is +/// account state the adapter owns, and that rate limiting is a counter with no port anywhere in +/// this crate. Cleared by a success and by a password change, which the port requires — leaving +/// it behind would bar somebody from an account they just proved they own. +pub const MAX_FAILED_ATTEMPTS: u32 = 10; + +/// One account, as this adapter holds it. +#[derive(Debug, Clone)] +struct Account { + /// The address it signs in with, verbatim as it was registered. + email: String, + /// The id every session and every manifest names. + user_id: UserId, + /// The Argon2id PHC string. Never a password. + stored: String, + /// The name it chose to be shown as. + display_name: Option, + /// When it was created. + created_at: Timestamp, + /// Consecutive failed credential presentations, since the last success or decay. + failures: u32, + /// When the most recent one was, if there has been one. + /// + /// The lockout's whole clock. Without it the count is a one-way door: **nothing in this + /// server can clear a lockout.** `login`, `reauthenticate` and `password` each ask the + /// directory first and refuse on `Locked` before verifying anything, there is no unlock + /// operation on any surface, and no operator command reaches this state — so a permanent + /// lockout is a permanently lost account. + last_failure_at: Option, +} + +impl Account { + /// Whether enough recent failures have accumulated to refuse a correct password. + /// + /// Recent is the operative word. The window is measured from the last **counted** failure, so + /// a person who mistyped their password ten times and walked away gets back in. + fn locked(&self, now: Timestamp, threshold: u32, window: SignedDuration) -> bool { + self.failures >= threshold + && self + .last_failure_at + .is_some_and(|at| now.duration_since(at) < window) + } + + /// The profile view of this account. + fn profile(&self) -> ProfileRecord { + ProfileRecord { + user_id: self.user_id.clone(), + email: self.email.clone(), + display_name: self.display_name.clone(), + created_at: self.created_at, + } + } +} + +/// Accounts held in this process, keyed by the address they registered with. +/// +/// Addresses are compared **verbatim**, exactly as the suite's double compares them. Case +/// folding would be a normalization policy this port does not describe, and a policy invented +/// here is a policy the Postgres adapter would have to guess at: `Foo@example.test` and +/// `foo@example.test` are two accounts until a slice says otherwise, and saying otherwise is a +/// decision about identity rather than about storage. +#[derive(Debug)] +pub struct InMemoryAccounts { + credentials: Credentials, + clock: Arc, + lockout_attempts: u32, + lockout_window: SignedDuration, + accounts: Mutex>, +} + +impl InMemoryAccounts { + /// An empty directory over `credentials`, locking an account out for `lockout_window` after + /// `lockout_attempts` consecutive failures ([`MAX_FAILED_ATTEMPTS`] by default). + /// + /// The verifier is passed in rather than constructed here because building one costs an + /// Argon2id hash (the decoy), and a composition root that builds several adapters should pay + /// that once. The clock is injected for the reason every other adapter in this crate injects + /// one: expiry that reads the wall clock directly is expiry a test can only assert by + /// sleeping. + pub fn new( + credentials: Credentials, + clock: Arc, + lockout_attempts: u32, + lockout_window: SignedDuration, + ) -> Self { + Self { + credentials, + clock, + lockout_attempts, + lockout_window, + accounts: Mutex::new(BTreeMap::new()), + } + } + + /// How many accounts are held, for a caller that logs the profile it came up on. + pub fn len(&self) -> usize { + self.accounts().len() + } + + /// Whether no account has been registered yet. + pub fn is_empty(&self) -> bool { + self.accounts().is_empty() + } + + /// Take the lock, recovering rather than propagating a poisoned one. + /// + /// The same choice [`crate::store::memory`] makes: a panic in one request must not turn + /// every later account lookup into a second panic, and the invariant this map holds is a + /// `BTreeMap`'s own rather than one a half-finished write could break. + fn accounts(&self) -> MutexGuard<'_, BTreeMap> { + self.accounts.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// A snapshot of the account `email` names, if there is one. + fn by_email(&self, email: &str) -> Option { + self.accounts().get(email).cloned() + } + + /// A snapshot of the account `user` names, if there is one. + fn by_id(&self, user: &UserId) -> Option { + self.accounts() + .values() + .find(|held| &held.user_id == user) + .cloned() + } + + /// Record the outcome of a credential presentation against `email`. + /// + /// One place, so the reset-on-success half cannot be forgotten at one of the two call sites. + fn record(&self, email: &str, granted: bool) { + let now = self.clock.now(); + let window = self.lockout_window; + if let Some(held) = self.accounts().get_mut(email) { + if granted { + held.failures = 0; + held.last_failure_at = None; + return; + } + // A failure after the window has passed starts a fresh run rather than tipping a + // stale count over. Otherwise ten mistypes spread over a year would lock an account + // on the tenth, which is not a guessing run and not what the ceiling is counting. + if held + .last_failure_at + .is_some_and(|at| now.duration_since(at) >= window) + { + held.failures = 0; + } + held.failures = held.failures.saturating_add(1); + held.last_failure_at = Some(now); + if held.failures == self.lockout_attempts { + tracing::warn!( + user = %held.user_id, + failures = held.failures, + window = %window, + "an account reached the failed-attempt ceiling and is locked out" + ); + } + } + } + + /// Decide `password` against a snapshot, and record what happened. + /// + /// The shared body of the two [`AccountDirectory`] methods: they differ only in how they + /// find the account, and that is exactly the difference the port wants them to have. + fn decide( + &self, + held: Option, + password: &str, + ) -> Result { + let Some(held) = held else { + // The timing-equalized miss. Refusing here without doing the work would leak the + // difference between an unknown address and a wrong password in the response time, + // whatever the body said. + self.credentials.absorb_miss(password); + return Ok(Authentication::Refused); + }; + if held.locked(self.clock.now(), self.lockout_attempts, self.lockout_window) { + // Still absorbed: a locked account that returned instantly would tell an attacker + // which addresses they have already spent attempts on. + self.credentials.absorb_miss(password); + return Ok(Authentication::Locked); + } + let granted = self + .credentials + .verify(password, &held.stored) + .map_err(unavailable)?; + self.record(&held.email, granted); + if granted { + Ok(Authentication::Granted(held.user_id)) + } else { + Ok(Authentication::Refused) + } + } +} + +/// A credential fault is a directory fault: no decision was reached. +/// +/// It is [`DirectoryError::Unavailable`] rather than a refusal because a stored hash this server +/// cannot read is a broken row, and answering "your password is wrong" would send somebody round +/// a loop that cannot succeed. +fn unavailable(error: CredentialError) -> DirectoryError { + tracing::error!(%error, "a stored credential could not be processed"); + DirectoryError::Unavailable { + detail: error.to_string(), + } +} + +impl AccountDirectory for InMemoryAccounts { + fn authenticate<'a>( + &'a self, + email: &'a str, + password: &'a str, + ) -> DirectoryFuture<'a, Authentication> { + Box::pin(async move { self.decide(self.by_email(email), password) }) + } + + fn authenticate_user<'a>( + &'a self, + user: &'a UserId, + password: &'a str, + ) -> DirectoryFuture<'a, Authentication> { + Box::pin(async move { self.decide(self.by_id(user), password) }) + } +} + +impl AccountRegistry for InMemoryAccounts { + fn create<'a>( + &'a self, + email: &'a str, + password: &'a str, + user: &'a UserId, + at: Timestamp, + ) -> DirectoryFuture<'a, Registration> { + Box::pin(async move { + // Hashed before the lock, so the check-and-write below is short. The cost of that + // ordering is a hash computed for an address that turns out to be taken, which is + // the cheap direction to be wrong in. + let stored = self.credentials.hash(password).map_err(unavailable)?; + // One critical section, as the port requires: a caller that read, saw nothing and + // then wrote has a window in which a second registration for the same address + // lands, and both would believe they own it. + let mut accounts = self.accounts(); + if accounts.contains_key(email) { + return Ok(Registration::AlreadyExists); + } + accounts.insert( + email.to_owned(), + Account { + email: email.to_owned(), + user_id: user.clone(), + stored, + display_name: None, + created_at: at, + failures: 0, + last_failure_at: None, + }, + ); + tracing::info!(%user, "an account was created in the in-memory directory"); + Ok(Registration::Created(user.clone())) + }) + } +} + +impl AccountProfiles for InMemoryAccounts { + fn read<'a>(&'a self, user: &'a UserId) -> DirectoryFuture<'a, Option> { + Box::pin(async move { Ok(self.by_id(user).as_ref().map(Account::profile)) }) + } + + fn update<'a>( + &'a self, + user: &'a UserId, + update: &'a ProfileUpdate, + ) -> DirectoryFuture<'a, Option> { + Box::pin(async move { + // One critical section, as the port requires: a read-modify-write a caller could + // interleave is an edit from another device silently clobbered. + let mut accounts = self.accounts(); + let Some(held) = accounts.values_mut().find(|held| &held.user_id == user) else { + return Ok(None); + }; + if let Some(display_name) = update.display_name.clone() { + held.display_name = display_name; + } + Ok(Some(held.profile())) + }) + } +} + +impl PasswordChange for InMemoryAccounts { + fn set_password<'a>( + &'a self, + user: &'a UserId, + password: &'a str, + _at: Timestamp, + ) -> DirectoryFuture<'a, PasswordChanged> { + Box::pin(async move { + let stored = self.credentials.hash(password).map_err(unavailable)?; + let mut accounts = self.accounts(); + let Some(held) = accounts.values_mut().find(|held| &held.user_id == user) else { + return Ok(PasswordChanged::NoSuchAccount); + }; + held.stored = stored; + // The port requires it: a change is a successful credential presentation, and + // leaving the lockout behind would bar somebody from an account they just proved + // they own. + held.failures = 0; + held.last_failure_at = None; + tracing::info!(%user, "an account's password was replaced"); + Ok(PasswordChanged::Yes) + }) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use jiff::{SignedDuration, Timestamp}; + + use super::{Credentials, InMemoryAccounts, MAX_FAILED_ATTEMPTS}; + use crate::auth::directory::{AccountDirectory, Authentication}; + use crate::auth::profile::{AccountProfiles, PasswordChange, PasswordChanged, ProfileUpdate}; + use crate::auth::registry::{AccountRegistry, Registration}; + use crate::store::UserId; + use crate::store::memory::ManualClock; + + const EMAIL: &str = "somebody@example.test"; + const PASSWORD: &str = "correct horse battery staple"; + + fn user() -> UserId { + UserId::new("018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e6f") + } + + /// A fifteen-minute lockout window, as a deployment gets by default. + const WINDOW: SignedDuration = SignedDuration::from_mins(15); + + /// A directory with one registered account, over a clock the test drives. + async fn seeded_on(clock: Arc) -> InMemoryAccounts { + let accounts = InMemoryAccounts::new( + Credentials::new().expect("the platform hashes"), + clock, + MAX_FAILED_ATTEMPTS, + WINDOW, + ); + assert_eq!( + accounts + .create(EMAIL, PASSWORD, &user(), Timestamp::UNIX_EPOCH) + .await + .expect("it writes"), + Registration::Created(user()) + ); + accounts + } + + /// The same, for a case with nothing to say about time. + async fn seeded() -> InMemoryAccounts { + seeded_on(Arc::new(ManualClock::default())).await + } + + /// Present a wrong password `times` times. + async fn fail(accounts: &InMemoryAccounts, times: u32) { + for _ in 0..times { + let _ = accounts.authenticate(EMAIL, "wrong").await; + } + } + + #[tokio::test] + async fn registering_then_signing_in_works() { + // The whole reason this adapter exists rather than a fail-closed stub. + let accounts = seeded().await; + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Granted(user()) + ); + } + + #[tokio::test] + async fn no_plaintext_password_is_retained() { + // The property the port is built on: the credential never rises above the adapter, and + // it is not sitting in the adapter either. + let accounts = seeded().await; + let held = accounts.by_email(EMAIL).expect("it is held"); + assert!(held.stored.starts_with("$argon2id$"), "{}", held.stored); + assert!(!held.stored.contains(PASSWORD)); + } + + #[tokio::test] + async fn a_taken_address_is_reported_and_nothing_is_written() { + let accounts = seeded().await; + let other = UserId::new("018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e70"); + assert_eq!( + accounts + .create( + EMAIL, + "a different password entirely", + &other, + Timestamp::UNIX_EPOCH + ) + .await + .expect("it answers"), + Registration::AlreadyExists + ); + // The first account's credential still works, so nothing was overwritten. + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Granted(user()) + ); + } + + #[tokio::test] + async fn an_unknown_address_and_a_wrong_password_are_one_answer() { + // The port collapses them into one value on purpose, so no caller *can* tell them apart. + let accounts = seeded().await; + assert_eq!( + accounts + .authenticate("nobody@example.test", PASSWORD) + .await + .expect("it answers"), + Authentication::Refused + ); + assert_eq!( + accounts + .authenticate(EMAIL, "the wrong password") + .await + .expect("it answers"), + Authentication::Refused + ); + } + + #[tokio::test] + async fn enough_failures_lock_the_account_and_a_correct_password_is_told_so() { + // `Locked` is the one refusal a *correct* password also receives, which is why it is a + // separate value: a client showing "wrong password" here would send somebody round a + // loop that cannot succeed. + let accounts = seeded().await; + for _ in 0..MAX_FAILED_ATTEMPTS { + assert_eq!( + accounts + .authenticate(EMAIL, "wrong") + .await + .expect("it answers"), + Authentication::Refused + ); + } + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Locked + ); + } + + #[tokio::test] + async fn a_lockout_decays_because_nothing_else_can_clear_it() { + // There is no unlock operation on any surface, and `login`, `reauthenticate` and + // `password` all refuse on `Locked` before they verify anything — so without a decay a + // lockout is a permanently lost account rather than a throttle. + let clock = Arc::new(ManualClock::default()); + let accounts = seeded_on(clock.clone()).await; + fail(&accounts, MAX_FAILED_ATTEMPTS).await; + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Locked + ); + + // One second short of the window: still locked. The boundary is asserted because an + // off-by-one here is a lockout that never engages. + clock.advance(WINDOW - SignedDuration::from_secs(1)); + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Locked + ); + + clock.advance(SignedDuration::from_secs(1)); + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Granted(user()) + ); + } + + #[tokio::test] + async fn attempts_during_a_lockout_do_not_extend_it() { + // Deliberate, and the direction is not obvious. Extending the window on every attempt + // would keep a live guessing run permanently locked out — and would hand anybody who can + // reach the endpoint a way to keep *somebody else's* account locked forever by hammering + // it, which is a denial of service on an account rather than a defence of it. So the + // window runs from the last **counted** failure, and an attempt made while locked is + // refused without being counted. + // + // What that costs is bounded and small: a run gets `MAX_FAILED_ATTEMPTS` guesses per + // window and no more, which is four a minute at the default. What bounds an attacker + // across *many* accounts is a rate limiter, and the counter port that would carry one + // has no trusted client address to key on (`registry`, the disclosure section). + let clock = Arc::new(ManualClock::default()); + let accounts = seeded_on(clock.clone()).await; + fail(&accounts, MAX_FAILED_ATTEMPTS).await; + for _ in 0..3 { + clock.advance(SignedDuration::from_mins(1)); + fail(&accounts, 1).await; + } + // Still inside the window measured from the tenth *counted* failure: locked. + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Locked + ); + // Past it: open, and the hammering did not move the deadline. + clock.advance(WINDOW); + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Granted(user()) + ); + } + + #[tokio::test] + async fn failures_spread_wider_than_the_window_never_accumulate() { + // Ten mistypes over a year is not a guessing run, and counting them as one would lock an + // account on a tenth attempt made months after the ninth. + let clock = Arc::new(ManualClock::default()); + let accounts = seeded_on(clock.clone()).await; + for _ in 0..MAX_FAILED_ATTEMPTS * 2 { + fail(&accounts, 1).await; + clock.advance(WINDOW + SignedDuration::from_secs(1)); + } + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Granted(user()) + ); + } + + #[tokio::test] + async fn a_success_before_the_ceiling_clears_the_count() { + let accounts = seeded().await; + for _ in 0..MAX_FAILED_ATTEMPTS - 1 { + let _ = accounts.authenticate(EMAIL, "wrong").await; + } + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Granted(user()) + ); + // Back to zero: the next wrong password does not tip an already-full counter over. + let _ = accounts.authenticate(EMAIL, "wrong").await; + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Granted(user()) + ); + } + + #[tokio::test] + async fn a_password_change_clears_a_lockout() { + // The port requires it: a change is a successful credential presentation. + let accounts = seeded().await; + for _ in 0..MAX_FAILED_ATTEMPTS { + let _ = accounts.authenticate(EMAIL, "wrong").await; + } + assert_eq!( + accounts + .set_password(&user(), "a brand new password", Timestamp::UNIX_EPOCH) + .await + .expect("it writes"), + PasswordChanged::Yes + ); + assert_eq!( + accounts + .authenticate(EMAIL, "a brand new password") + .await + .expect("it answers"), + Authentication::Granted(user()) + ); + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Refused + ); + } + + #[tokio::test] + async fn re_authentication_takes_the_account_from_the_credential_and_not_the_request() { + let accounts = seeded().await; + assert_eq!( + accounts + .authenticate_user(&user(), PASSWORD) + .await + .expect("it answers"), + Authentication::Granted(user()) + ); + let stranger = UserId::new("018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e71"); + assert_eq!( + accounts + .authenticate_user(&stranger, PASSWORD) + .await + .expect("it answers"), + Authentication::Refused + ); + } + + #[tokio::test] + async fn changing_a_password_for_an_absent_account_writes_nothing() { + let accounts = seeded().await; + let stranger = UserId::new("018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e72"); + assert_eq!( + accounts + .set_password(&stranger, "irrelevant", Timestamp::UNIX_EPOCH) + .await + .expect("it answers"), + PasswordChanged::NoSuchAccount + ); + } + + #[tokio::test] + async fn a_profile_reads_back_what_registration_wrote_and_takes_an_edit() { + let accounts = seeded().await; + let profile = accounts + .read(&user()) + .await + .expect("it answers") + .expect("the account exists"); + assert_eq!(profile.email, EMAIL); + assert_eq!(profile.display_name, None); + assert_eq!(profile.created_at, Timestamp::UNIX_EPOCH); + + let updated = accounts + .update( + &user(), + &ProfileUpdate { + display_name: Some(Some("Ada Lovelace".to_owned())), + }, + ) + .await + .expect("it answers") + .expect("the account exists"); + assert_eq!(updated.display_name.as_deref(), Some("Ada Lovelace")); + + // An absent field leaves the name alone; `Some(None)` clears it. + let untouched = accounts + .update(&user(), &ProfileUpdate::default()) + .await + .expect("it answers") + .expect("the account exists"); + assert_eq!(untouched.display_name.as_deref(), Some("Ada Lovelace")); + + let cleared = accounts + .update( + &user(), + &ProfileUpdate { + display_name: Some(None), + }, + ) + .await + .expect("it answers") + .expect("the account exists"); + assert_eq!(cleared.display_name, None); + } + + #[tokio::test] + async fn a_profile_for_an_absent_account_is_absent_and_not_an_error() { + // Reachable with a perfectly valid credential: a session outlives the account row it + // names if the account is deleted while a token is live. + let accounts = seeded().await; + let stranger = UserId::new("018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e73"); + assert!( + accounts + .read(&stranger) + .await + .expect("it answers") + .is_none() + ); + assert!( + accounts + .update(&stranger, &ProfileUpdate::default()) + .await + .expect("it answers") + .is_none() + ); + } + + #[tokio::test] + async fn addresses_are_compared_verbatim() { + // Recorded as a test rather than left implicit: case folding is a normalization policy + // this port does not describe, and #402's adapter has to make the same choice. + let accounts = seeded().await; + assert_eq!( + accounts + .authenticate("Somebody@Example.test", PASSWORD) + .await + .expect("it answers"), + Authentication::Refused + ); + } +} diff --git a/capsule-server/src/auth/credential.rs b/capsule-server/src/auth/credential.rs new file mode 100644 index 00000000..ddf906e5 --- /dev/null +++ b/capsule-server/src/auth/credential.rs @@ -0,0 +1,257 @@ +//! [`Credentials`] — the one place this server hashes and checks a password. +//! +//! # Why a helper and not a method on each adapter +//! +//! Three ports oblige their adapter to own credential verification end to end: +//! [`AccountDirectory`](super::AccountDirectory) verifies, +//! [`AccountRegistry`](super::AccountRegistry) hashes, and +//! [`PasswordChange`](super::PasswordChange) re-hashes. Their docs say why — a password hash +//! that crossed the port boundary would be a secret in a type that does not know it is one, and +//! it would put Argon2id's parameters in the routing layer, where a second call site can get +//! them subtly wrong. +//! +//! What that leaves is an obligation each *adapter* has to discharge identically. The in-memory +//! adapter beside this file discharges it, and the Postgres adapter (#402) discharges the same +//! one; written twice they would be two answers to a question with one right answer, and the +//! first divergence would be a parameter set — the thing that is invisible until somebody's +//! password is cheap to crack. So the algorithm is here, once, and an adapter owns *where the +//! hash is kept* rather than *what a hash is*. +//! +//! # Argon2id, at the crate's own defaults +//! +//! `Argon2::default()` is Argon2id, version 0x13, m=19456 KiB, t=2, p=1 — the parameter set the +//! RustCrypto crate publishes as its recommendation. Not the tiered parameters +//! [`capsule_core::crypto::pwkdf`] uses: those describe a *key derivation* that has to run on +//! the weakest device that will ever unwrap the blob, and this is a server-side verification +//! whose cost is paid on the server. The two are deliberately unrelated numbers, and the +//! parameters ride inside every PHC string this writes, so raising them is not a flag day. +//! +//! # The timing-equalized miss +//! +//! [`AccountDirectory`](super::AccountDirectory)'s contract is that no caller can tell an +//! unknown account from a wrong password. Returning early for an unknown address would leak +//! that difference in the response *time* whatever the body said, so an adapter must still do +//! the work — [`Credentials::absorb_miss`] is that work, verifying against a decoy hash +//! computed once at construction and discarding the answer. + +use std::fmt; + +use argon2::Argon2; +use argon2::password_hash::{PasswordHash, PasswordHasher as _, PasswordVerifier as _, SaltString}; + +/// How many bytes of salt every hash carries. +/// +/// Sixteen, which is what the PHC specification recommends and what `Argon2::default()` would +/// have generated. Longer buys nothing: the salt is a uniqueness device, not a secret. +const SALT_LEN: usize = 16; + +/// The password the decoy hash is built over. +/// +/// A constant, and it does not matter what it is: [`Credentials::absorb_miss`] never compares +/// against it successfully, and the only property required of the decoy is that verifying +/// against it costs what verifying against a real hash costs. +const DECOY_PASSWORD: &[u8] = b"capsule/decoy/there-is-no-such-account"; + +/// Something went wrong hashing or reading a credential. +/// +/// Never carries the password, the hash, or any part of either: this error is logged, and a +/// library that put a PHC string in a log line would put every account's salt there with it. +#[derive(Debug, thiserror::Error)] +#[error("a stored credential could not be processed: {detail}")] +pub struct CredentialError { + /// The algorithm's own description of the failure. + pub detail: String, +} + +/// Hashing and checking passwords, at one parameter set. +/// +/// `Debug` is hand-written and names the algorithm rather than the state, because the state +/// includes a hash. +#[derive(Clone)] +pub struct Credentials { + argon: Argon2<'static>, + /// A real Argon2id hash of a password nobody has, so a lookup that found nothing can cost + /// what a lookup that found something costs. + decoy: String, +} + +impl fmt::Debug for Credentials { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Credentials") + .field("algorithm", &"argon2id") + .finish_non_exhaustive() + } +} + +impl Credentials { + /// A verifier at the crate's default Argon2id parameters. + /// + /// Pays for one hash — the decoy — so that no request ever has to. + /// + /// # Errors + /// + /// Returns [`CredentialError`] if the platform cannot produce a hash at all, which is a + /// startup failure rather than a request failure: a server that cannot hash a password + /// cannot authenticate anybody and must refuse to start. + pub fn new() -> Result { + let argon = Argon2::default(); + let decoy = hash_with(&argon, DECOY_PASSWORD)?; + Ok(Self { argon, decoy }) + } + + /// The PHC string to store for `password`. + /// + /// A fresh random salt every time, so two accounts with the same password have different + /// stored hashes and a stolen table cannot be attacked once for both. + /// + /// # Errors + /// + /// Returns [`CredentialError`] if the hash cannot be computed. + pub fn hash(&self, password: &str) -> Result { + hash_with(&self.argon, password.as_bytes()) + } + + /// Whether `password` is the one `stored` was made from. + /// + /// A wrong password is `Ok(false)`, not an error: a refused credential is a normal answer to + /// a normal question, and modelling it as a failure is what leads to a `?` that turns a + /// sign-in rejection into a 500. + /// + /// # Errors + /// + /// Returns [`CredentialError`] only when `stored` is not a PHC string this server can read — + /// a corrupted row, not a wrong password. + pub fn verify(&self, password: &str, stored: &str) -> Result { + let parsed = PasswordHash::new(stored).map_err(|error| CredentialError { + detail: format!("the stored hash is not a readable PHC string ({error})"), + })?; + match self.argon.verify_password(password.as_bytes(), &parsed) { + Ok(()) => Ok(true), + Err(argon2::password_hash::Error::Password) => Ok(false), + Err(error) => Err(CredentialError { + detail: error.to_string(), + }), + } + } + + /// Spend what a verification costs, having found no account to verify against. + /// + /// The timing-equalized miss; see the module docs. The result is deliberately discarded — + /// there is nothing to learn from it, and a caller that branched on it would be branching + /// on whether a decoy password happens to be somebody's. + pub fn absorb_miss(&self, password: &str) { + let _ = self.verify(password, &self.decoy); + } +} + +/// Hash `password` under `argon` with a fresh salt. +fn hash_with(argon: &Argon2<'static>, password: &[u8]) -> Result { + let mut bytes = [0u8; SALT_LEN]; + // `ring`'s CSPRNG rather than `password_hash`'s optional `rand` feature: it is already this + // crate's source of randomness and its key generator, so one binary has one CSPRNG. + ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut bytes).map_err( + |error| CredentialError { + detail: format!("the platform could not produce a salt ({error})"), + }, + )?; + let salt = SaltString::encode_b64(&bytes).map_err(|error| CredentialError { + detail: format!("the salt could not be encoded ({error})"), + })?; + Ok(argon + .hash_password(password, &salt) + .map_err(|error| CredentialError { + detail: error.to_string(), + })? + .to_string()) +} + +#[cfg(test)] +mod tests { + use super::Credentials; + + /// One instance for the whole module: `Credentials::new` pays for an Argon2id hash, and + /// paying for it once per test is the difference between a fast suite and a slow one. + fn credentials() -> Credentials { + Credentials::new().expect("the platform hashes") + } + + #[test] + fn the_password_it_hashed_is_the_password_it_accepts() { + let credentials = credentials(); + let stored = credentials + .hash("correct horse battery staple") + .expect("it hashes"); + assert!( + credentials + .verify("correct horse battery staple", &stored) + .expect("it reads") + ); + } + + #[test] + fn a_wrong_password_is_a_refusal_and_not_an_error() { + // The distinction the port is built on: a refused credential is an answer, so a route + // cannot accidentally `?` it into a 500. + let credentials = credentials(); + let stored = credentials + .hash("correct horse battery staple") + .expect("it hashes"); + assert!( + !credentials + .verify("Correct Horse Battery Staple", &stored) + .expect("it reads") + ); + } + + #[test] + fn the_stored_hash_is_a_phc_string_naming_argon2id_and_never_the_password() { + let credentials = credentials(); + let stored = credentials + .hash("a password worth protecting") + .expect("it hashes"); + assert!(stored.starts_with("$argon2id$"), "{stored}"); + assert!(!stored.contains("a password worth protecting"), "{stored}"); + } + + #[test] + fn two_accounts_with_one_password_do_not_share_a_hash() { + // A fresh salt per hash, which is what stops one offline attack from covering both. + let credentials = credentials(); + let first = credentials.hash("shared").expect("it hashes"); + let second = credentials.hash("shared").expect("it hashes"); + assert_ne!(first, second); + assert!(credentials.verify("shared", &first).expect("it reads")); + assert!(credentials.verify("shared", &second).expect("it reads")); + } + + #[test] + fn a_corrupted_stored_hash_is_a_fault_and_not_a_refusal() { + // It must not read as "your password is wrong", which would send somebody round a loop + // that cannot succeed. + let credentials = credentials(); + assert!(credentials.verify("anything", "not a PHC string").is_err()); + } + + #[test] + fn absorbing_a_miss_costs_what_a_verification_costs() { + // Not a timing assertion — those are flaky by nature. What is asserted is that the + // decoy is a real hash the verifier reads, so the work actually happens: a decoy that + // failed to parse would return in microseconds and leak the account oracle the port + // exists to close. + let credentials = credentials(); + credentials.absorb_miss("anything at all"); + assert!( + !credentials + .verify("anything at all", &credentials.decoy) + .expect("the decoy parses") + ); + } + + #[test] + fn debug_names_the_algorithm_and_prints_no_hash() { + let credentials = credentials(); + let rendered = format!("{credentials:?}"); + assert!(rendered.contains("argon2id"), "{rendered}"); + assert!(!rendered.contains('$'), "{rendered}"); + } +} diff --git a/capsule-server/src/auth/mod.rs b/capsule-server/src/auth/mod.rs index dad5adb2..564b8010 100644 --- a/capsule-server/src/auth/mod.rs +++ b/capsule-server/src/auth/mod.rs @@ -28,14 +28,26 @@ //! constructor that will eventually be got wrong positionally, and two `Arc` swapped at a //! call site is a compile error only by luck. //! -//! # Adapters this slice does not write +//! # Adapters, and the one that is still owed //! -//! There is no [`AccountDirectory`] implementation in `src/`, and that is deliberate rather than -//! unfinished: the real one is Postgres, the test one is a double, and a double in `src/` is a -//! fake credential directory shipped inside the server binary. The suite's doubles live in -//! `tests/support/`. Same reasoning, one step further than `S-C29` took it for the session -//! store, and it is why [`SessionTokens`] is not a trait at all. +//! [`InMemoryAccounts`] implements all four account ports over a map, verifying with the +//! [`credential`] helper's Argon2id — so the development profile can register an account and +//! sign in to it — and [`InMemoryTotp`] implements the second factor's. Neither is durable, and +//! neither is reachable without `--memory` +//! ([`Backends::Memory`](crate::config::Backends)), which is an explicit operator act. +//! +//! What is deliberately **not** here is a permissive one. `tests/support/mod.rs` holds a +//! credential directory that "accepts whatever password it was told to accept", and its own docs +//! say why that "belongs in a test binary and nowhere a server could link it". The distinction +//! the port modules were drawing is between a double and an implementation, not between +//! Postgres and everything else. +//! +//! The Postgres adapters are owed (#402), and they are written against these ports and the +//! suites over them. [`SessionTokens`] is not a trait at all, for the reason `credential` +//! records: it is a pure function of a key and a clock. +pub mod accounts_memory; +pub mod credential; pub mod directory; pub mod profile; pub mod registry; @@ -45,6 +57,8 @@ pub mod totp; use std::sync::Arc; +pub use self::accounts_memory::InMemoryAccounts; +pub use self::credential::{CredentialError, Credentials}; pub use self::directory::{AccountDirectory, Authentication, DirectoryError, DirectoryFuture}; pub use self::profile::{ AccountProfiles, MAX_DISPLAY_NAME_CHARS, MalformedProfile, PasswordChange, PasswordChanged, @@ -57,8 +71,8 @@ pub use self::tokens::{ TokenError, TokenKind, VerifiedChallenge, VerifiedToken, }; pub use self::totp::{ - ActivateOutcome, BeginOutcome, CHALLENGE_TTL, ConsumeOutcome, EnrollmentState, TotpCodes, - TotpContext, TotpEnrollment, TotpSecret, TotpStore, UnusableSecret, + ActivateOutcome, BeginOutcome, CHALLENGE_TTL, ConsumeOutcome, EnrollmentState, InMemoryTotp, + TotpCodes, TotpContext, TotpEnrollment, TotpSecret, TotpStore, UnusableSecret, }; use crate::store::{AuthStateStore, Clock}; diff --git a/capsule-server/src/auth/totp.rs b/capsule-server/src/auth/totp.rs index 26a9f703..0b891f40 100644 --- a/capsule-server/src/auth/totp.rs +++ b/capsule-server/src/auth/totp.rs @@ -38,14 +38,23 @@ //! would otherwise turn off the control that exists to make a stolen access token insufficient. //! The retired surface got this right and it is preserved deliberately. //! -//! # No adapter here +//! # The one adapter that belongs in `src/` //! -//! Same reason [`AccountDirectory`](super::AccountDirectory) has none: the real one is Postgres, -//! and a shared-secret store in `src/` is a fake credential store shipped inside the server -//! binary. The suite's lives in `tests/support/`. +//! [`InMemoryTotp`] is here, and the account ports' "a double in `src/` is a fake credential +//! store shipped inside the server binary" reasoning does not reach it. A TOTP secret is +//! **server-generated**: nothing a caller presents is ever stored, so there is no credential to +//! be permissive about, and the three properties the port promises — the check-and-write in +//! [`TotpStore::begin`], the pending-only [`TotpStore::activate`], and the compare-and-set in +//! [`TotpStore::consume`] — are all expressible over a map without fudging any of them. What it +//! is missing is durability, which is what makes it the development profile +//! ([`Backends::Memory`](crate::config::Backends)) rather than a deployment. +//! +//! The Postgres adapter is still owed (#402), and it is written against this contract and the +//! suite over it rather than against this type. +use std::collections::BTreeMap; use std::fmt; -use std::sync::Arc; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use jiff::{SignedDuration, Timestamp}; use subtle::ConstantTimeEq as _; @@ -357,6 +366,98 @@ impl TotpContext { } } +/// The second-factor enrollments this process holds. +/// +/// A real implementation of the port's contract rather than a stub; see the module docs for why +/// this one belongs in `src/` when the account ports' adapters do not. +#[derive(Debug, Default)] +pub struct InMemoryTotp { + held: Mutex>, +} + +impl InMemoryTotp { + /// An empty store. + pub fn new() -> Self { + Self::default() + } + + /// Take the lock, recovering rather than propagating a poisoned one. + /// + /// The same choice [`crate::store::memory`] makes: a panic in one request must not turn + /// every later second-factor check into a second panic. + fn enrollments(&self) -> MutexGuard<'_, BTreeMap> { + self.held.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +impl TotpStore for InMemoryTotp { + fn begin(&self, record: TotpEnrollment) -> DirectoryFuture<'_, BeginOutcome> { + Box::pin(async move { + // One critical section, as the port requires: a caller that read, saw no active + // enrollment and then wrote has a window in which a confirmation lands, and the + // confirmed factor is then silently replaced. + let mut held = self.enrollments(); + if held + .get(&record.user_id) + .is_some_and(|existing| existing.state == EnrollmentState::Active) + { + return Ok(BeginOutcome::AlreadyActive); + } + // A *pending* enrollment is replaced without ceremony: somebody who abandoned a QR + // code and started again is the ordinary case, and nothing is protecting an + // unconfirmed secret. + held.insert(record.user_id.clone(), record); + Ok(BeginOutcome::Started) + }) + } + + fn read<'a>(&'a self, user: &'a UserId) -> DirectoryFuture<'a, Option> { + Box::pin(async move { Ok(self.enrollments().get(user).cloned()) }) + } + + fn activate<'a>( + &'a self, + user: &'a UserId, + step: u64, + at: Timestamp, + ) -> DirectoryFuture<'a, ActivateOutcome> { + Box::pin(async move { + let mut held = self.enrollments(); + let Some(record) = held.get_mut(user) else { + return Ok(ActivateOutcome::NotPending); + }; + if record.state != EnrollmentState::Pending { + return Ok(ActivateOutcome::NotPending); + } + record.state = EnrollmentState::Active; + record.activated_at = Some(at); + // The confirming code is spent, so it cannot also complete a sign-in. + record.last_step = Some(step); + Ok(ActivateOutcome::Activated) + }) + } + + fn consume<'a>(&'a self, user: &'a UserId, step: u64) -> DirectoryFuture<'a, ConsumeOutcome> { + Box::pin(async move { + // Compare-and-set inside one critical section, not a read followed by a write: two + // sign-ins racing on the same six digits is exactly the case a read-then-write + // loses, and losing it accepts a replay. + let mut held = self.enrollments(); + let Some(record) = held.get_mut(user) else { + return Ok(ConsumeOutcome::NotEnrolled); + }; + if record.last_step.is_some_and(|last| step <= last) { + return Ok(ConsumeOutcome::Replayed); + } + record.last_step = Some(step); + Ok(ConsumeOutcome::Fresh) + }) + } + + fn disable<'a>(&'a self, user: &'a UserId) -> DirectoryFuture<'a, bool> { + Box::pin(async move { Ok(self.enrollments().remove(user).is_some()) }) + } +} #[cfg(test)] mod tests { use jiff::Timestamp; @@ -501,3 +602,142 @@ mod tests { assert!(!format!("{secret:?}").contains("JBSWY")); } } +#[cfg(test)] +mod memory_tests { + use jiff::Timestamp; + + use super::{ + ActivateOutcome, BeginOutcome, ConsumeOutcome, EnrollmentState, InMemoryTotp, TotpCodes, + TotpEnrollment, TotpStore, + }; + use crate::store::UserId; + + fn user() -> UserId { + UserId::new("018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e6f") + } + + fn pending() -> TotpEnrollment { + TotpEnrollment { + user_id: user(), + secret: TotpCodes::generate_secret(), + state: EnrollmentState::Pending, + last_step: None, + enrolled_at: Timestamp::UNIX_EPOCH, + activated_at: None, + } + } + + #[tokio::test] + async fn an_abandoned_pending_enrollment_is_replaced_without_ceremony() { + let store = InMemoryTotp::new(); + assert_eq!( + store.begin(pending()).await.expect("it writes"), + BeginOutcome::Started + ); + let second = pending(); + let expected = second.secret.clone(); + assert_eq!( + store.begin(second).await.expect("it writes"), + BeginOutcome::Started + ); + let held = store + .read(&user()) + .await + .expect("it answers") + .expect("it is held"); + assert_eq!(held.secret, expected); + } + + #[tokio::test] + async fn an_active_enrollment_is_never_silently_replaced() { + // The one refusal `begin` makes, and the reason it exists: an enroll that overwrote an + // active secret would let a stolen session swap the factor for one the attacker holds + // without ever presenting a code. + let store = InMemoryTotp::new(); + store.begin(pending()).await.expect("it writes"); + store + .activate(&user(), 7, Timestamp::UNIX_EPOCH) + .await + .expect("it writes"); + assert_eq!( + store.begin(pending()).await.expect("it answers"), + BeginOutcome::AlreadyActive + ); + } + + #[tokio::test] + async fn only_a_pending_enrollment_activates_and_the_confirming_code_is_spent() { + let store = InMemoryTotp::new(); + assert_eq!( + store + .activate(&user(), 7, Timestamp::UNIX_EPOCH) + .await + .expect("it answers"), + ActivateOutcome::NotPending, + "there is nothing to confirm" + ); + store.begin(pending()).await.expect("it writes"); + assert_eq!( + store + .activate(&user(), 7, Timestamp::UNIX_EPOCH) + .await + .expect("it writes"), + ActivateOutcome::Activated + ); + assert_eq!( + store + .activate(&user(), 8, Timestamp::UNIX_EPOCH) + .await + .expect("it answers"), + ActivateOutcome::NotPending, + "an active enrollment is not pending" + ); + // The code that confirmed the enrollment must not also complete a sign-in. + assert_eq!( + store.consume(&user(), 7).await.expect("it answers"), + ConsumeOutcome::Replayed + ); + } + + #[tokio::test] + async fn a_step_at_or_below_the_last_used_one_is_a_replay() { + // RFC 6238 §5.2: a code is accepted at most once, and a code is valid for three steps + // with drift — so re-typing it twelve seconds later is a real attack. + let store = InMemoryTotp::new(); + store.begin(pending()).await.expect("it writes"); + assert_eq!( + store.consume(&user(), 10).await.expect("it answers"), + ConsumeOutcome::Fresh + ); + assert_eq!( + store.consume(&user(), 10).await.expect("it answers"), + ConsumeOutcome::Replayed + ); + assert_eq!( + store.consume(&user(), 9).await.expect("it answers"), + ConsumeOutcome::Replayed + ); + assert_eq!( + store.consume(&user(), 11).await.expect("it answers"), + ConsumeOutcome::Fresh + ); + } + + #[tokio::test] + async fn consuming_against_nothing_says_so_rather_than_accepting() { + let store = InMemoryTotp::new(); + assert_eq!( + store.consume(&user(), 1).await.expect("it answers"), + ConsumeOutcome::NotEnrolled + ); + } + + #[tokio::test] + async fn disabling_reports_whether_there_was_anything_to_disable() { + let store = InMemoryTotp::new(); + assert!(!store.disable(&user()).await.expect("it answers")); + store.begin(pending()).await.expect("it writes"); + assert!(store.disable(&user()).await.expect("it answers")); + assert!(store.read(&user()).await.expect("it answers").is_none()); + } +} diff --git a/capsule-server/src/bin/gen_openapi.rs b/capsule-server/src/bin/gen_openapi.rs deleted file mode 100644 index 11a40402..00000000 --- a/capsule-server/src/bin/gen_openapi.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! Deterministic OpenAPI **3.2** document dump for the Kynos server (slice `S-C34`). -//! -//! Serializes [`capsule_server::openapi()`] to `capsule-server/openapi.json` and, with -//! `--check`, fails when the committed copy is stale. It is the drift guard for the rebuild's -//! central claim: that the description is derived from the types and cannot disagree with them. -//! -//! That claim is already enforced *inside* the crate — `assert_conformance` catches a response -//! the document did not predict, and `assert_declared_responses_covered` catches a promise no -//! test produced. Neither helps a **client**. A surface can be ported, the emitted document can -//! change shape, and nothing outside the crate notices until someone regenerates by hand. This -//! binary is what makes such a change fail. -//! -//! It needs no database, no Valkey, no key material, no disk and no network: `openapi()` builds -//! the router purely to describe it. That is what lets `--check` run in the Rust check gate, -//! exactly as `i18n-check` and the Salvo `openapi-check` do. -//! -//! **This is not yet the SDK's contract.** `capsule-sdk` still generates from -//! `capsule-sdk/openapi.json`, the Salvo document, and the two are deliberately gated -//! separately while the port proceeds — committing both as *the* contract at once would leave -//! no way to say which one a client should believe. The changeover is its own step: it also -//! drops the four `spargen::OmitRule` narrowings, which exist only because the Salvo document is -//! structurally invalid in ways Kynos cannot express. -//! -//! Usage: -//! - `gen_openapi [FILE]` writes the document (default `capsule-server/openapi.json`). -//! - `gen_openapi --check [FILE]` fails if the committed document is stale, writing nothing. - -use std::path::PathBuf; - -use clap::Parser; -use color_eyre::eyre::{Context, Result, bail}; - -#[derive(Parser)] -#[command(author, version, about, long_about = None)] -struct Cli { - /// Output path for the OpenAPI 3.2 document (relative to the repo root). - #[arg(value_name = "FILE", default_value = "capsule-server/openapi.json")] - output: PathBuf, - - /// Verify the committed document is up to date instead of writing it (CI drift gate). - #[arg(long)] - check: bool, -} - -fn main() -> Result<()> { - color_eyre::install()?; - let cli = Cli::parse(); - - let document = capsule_server::openapi() - .map_err(|e| color_eyre::eyre::eyre!("describing the router: {e}"))?; - // `to_json` is already pretty-printed; the trailing newline matches the Salvo dump so both - // committed documents are ordinary text files rather than one-line blobs in a diff. - let mut json = document - .to_json() - .wrap_err("serializing the OpenAPI document to JSON")?; - json.push('\n'); - - if cli.check { - let committed = std::fs::read_to_string(&cli.output).wrap_err_with(|| { - format!("cannot read committed document at {}", cli.output.display()) - })?; - if committed != json { - bail!( - "OpenAPI document at {} is out of sync with the server; run \ - `mise run openapi-kynos` and commit the result", - cli.output.display() - ); - } - println!("OpenAPI document is up to date: {}", cli.output.display()); - } else { - if let Some(parent) = cli.output.parent() { - std::fs::create_dir_all(parent) - .wrap_err_with(|| format!("creating {}", parent.display()))?; - } - std::fs::write(&cli.output, &json) - .wrap_err_with(|| format!("writing {}", cli.output.display()))?; - println!("Wrote {}", cli.output.display()); - } - - Ok(()) -} diff --git a/capsule-server/src/boot.rs b/capsule-server/src/boot.rs new file mode 100644 index 00000000..9552433e --- /dev/null +++ b/capsule-server/src/boot.rs @@ -0,0 +1,833 @@ +//! [`assemble`] — the one composition root, and the only place adapters are chosen. +//! +//! # Why this is a library module and not `main` +//! +//! Until this slice the only composition root in the tree was `tests/support/mod.rs`, which +//! assembles seventeen module contexts out of test doubles. Nothing assembled the server. A +//! composition root that lives in `main` is a composition root nothing tests: "the router +//! builds", "every port has an adapter" and "the published signing key is the one the tokens +//! verify under" are all properties of *this function*, and they are asserted below rather than +//! discovered on a deployment. +//! +//! # The seam, and what #402 and #403 change +//! +//! Selection is a two-arm `match` on [`Backends`] and not a trait. The `Arc` fields in +//! [`Modules`] already **are** the abstraction; a second one over the top would abstract the +//! composition root from itself. When the Postgres (#402) and Valkey (#403) adapters land they +//! fill the [`Backends::Durable`] arm, and nothing else here moves. +//! +//! Today that arm refuses. `store/mod.rs` has said since `S-C29` that *"Valkey is required; the +//! server refuses to boot without `VALKEY_URL`"* and that the in-memory adapters are a test +//! double rather than a deployment profile — and nothing enforced either sentence, because there +//! was no boot path to enforce it in. Now there is: no `VALKEY_URL` and no `--memory` is a +//! configuration fault naming `VALKEY_URL` ([`Config::load`]), and `VALKEY_URL` set is +//! [`BootError::AdapterUnavailable`] naming the issue that will honour it. Neither ever silently +//! becomes an in-memory server. +//! +//! # What the memory profile is, precisely +//! +//! Every deterministic in-crate adapter, over a **real** [`FilesystemBlobStore`] and a real +//! [`SystemClock`]. Two consequences worth stating because an operator will meet both: +//! +//! - **The blobs survive a restart and the index does not.** That is not a bug to route around, +//! it is the shape of a profile whose durable half is exactly the one adapter that has been +//! written. It also makes the profile useful to `scrub`, which compares those two halves and +//! will honestly report every blob as an orphan. +//! - **The collector's marks do not survive either.** [`crate::gc::collect`] marks a blob on one +//! pass and sweeps it on a later pass once the grace window has passed, so a fresh process can +//! only ever mark. Sweeping needs the durable mark store #402 brings. + +use std::sync::Arc; + +use jiff::Timestamp; + +use crate::album::authority::ProvisionedAuthority; +use crate::album::{AlbumContext, InMemoryAlbums}; +use crate::app::{App, Modules}; +use crate::attestation::{AttestationContext, InMemoryReceipts, LocalAttestationKey}; +use crate::auth::{ + AuthCollaborators, AuthContext, Credentials, InMemoryAccounts, InMemoryTotp, SessionTokens, + TotpCodes, TotpContext, +}; +use crate::blob::FilesystemBlobStore; +use crate::config::{Backends, Config}; +use crate::counter::{CounterContext, InMemoryCounters}; +use crate::directory::{DeviceDirectoryContext, InMemoryDeviceDirectory}; +use crate::discovery::revocation::InMemoryRevocations; +use crate::discovery::{DiscoveryContext, ProtocolWindow, ServerInfo}; +use crate::drop::{DropContext, InMemoryDrops}; +use crate::enrollment::EnrollmentContext; +use crate::escrow::{EscrowContext, InMemoryEscrow}; +use crate::gc::CollectionContext; +use crate::gc::memory::InMemoryCollection; +use crate::index::memory::InMemoryAssetIndex; +use crate::moderation::{InMemoryModeration, ModerationContext}; +use crate::quota::{InMemoryQuota, QuotaContext, QuotaLimits}; +use crate::scrub::ScrubContext; +use crate::serve::ServeContext; +use crate::share::{InMemoryShares, ShareContext}; +use crate::store::SystemClock; +use crate::store::memory::{ + InMemoryAuthState, InMemoryChallenges, InMemoryChannels, InMemoryCohorts, InMemoryEnrollments, + InMemoryUploadSessions, +}; +use crate::sync::{CursorCodec, SyncContext}; +use crate::upload::{UploadContext, UploadPolicy}; +use crate::verify::VerifyContext; + +/// Why a process could not be assembled. +/// +/// Every variant is a **startup** failure. There is deliberately no variant for a degraded boot: +/// a server that came up with one port missing would answer some requests and 500 on others, +/// which is harder to diagnose than a process that refused to start and said why. +#[derive(Debug, thiserror::Error)] +pub enum BootError { + /// The configuration itself is not usable. + #[error(transparent)] + Configuration(#[from] crate::config::ConfigError), + /// A setting [`Config::load`] treats as optional is required by this path. + /// + /// The backstop behind [`crate::config::Demands`], which is the aggregating front door an + /// operator reads. This variant fires only when the two disagree — a subcommand asking for + /// more than it declared — which is a programming error rather than a deployment one, and + /// failing loudly beats an `expect`. + #[error("{key} is required to assemble this server and is not set")] + Missing { + /// The setting. + key: &'static str, + }, + /// The blob root could not be opened. + /// + /// Refused rather than deferred: a store that cannot be created now is a store every upload + /// will fail against at write time, and a server that accepts bytes it cannot keep is worse + /// than one that does not start. + #[error("the blob store at {root} could not be opened: {detail}")] + BlobRoot { + /// The path that was tried. + root: String, + /// The filesystem's own description. + detail: String, + }, + /// The token-signing key could not be read. + #[error("the server's token-signing key could not be loaded: {detail}")] + SigningKey { + /// What was wrong with it. Never the key. + detail: String, + }, + /// The credential verifier could not be built. + #[error("the credential verifier could not be built: {detail}")] + Credentials { + /// The algorithm's own description. + detail: String, + }, + /// A durable backend was selected and its adapter is not written yet. + /// + /// Named with the issue that will honour it, because "not implemented" without a pointer is + /// a dead end for whoever reads it. + #[error("{key} selects a durable adapter that is not implemented yet (see {issue})")] + AdapterUnavailable { + /// The setting that selected it. + key: &'static str, + /// Where the work is tracked. + issue: &'static str, + }, + /// An operator command was run without `--memory` and there is no durable index to read. + /// + /// Deliberately not [`Self::AdapterUnavailable`]: that one names `VALKEY_URL`, which an + /// operator running `capsule-server scrub` has typically never set, and pointing them at a + /// variable that would not have helped is worse than saying nothing. + #[error( + "this command needs `--memory`: it compares the index against the blob store, and the \ + only index adapter written is the in-memory one (see {issue})" + )] + MaintenanceNeedsMemory { + /// Where the work is tracked. + issue: &'static str, + }, + /// The router's own types do not describe a buildable server. + /// + /// Unreachable in practice — the conformance suite builds the same router on every test run + /// — and kept because the alternative is an `expect` in the composition root. + #[error("the router could not be built: {detail}")] + Router { + /// Kynos's own description. + detail: String, + }, +} + +/// The two operator workers' collaborators. +/// +/// Assembled **without any key material**, which is what makes `config`'s claim that +/// `gc`/`purge`/`scrub` need none structural rather than a promise: there is no signing key in +/// scope here to accidentally require. A maintenance host that had to hold the production +/// token-signing key to sweep a directory would be a reason to put the key on a maintenance +/// host. +/// +/// Neither worker has a wire surface, so neither is reachable through the router — which is why +/// they are a separate assembly rather than fields on [`App`]. +#[derive(Debug)] +pub struct Maintenance { + /// The collector's collaborators (`gc`, `purge`). + pub collection: CollectionContext, + /// The integrity scrub's collaborators (`scrub`). + pub scrub: ScrubContext, +} + +/// A server, ready to serve. +/// +/// Carries [`Maintenance`] as well, over the **same** stores: one index, one blob store and one +/// mark store behind all three, which is what makes "upload it, then let the collector see it" a +/// property of the server rather than of three disconnected assemblies. +#[derive(Debug)] +pub struct Assembled { + /// The application context every operation resolves its dependencies from. + pub app: App, + /// The operator workers, over the same stores. + pub maintenance: Maintenance, +} + +impl Assembled { + /// Build the service the listener drives. + /// + /// # Errors + /// + /// Returns [`BootError::Router`] if the router's types do not describe a buildable server. + pub fn service(&self) -> Result, BootError> { + crate::service(self.app.clone()).map_err(|error| BootError::Router { + detail: error.to_string(), + }) + } +} + +/// Assemble a server from `config`. +/// +/// # Errors +/// +/// Returns [`BootError`] for any of the startup failures above. Nothing is left half-built: the +/// blob root is the only side effect, and it is idempotent. +pub async fn assemble(config: &Config) -> Result { + let stores = stores(config).await?; + match config.backends { + Backends::Memory => memory(config, stores), + Backends::Durable => Err(durable()), + } +} + +/// Assemble only what `gc`, `purge` and `scrub` read. +/// +/// # Errors +/// +/// Returns [`BootError`] for the blob root or an unimplemented durable backend. It cannot fail +/// on key material, because it asks for none. +pub async fn assemble_maintenance(config: &Config) -> Result { + let stores = stores(config).await?; + match config.backends { + Backends::Memory => { + let maintenance = stores.maintenance(config.grace_window); + tracing::info!( + blob_root = %stores.root.display(), + grace_window = %config.grace_window, + "assembled the operator workers on the in-memory adapters" + ); + Ok(maintenance) + } + // **Not** `durable()`. A maintenance command reaching here has almost always set no + // backend variable at all — `gc`/`purge`/`scrub` never demand `VALKEY_URL`, so naming it + // would send an operator to configure a variable that would not have helped. What is + // actually missing is the durable **index**: these two workers compare the index against + // the blob store, and the only index this crate has is the in-memory one, which is what + // `--memory` selects. + Backends::Durable => Err(BootError::MaintenanceNeedsMemory { + issue: "#402 (the Postgres index)", + }), + } +} + +/// The refusal `store/mod.rs` documents. +/// +/// `Config::load` already turned "no `VALKEY_URL` and no `--memory`" into a configuration fault +/// naming the variable, so reaching here means the operator *did* set it — and the honest answer +/// is that nothing reads it yet. +fn durable() -> BootError { + BootError::AdapterUnavailable { + key: "VALKEY_URL", + issue: "#403 (Valkey) and #402 (Postgres)", + } +} + +/// The stores every subcommand shares, and the only one of them that is durable. +/// +/// A struct rather than six locals because [`assemble`] and [`assemble_maintenance`] must build +/// the *same* stores: two functions each opening their own index is two servers that disagree +/// about what is in it. +#[derive(Debug)] +struct Stores { + root: std::path::PathBuf, + clock: Arc, + blobs: Arc, + index: Arc, + uploads: Arc, + marks: Arc, + quotas: Arc, +} + +impl Stores { + /// The operator workers over these stores. + fn maintenance(&self, grace_window: jiff::SignedDuration) -> Maintenance { + Maintenance { + collection: CollectionContext::new( + self.index.clone(), + self.blobs.clone(), + self.marks.clone(), + self.quotas.clone(), + self.clock.clone(), + grace_window, + ), + scrub: ScrubContext::new(self.index.clone(), self.blobs.clone(), self.uploads.clone()), + } + } +} + +/// Open the blob root and build the stores over it. +/// +/// The blob root is the one thing on this path that touches the filesystem, and it is refused +/// rather than deferred: a store that cannot be created now is a store every upload will fail +/// against at write time, and a server that accepts bytes it cannot keep is worse than one that +/// does not start. +async fn stores(config: &Config) -> Result { + let root = config + .blob_root + .clone() + .ok_or(BootError::Missing { key: "BLOB_ROOT" })?; + let clock = Arc::new(SystemClock); + let blobs = + Arc::new( + FilesystemBlobStore::open(&root) + .await + .map_err(|error| BootError::BlobRoot { + root: root.display().to_string(), + detail: error.to_string(), + })?, + ); + Ok(Stores { + root, + index: Arc::new(InMemoryAssetIndex::new()), + uploads: Arc::new(InMemoryUploadSessions::with_default_ttl(clock.clone())), + marks: Arc::new(InMemoryCollection::new()), + quotas: Arc::new(InMemoryQuota::new()), + blobs, + clock, + }) +} + +/// The development profile: every in-crate adapter, over a real blob store and a real clock. +#[allow( + clippy::too_many_lines, + reason = "seventeen module contexts, named once each; splitting it would hide the shape" +)] +fn memory(config: &Config, stores: Stores) -> Result { + let der = config.signing_key_der.as_ref().ok_or(BootError::Missing { + key: "JWT_ED25519_DER", + })?; + let cursor_key = config.sync_cursor_mac_key.ok_or(BootError::Missing { + key: "SYNC_CURSOR_MAC_KEY", + })?; + let seed = config.attestation_key_seed.ok_or(BootError::Missing { + key: "ATTESTATION_KEY_SEED", + })?; + + // Cloned rather than moved: `stores` is handed to `Stores::maintenance` at the end, so the + // application and the two operator workers are built over the *same* stores. Every clone + // here is an `Arc` refcount bump. + let root = stores.root.clone(); + let clock = stores.clock.clone(); + let blobs = stores.blobs.clone(); + let index = stores.index.clone(); + let uploads = stores.uploads.clone(); + let marks = stores.marks.clone(); + let quotas = stores.quotas.clone(); + + // The signer is built from the private key alone and derives its own public half, which is + // what lets `ServerInfo` below publish the key tokens actually verify under rather than one + // an operator pasted beside it. + let tokens = Arc::new( + SessionTokens::from_pkcs8(der.expose(), clock.clone()).map_err(|error| { + BootError::SigningKey { + detail: error.detail, + } + })?, + ); + + // One verifier, shared: building it costs an Argon2id hash (the timing-equalized miss's + // decoy), and that is a startup cost rather than a per-request one. + let credentials = Credentials::new().map_err(|error| BootError::Credentials { + detail: error.detail, + })?; + let accounts = Arc::new(InMemoryAccounts::new( + credentials, + clock.clone(), + config.lockout_attempts, + config.lockout_window, + )); + + let albums = Arc::new(InMemoryAlbums::new()); + let directories = Arc::new(InMemoryDeviceDirectory::new()); + // The production write authority (`S-C19`/`S-C20`), not a permissive double: it reads the + // album's own pin and the account's published device directory, so invariants 6 and 7 mean + // what they say even in the development profile. + let authority = Arc::new(ProvisionedAuthority::new( + albums.clone(), + directories.clone(), + clock.clone(), + )); + let receipts = Arc::new(InMemoryReceipts::new()); + // Distinct from the token signer, as the design requires: a receipt that verified under the + // operational key would let anything holding that key manufacture custody evidence. + // + // Which is why a durable deployment has to **supply** `ATTESTATION_KEY_SEED` rather than + // have it derived (`config`, the key-material section). A different HKDF `info` over the + // same input is not a separation at all — anyone holding `JWT_ED25519_DER` recomputes it — + // and it read as one, which is worse than no comment. The derivation survives only under + // `Backends::Memory`, where the server is a development act whose state is discarded. + let attestation_key = Arc::new(LocalAttestationKey::new( + config.server_domain.clone(), + capsule_core::crypto::keys::HybridSigningKey::from_seed64(&seed), + )); + + let server_info = Arc::new(ServerInfo::new( + config.server_domain.clone(), + config.api_base_url.clone(), + ProtocolWindow { + min: config.protocol_min.clone(), + max: config.protocol_max.clone(), + }, + tokens.public_key().to_vec(), + )); + + let app = App::new(Modules { + auth: AuthContext::new(AuthCollaborators { + sessions: Arc::new(InMemoryAuthState::with_default_ttl(clock.clone())), + accounts: accounts.clone(), + registry: accounts.clone(), + profiles: accounts.clone(), + passwords: accounts.clone(), + challenges: Arc::new(InMemoryChallenges::with_default_ttl(clock.clone())), + cohorts: Arc::new(InMemoryCohorts::new()), + tokens: tokens.clone(), + clock: clock.clone(), + }), + totp: TotpContext::new( + Arc::new(InMemoryTotp::new()), + // The issuer is what an authenticator app shows beside the code, so it is this + // deployment's own name rather than a constant every deployment shares. + Arc::new(TotpCodes::new(config.server_domain.clone())), + ), + upload: UploadContext::new( + uploads.clone(), + blobs.clone(), + index.clone(), + authority.clone(), + clock.clone(), + UploadPolicy::default(), + ), + sync: SyncContext::new( + index.clone(), + blobs.clone(), + Arc::new(CursorCodec::new(&cursor_key)), + ), + serve: ServeContext::new( + index.clone(), + blobs.clone(), + marks.clone(), + uploads.clone(), + crate::serve::owned_assets(), + ), + verify: VerifyContext::new(index.clone(), blobs.clone(), marks.clone(), clock.clone()), + directories: DeviceDirectoryContext::new(directories.clone(), clock.clone()), + albums: AlbumContext::new(albums.clone(), clock.clone()), + // Unlimited, which is what a self-hosted deployment runs. A configurable ceiling is a + // quota policy this slice does not own; `QuotaLimits` already takes one. + quota: QuotaContext::new(quotas.clone(), clock.clone(), QuotaLimits::unlimited()), + attestation: AttestationContext::new( + receipts.clone(), + attestation_key, + // The published key has been active since the epoch, because the seed is derived + // deterministically and has therefore never *not* been this deployment's key. + // Publishing a rotation history is `ATTESTATION_KEY_HISTORY`'s job and nobody's yet. + Timestamp::UNIX_EPOCH, + ), + discovery: DiscoveryContext::new( + server_info, + Arc::new(InMemoryRevocations::new(clock.clone())), + ), + escrow: EscrowContext::new(Arc::new(InMemoryEscrow::new()), clock.clone()), + enrollment: EnrollmentContext::new( + Arc::new(InMemoryEnrollments::with_default_ttl(clock.clone())), + Arc::new(InMemoryChannels::with_default_ttl(clock.clone())), + clock.clone(), + ), + moderation: ModerationContext::new(Arc::new(InMemoryModeration::new())), + share: ShareContext::new( + Arc::new(InMemoryShares::new()), + blobs.clone(), + clock.clone(), + ), + drops: DropContext::new( + Arc::new(InMemoryDrops::new()), + uploads.clone(), + blobs.clone(), + clock.clone(), + ), + counters: CounterContext::new(Arc::new(InMemoryCounters::new()), clock.clone()), + }); + + tracing::info!( + blob_root = %root.display(), + server_id = %config.server_domain, + protocol_min = %config.protocol_min, + protocol_max = %config.protocol_max, + "assembled a server on the in-memory adapters" + ); + + Ok(Assembled { + app, + // The same stores, so "upload it, then let the collector see it" is a property of the + // server rather than of two disconnected assemblies. + maintenance: stores.maintenance(config.grace_window), + }) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::{App, BootError, assemble}; + use crate::config::{Config, Demands, Overrides}; + + /// A PKCS#8 v1 Ed25519 key, base64. Signs nothing; see `config`'s own tests. + const EXAMPLE_DER: &str = "MC4CAQAwBQYDK2VwBCIEIN6eTvXEL7xMZWHY8rTk7VbQSGSuRkle5MVfiiYUStLF"; + + fn memory_config(root: &std::path::Path) -> Config { + memory_config_with(root, &[]) + } + + /// The same, with `extra` on top of the environment. + fn memory_config_with(root: &std::path::Path, extra: &[(&str, &str)]) -> Config { + let mut environment: BTreeMap = [ + ("BLOB_ROOT".to_owned(), root.display().to_string()), + ("JWT_ED25519_DER".to_owned(), EXAMPLE_DER.to_owned()), + ] + .into_iter() + .collect(); + for (key, value) in extra { + environment.insert((*key).to_owned(), (*value).to_owned()); + } + let overrides = Overrides { + memory: true, + ..Overrides::default() + }; + Config::load(&environment, &overrides, Demands::Serve).expect("the configuration loads") + } + + /// Register the account the auth cases sign in with, through the surface. + async fn register(client: &kynos::test::TestClient, password: &str) { + client + .post("/v1/auth/register") + .header("accept", "application/json") + .json(&serde_json::json!({ "email": "somebody@example.test", "password": password })) + .send() + .await + .assert_status(kynos::http::StatusCode::OK); + } + + /// Attempt a sign-in and return the status the route answered with. + async fn login( + client: &kynos::test::TestClient, + password: &str, + ) -> kynos::http::StatusCode { + client + .post("/v1/auth/login") + .header("accept", "application/json") + .json(&serde_json::json!({ "email": "somebody@example.test", "password": password })) + .send() + .await + .status() + } + + #[tokio::test] + async fn the_memory_profile_assembles_a_server_whose_router_builds() { + // The property nothing in this crate asserted before: seventeen module contexts, every + // port filled, and a router Kynos will build out of them. + let root = tempfile::tempdir().expect("a scratch directory"); + let assembled = assemble(&memory_config(root.path())) + .await + .expect("it assembles"); + assembled.service().expect("the router builds"); + } + + #[tokio::test] + async fn the_blob_root_is_created_rather_than_required_to_exist() { + let parent = tempfile::tempdir().expect("a scratch directory"); + let root = parent.path().join("does/not/exist/yet"); + let assembled = assemble(&memory_config(&root)).await.expect("it assembles"); + assert!(root.join("blobs").is_dir(), "the store's tree is created"); + drop(assembled); + } + + #[tokio::test] + async fn a_signing_key_that_is_not_ed25519_refuses_the_boot() { + // `SigningKeyError` has always been documented as a startup failure. This is the startup + // it fails. + let root = tempfile::tempdir().expect("a scratch directory"); + let environment: BTreeMap = [ + ("BLOB_ROOT".to_owned(), root.path().display().to_string()), + ( + "JWT_ED25519_DER".to_owned(), + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + b"not a PKCS#8 document", + ), + ), + ] + .into_iter() + .collect(); + let overrides = Overrides { + memory: true, + ..Overrides::default() + }; + let config = + Config::load(&environment, &overrides, Demands::Serve).expect("it is well-formed"); + let error = assemble(&config).await.expect_err("it refuses"); + assert!(matches!(error, BootError::SigningKey { .. }), "{error:?}"); + } + + #[tokio::test] + async fn a_durable_backend_refuses_by_name_rather_than_falling_back() { + // The half of `store/mod.rs`'s claim that `Config::load` cannot make: the operator did + // set `VALKEY_URL`, and nothing reads it yet. Falling back to the in-memory adapters + // here is the one thing that must never happen. + let root = tempfile::tempdir().expect("a scratch directory"); + let environment: BTreeMap = [ + ("BLOB_ROOT".to_owned(), root.path().display().to_string()), + ("JWT_ED25519_DER".to_owned(), EXAMPLE_DER.to_owned()), + ("VALKEY_URL".to_owned(), "redis://127.0.0.1:6379".to_owned()), + // A durable deployment supplies its own attestation identity rather than having one + // derived from the token signer; `config` refuses without it. + ( + "ATTESTATION_KEY_SEED".to_owned(), + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + [9_u8; 64].as_slice(), + ), + ), + ] + .into_iter() + .collect(); + let config = Config::load(&environment, &Overrides::default(), Demands::Serve) + .expect("it is well-formed"); + let error = assemble(&config).await.expect_err("it refuses"); + assert!( + matches!( + error, + BootError::AdapterUnavailable { + key: "VALKEY_URL", + .. + } + ), + "{error:?}" + ); + assert!(format!("{error}").contains("#403"), "{error}"); + } + + #[tokio::test] + async fn the_published_signing_key_is_the_one_the_tokens_verify_under() { + // Not a coincidence to be re-checked at every deployment: `ServerInfo` is built from + // `tokens.public_key()`, so there is no second copy for an operator to paste wrongly. + use crate::auth::SessionTokens; + use crate::store::SystemClock; + + let root = tempfile::tempdir().expect("a scratch directory"); + let config = memory_config(root.path()); + let assembled = assemble(&config).await.expect("it assembles"); + let expected = SessionTokens::from_pkcs8( + config + .signing_key_der + .as_ref() + .expect("the key is configured") + .expose(), + std::sync::Arc::new(SystemClock), + ) + .expect("the key parses") + .public_key() + .to_vec(); + + // Read back the way a client would, through the surface rather than through a field. + let client = kynos::test::TestClient::new(assembled.service().expect("the router builds")); + let body: serde_json::Value = client + .get("/.well-known/capsule/server-info") + .header("accept", "application/json") + .send() + .await + .assert_status(kynos::http::StatusCode::OK) + .json(); + let published = body["signing_key"].as_str().expect("it is published"); + assert_eq!( + published, + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &expected) + ); + } + + #[tokio::test] + async fn the_published_protocol_window_is_the_configured_one() { + let root = tempfile::tempdir().expect("a scratch directory"); + let config = memory_config(root.path()); + let assembled = assemble(&config).await.expect("it assembles"); + let client = kynos::test::TestClient::new(assembled.service().expect("the router builds")); + let body: serde_json::Value = client + .get("/.well-known/capsule/server-info") + .header("accept", "application/json") + .send() + .await + .assert_status(kynos::http::StatusCode::OK) + .json(); + assert_eq!(body["protocol_version"]["min"], config.protocol_min); + assert_eq!(body["protocol_version"]["max"], config.protocol_max); + assert_eq!(body["server_id"], config.server_domain); + assert_eq!(body["api_base_url"], config.api_base_url); + } + + #[tokio::test] + async fn an_account_can_be_registered_and_signed_in_to() { + // The whole point of the amended deliverable boundary: `mise run serve-memory` is a + // server a client developer can point at, not a surface they can only read. + let root = tempfile::tempdir().expect("a scratch directory"); + let assembled = assemble(&memory_config(root.path())) + .await + .expect("it assembles"); + let client = kynos::test::TestClient::new(assembled.service().expect("the router builds")); + + let registered: serde_json::Value = client + .post("/v1/auth/register") + .header("accept", "application/json") + .json(&serde_json::json!({ + "email": "somebody@example.test", + "password": "correct horse battery staple", + })) + .send() + .await + .assert_status(kynos::http::StatusCode::OK) + .json(); + assert!(registered["access_token"].is_string(), "{registered}"); + + let signed_in: serde_json::Value = client + .post("/v1/auth/login") + .header("accept", "application/json") + .json(&serde_json::json!({ + "email": "somebody@example.test", + "password": "correct horse battery staple", + })) + .send() + .await + .assert_status(kynos::http::StatusCode::OK) + .json(); + assert!(signed_in["access_token"].is_string(), "{signed_in}"); + } + + #[tokio::test] + async fn a_locked_account_recovers_through_the_login_route_once_the_window_passes() { + // Asserted through the **route** rather than against the adapter, because that is where + // the property actually has to hold: `login` asks the directory first and answers `423` + // before it verifies anything, so a lockout that did not decay would be an account no + // request could ever open again — there is no unlock operation on any surface. + // + // A one-attempt threshold, a one-second window and a real wait. Both numbers are + // settings rather than constants precisely so this is expressible: driving the default + // ten-failure ceiling through the route would cost ten Argon2id verifications, and on a + // loaded machine the gaps between them can themselves exceed a short window — a test + // whose setup races its own subject. The alternative was a clock seam through the whole + // composition root for one case, and the composition root is the thing under test. + let root = tempfile::tempdir().expect("a scratch directory"); + let config = memory_config_with( + root.path(), + &[ + ("LOCKOUT_MAX_ATTEMPTS", "1"), + ("LOCKOUT_WINDOW_SECONDS", "1"), + ], + ); + let assembled = assemble(&config).await.expect("it assembles"); + let client = kynos::test::TestClient::new(assembled.service().expect("the router builds")); + + register(&client, "correct horse battery staple").await; + assert_eq!( + login(&client, "wrong").await, + kynos::http::StatusCode::UNAUTHORIZED + ); + assert_eq!( + login(&client, "correct horse battery staple").await, + kynos::http::StatusCode::LOCKED, + "the ceiling engages, and a correct password is told so rather than refused" + ); + + tokio::time::sleep(std::time::Duration::from_millis(1_500)).await; + assert_eq!( + login(&client, "correct horse battery staple").await, + kynos::http::StatusCode::OK, + "the window passed, so the account is the owner's again" + ); + } + + #[tokio::test] + async fn a_maintenance_command_is_told_it_needs_memory_and_not_valkey() { + // An operator running `capsule-server scrub` has typically set no backend variable at + // all. Naming `VALKEY_URL` would send them to configure something that would not have + // helped; what is missing is the durable index these workers read. + let root = tempfile::tempdir().expect("a scratch directory"); + let environment: BTreeMap = + [("BLOB_ROOT".to_owned(), root.path().display().to_string())] + .into_iter() + .collect(); + let config = Config::load(&environment, &Overrides::default(), Demands::Maintenance) + .expect("maintenance demands nothing else"); + let error = super::assemble_maintenance(&config) + .await + .expect_err("it refuses"); + assert!( + matches!(error, BootError::MaintenanceNeedsMemory { .. }), + "{error:?}" + ); + let message = format!("{error}"); + assert!(message.contains("--memory"), "{message}"); + assert!(!message.contains("VALKEY_URL"), "{message}"); + } + + #[tokio::test] + async fn a_wrong_password_is_refused_rather_than_granted() { + // The property that makes the adapter real rather than permissive: the credential + // double `tests/support/mod.rs` warns about would accept this. + let root = tempfile::tempdir().expect("a scratch directory"); + let assembled = assemble(&memory_config(root.path())) + .await + .expect("it assembles"); + let client = kynos::test::TestClient::new(assembled.service().expect("the router builds")); + client + .post("/v1/auth/register") + .header("accept", "application/json") + .json(&serde_json::json!({ + "email": "somebody@example.test", + "password": "correct horse battery staple", + })) + .send() + .await + .assert_status(kynos::http::StatusCode::OK); + client + .post("/v1/auth/login") + .header("accept", "application/json") + .json(&serde_json::json!({ + "email": "somebody@example.test", + "password": "the wrong password entirely", + })) + .send() + .await + .assert_status(kynos::http::StatusCode::UNAUTHORIZED); + } +} diff --git a/capsule-server/src/cli.rs b/capsule-server/src/cli.rs new file mode 100644 index 00000000..136af3c5 --- /dev/null +++ b/capsule-server/src/cli.rs @@ -0,0 +1,860 @@ +//! The `capsule-server` command line: one binary, several subcommands. +//! +//! # One binary and not four +//! +//! The Salvo tree shipped `capsule-gc`, `capsule-scrub`, `capsule-keygen` and `gen_openapi` as +//! separate `[[bin]]`s. Four executables would each need their own copy of the configuration +//! loader and the adapter seam — which is the duplication [`crate::boot`] exists to prevent — +//! and `design/filesystem/maintenance.md` calls the scrub "an operator-invoked command, +//! schedulable as a job" rather than a distinct executable. `capsule-cli` already sets the +//! one-binary-many-subcommands precedent. +//! +//! # Why the bodies live here and not in `main` +//! +//! `main.rs` installs error reporting and dispatches; everything a subcommand actually does is +//! in this module, in the library. That is what lets `tests/binary.rs` assert against the same +//! code the binary runs, and it is the shape `capsule-cli/src/main.rs` already has. +//! +//! # stdout is a data channel +//! +//! Every log line goes to **stderr**. `gen-openapi` writes a path to stdout and the operator +//! commands write a report there, and a subscriber sharing that stream is how a pipeline ends up +//! parsing a log line. `capsule-cli/tests/cull_round_trip.rs` has to set `RUST_LOG=off` to keep +//! stdout parseable, which is the failure mode being avoided here. + +use std::net::SocketAddr; +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::{Args, Parser, Subcommand}; +use color_eyre::eyre::{Context as _, Result, bail, eyre}; +use kynos::server::Server; +use kynos::server::shutdown::Shutdown; +use tracing_subscriber::prelude::*; +use tracing_subscriber::{EnvFilter, fmt as log_fmt}; + +use crate::boot::{self, Assembled, Maintenance}; +use crate::config::{Config, Demands, Environment, LogFormat, Overrides, ProcessEnvironment}; +use crate::gc::{CollectionReport, Mode, PurgeReport}; +use crate::scrub::{Depth, ScrubReport}; + +/// The exit code a configuration refusal produces. +/// +/// Two, and distinct from [`EXIT_FINDINGS`] on purpose: a wrapper script has to be able to tell +/// "you configured this wrongly" from "the store is not clean", and both being `1` would make a +/// misconfigured cron job look like a corrupted store. It is also the code clap itself uses for +/// a usage error, so the two kinds of "the invocation was wrong" agree. +pub const EXIT_MISCONFIGURED: u8 = 2; + +/// The exit code a read-only check produces when it found something. +/// +/// One. `design/filesystem/maintenance.md` requires that the scrub "exits non-zero, and mutates +/// nothing", which is what makes it usable as a monitoring probe. +pub const EXIT_FINDINGS: u8 = 1; + +/// How many tombstoned assets one `purge` pass considers. +/// +/// A bound rather than a policy: the pass walks the index and a retention sweep on a large +/// deployment should be a job that finishes, not one that holds a read for an hour. An operator +/// who wants more runs it again. +const DEFAULT_PURGE_LIMIT: usize = 1_000; + +/// How many bytes one `scrub --deep` pass will read when no budget is given. +/// +/// One gibibyte. `Depth::Deep` carries a budget precisely because re-hashing every blob is +/// heavy I/O by definition, and "a scrub that saturates the disk is a scrub an operator turns +/// off". A truncated pass says so in its report, so the default cannot silently pass a store it +/// did not finish looking at. +const DEFAULT_SCRUB_BUDGET: u64 = 1024 * 1024 * 1024; + +/// The Capsule server. +#[derive(Debug, Parser)] +#[command(name = "capsule-server", author, version, about, long_about = None)] +pub struct Cli { + /// Read settings from a configuration file. + /// + /// Reserved and **not implemented**: every setting is read from the environment. The flag + /// exists so the refusal is a sentence rather than clap's "unexpected argument". + #[arg(long, value_name = "PATH", global = true)] + pub config: Option, + + /// What to do. + #[command(subcommand)] + pub command: Command, +} + +/// Where a subcommand's state lives. +/// +/// Flattened into every subcommand that touches a store rather than declared once on [`Cli`], +/// because `gen-openapi` touches none and a global `--blob-root` would advertise otherwise. +#[derive(Debug, Args)] +pub struct BackendArgs { + /// The filesystem tree ciphertext blobs are written to (`BLOB_ROOT`). + #[arg(long, value_name = "PATH")] + pub blob_root: Option, + + /// Run on the in-memory adapters instead of Postgres and Valkey. + /// + /// A development profile, and an explicit act: a deployment that merely forgot `VALKEY_URL` + /// must fail closed rather than come up holding state it loses on the next restart. + #[arg(long)] + pub memory: bool, +} + +/// The things this binary does. +#[derive(Debug, Subcommand)] +pub enum Command { + /// Accept requests until a termination signal, then drain. + Serve { + /// The address to bind (`SERVER_HOST`/`SERVER_PORT`, default `0.0.0.0:3000`). + /// + /// Port `0` asks the operating system to choose one, and the chosen address is written + /// to stdout — see [`serve`]. + #[arg(long, value_name = "HOST:PORT")] + listen: Option, + + /// Where state lives. + #[command(flatten)] + backend: BackendArgs, + }, + + /// Sweep blobs nothing references any more. + /// + /// Two passes, by design: a blob that reaches zero references is *marked*, and a later pass + /// sweeps it once the grace window has passed and the count is still zero. That is what + /// gives an in-flight finalization retry time to re-reference it. + Gc { + /// Carry it out. Without this nothing is marked, unmarked or swept. + /// + /// Dry run is the default for the two subcommands that write, because the first thing an + /// operator does with a collector is find out what it thinks. + #[arg(long)] + apply: bool, + + /// How long a blob must sit at zero references before it may be swept + /// (`GC_GRACE_WINDOW_HOURS`, default 24). + #[arg(long, value_name = "HOURS")] + grace_window_hours: Option, + + /// Where state lives. + #[command(flatten)] + backend: BackendArgs, + }, + + /// Drop the blob references of tombstoned assets whose retention window has passed. + /// + /// The tombstone itself stays: a client that has not synced since the delete still has to + /// learn about it, and removing the row would make the deletion invisible rather than final. + Purge { + /// Carry it out. Without this nothing is dropped. + #[arg(long)] + apply: bool, + + /// How many tombstoned assets to consider in this pass. + #[arg(long, value_name = "N", default_value_t = DEFAULT_PURGE_LIMIT)] + limit: usize, + + /// Where state lives. + #[command(flatten)] + backend: BackendArgs, + }, + + /// Compare the index against the store and report every disagreement. + /// + /// Mutates nothing, by construction, and exits non-zero on a non-empty report — which is + /// what makes it usable as a monitoring probe. + Scrub { + /// Also re-hash every blob's bytes: the bit-rot check. + #[arg(long)] + deep: bool, + + /// The most bytes a deep pass will read. Blobs past it are left for the next run. + #[arg(long, value_name = "BYTES", default_value_t = DEFAULT_SCRUB_BUDGET)] + budget: u64, + + /// Where state lives. + #[command(flatten)] + backend: BackendArgs, + }, + + /// Emit the OpenAPI 3.2 document the SDK's client is generated from. + /// + /// Needs no database, no Valkey, no key material, no disk and no network: the router is + /// built purely to describe it, which is what lets `--check` run in the Rust check gate. + GenOpenapi { + /// Output path for the document, relative to the repo root. + #[arg(value_name = "FILE", default_value = "capsule-server/openapi.json")] + output: PathBuf, + + /// Verify the committed document is up to date instead of writing it (CI drift gate). + #[arg(long)] + check: bool, + }, +} + +impl Command { + /// What this subcommand needs from the configuration. + fn demands(&self) -> Demands { + match self { + Self::Serve { .. } => Demands::Serve, + // No key material. A maintenance host that had to hold the production + // token-signing key to sweep a directory would be a reason to put the key there. + Self::Gc { .. } | Self::Purge { .. } | Self::Scrub { .. } => Demands::Maintenance, + Self::GenOpenapi { .. } => Demands::Nothing, + } + } + + /// What this subcommand overrides on the command line. + fn overrides(&self, config_file: Option) -> Overrides { + let mut overrides = Overrides { + config_file, + ..Overrides::default() + }; + match self { + Self::Serve { listen, backend } => { + overrides.listen = *listen; + overrides.blob_root.clone_from(&backend.blob_root); + overrides.memory = backend.memory; + } + Self::Gc { + grace_window_hours, + backend, + .. + } => { + overrides.grace_window_hours = *grace_window_hours; + overrides.blob_root.clone_from(&backend.blob_root); + overrides.memory = backend.memory; + } + Self::Purge { backend, .. } | Self::Scrub { backend, .. } => { + overrides.blob_root.clone_from(&backend.blob_root); + overrides.memory = backend.memory; + } + Self::GenOpenapi { .. } => {} + } + overrides + } +} + +/// Parse the command line, install the log stream, and do what was asked. +/// +/// # Errors +/// +/// Returns whatever the subcommand could not finish. A configuration refusal is **not** an +/// error here — it is [`EXIT_MISCONFIGURED`] with its own report on stderr, because +/// `ConfigError` already renders the full list of faults and an error chain around it would bury +/// them. +pub async fn run() -> Result { + let cli = Cli::parse(); + let environment = ProcessEnvironment; + let overrides = cli.command.overrides(cli.config.clone()); + + install_tracing(&environment, &overrides); + + let config = match Config::load(&environment, &overrides, cli.command.demands()) { + Ok(config) => config, + Err(error) => { + // Straight to stderr rather than through `tracing`: a startup refusal has to be + // visible whatever `RUST_LOG` says, and this is the one message an operator who + // mis-typed a variable needs to read. + eprintln!("capsule-server: {error}"); + return Ok(ExitCode::from(EXIT_MISCONFIGURED)); + } + }; + + match cli.command { + Command::Serve { .. } => serve(&config).await, + Command::Gc { apply, .. } => collect(&config, mode(apply)).await, + Command::Purge { apply, limit, .. } => purge(&config, mode(apply), limit).await, + Command::Scrub { deep, budget, .. } => { + let depth = if deep { + Depth::Deep { budget } + } else { + Depth::Structural + }; + scrub(&config, depth).await + } + // The document is a property of the router's types, so the configuration is loaded only + // to refuse `--config` and is deliberately not logged: `mise run openapi-check-kynos` is + // a check gate, and a settings dump on its stderr is noise in every CI log that runs it. + Command::GenOpenapi { output, check } => { + drop(config); + gen_openapi(&output, check) + } + } +} + +/// Assemble the server from `config`, logging what it came up on. +/// +/// Shared by every subcommand that needs a store, so the settings dump an operator reads after +/// an incident is written once and says the same thing whichever command produced it. +async fn assemble(config: &Config) -> Result { + tracing::debug!(?config, "loaded the configuration"); + Ok(boot::assemble(config).await?) +} + +/// Assemble only what the operator workers read. +/// +/// A separate entry point rather than reaching into [`Assembled`], because `gc`, `purge` and +/// `scrub` need **no key material** and the way to make that true is for the assembly they use +/// to have none in scope — not for it to build a token signer and then not use it. +async fn maintenance(config: &Config) -> Result { + tracing::debug!(?config, "loaded the configuration"); + Ok(boot::assemble_maintenance(config).await?) +} + +/// Accept requests until a termination signal, then drain. +/// +/// # The bound address goes to stdout +/// +/// It is logged at `INFO` **and** written to stdout as one `listening on ` line. That is +/// not a duplicate: `--listen 127.0.0.1:0` is a request for the operating system to choose a +/// port, and a caller that asked for that has no other way to learn which one it got. Making +/// them parse a log format — which `LOG_FORMAT` can change under them — would be a contract +/// nobody wrote down. Rust's stdout is line-buffered, so the line is readable the moment it is +/// written. +/// +/// # No TLS +/// +/// `design/cryptography/failure-modes.md` is explicit that "application servers do not terminate +/// TLS", and scopes in-code TLS to the SDK client, LAN peering and server-to-server egress — +/// none of which is this listener. Kynos's `tls` feature stays off, so a certificate cannot be +/// configured by accident. +async fn serve(config: &Config) -> Result { + let assembled = assemble(config).await?; + let bound = Server::new(assembled.service()?) + .bind(config.listen) + // SIGINT and SIGTERM, with a second one forcing. Kynos keeps its listeners alive + // through the drain, so an impatient operator's second Ctrl-C is honoured rather than + // ignored. + .graceful_shutdown(Shutdown::signals()) + .shutdown_timeout(config.shutdown_timeout) + .max_connections(config.max_connections) + .prepare() + .await + .map_err(|error| eyre!("binding {}: {error}", config.listen))?; + + for address in bound.local_addrs() { + tracing::info!(%address, "listening"); + println!("listening on http://{address}"); + } + + bound + .serve() + .await + .map_err(|error| eyre!("serving: {error}"))?; + tracing::info!("drained and stopped"); + Ok(ExitCode::SUCCESS) +} + +/// Install the log stream, on stderr. +/// +/// The format is read best-effort — [`Demands::Nothing`] never fails on a missing setting, and a +/// malformed one falls back to the build profile's default — because the configuration error +/// this cannot read has to be *reported*, and reporting it needs a subscriber. +fn install_tracing(environment: &dyn Environment, overrides: &Overrides) { + let format = Config::load(environment, overrides, Demands::Nothing).map_or_else( + |_| { + if cfg!(debug_assertions) { + LogFormat::Pretty + } else { + LogFormat::Json + } + }, + |config| config.log_format, + ); + + let filter = EnvFilter::try_from_default_env() + .or_else(|_| { + if cfg!(debug_assertions) { + EnvFilter::try_new("debug") + } else { + EnvFilter::try_new("info") + } + }) + .expect("built-in log filter directives are valid"); + + let registry = tracing_subscriber::registry().with(filter); + match format { + LogFormat::Json => registry + .with( + log_fmt::layer() + .json() + .flatten_event(true) + .with_writer(std::io::stderr), + ) + .init(), + LogFormat::Pretty => registry + .with( + log_fmt::layer() + .pretty() + .with_file(true) + .with_line_number(true) + .with_writer(std::io::stderr), + ) + .init(), + } +} + +/// Whether `--apply` was passed. +/// +/// A free function rather than a `From` on [`Mode`]: the boolean is a command-line flag, +/// and a blanket conversion would let any `bool` in the crate become a write mode. +fn mode(apply: bool) -> Mode { + if apply { Mode::Apply } else { Mode::DryRun } +} + +/// Sweep blobs nothing references any more. +/// +/// # What an interrupted pass leaves, and what the operator is told +/// +/// `gc::collect` returns `Result`, so a pass that fails part-way +/// returns **no report at all** — the work it did before the failure is not recoverable from +/// here, and stdout stays empty. What the operator sees is the store error on stderr and a +/// non-zero exit; what they have to do to find out how far it got is read the `INFO` lines the +/// collector logs as it marks and sweeps. +/// +/// That is a real gap and it is the library's to close: the report would have to come back +/// alongside the error (`Result<(CollectionReport, Option), _>` or equivalent), and +/// `gc/mod.rs` is not this change's to edit. The state left behind is safe either way, by the +/// module's own argument — a mark is reversible, and a sweep only ever removed a blob confirmed +/// unreferenced twice — so re-running the pass is the correct response to one that stopped. +async fn collect(config: &Config, mode: Mode) -> Result { + let maintenance = maintenance(config).await?; + let report = crate::gc::collect(&maintenance.collection, mode) + .await + .map_err(|error| eyre!("the collection pass could not finish: {error}"))?; + print!("{}", render_collection(&report, mode)); + Ok(ExitCode::SUCCESS) +} + +/// Drop the blob references of tombstoned assets past their retention window. +/// +/// An interrupted pass reports nothing, for the reason [`collect`] records. +async fn purge(config: &Config, mode: Mode, limit: usize) -> Result { + let maintenance = maintenance(config).await?; + let report = crate::gc::purge_expired(&maintenance.collection, mode, limit) + .await + .map_err(|error| eyre!("the retention purge could not finish: {error}"))?; + print!("{}", render_purge(&report, mode)); + Ok(ExitCode::SUCCESS) +} + +/// Compare the index against the store. +/// +/// Exits [`EXIT_FINDINGS`] on a non-empty report, which `design/filesystem/maintenance.md` +/// requires of it — and a **truncated** deep pass is not clean even with no findings, because it +/// did not finish looking. `ScrubReport::is_clean` already draws that distinction; this only has +/// to honour it. +async fn scrub(config: &Config, depth: Depth) -> Result { + let maintenance = maintenance(config).await?; + let report = crate::scrub::scrub(&maintenance.scrub, depth) + .await + .map_err(|error| eyre!("the integrity scrub could not finish: {error}"))?; + print!("{}", render_scrub(&report)); + Ok(if report.is_clean() { + ExitCode::SUCCESS + } else { + ExitCode::from(EXIT_FINDINGS) + }) +} + +/// One pass's report, rendered for a person. +/// +/// A [`std::fmt::Display`] wrapper rather than a `-> String` helper, so every line is one `writeln!` +/// into the caller's formatter: building the whole report in a `String` first meant an +/// allocation per line and a clippy lint saying so. +struct Rendered<'a, T>(&'a T, Mode); + +/// What a dry run prints above its report, so nobody reads one as an action. +fn posture(mode: Mode) -> &'static str { + match mode { + Mode::Apply => "applied", + Mode::DryRun => "dry run — nothing was changed", + } +} + +/// Render a collection pass. +/// +/// Every class names its blobs rather than counting them. [`CollectionReport`]'s own docs say +/// why: "a count tells an operator that something happened without telling them what to look +/// at." Empty classes are omitted, so a quiet pass is one short line rather than six zeroes. +fn render_collection(report: &CollectionReport, mode: Mode) -> Rendered<'_, CollectionReport> { + Rendered(report, mode) +} + +impl std::fmt::Display for Rendered<'_, CollectionReport> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self(report, mode) = *self; + writeln!(f, "garbage collection ({})", posture(mode))?; + let mut quiet = true; + for (class, addresses) in [ + ("marked", &report.marked), + ("unmarked", &report.unmarked), + ("swept", &report.swept), + ("reprieved", &report.reprieved), + ("dangling", &report.dangling), + ] { + if addresses.is_empty() { + continue; + } + quiet = false; + writeln!(f, " {class} ({})", addresses.len())?; + for address in addresses { + writeln!(f, " {address}")?; + } + } + if !report.credited.is_empty() { + quiet = false; + let total: u64 = report.credited.iter().map(|(_, bytes)| *bytes).sum(); + writeln!( + f, + " credited ({} accounts, {total} bytes)", + report.credited.len() + )?; + for (user, bytes) in &report.credited { + writeln!(f, " {user} {bytes}")?; + } + } + if quiet { + writeln!(f, " nothing to do")?; + } + Ok(()) + } +} + +/// Render a retention purge. +/// +/// `retained` is reported as well as `purged`, because "why has this not gone yet" is exactly +/// the question a dry run is run to answer. +fn render_purge(report: &PurgeReport, mode: Mode) -> Rendered<'_, PurgeReport> { + Rendered(report, mode) +} + +impl std::fmt::Display for Rendered<'_, PurgeReport> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self(report, mode) = *self; + writeln!(f, "retention purge ({})", posture(mode))?; + let mut quiet = true; + for (class, assets) in [("purged", &report.purged), ("retained", &report.retained)] { + if assets.is_empty() { + continue; + } + quiet = false; + writeln!(f, " {class} ({})", assets.len())?; + for asset in assets { + writeln!(f, " {asset}")?; + } + } + if quiet { + writeln!(f, " nothing to do")?; + } + Ok(()) + } +} + +/// Render an integrity scrub. +/// +/// A scrub never writes, so it has no posture; the mode is carried and ignored. +fn render_scrub(report: &ScrubReport) -> Rendered<'_, ScrubReport> { + Rendered(report, Mode::DryRun) +} + +/// Grouped by the class an operator alerts on, and each finding printed through its **own** +/// `Debug`. Not a hand-written line per variant: every `Finding` variant already carries both +/// sides' evidence, a second rendering would be a second place for the two to disagree, and a +/// variant added later would otherwise render as nothing at all — which is the one failure mode +/// a report must not have. +impl std::fmt::Display for Rendered<'_, ScrubReport> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let report = self.0; + writeln!( + f, + "integrity scrub ({} finding{}, {} bytes hashed{})", + report.findings.len(), + if report.findings.len() == 1 { "" } else { "s" }, + report.bytes_hashed, + if report.budget_exhausted { + ", budget exhausted — the pass did not finish looking" + } else { + "" + } + )?; + for (class, count) in report.counts() { + writeln!(f, " {class} ({count})")?; + for finding in report + .findings + .iter() + .filter(|finding| finding.class() == class) + { + writeln!(f, " {finding:?}")?; + } + } + if report.is_clean() { + writeln!(f, " the index and the store agree")?; + } + Ok(()) + } +} + +/// Write, or verify, the committed OpenAPI 3.2 document (slice `S-C34`). +/// +/// The drift guard for the rebuild's central claim: that the description is derived from the +/// types and cannot disagree with them. That claim is already enforced *inside* the crate — +/// `assert_conformance` catches a response the document did not predict, and +/// `assert_declared_responses_covered` catches a promise no test produced. Neither helps a +/// **client**: a surface can be ported, the emitted document can change shape, and nothing +/// outside the crate notices until somebody regenerates by hand. This is what makes such a +/// change fail. +fn gen_openapi(output: &PathBuf, check: bool) -> Result { + let document = + crate::openapi().map_err(|e| color_eyre::eyre::eyre!("describing the router: {e}"))?; + // `to_json` is already pretty-printed; the trailing newline keeps the committed document an + // ordinary text file rather than a one-line blob in a diff. + let mut json = document + .to_json() + .wrap_err("serializing the OpenAPI document to JSON")?; + json.push('\n'); + + if check { + let committed = std::fs::read_to_string(output) + .wrap_err_with(|| format!("cannot read committed document at {}", output.display()))?; + if committed != json { + bail!( + "OpenAPI document at {} is out of sync with the server; run \ + `mise run openapi-kynos` and commit the result", + output.display() + ); + } + println!("OpenAPI document is up to date: {}", output.display()); + } else { + if let Some(parent) = output.parent() { + std::fs::create_dir_all(parent) + .wrap_err_with(|| format!("creating {}", parent.display()))?; + } + std::fs::write(output, &json).wrap_err_with(|| format!("writing {}", output.display()))?; + println!("Wrote {}", output.display()); + } + + Ok(ExitCode::SUCCESS) +} + +#[cfg(test)] +mod tests { + use clap::{CommandFactory as _, Parser as _}; + + use super::{ + Cli, CollectionReport, Command, Mode, PurgeReport, ScrubReport, mode, render_collection, + render_purge, render_scrub, + }; + + #[test] + fn the_command_line_is_well_formed() { + // clap's own consistency check: a duplicate long flag, a subcommand with two positional + // arguments in the wrong order, or an argument whose value name collides is a panic + // here rather than a report from the first operator to run `--help`. + Cli::command().debug_assert(); + } + + #[test] + fn gen_openapi_defaults_to_the_committed_document() { + let cli = Cli::parse_from(["capsule-server", "gen-openapi"]); + let Command::GenOpenapi { output, check } = cli.command else { + panic!("that is the subcommand that was parsed") + }; + assert_eq!(output, std::path::Path::new("capsule-server/openapi.json")); + assert!(!check, "writing is the default; checking is opt-in"); + } + + #[test] + fn serve_carries_its_flags_into_the_overrides_the_loader_reads() { + // The command line's half of the precedence table. A flag that parsed but never reached + // `Config::load` would be a flag that silently does nothing. + let cli = Cli::parse_from([ + "capsule-server", + "serve", + "--memory", + "--listen", + "127.0.0.1:6000", + "--blob-root", + "/var/lib/capsule/blobs", + ]); + let overrides = cli.command.overrides(None); + assert!(overrides.memory); + assert_eq!( + overrides.listen, + Some("127.0.0.1:6000".parse().expect("a literal address parses")) + ); + assert_eq!( + overrides.blob_root.as_deref(), + Some(std::path::Path::new("/var/lib/capsule/blobs")) + ); + } + + #[test] + fn serving_demands_a_key_and_describing_the_router_demands_nothing() { + assert_eq!( + Cli::parse_from(["capsule-server", "serve"]) + .command + .demands(), + crate::config::Demands::Serve + ); + assert_eq!( + Cli::parse_from(["capsule-server", "gen-openapi"]) + .command + .demands(), + crate::config::Demands::Nothing + ); + } + + #[test] + fn a_config_path_is_accepted_by_the_parser_so_it_can_be_refused_by_the_loader() { + let cli = Cli::parse_from([ + "capsule-server", + "--config", + "/etc/capsule.toml", + "gen-openapi", + ]); + assert_eq!( + cli.config.as_deref(), + Some(std::path::Path::new("/etc/capsule.toml")) + ); + } + + /// A well-formed content address, distinguished by `seed`. + fn address(seed: u8) -> crate::blob::ContentAddress { + let hex: String = std::iter::repeat_n(format!("{seed:02x}"), 32).collect(); + crate::blob::ContentAddress::parse(&hex).expect("an address") + } + + #[test] + fn a_quiet_collection_pass_is_one_short_line() { + // Six zeroes would be six lines an operator learns to skip, and the whole point of the + // report is that they read it. + let rendered = render_collection(&CollectionReport::default(), Mode::DryRun).to_string(); + assert!(rendered.contains("dry run"), "{rendered}"); + assert!(rendered.contains("nothing to do"), "{rendered}"); + } + + #[test] + fn a_collection_pass_names_the_blobs_rather_than_counting_them() { + // `CollectionReport`'s own docs: "a count tells an operator that something happened + // without telling them what to look at." + let report = CollectionReport { + marked: vec![address(0xAA), address(0xBB)], + swept: vec![address(0xCC)], + credited: vec![(crate::store::UserId::new("a-user"), 4096)], + ..CollectionReport::default() + }; + let rendered = render_collection(&report, Mode::Apply).to_string(); + assert!(rendered.contains("applied"), "{rendered}"); + assert!(rendered.contains("marked (2)"), "{rendered}"); + assert!(rendered.contains(&address(0xAA).to_string()), "{rendered}"); + assert!(rendered.contains(&address(0xBB).to_string()), "{rendered}"); + assert!(rendered.contains("swept (1)"), "{rendered}"); + assert!( + rendered.contains("credited (1 accounts, 4096 bytes)"), + "{rendered}" + ); + // Classes with nothing in them are omitted rather than printed as zero. + assert!(!rendered.contains("unmarked"), "{rendered}"); + assert!(!rendered.contains("nothing to do"), "{rendered}"); + } + + #[test] + fn a_purge_reports_what_is_still_waiting() { + // "Why has this not gone yet" is exactly the question a dry run is run to answer. + let report = PurgeReport { + purged: vec![crate::store::AssetId::new("gone")], + retained: vec![crate::store::AssetId::new("waiting")], + }; + let rendered = render_purge(&report, Mode::DryRun).to_string(); + assert!(rendered.contains("purged (1)"), "{rendered}"); + assert!(rendered.contains("gone"), "{rendered}"); + assert!(rendered.contains("retained (1)"), "{rendered}"); + assert!(rendered.contains("waiting"), "{rendered}"); + } + + #[test] + fn a_clean_scrub_says_the_two_sides_agree() { + let rendered = render_scrub(&ScrubReport::default()).to_string(); + assert!(rendered.contains("0 findings"), "{rendered}"); + assert!(rendered.contains("agree"), "{rendered}"); + } + + #[test] + fn a_scrub_groups_findings_by_the_class_an_operator_alerts_on() { + let report = ScrubReport { + findings: vec![ + crate::scrub::Finding::Orphan { + address: address(0xAA), + }, + crate::scrub::Finding::Orphan { + address: address(0xBB), + }, + crate::scrub::Finding::Debris { + path: "blobs/aa/not-a-blob".to_owned(), + }, + ], + bytes_hashed: 0, + budget_exhausted: false, + }; + let rendered = render_scrub(&report).to_string(); + assert!(rendered.contains("3 findings"), "{rendered}"); + assert!(rendered.contains("orphan (2)"), "{rendered}"); + assert!(rendered.contains("debris (1)"), "{rendered}"); + assert!(rendered.contains("not-a-blob"), "{rendered}"); + assert!(!rendered.contains("agree"), "{rendered}"); + } + + #[test] + fn a_truncated_deep_pass_is_not_a_clean_one() { + // A clean report from a pass that stopped early is the one answer a scrub must never + // give, so the rendering says so out loud as well. + let report = ScrubReport { + findings: Vec::new(), + bytes_hashed: 1024, + budget_exhausted: true, + }; + let rendered = render_scrub(&report).to_string(); + assert!(rendered.contains("budget exhausted"), "{rendered}"); + assert!(!rendered.contains("agree"), "{rendered}"); + } + + #[test] + fn dry_run_is_the_default_for_the_two_subcommands_that_write() { + // The first thing an operator does with a collector is find out what it thinks. + let gc = Cli::parse_from(["capsule-server", "gc"]).command; + assert!(matches!(gc, Command::Gc { apply: false, .. })); + let purge = Cli::parse_from(["capsule-server", "purge"]).command; + assert!(matches!(purge, Command::Purge { apply: false, .. })); + assert_eq!(mode(false), Mode::DryRun); + assert_eq!(mode(true), Mode::Apply); + } + + #[test] + fn a_structural_scrub_is_the_default_and_deep_carries_a_budget() { + let shallow = Cli::parse_from(["capsule-server", "scrub"]).command; + assert!(matches!(shallow, Command::Scrub { deep: false, .. })); + let deep = Cli::parse_from(["capsule-server", "scrub", "--deep"]).command; + let Command::Scrub { deep, budget, .. } = deep else { + panic!("that is the subcommand that was parsed") + }; + assert!(deep); + assert_eq!(budget, super::DEFAULT_SCRUB_BUDGET); + } + + #[test] + fn the_operator_commands_demand_a_blob_root_and_no_key_material() { + for argv in [ + ["capsule-server", "gc"], + ["capsule-server", "purge"], + ["capsule-server", "scrub"], + ] { + assert_eq!( + Cli::parse_from(argv).command.demands(), + crate::config::Demands::Maintenance, + "{argv:?}" + ); + } + } +} diff --git a/capsule-server/src/config.rs b/capsule-server/src/config.rs new file mode 100644 index 00000000..806f8640 --- /dev/null +++ b/capsule-server/src/config.rs @@ -0,0 +1,1084 @@ +//! [`Config`] — everything an operator gets to decide, read once at startup. +//! +//! # Environment only, and why there is no file +//! +//! `--config PATH` is accepted on the command line and **refused**: a configuration-file crate +//! would be a new dependency in a domain `design/dependencies.md` has no row for, and the +//! server this replaces was environment-only plus `dotenvy` +//! (`legacy-review/server-salvo/environment/`), so an operator loses nothing familiar. The flag +//! exists rather than being absent so the refusal is a sentence rather than clap's "unexpected +//! argument", and so the precedence table below already names the slot a file layer would sit +//! in. +//! +//! Precedence, highest first: **command line → process environment → built-in default.** +//! +//! # Every fault, once +//! +//! [`Config::load`] reports **all** of them ([`ConfigError`] holds a list) instead of failing on +//! the first. An operator bringing a deployment up otherwise restarts the process once per +//! variable, learning one missing key at a time from a server that already knew about four. +//! +//! # What is required depends on the subcommand +//! +//! `gc`, `purge` and `scrub` need a blob root and **no key material** — demanding a signing key +//! to sweep a directory would be a reason to keep a production key on a maintenance host. So +//! the requirement set is a parameter ([`Demands`]) rather than a property of the type. +//! +//! # Secrets +//! +//! [`SecretBytes`] redacts itself in `Debug`, the way +//! [`SessionTokens`](crate::auth::SessionTokens) does by hand: `Config` is logged at startup, +//! and a `Debug` that printed the token-signing key is how one reaches a log file. + +use std::collections::BTreeMap; +use std::fmt; +use std::net::{IpAddr, SocketAddr}; +use std::num::NonZeroUsize; +use std::path::PathBuf; + +use base64::Engine as _; +use base64::engine::general_purpose::{STANDARD as BASE64, STANDARD_NO_PAD as BASE64_NO_PAD}; +use jiff::SignedDuration; + +use crate::sync::CURSOR_KEY_LEN; + +/// The bind address a deployment gets without saying anything. +const DEFAULT_LISTEN: &str = "0.0.0.0:3000"; + +/// The port half of [`DEFAULT_LISTEN`], for composing `SERVER_HOST` with no `SERVER_PORT`. +const DEFAULT_PORT: u16 = 3000; + +/// The domain a deployment gets without saying anything. +const DEFAULT_DOMAIN: &str = "localhost"; + +/// The drain deadline, matching Kynos's own default — under the usual 30-second orchestrator +/// termination window, which is the whole reason that number is what it is. +const DEFAULT_SHUTDOWN_TIMEOUT: u64 = 25; + +/// The accepted-connection ceiling, matching Kynos's own default. +const DEFAULT_MAX_CONNECTIONS: usize = 10_000; + +/// How long an account stays locked after enough failed credential presentations. +/// +/// Fifteen minutes. `design/authentication.md` names no figure — it says only that a locked +/// account is locked at password change too — so this is a decision recorded here rather than a +/// value read from somewhere: long enough that an online guessing run is throttled to +/// uselessness, short enough that a person who mistyped their password four times gets back into +/// their own account without an operator. +/// +/// It has to decay at all, because there is **no unlock endpoint and no operator command that +/// clears it**: every route that could reset the state (`login`, `reauthenticate`, +/// `password`) refuses on `Locked` before it verifies anything, so a permanent lockout is +/// a permanently lost account. Seconds rather than minutes as the unit so a test can pick a +/// window it can actually wait out. +const DEFAULT_LOCKOUT_WINDOW_SECONDS: u64 = 15 * 60; + +/// How many consecutive failures inside that window lock an account. +/// +/// The companion number to the window — a lockout is not one policy but two, and a deployment +/// that wants a tighter one needs to move both. Ten is +/// [`MAX_FAILED_ATTEMPTS`](crate::auth::accounts_memory::MAX_FAILED_ATTEMPTS), which is where the +/// reasoning for the figure lives. +const DEFAULT_LOCKOUT_ATTEMPTS: u32 = crate::auth::accounts_memory::MAX_FAILED_ATTEMPTS; + +/// The seed [`HybridSigningKey`](capsule_core::crypto::keys::HybridSigningKey) is built from. +const ATTESTATION_SEED_LEN: usize = 64; + +/// HKDF `info` for the sync-cursor MAC key derived from the token-signing key. +const CURSOR_KEY_INFO: &[u8] = b"capsule/sync-cursor-mac/v1"; + +/// HKDF `info` for the attestation seed derived from the token-signing key. +const ATTESTATION_SEED_INFO: &[u8] = b"capsule/attestation-seed/v1"; + +/// Where a setting is read from. +/// +/// A trait rather than [`std::env::var`] directly so the precedence table is a unit test rather +/// than a claim: a test builds the environment it wants and asserts what came out, with no +/// process-global state two concurrent tests would fight over. +pub trait Environment: fmt::Debug { + /// The value of `key`, or `None` when it is unset **or set to the empty string**. + /// + /// Empty is absent on purpose. `FOO=` in a compose file or a `.env` is how an operator + /// writes "I did not set this", and a server that read it as a zero-length signing key + /// would fail somewhere much less obvious than here. + fn var(&self, key: &str) -> Option; +} + +/// The real process environment. +#[derive(Debug, Clone, Copy)] +pub struct ProcessEnvironment; + +impl Environment for ProcessEnvironment { + fn var(&self, key: &str) -> Option { + std::env::var(key).ok().filter(|value| !value.is_empty()) + } +} + +impl Environment for BTreeMap { + fn var(&self, key: &str) -> Option { + self.get(key).filter(|value| !value.is_empty()).cloned() + } +} + +/// Bytes that must not be printed. +#[derive(Clone, PartialEq, Eq)] +pub struct SecretBytes(Vec); + +impl SecretBytes { + /// Hold `bytes` as a secret. + pub fn new(bytes: Vec) -> Self { + Self(bytes) + } + + /// The bytes, for the one caller that has to use them. + pub fn expose(&self) -> &[u8] { + &self.0 + } +} + +impl fmt::Debug for SecretBytes { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SecretBytes()") + } +} + +/// Which family of adapters a process runs on. +/// +/// Two arms, and the second one is not implemented yet — see +/// [`assemble`](crate::boot::assemble). It is an enum rather than a trait because the +/// `Arc` fields in [`Modules`](crate::app::Modules) already **are** the abstraction; +/// a second one over the top would abstract the composition root from itself. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Backends { + /// Every deterministic in-crate adapter, over a real filesystem blob store. + /// + /// An explicit operator act (`--memory`, or `CAPSULE_PROFILE=memory`) and **never** a + /// fallback: a deployment that forgets `VALKEY_URL` must fail closed rather than come up + /// holding state it will lose on the next restart. + Memory, + /// Postgres and Valkey, selected by `DATABASE_URL` and `VALKEY_URL`. + Durable, +} + +/// How the log stream is rendered. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogFormat { + /// One JSON object per event — what a log shipper wants. + Json, + /// Multi-line, coloured, human-first — what a developer wants. + Pretty, +} + +/// What a subcommand needs before it can do anything. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Demands { + /// `serve`: a blob root, a token-signing key, and a chosen backend family. + Serve, + /// `gc` / `purge` / `scrub`: a blob root, and deliberately no key material. + Maintenance, + /// `gen-openapi`: nothing at all. The document is a property of the router's types. + Nothing, +} + +/// One thing wrong with the configuration. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ConfigFault { + /// A required setting is absent. + #[error("{key} is required and is not set")] + Missing { + /// The environment variable or flag an operator has to set. + key: &'static str, + }, + /// A setting is present and cannot be used. + /// + /// `detail` never quotes the value: `JWT_ED25519_DER` is a private key, and a startup error + /// is the most-copied line in any incident channel. + #[error("{key} is not usable: {detail}")] + Invalid { + /// The setting. + key: &'static str, + /// What is wrong with it — never the value itself. + detail: String, + }, + /// A setting is understood, accepted at the boundary, and not implemented. + #[error("{key} is not supported yet: {detail}")] + Unsupported { + /// The setting. + key: &'static str, + /// What to do instead. + detail: String, + }, +} + +/// Everything wrong with the configuration, in one message. +/// +/// `Display` is hand-written rather than `thiserror`-generated because the message *is* a list; +/// a derived one-line format would put five faults on one line, which is the shape an operator +/// reads worst. The variants themselves are `thiserror` as the repository requires. +#[derive(Debug, PartialEq, Eq)] +pub struct ConfigError { + faults: Vec, +} + +impl ConfigError { + /// Every fault found, in the order the fields are read. + pub fn faults(&self) -> &[ConfigFault] { + &self.faults + } + + /// Whether `key` is among the faults, for a test that asserts one was reported. + pub fn names(&self, key: &str) -> bool { + self.faults.iter().any(|fault| match fault { + ConfigFault::Missing { key: named } + | ConfigFault::Invalid { key: named, .. } + | ConfigFault::Unsupported { key: named, .. } => *named == key, + }) + } +} + +impl fmt::Display for ConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "the server configuration is not usable ({} problem{})", + self.faults.len(), + if self.faults.len() == 1 { "" } else { "s" } + )?; + for fault in &self.faults { + write!(f, "\n - {fault}")?; + } + Ok(()) + } +} + +impl std::error::Error for ConfigError {} + +/// The command line's say, applied over the environment. +/// +/// Every field is an `Option` (or a `bool` that is only ever set) so "the operator did not pass +/// this flag" and "the operator passed this flag with the default value" are different states — +/// which is what makes the precedence table implementable at all. +#[derive(Debug, Clone, Default)] +pub struct Overrides { + /// `--config PATH`. Refused; see the module docs. + pub config_file: Option, + /// `--listen HOST:PORT`. + pub listen: Option, + /// `--blob-root PATH`. + pub blob_root: Option, + /// `--memory`. + pub memory: bool, + /// `--grace-window-hours N`. + pub grace_window_hours: Option, +} + +/// Everything an operator gets to decide. +#[derive(Debug, Clone)] +pub struct Config { + /// Where the process accepts connections. + pub listen: SocketAddr, + /// This deployment's canonical origin — the `server_id` every published record carries. + pub server_domain: String, + /// The absolute base URL clients reach the versioned API at. + pub api_base_url: String, + /// The filesystem tree ciphertext blobs are written to. There is no object store. + pub blob_root: Option, + /// The Postgres URL, once an adapter reads it (#402). + pub database_url: Option, + /// The Valkey URL, once an adapter reads it (#403). + pub valkey_url: Option, + /// The PKCS#8 Ed25519 private key access and refresh tokens are signed with. + pub signing_key_der: Option, + /// The HMAC key sync cursors are authenticated under. + pub sync_cursor_mac_key: Option<[u8; CURSOR_KEY_LEN]>, + /// The seed the attestation signing key is built from. + pub attestation_key_seed: Option<[u8; ATTESTATION_SEED_LEN]>, + /// The oldest `protocol_version` accepted for writes. + pub protocol_min: String, + /// The newest `protocol_version` this server speaks. + pub protocol_max: String, + /// How long a blob sits at zero references before the collector may sweep it. + pub grace_window: SignedDuration, + /// How long an account stays locked after too many failed credential presentations. + pub lockout_window: SignedDuration, + /// How many consecutive failures inside that window lock it. + pub lockout_attempts: u32, + /// How long a shutdown may take to drain. + pub shutdown_timeout: std::time::Duration, + /// The accepted-connection ceiling. + pub max_connections: NonZeroUsize, + /// How the log stream is rendered. + pub log_format: LogFormat, + /// Which family of adapters to run on. + pub backends: Backends, +} + +impl Config { + /// Read the configuration for a subcommand that demands `demands`. + /// + /// # Errors + /// + /// Returns [`ConfigError`] carrying **every** fault found, never only the first. + #[allow( + clippy::too_many_lines, + reason = "one pass over the settings table, in the order the table is written" + )] + pub fn load( + env: &dyn Environment, + overrides: &Overrides, + demands: Demands, + ) -> Result { + let mut faults = Vec::new(); + + if let Some(path) = &overrides.config_file { + faults.push(ConfigFault::Unsupported { + key: "--config", + detail: format!( + "config files are not supported yet; every setting is read from the \ + environment, so drop `--config {}` and export the variables instead", + path.display() + ), + }); + } + + // ── Listener ──────────────────────────────────────────────────────────────────── + let port = parse_number::(env, "SERVER_PORT", &mut faults).unwrap_or(DEFAULT_PORT); + let listen = overrides.listen.or_else(|| { + let host = env.var("SERVER_HOST")?; + match host.parse::() { + Ok(address) => Some(SocketAddr::new(address, port)), + Err(error) => { + faults.push(ConfigFault::Invalid { + key: "SERVER_HOST", + // The value is an address, not a secret, and a typo is the whole point. + detail: format!("`{host}` is not an IP address to bind ({error})"), + }); + None + } + } + }); + let listen = listen.unwrap_or_else(|| { + let default: SocketAddr = DEFAULT_LISTEN + .parse() + .expect("the built-in default listener parses"); + SocketAddr::new(default.ip(), port) + }); + + // ── Identity ──────────────────────────────────────────────────────────────────── + let server_domain = env + .var("SERVER_DOMAIN") + .unwrap_or_else(|| DEFAULT_DOMAIN.to_owned()); + // `/v1` included: `ServerInfo` derives the published auth endpoints by appending to this, + // so a base URL without the version prefix publishes `http://host/auth/login`, which no + // route serves. The default is what a developer reaches on their own machine. + let api_base_url = env + .var("API_BASE_URL") + .unwrap_or_else(|| format!("http://{server_domain}:{}/v1", listen.port())); + + // ── Storage ───────────────────────────────────────────────────────────────────── + // `UPLOAD_DIR` is the name the retired deployment used, accepted so an operator's + // existing environment keeps working, and warned about so it does not become the name. + let blob_root = overrides + .blob_root + .clone() + .or_else(|| env.var("BLOB_ROOT").map(PathBuf::from)) + .or_else(|| { + let legacy = env.var("UPLOAD_DIR").map(PathBuf::from)?; + tracing::warn!( + "UPLOAD_DIR is the retired name for BLOB_ROOT and is still honoured; \ + rename it" + ); + Some(legacy) + }); + let database_url = env.var("DATABASE_URL"); + let valkey_url = env.var("VALKEY_URL"); + + // ── Backend family ────────────────────────────────────────────────────────────── + let backends = if overrides.memory + || env + .var("CAPSULE_PROFILE") + .is_some_and(|profile| profile.eq_ignore_ascii_case("memory")) + { + Backends::Memory + } else { + Backends::Durable + }; + + // ── Key material ──────────────────────────────────────────────────────────────── + let signing_key_der = + decode_base64(env, "JWT_ED25519_DER", &mut faults).map(SecretBytes::new); + let sync_cursor_mac_key = + decode_fixed::(env, "SYNC_CURSOR_MAC_KEY", &mut faults); + let attestation_key_seed = decode_seed(env, &mut faults); + + // The sync-cursor MAC key is HKDF-derived from the token-signing key when unset. That is + // sound: a cursor MAC and a session token are the same trust domain — both are + // operational secrets this server holds to authenticate its own output — so deriving one + // from the other adds no capability to anybody who holds either. + // + // **The attestation seed is not, outside the development profile**, and that is a + // correction rather than a preference. `attestation/mod.rs` requires the attestation key + // to be distinct from the operational key precisely so that holding the operational key + // does not let anything manufacture custody evidence. Deriving the seed from + // `JWT_ED25519_DER` collapses exactly that distinction: anyone with the token-signing key + // recomputes the attestation key and signs receipts. So a real deployment must set + // `ATTESTATION_KEY_SEED` (see the `Demands::Serve` arm below), and only + // `Backends::Memory` — an explicit `--memory`, a development act, where the whole + // application state is discarded on exit — keeps the derivation, so `serve --memory` + // needs one variable rather than two. + let sync_cursor_mac_key = sync_cursor_mac_key.or_else(|| { + derive::(signing_key_der.as_ref()?.expose(), CURSOR_KEY_INFO) + }); + let attestation_key_seed = attestation_key_seed.or_else(|| match backends { + Backends::Memory => derive::( + signing_key_der.as_ref()?.expose(), + ATTESTATION_SEED_INFO, + ), + Backends::Durable => None, + }); + + // ── Protocol window ───────────────────────────────────────────────────────────── + let protocol_max = env + .var("PROTOCOL_MAX") + .unwrap_or_else(|| capsule_core::crypto::PROTOCOL_VERSION.to_owned()); + let protocol_min = env + .var("PROTOCOL_MIN") + .unwrap_or_else(|| capsule_core::crypto::PROTOCOL_VERSION.to_owned()); + if protocol_min > protocol_max { + faults.push(ConfigFault::Invalid { + key: "PROTOCOL_MIN", + detail: format!("`{protocol_min}` is newer than PROTOCOL_MAX `{protocol_max}`"), + }); + } + + // ── Operational knobs ─────────────────────────────────────────────────────────── + let grace_window = overrides + .grace_window_hours + .or_else(|| parse_number::(env, "GC_GRACE_WINDOW_HOURS", &mut faults)) + .map_or(crate::gc::DEFAULT_GRACE_WINDOW, |hours| { + SignedDuration::from_hours(i64::try_from(hours).unwrap_or(i64::MAX)) + }); + let lockout_window = SignedDuration::from_secs( + parse_number::(env, "LOCKOUT_WINDOW_SECONDS", &mut faults).map_or_else( + || { + i64::try_from(DEFAULT_LOCKOUT_WINDOW_SECONDS) + .expect("the built-in lockout window fits") + }, + |seconds| seconds.max(0), + ), + ); + let lockout_attempts = parse_number::(env, "LOCKOUT_MAX_ATTEMPTS", &mut faults) + .and_then(|attempts| { + if attempts == 0 { + // Zero would lock every account on its first wrong keystroke and never + // unlock it before the window passed, which is a denial of service dressed + // as a policy. Refused rather than clamped to one: an operator who typed it + // meant something, and guessing what is worse than saying it is not allowed. + faults.push(ConfigFault::Invalid { + key: "LOCKOUT_MAX_ATTEMPTS", + detail: "zero would lock every account on its first failure".to_owned(), + }); + None + } else { + Some(attempts) + } + }) + .unwrap_or(DEFAULT_LOCKOUT_ATTEMPTS); + let shutdown_timeout = std::time::Duration::from_secs( + parse_number::(env, "SHUTDOWN_TIMEOUT_SECONDS", &mut faults) + .unwrap_or(DEFAULT_SHUTDOWN_TIMEOUT), + ); + let max_connections = parse_number::(env, "MAX_CONNECTIONS", &mut faults) + .and_then(|limit| { + NonZeroUsize::new(limit).or_else(|| { + faults.push(ConfigFault::Invalid { + key: "MAX_CONNECTIONS", + detail: "zero would accept nothing at all".to_owned(), + }); + None + }) + }) + .unwrap_or_else(|| { + NonZeroUsize::new(DEFAULT_MAX_CONNECTIONS) + .expect("the built-in connection ceiling is non-zero") + }); + let log_format = match env.var("LOG_FORMAT") { + None => { + // JSON in release because the reader is a log shipper; pretty in debug because + // the reader is a person with the source open. + if cfg!(debug_assertions) { + LogFormat::Pretty + } else { + LogFormat::Json + } + } + Some(format) if format.eq_ignore_ascii_case("json") => LogFormat::Json, + Some(format) if format.eq_ignore_ascii_case("pretty") => LogFormat::Pretty, + Some(format) => { + faults.push(ConfigFault::Invalid { + key: "LOG_FORMAT", + detail: format!("`{format}` is neither `json` nor `pretty`"), + }); + LogFormat::Json + } + }; + + // ── What the subcommand demands ───────────────────────────────────────────────── + // + // Last, and after every parse, so one message carries both "this is malformed" and + // "that is missing" rather than a restart between them. + match demands { + Demands::Nothing => {} + Demands::Maintenance => { + require(blob_root.is_some(), "BLOB_ROOT", &mut faults); + } + Demands::Serve => { + require(blob_root.is_some(), "BLOB_ROOT", &mut faults); + require(signing_key_der.is_some(), "JWT_ED25519_DER", &mut faults); + // The refusal `store/mod.rs` has always documented and nothing has ever + // enforced: Valkey is required, and the in-memory adapters are a development + // profile an operator opts into rather than something to fall back on. + if backends == Backends::Durable { + if valkey_url.is_none() { + faults.push(ConfigFault::Missing { key: "VALKEY_URL" }); + } + // Required rather than derived; see the key-material section above for what + // deriving it from the token-signing key would give away. Demanded only on + // the durable path because `--memory` derives it, so a development server + // still comes up on one variable. + if attestation_key_seed.is_none() { + faults.push(ConfigFault::Missing { + key: "ATTESTATION_KEY_SEED", + }); + } + } + } + } + + if faults.is_empty() { + Ok(Self { + listen, + server_domain, + api_base_url, + blob_root, + database_url, + valkey_url, + signing_key_der, + sync_cursor_mac_key, + attestation_key_seed, + protocol_min, + protocol_max, + grace_window, + lockout_window, + lockout_attempts, + shutdown_timeout, + max_connections, + log_format, + backends, + }) + } else { + Err(ConfigError { faults }) + } + } +} + +/// Record a missing required setting. +fn require(present: bool, key: &'static str, faults: &mut Vec) { + if !present { + faults.push(ConfigFault::Missing { key }); + } +} + +/// Parse `key` as `T`, recording a fault rather than failing the whole read. +fn parse_number( + env: &dyn Environment, + key: &'static str, + faults: &mut Vec, +) -> Option +where + T: std::str::FromStr, + T::Err: fmt::Display, +{ + let raw = env.var(key)?; + match raw.trim().parse::() { + Ok(value) => Some(value), + Err(error) => { + faults.push(ConfigFault::Invalid { + key, + detail: format!("`{raw}` is not a number this setting accepts ({error})"), + }); + None + } + } +} + +/// Decode `key` from base64, accepting padded and unpadded input. +/// +/// Two alphabets rather than one because the documented way to produce `JWT_ED25519_DER` is +/// `openssl genpkey … | base64 -w 0`, and a shell pipeline that strips the padding is common +/// enough that refusing it would be a support question rather than a security property. +fn decode_base64( + env: &dyn Environment, + key: &'static str, + faults: &mut Vec, +) -> Option> { + let raw = env.var(key)?; + let trimmed = raw.trim(); + if let Ok(bytes) = BASE64.decode(trimmed) { + return Some(bytes); + } + match BASE64_NO_PAD.decode(trimmed) { + Ok(bytes) => Some(bytes), + Err(error) => { + faults.push(ConfigFault::Invalid { + key, + // The error names a position, never the bytes: this value is a private key. + detail: format!("it is not base64 ({error})"), + }); + None + } + } +} + +/// Decode `key` from base64 and require exactly `N` bytes. +fn decode_fixed( + env: &dyn Environment, + key: &'static str, + faults: &mut Vec, +) -> Option<[u8; N]> { + let bytes = decode_base64(env, key, faults)?; + let found = bytes.len(); + <[u8; N]>::try_from(bytes.as_slice()).ok().or_else(|| { + faults.push(ConfigFault::Invalid { + key, + detail: format!("it decodes to {found} bytes and must be exactly {N}"), + }); + None + }) +} + +/// Decode `ATTESTATION_KEY_SEED`, accepting 32 bytes and expanding them to 64. +/// +/// Thirty-two is what every general-purpose "generate a seed" instruction produces, and the +/// hybrid signing key needs sixty-four; expanding rather than refusing means an operator's +/// `openssl rand -base64 32` works, and the expansion is domain-separated so the two halves are +/// not the same 32 bytes twice. +fn decode_seed( + env: &dyn Environment, + faults: &mut Vec, +) -> Option<[u8; ATTESTATION_SEED_LEN]> { + let bytes = decode_base64(env, "ATTESTATION_KEY_SEED", faults)?; + match bytes.len() { + ATTESTATION_SEED_LEN => <[u8; ATTESTATION_SEED_LEN]>::try_from(bytes.as_slice()).ok(), + 32 => derive::(&bytes, ATTESTATION_SEED_INFO), + found => { + faults.push(ConfigFault::Invalid { + key: "ATTESTATION_KEY_SEED", + detail: format!( + "it decodes to {found} bytes and must be 32 or {ATTESTATION_SEED_LEN}" + ), + }); + None + } + } +} + +/// HKDF-SHA256 `secret` into `N` bytes under `info`. +/// +/// `ring` rather than a second HKDF implementation: it is already this crate's HMAC for the sync +/// cursor, so the derived key and the key it authenticates come from one primitive. +fn derive(secret: &[u8], info: &[u8]) -> Option<[u8; N]> { + /// The output length, as `ring`'s key-type trait wants it. + #[derive(Debug, Clone, Copy)] + struct Len(usize); + + impl ring::hkdf::KeyType for Len { + fn len(&self) -> usize { + self.0 + } + } + + // An empty salt is HKDF's documented default and the right one here: the derivation is + // domain-separated by `info`, and a salt would have to be configured — one more variable an + // operator can get wrong for no gain, because the input is already a private key. + let prk = ring::hkdf::Salt::new(ring::hkdf::HKDF_SHA256, &[]).extract(secret); + let mut out = [0u8; N]; + prk.expand(&[info], Len(N)).ok()?.fill(&mut out).ok()?; + Some(out) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use jiff::SignedDuration; + + use super::{Backends, Config, Demands, LogFormat, Overrides}; + + /// A PKCS#8 v1 Ed25519 key, base64, from the retired deployment's own `.env.example`. + /// + /// A committed *example* key rather than a generated one, because these tests assert + /// **derivation is deterministic**, and a fresh key per run would make that unassertable. + /// It signs nothing: no deployment ever used it, and any that did published the fact. + const EXAMPLE_DER: &str = "MC4CAQAwBQYDK2VwBCIEIN6eTvXEL7xMZWHY8rTk7VbQSGSuRkle5MVfiiYUStLF"; + + fn env(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect() + } + + /// The environment a `serve --memory` needs and nothing more. + fn serveable() -> BTreeMap { + env(&[ + ("BLOB_ROOT", "/var/lib/capsule/blobs"), + ("JWT_ED25519_DER", EXAMPLE_DER), + ]) + } + + fn memory() -> Overrides { + Overrides { + memory: true, + ..Overrides::default() + } + } + + #[test] + fn the_defaults_are_a_complete_configuration() { + let config = Config::load(&serveable(), &memory(), Demands::Serve).expect("it loads"); + assert_eq!(config.listen.to_string(), "0.0.0.0:3000"); + assert_eq!(config.server_domain, "localhost"); + assert_eq!(config.api_base_url, "http://localhost:3000/v1"); + assert_eq!(config.backends, Backends::Memory); + assert_eq!(config.protocol_min, config.protocol_max); + } + + #[test] + fn a_flag_beats_the_environment_which_beats_the_default() { + // The whole precedence table in one case: the environment moves the port off the + // built-in default, and the flag moves it off the environment. + let mut environment = serveable(); + environment.insert("SERVER_PORT".to_owned(), "5000".to_owned()); + + let from_env = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + assert_eq!(from_env.listen.to_string(), "0.0.0.0:5000"); + + let overridden = Overrides { + listen: Some("127.0.0.1:6000".parse().expect("a literal address parses")), + ..memory() + }; + let from_flag = Config::load(&environment, &overridden, Demands::Serve).expect("it loads"); + assert_eq!(from_flag.listen.to_string(), "127.0.0.1:6000"); + } + + #[test] + fn server_host_and_server_port_compose_into_one_listener() { + let environment = env(&[ + ("BLOB_ROOT", "/blobs"), + ("JWT_ED25519_DER", EXAMPLE_DER), + ("SERVER_HOST", "127.0.0.1"), + ("SERVER_PORT", "8080"), + ]); + let config = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + assert_eq!(config.listen.to_string(), "127.0.0.1:8080"); + } + + #[test] + fn every_fault_is_reported_in_one_pass() { + // The property the aggregate exists for: an operator bringing a deployment up learns + // about all four at once rather than restarting four times. + let environment = env(&[ + ("SERVER_PORT", "not-a-port"), + ("LOG_FORMAT", "yaml"), + ("MAX_CONNECTIONS", "0"), + ]); + let error = Config::load(&environment, &memory(), Demands::Serve).expect_err("it refuses"); + assert!(error.names("SERVER_PORT"), "{error}"); + assert!(error.names("LOG_FORMAT"), "{error}"); + assert!(error.names("MAX_CONNECTIONS"), "{error}"); + assert!(error.names("BLOB_ROOT"), "{error}"); + assert!(error.names("JWT_ED25519_DER"), "{error}"); + // Not `ATTESTATION_KEY_SEED`: `--memory` derives it, so naming it here would send a + // developer looking for a variable the development profile does not want. + assert!(!error.names("ATTESTATION_KEY_SEED"), "{error}"); + + // The durable path names both of its own, alongside everything else. + let error = Config::load(&environment, &Overrides::default(), Demands::Serve) + .expect_err("it refuses"); + assert!(error.names("VALKEY_URL"), "{error}"); + assert!(error.names("ATTESTATION_KEY_SEED"), "{error}"); + } + + #[test] + fn serving_without_valkey_and_without_the_memory_profile_is_refused_by_name() { + // `store/mod.rs` has documented this refusal since `S-C29` and nothing enforced it. + let error = Config::load(&serveable(), &Overrides::default(), Demands::Serve) + .expect_err("it refuses"); + assert!(error.names("VALKEY_URL"), "{error}"); + } + + #[test] + fn the_memory_profile_is_also_reachable_from_the_environment() { + let mut environment = serveable(); + environment.insert("CAPSULE_PROFILE".to_owned(), "Memory".to_owned()); + let config = + Config::load(&environment, &Overrides::default(), Demands::Serve).expect("it loads"); + assert_eq!(config.backends, Backends::Memory); + } + + #[test] + fn maintenance_needs_a_blob_root_and_no_key_material() { + // A maintenance host that had to hold the production token-signing key to sweep a + // directory would be a reason to put the key on a maintenance host. + let config = Config::load( + &env(&[("BLOB_ROOT", "/blobs")]), + &Overrides::default(), + Demands::Maintenance, + ) + .expect("it loads"); + assert!(config.signing_key_der.is_none()); + + let error = Config::load( + &env(&[("JWT_ED25519_DER", EXAMPLE_DER)]), + &Overrides::default(), + Demands::Maintenance, + ) + .expect_err("it refuses"); + assert!(error.names("BLOB_ROOT"), "{error}"); + } + + #[test] + fn describing_the_router_needs_nothing() { + let config = Config::load(&BTreeMap::new(), &Overrides::default(), Demands::Nothing) + .expect("it loads"); + assert!(config.blob_root.is_none()); + } + + #[test] + fn a_durable_serve_must_be_given_an_attestation_seed() { + // The attestation key must be distinct from the operational key — `attestation/mod.rs` + // requires it so that holding the token signer does not let anything manufacture custody + // evidence. Deriving the seed from `JWT_ED25519_DER` collapses exactly that, so a real + // deployment is made to say what its attestation identity is. + let environment = env(&[ + ("BLOB_ROOT", "/blobs"), + ("JWT_ED25519_DER", EXAMPLE_DER), + ("VALKEY_URL", "redis://127.0.0.1:6379"), + ]); + let error = Config::load(&environment, &Overrides::default(), Demands::Serve) + .expect_err("it refuses"); + assert!(error.names("ATTESTATION_KEY_SEED"), "{error}"); + + let mut with_seed = environment.clone(); + with_seed.insert( + "ATTESTATION_KEY_SEED".to_owned(), + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, [9_u8; 64]), + ); + let config = Config::load(&with_seed, &Overrides::default(), Demands::Serve) + .expect("it loads with a seed of its own"); + assert_eq!(config.attestation_key_seed, Some([9; 64])); + } + + #[test] + fn the_cursor_key_and_the_attestation_seed_are_derived_from_the_signing_key() { + // The **development** profile only. A durable deployment is made to set the seed; see + // the case above. + let first = Config::load(&serveable(), &memory(), Demands::Serve).expect("it loads"); + let second = Config::load(&serveable(), &memory(), Demands::Serve).expect("it loads"); + + let cursor = first.sync_cursor_mac_key.expect("it is derived"); + let seed = first.attestation_key_seed.expect("it is derived"); + assert_eq!( + Some(cursor), + second.sync_cursor_mac_key, + "derivation is stable" + ); + assert_eq!( + Some(seed), + second.attestation_key_seed, + "derivation is stable" + ); + // Domain separation: the two derivations of one key must not be the same bytes. + assert_ne!( + &seed[..32], + &cursor[..], + "the two infos separate the outputs" + ); + } + + #[test] + fn the_lockout_threshold_is_configurable_and_may_not_be_zero() { + // A lockout is two numbers, not one, and a deployment that wants a tighter policy has to + // be able to move both. + let config = Config::load(&serveable(), &memory(), Demands::Serve).expect("it loads"); + assert_eq!(config.lockout_attempts, 10); + + let mut environment = serveable(); + environment.insert("LOCKOUT_MAX_ATTEMPTS".to_owned(), "3".to_owned()); + let config = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + assert_eq!(config.lockout_attempts, 3); + + environment.insert("LOCKOUT_MAX_ATTEMPTS".to_owned(), "0".to_owned()); + let error = Config::load(&environment, &memory(), Demands::Serve).expect_err("it refuses"); + assert!(error.names("LOCKOUT_MAX_ATTEMPTS"), "{error}"); + } + + #[test] + fn the_lockout_window_defaults_to_fifteen_minutes_and_is_configurable() { + // It has to decay at all: no route resets a lockout — every one of them refuses on + // `Locked` before it verifies anything — so a permanent lockout is a lost account. + let config = Config::load(&serveable(), &memory(), Demands::Serve).expect("it loads"); + assert_eq!(config.lockout_window, SignedDuration::from_mins(15)); + + let mut environment = serveable(); + environment.insert("LOCKOUT_WINDOW_SECONDS".to_owned(), "1".to_owned()); + let config = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + assert_eq!(config.lockout_window, SignedDuration::from_secs(1)); + } + + #[test] + fn an_explicit_cursor_key_wins_over_the_derived_one() { + let mut environment = serveable(); + let explicit = base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + [0x5C_u8; super::CURSOR_KEY_LEN], + ); + environment.insert("SYNC_CURSOR_MAC_KEY".to_owned(), explicit); + let config = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + assert_eq!( + config.sync_cursor_mac_key, + Some([0x5C; super::CURSOR_KEY_LEN]) + ); + } + + #[test] + fn a_cursor_key_of_the_wrong_length_is_refused_by_length_and_not_by_value() { + let mut environment = serveable(); + environment.insert( + "SYNC_CURSOR_MAC_KEY".to_owned(), + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, [7_u8; 16]), + ); + let error = Config::load(&environment, &memory(), Demands::Serve).expect_err("it refuses"); + assert!(error.names("SYNC_CURSOR_MAC_KEY"), "{error}"); + assert!(format!("{error}").contains("16 bytes"), "{error}"); + } + + #[test] + fn a_thirty_two_byte_attestation_seed_is_expanded_rather_than_refused() { + let mut environment = serveable(); + environment.insert( + "ATTESTATION_KEY_SEED".to_owned(), + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, [3_u8; 32]), + ); + let config = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + let seed = config.attestation_key_seed.expect("it is expanded"); + // Expanded, not repeated: the two halves of the hybrid key must not share a seed. + assert_ne!(&seed[..32], &seed[32..]); + } + + #[test] + fn a_signing_key_that_is_not_base64_is_refused_without_quoting_it() { + let mut environment = serveable(); + environment.insert( + "JWT_ED25519_DER".to_owned(), + "not base64 at all!!".to_owned(), + ); + let error = Config::load(&environment, &memory(), Demands::Serve).expect_err("it refuses"); + let message = format!("{error}"); + assert!(error.names("JWT_ED25519_DER"), "{message}"); + assert!( + !message.contains("not base64 at all"), + "a startup error must not echo the key material: {message}" + ); + } + + #[test] + fn an_unpadded_signing_key_loads() { + // `openssl genpkey … | base64` piped through anything that strips `=` is common enough + // that refusing it would be a support question rather than a security property. + let mut environment = serveable(); + environment.insert( + "JWT_ED25519_DER".to_owned(), + EXAMPLE_DER.trim_end_matches('=').to_owned(), + ); + assert!(Config::load(&environment, &memory(), Demands::Serve).is_ok()); + } + + #[test] + fn an_empty_variable_is_an_absent_one() { + // `FOO=` in a compose file means "I did not set this", and reading it as a zero-length + // signing key would fail somewhere much less obvious than here. + let mut environment = serveable(); + environment.insert("JWT_ED25519_DER".to_owned(), String::new()); + let error = Config::load(&environment, &memory(), Demands::Serve).expect_err("it refuses"); + assert!(error.names("JWT_ED25519_DER"), "{error}"); + } + + #[test] + fn the_retired_upload_dir_name_is_still_honoured() { + let environment = env(&[ + ("UPLOAD_DIR", "/legacy/uploads"), + ("JWT_ED25519_DER", EXAMPLE_DER), + ]); + let config = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + assert_eq!( + config.blob_root.as_deref(), + Some(std::path::Path::new("/legacy/uploads")) + ); + } + + #[test] + fn a_config_file_is_refused_with_what_to_do_instead() { + let overrides = Overrides { + config_file: Some("/etc/capsule/server.toml".into()), + ..memory() + }; + let error = Config::load(&serveable(), &overrides, Demands::Serve).expect_err("it refuses"); + assert!(error.names("--config"), "{error}"); + assert!(format!("{error}").contains("environment"), "{error}"); + } + + #[test] + fn an_inverted_protocol_window_is_refused() { + let mut environment = serveable(); + environment.insert("PROTOCOL_MIN".to_owned(), "2099-01-01".to_owned()); + environment.insert("PROTOCOL_MAX".to_owned(), "2025-01-01".to_owned()); + let error = Config::load(&environment, &memory(), Demands::Serve).expect_err("it refuses"); + assert!(error.names("PROTOCOL_MIN"), "{error}"); + } + + #[test] + fn the_log_format_follows_the_build_profile_when_unset() { + let config = Config::load(&serveable(), &memory(), Demands::Serve).expect("it loads"); + let expected = if cfg!(debug_assertions) { + LogFormat::Pretty + } else { + LogFormat::Json + }; + assert_eq!(config.log_format, expected); + } + + #[test] + fn the_grace_window_is_hours_and_the_flag_beats_the_environment() { + let mut environment = serveable(); + environment.insert("GC_GRACE_WINDOW_HOURS".to_owned(), "72".to_owned()); + let config = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + assert_eq!(config.grace_window, jiff::SignedDuration::from_hours(72)); + + let overrides = Overrides { + grace_window_hours: Some(1), + ..memory() + }; + let config = Config::load(&environment, &overrides, Demands::Serve).expect("it loads"); + assert_eq!(config.grace_window, jiff::SignedDuration::from_hours(1)); + } + + #[test] + fn a_secret_is_not_printed_by_debug() { + let config = Config::load(&serveable(), &memory(), Demands::Serve).expect("it loads"); + let rendered = format!("{config:?}"); + assert!(rendered.contains(""), "{rendered}"); + assert!(!rendered.contains("MC4CAQAwBQYDK2Vw"), "{rendered}"); + } +} diff --git a/capsule-server/src/lib.rs b/capsule-server/src/lib.rs index b021f1f8..5d018f32 100644 --- a/capsule-server/src/lib.rs +++ b/capsule-server/src/lib.rs @@ -32,6 +32,14 @@ //! [`verify`] — is framework-free and testable without a router, which is why the operator //! workers ([`gc`], [`scrub`]) have no wire surface at all and cost nothing to exercise. //! +//! # How a process is assembled +//! +//! [`config`] reads what an operator decided; [`boot`] turns it into the one [`App`] the router +//! is built with. Both are library modules rather than binary code, because a composition root +//! that lives in `main` is a composition root nothing tests: `boot::assemble` is driven by unit +//! tests here and by the binary identically, so "the server can be built at all" is an +//! assertion rather than something discovered on a deployment. +//! //! # Every adapter is in-memory //! //! Every port in this crate has a deterministic in-memory adapter and a conformance suite, and @@ -47,6 +55,9 @@ pub mod attestation; pub mod auth; pub mod blob; pub mod body; +pub mod boot; +pub mod cli; +pub mod config; pub mod counter; pub mod directory; pub mod discovery; diff --git a/capsule-server/src/main.rs b/capsule-server/src/main.rs new file mode 100644 index 00000000..4b8e4513 --- /dev/null +++ b/capsule-server/src/main.rs @@ -0,0 +1,19 @@ +//! The `capsule-server` binary: a thin shim over the [`capsule_server`] library, which owns the +//! subcommand implementations. +//! +//! This binary installs error reporting and dispatches, in the shape `capsule-cli/src/main.rs` +//! already has. Everything else — parsing, the log stream, the configuration, the composition +//! root and each subcommand's body — is in the library, so `tests/binary.rs` asserts against the +//! same code this runs and a unit test can drive `boot::assemble` without a subprocess. +//! +//! Replaces the `gen_openapi` `[[bin]]`, which was this crate's only executable: the document +//! dump is now `capsule-server gen-openapi`, one subcommand of the one binary. See +//! [`capsule_server::cli`] for why four executables were rejected. + +use color_eyre::eyre::Result; + +#[tokio::main] +async fn main() -> Result { + color_eyre::install()?; + capsule_server::cli::run().await +} diff --git a/capsule-server/tests/binary.rs b/capsule-server/tests/binary.rs new file mode 100644 index 00000000..8f99fb36 --- /dev/null +++ b/capsule-server/tests/binary.rs @@ -0,0 +1,640 @@ +//! The `capsule-server` binary, driven as a process (issue #401). +//! +//! # Why a subprocess when everything else here is in-process +//! +//! Every other case in this suite drives a built `Service` through +//! `kynos::test::TestClient` — no socket, no port, nothing to flake — and that is the right +//! shape for asserting what the server *decides*. It cannot assert anything about the +//! **binary**, and the binary is what this issue delivers. Four properties only a process has: +//! +//! - it **binds**, and on `--listen 127.0.0.1:0` it says which port it got, so a caller that +//! asked the operating system to choose one can find it; +//! - it **drains on SIGTERM and exits 0**, which is the contract an orchestrator's termination +//! window is written against; +//! - it **refuses to start** on a bad configuration, with a non-zero code and every fault named +//! once — the aggregate report exists for an operator reading a crash loop's logs; +//! - and a real client reaches it over TCP. The client is `capsule-sdk`'s, generated from the +//! committed `openapi.json`, which makes this the round trip `tests/sdk_client.rs` proves for +//! the router proved for the *binary*: the document, the generated client, the socket and the +//! composition root all agreeing at once. +//! +//! # Sending the signal +//! +//! `kill -TERM` through a subprocess rather than `libc::kill`. `libc` is not a dependency of +//! this crate and adding one so a test can send a signal would be a dependency in the binary's +//! own tree; `Child::kill` is `SIGKILL`, which is the one signal that proves nothing about a +//! graceful drain. The signal cases are `#[cfg(unix)]`; Windows's console-event equivalent is +//! not something a test can raise in a child process. + +#![cfg(unix)] + +use std::io::{BufRead as _, BufReader}; +use std::process::{Child, Command, Stdio}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use capsule_server::auth::SessionTokens; +use capsule_server::store::SystemClock; + +/// A PKCS#8 v1 Ed25519 key, base64. +/// +/// The retired deployment's own `.env.example` value, and it signs nothing anywhere: no +/// deployment ever used it. A committed key rather than a generated one because these cases +/// assert the **published** key is the one the tokens verify under, which needs a key both +/// sides can name. +const EXAMPLE_DER: &str = "MC4CAQAwBQYDK2VwBCIEIN6eTvXEL7xMZWHY8rTk7VbQSGSuRkle5MVfiiYUStLF"; + +/// A 64-byte attestation seed, base64. +/// +/// A durable deployment has to supply its own — it is deliberately not derived from +/// `JWT_ED25519_DER`, because a receipt that verified under the operational key would let +/// anything holding that key manufacture custody evidence. +const EXAMPLE_SEED: &str = + "CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQ=="; + +/// The address the operating system will pick a port under. +const EPHEMERAL: &str = "127.0.0.1:0"; + +/// How long to wait for a spawned server to say where it is listening. +/// +/// Generous: a debug-build first run pays for `Credentials::new`'s Argon2id decoy hash before it +/// binds, and a loaded CI machine can take a while over it. A timeout here fails the test with +/// the child's own output rather than hanging the suite. +const BIND_TIMEOUT: Duration = Duration::from_secs(60); + +/// How long to wait for a signalled server to exit. +const EXIT_TIMEOUT: Duration = Duration::from_secs(30); + +/// A `capsule-server` invocation with a clean environment. +/// +/// Every setting this binary reads is removed before anything is set, because the test runner's +/// own environment is not this test's to trust: a developer with `DATABASE_URL` exported would +/// otherwise see a different server from CI. +fn server(args: &[&str]) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_capsule-server")); + for key in [ + "BLOB_ROOT", + "UPLOAD_DIR", + "DATABASE_URL", + "VALKEY_URL", + "JWT_ED25519_DER", + "SYNC_CURSOR_MAC_KEY", + "ATTESTATION_KEY_SEED", + "SERVER_HOST", + "SERVER_PORT", + "SERVER_DOMAIN", + "API_BASE_URL", + "CAPSULE_PROFILE", + "PROTOCOL_MIN", + "PROTOCOL_MAX", + "GC_GRACE_WINDOW_HOURS", + "SHUTDOWN_TIMEOUT_SECONDS", + "MAX_CONNECTIONS", + "LOG_FORMAT", + ] { + command.env_remove(key); + } + // Errors and the bind line only. A debug-build default of `debug` would put a few hundred + // lines of module chatter into the failure output of every case here. + command.env("RUST_LOG", "warn"); + command.args(args); + command +} + +/// Run `args` to completion and return `(exit code, stdout, stderr)`. +fn run(command: &mut Command) -> (Option, String, String) { + let output = command + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .expect("the binary runs"); + ( + output.status.code(), + String::from_utf8_lossy(&output.stdout).into_owned(), + String::from_utf8_lossy(&output.stderr).into_owned(), + ) +} + +/// A running server, and the base URL it answers on. +struct Serving { + child: Child, + base_url: String, +} + +impl Serving { + /// Spawn `serve --memory` on an ephemeral port and wait for it to say where it landed. + fn spawn(blob_root: &std::path::Path) -> Self { + let mut child = server(&[ + "serve", + "--memory", + "--listen", + EPHEMERAL, + "--blob-root", + &blob_root.display().to_string(), + ]) + .env("JWT_ED25519_DER", EXAMPLE_DER) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("the binary spawns"); + + let stdout = child.stdout.take().expect("stdout is piped"); + let mut lines = BufReader::new(stdout).lines(); + let deadline = Instant::now() + BIND_TIMEOUT; + // A blocking read on the child's pipe. The runtime this test is on has nothing else to + // do until the address arrives, and the child is a separate process. + let address = loop { + assert!( + Instant::now() < deadline, + "the server did not report a bound address within {BIND_TIMEOUT:?}" + ); + let line = lines + .next() + .expect("the server closed stdout without reporting an address") + .expect("the line is readable"); + if let Some(address) = line.strip_prefix("listening on ") { + break address.to_owned(); + } + }; + + Self { + child, + base_url: address, + } + } + + /// Ask it to stop the way an orchestrator does, and return its exit code. + fn terminate(mut self) -> Option { + let status = Command::new("kill") + .args(["-TERM", &self.child.id().to_string()]) + .status() + .expect("kill runs"); + assert!(status.success(), "the signal was delivered"); + + let deadline = Instant::now() + EXIT_TIMEOUT; + loop { + if let Some(status) = self.child.try_wait().expect("the child is waitable") { + return status.code(); + } + assert!( + Instant::now() < deadline, + "the server did not exit within {EXIT_TIMEOUT:?} of SIGTERM" + ); + std::thread::sleep(Duration::from_millis(25)); + } + } +} + +impl Drop for Serving { + fn drop(&mut self) { + // A case that panicked before `terminate` must not leave a listener behind for the next + // one. `SIGKILL` is right here: the assertion has already failed and a drain would only + // delay the report. + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +#[tokio::test] +async fn it_binds_serves_the_generated_client_and_drains_on_sigterm() { + let root = tempfile::tempdir().expect("a scratch directory"); + let serving = Serving::spawn(root.path()); + + // The generated client, over reqwest, over TCP, against the binary. Nothing in this call is + // hand-written: the path, the response shape and the decoding all come from the committed + // `openapi.json`. + let client = capsule_sdk::rest::Client::new(&serving.base_url).expect("a base url"); + let published = client + .server_info() + .await + .expect("the record is served") + .into_inner(); + + // The signing key it publishes is derived from the configured private key, so an operator + // cannot publish one the tokens do not verify under. + let expected = SessionTokens::from_pkcs8( + &base64::Engine::decode(&base64::engine::general_purpose::STANDARD, EXAMPLE_DER) + .expect("the example key is base64"), + Arc::new(SystemClock), + ) + .expect("the example key parses") + .public_key() + .to_vec(); + assert_eq!( + published.signing_key, + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &expected) + ); + // The published login endpoint is one this server actually serves, which is the property + // `api_base_url` exists to keep: it is composed from the configuration, not pasted. + assert!( + published.auth.login.ends_with("/v1/auth/login"), + "{}", + published.auth.login + ); + + assert_eq!( + serving.terminate(), + Some(0), + "a drained shutdown is a successful one" + ); +} + +#[tokio::test] +async fn an_account_registers_and_signs_in_against_the_running_binary() { + // The whole reason the development profile ships a real account adapter rather than a + // fail-closed stub: `mise run serve-memory` is a server a client can be pointed at. + let root = tempfile::tempdir().expect("a scratch directory"); + let serving = Serving::spawn(root.path()); + + let auth = capsule_sdk::auth::AuthClient::new(&format!("{}/v1/auth", serving.base_url)) + .expect("a base url"); + auth.register("somebody@example.test", "correct horse battery staple") + .await + .expect("registration succeeds"); + let signed_in = auth + .login("somebody@example.test", "correct horse battery staple") + .await + .expect("the account signs in") + .into_session(); + assert!( + signed_in.is_ok(), + "a fresh account has no second factor to answer" + ); + + // And the credential is actually checked — this is not the permissive double. + let refused = auth + .login("somebody@example.test", "the wrong password entirely") + .await; + assert!(refused.is_err(), "a wrong password is refused"); + + assert_eq!(serving.terminate(), Some(0)); +} + +#[test] +fn serving_without_valkey_and_without_the_memory_profile_refuses_by_name() { + // `store/mod.rs` has documented this refusal since `S-C29` and nothing enforced it, because + // there was no boot path to enforce it in. + let root = tempfile::tempdir().expect("a scratch directory"); + let (code, _, stderr) = run(server(&[ + "serve", + "--listen", + EPHEMERAL, + "--blob-root", + &root.path().display().to_string(), + ]) + .env("JWT_ED25519_DER", EXAMPLE_DER)); + assert_eq!(code, Some(2), "{stderr}"); + assert!(stderr.contains("VALKEY_URL"), "{stderr}"); +} + +#[test] +fn a_durable_backend_refuses_with_the_issue_that_will_honour_it() { + // The other half: the operator *did* set `VALKEY_URL`, and nothing reads it yet. Falling + // back to the in-memory adapters here is the one thing that must never happen. + let root = tempfile::tempdir().expect("a scratch directory"); + let (code, _, stderr) = run(server(&[ + "serve", + "--listen", + EPHEMERAL, + "--blob-root", + &root.path().display().to_string(), + ]) + .env("JWT_ED25519_DER", EXAMPLE_DER) + .env("ATTESTATION_KEY_SEED", EXAMPLE_SEED) + .env("VALKEY_URL", "redis://127.0.0.1:6379")); + assert_ne!(code, Some(0), "{stderr}"); + assert!(stderr.contains("#403"), "{stderr}"); +} + +#[test] +fn a_durable_serve_is_refused_without_an_attestation_seed_of_its_own() { + // The attestation key must be distinct from the token signer — `attestation/mod.rs` requires + // it so that holding the operational key does not let anything manufacture custody evidence. + // Deriving the seed from `JWT_ED25519_DER` under a different HKDF label is not a separation: + // anyone with the token key recomputes it. So a real deployment is made to say what its + // attestation identity is, and only `--memory` keeps the derivation. + let root = tempfile::tempdir().expect("a scratch directory"); + let (code, _, stderr) = run(server(&[ + "serve", + "--listen", + EPHEMERAL, + "--blob-root", + &root.path().display().to_string(), + ]) + .env("JWT_ED25519_DER", EXAMPLE_DER) + .env("VALKEY_URL", "redis://127.0.0.1:6379")); + assert_eq!(code, Some(2), "{stderr}"); + assert!(stderr.contains("ATTESTATION_KEY_SEED"), "{stderr}"); +} + +#[test] +fn the_memory_profile_still_needs_only_one_key() { + // The other side of the same decision: a development server derives its attestation seed, so + // `serve --memory` comes up on one variable rather than two. Asserted through `gc`'s sibling + // path — a full `serve` is covered by the socket case above — by checking that the config + // layer raises no seed fault for `--memory`. + let root = tempfile::tempdir().expect("a scratch directory"); + let (code, _, stderr) = + run(server(&["serve", "--memory", "--listen", EPHEMERAL]) + .env("JWT_ED25519_DER", EXAMPLE_DER)); + assert_eq!(code, Some(2), "{stderr}"); + assert!(stderr.contains("BLOB_ROOT"), "{stderr}"); + assert!( + !stderr.contains("ATTESTATION_KEY_SEED"), + "the development profile derives it, so naming it would send a developer looking for a \ + variable it does not want: {stderr}" + ); + let _ = root; +} + +#[test] +fn every_missing_setting_is_named_in_one_message() { + // An operator reading a crash loop's logs learns about both at once rather than restarting + // the process to discover the second. + let (code, _, stderr) = run(&mut server(&["serve", "--memory", "--listen", EPHEMERAL])); + assert_eq!(code, Some(2), "{stderr}"); + assert!(stderr.contains("BLOB_ROOT"), "{stderr}"); + assert!(stderr.contains("JWT_ED25519_DER"), "{stderr}"); +} + +#[test] +fn a_config_file_is_refused_with_what_to_do_instead() { + let (code, _, stderr) = run(&mut server(&[ + "--config", + "/etc/capsule/server.toml", + "gen-openapi", + "--check", + ])); + assert_eq!(code, Some(2), "{stderr}"); + assert!(stderr.contains("not supported yet"), "{stderr}"); + assert!(stderr.contains("environment"), "{stderr}"); +} + +#[test] +fn the_committed_openapi_document_is_reproduced_byte_for_byte() { + // The gate `mise run openapi-check-kynos` runs, asserted here too so a change to the + // subcommand's own plumbing cannot quietly stop checking anything. `CARGO_MANIFEST_DIR` is + // `capsule-server/`, and the default output path is relative to the repo root. + let committed = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("openapi.json"); + let (code, stdout, stderr) = run(&mut server(&[ + "gen-openapi", + &committed.display().to_string(), + "--check", + ])); + assert_eq!(code, Some(0), "stdout: {stdout}\nstderr: {stderr}"); + assert!(stdout.contains("up to date"), "{stdout}"); +} +// =========================================================================================== +// The operator commands +// =========================================================================================== + +/// A blob root holding one file under `blobs/`, shaped the way the store shards them. +/// +/// `blobs/aa/aa/<64 a's>.bin`: the two shard segments are the address's own first four hex +/// characters and the suffix is `ContentAddress::file_name`'s, because a file the enumeration +/// walk cannot turn back into an address is *debris* rather than a blob — which would be a +/// different finding from the one each case here is about. +/// +/// Written directly rather than uploaded, because the point is a store the *index* knows +/// nothing about: in the `--memory` profile the index is empty on every invocation, so every +/// blob on disk is genuinely unreferenced and both the collector and the scrub have something +/// true to say about it. +fn seeded_root() -> (tempfile::TempDir, String) { + let root = tempfile::tempdir().expect("a scratch directory"); + let address = "a".repeat(64); + let shard = root.path().join("blobs").join("aa").join("aa"); + std::fs::create_dir_all(&shard).expect("the shard is created"); + std::fs::write( + shard.join(format!("{address}.bin")), + b"unreferenced ciphertext", + ) + .expect("the blob is written"); + (root, address) +} + +/// The path a seeded blob occupies, for asserting it is still there. +fn seeded_blob(root: &std::path::Path, address: &str) -> std::path::PathBuf { + root.join("blobs") + .join("aa") + .join("aa") + .join(format!("{address}.bin")) +} + +/// An operator command over `root`, in the development profile. +fn operator(subcommand: &str, root: &std::path::Path, extra: &[&str]) -> Command { + let mut args = vec![subcommand, "--memory", "--blob-root"]; + let root = root.display().to_string(); + args.push(&root); + args.extend_from_slice(extra); + let mut command = server(&args); + command.env_remove("JWT_ED25519_DER"); + command +} + +#[test] +fn a_collection_dry_run_names_the_unreferenced_blob_and_changes_nothing() { + let (root, address) = seeded_root(); + let blob = seeded_blob(root.path(), &address); + + let (code, stdout, stderr) = run(&mut operator("gc", root.path(), &[])); + assert_eq!(code, Some(0), "stdout: {stdout}\nstderr: {stderr}"); + assert!(stdout.contains("dry run"), "{stdout}"); + assert!(stdout.contains("marked (1)"), "{stdout}"); + assert!(stdout.contains(&address), "{stdout}"); + assert!(blob.is_file(), "a dry run does not touch the store"); +} + +#[test] +fn an_applied_collection_pass_marks_rather_than_sweeps_on_its_first_look() { + // Two passes by design: a blob that reaches zero references is marked, and swept only on a + // later pass once the grace window has passed. In this profile the mark store does not + // survive the process, so a fresh invocation can only ever mark — which is stated in + // `boot`'s docs and asserted here rather than left as a surprise. The cross-invocation + // sweep needs the durable mark store #402 brings; `gc`'s own unit tests prove the + // mark-then-sweep sequence in process. + let (root, address) = seeded_root(); + let blob = seeded_blob(root.path(), &address); + + let (code, stdout, stderr) = run(&mut operator("gc", root.path(), &["--apply"])); + assert_eq!(code, Some(0), "stdout: {stdout}\nstderr: {stderr}"); + assert!(stdout.contains("applied"), "{stdout}"); + assert!(stdout.contains("marked (1)"), "{stdout}"); + assert!(!stdout.contains("swept"), "{stdout}"); + assert!( + blob.is_file(), + "nothing has waited out its grace window yet" + ); +} + +#[test] +fn a_collection_pass_over_an_empty_store_has_nothing_to_do() { + let root = tempfile::tempdir().expect("a scratch directory"); + let (code, stdout, stderr) = run(&mut operator("gc", root.path(), &[])); + assert_eq!(code, Some(0), "stdout: {stdout}\nstderr: {stderr}"); + assert!(stdout.contains("nothing to do"), "{stdout}"); +} + +#[test] +fn a_retention_purge_runs_and_reports_an_empty_pass() { + // The index is empty in this profile, so there is no tombstone to purge. What is asserted + // is that the command runs, reports, and does not invent work. + let root = tempfile::tempdir().expect("a scratch directory"); + let (code, stdout, stderr) = run(&mut operator("purge", root.path(), &[])); + assert_eq!(code, Some(0), "stdout: {stdout}\nstderr: {stderr}"); + assert!(stdout.contains("retention purge"), "{stdout}"); + assert!(stdout.contains("nothing to do"), "{stdout}"); +} + +#[test] +fn a_scrub_exits_non_zero_on_a_finding_and_mutates_nothing() { + // `design/filesystem/maintenance.md`: it "exits non-zero, and mutates nothing". + let (root, address) = seeded_root(); + let blob = seeded_blob(root.path(), &address); + let before = std::fs::read(&blob).expect("the blob is readable"); + + let (code, stdout, stderr) = run(&mut operator("scrub", root.path(), &[])); + assert_eq!(code, Some(1), "stdout: {stdout}\nstderr: {stderr}"); + assert!(stdout.contains("orphan (1)"), "{stdout}"); + assert!(stdout.contains(&address), "{stdout}"); + assert_eq!( + std::fs::read(&blob).expect("the blob is still readable"), + before, + "the store is byte-identical afterwards" + ); +} + +#[test] +fn a_scrub_over_a_clean_store_exits_zero() { + let root = tempfile::tempdir().expect("a scratch directory"); + let (code, stdout, stderr) = run(&mut operator("scrub", root.path(), &[])); + assert_eq!(code, Some(0), "stdout: {stdout}\nstderr: {stderr}"); + assert!(stdout.contains("agree"), "{stdout}"); +} + +#[test] +fn a_deep_scrub_re_hashes_the_bytes_it_reads() { + // The bit-rot check. The seeded file's name is not its own hash, so a deep pass finds the + // mismatch a structural one cannot see — and reports how many bytes it read. + let (root, _) = seeded_root(); + let (code, stdout, stderr) = run(&mut operator("scrub", root.path(), &["--deep"])); + assert_eq!(code, Some(1), "stdout: {stdout}\nstderr: {stderr}"); + assert!(stdout.contains("byte_mismatch"), "{stdout}"); + assert!(!stdout.contains("0 bytes hashed"), "{stdout}"); +} + +#[test] +fn the_operator_commands_need_no_key_material() { + // A maintenance host that had to hold the production token-signing key to sweep a directory + // would be a reason to put the key on a maintenance host. `operator` removes it, so every + // case above already asserts this — this one says so on purpose. + let root = tempfile::tempdir().expect("a scratch directory"); + for subcommand in ["gc", "purge", "scrub"] { + let (code, stdout, stderr) = run(&mut operator(subcommand, root.path(), &[])); + assert_eq!(code, Some(0), "{subcommand}: {stdout}{stderr}"); + assert!( + !stderr.contains("JWT_ED25519_DER"), + "{subcommand}: {stderr}" + ); + } +} + +#[test] +fn an_operator_command_without_memory_is_told_that_and_not_about_valkey() { + // An operator running `capsule-server scrub` has typically set no backend variable at all. + // Naming `VALKEY_URL` — which these commands never demand — would send them to configure + // something that would not have helped; what is missing is the durable index they read. + let root = tempfile::tempdir().expect("a scratch directory"); + for subcommand in ["gc", "purge", "scrub"] { + let (code, _, stderr) = run(&mut server(&[ + subcommand, + "--blob-root", + &root.path().display().to_string(), + ])); + assert_ne!(code, Some(0), "{subcommand}: {stderr}"); + assert!(stderr.contains("--memory"), "{subcommand}: {stderr}"); + assert!(!stderr.contains("VALKEY_URL"), "{subcommand}: {stderr}"); + } +} + +/// The settings `capsule-server/.env.example` ships **uncommented**, as an env-file reader sees +/// them. +/// +/// Parsed rather than duplicated, so the assertions below are about the file an operator +/// actually copies. Comments and blank lines are dropped and the rest is split on the first `=` +/// — which is all `podman --env-file`, compose's `env_file:` and systemd's `EnvironmentFile=` +/// do. None of them expands anything. +fn shipped_template() -> Vec<(String, String)> { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".env.example"); + let text = std::fs::read_to_string(&path).expect("the template ships with the crate"); + let settings: Vec<(String, String)> = text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .filter_map(|line| line.split_once('=')) + .map(|(key, value)| (key.trim().to_owned(), value.trim().to_owned())) + .collect(); + assert!( + !settings.is_empty(), + "the template has no uncommented settings at all, so the parse below proves nothing" + ); + settings +} + +#[test] +fn the_template_ships_no_value_a_literal_env_file_reader_would_store_verbatim() { + // The defect this guards, in its general form. A placeholder shaped like a shell expression + // — `$(CHANGE_ME)`, `${FOO}`, a backtick — is replaced by nothing when the file is read by + // anything that is not a shell: the *literal characters* become the value. Sourced by bash it + // is worse than useless in the other direction, because it executes. + // + // Every secret in the template is therefore commented out, and every uncommented value is + // plain text. + for (key, value) in shipped_template() { + assert!( + !value.contains('$') && !value.contains('`'), + "{key} ships uncommented with a shell expression as its value ({value}): an env-file \ + reader stores it verbatim, and bash executes it" + ); + } +} + +#[test] +fn a_maintenance_command_runs_on_the_template_as_shipped() { + // `Demands::Maintenance` promises `gc`/`purge`/`scrub` need no key material. That promise is + // only worth anything if the file an operator copies does not hand them a broken one: an + // uncommented `ATTESTATION_KEY_SEED` placeholder is *present but malformed*, which is a + // `ConfigFault::Invalid` rather than an absence, and no `Demands` arm can excuse it. + let template = shipped_template(); + assert!( + !template + .iter() + .any(|(key, _)| key == "ATTESTATION_KEY_SEED" || key == "JWT_ED25519_DER"), + "both secrets must ship commented out: {template:?}" + ); + + let root = tempfile::tempdir().expect("a scratch directory"); + let mut command = server(&[ + "scrub", + "--memory", + // The one thing not taken from the template: a test must not write into the tree the + // template's own `BLOB_ROOT` points at. The flag outranks the environment either way. + "--blob-root", + &root.path().display().to_string(), + ]); + for (key, value) in template { + command.env(key, value); + } + let (code, stdout, stderr) = run(&mut command); + assert_eq!(code, Some(0), "stdout: {stdout}\nstderr: {stderr}"); + assert!( + !stderr.contains("ATTESTATION_KEY_SEED"), + "a maintenance command must not be stopped by key material: {stderr}" + ); +} + +#[test] +fn an_operator_command_without_a_blob_root_refuses_by_name() { + let (code, _, stderr) = run(&mut server(&["scrub", "--memory"])); + assert_eq!(code, Some(2), "{stderr}"); + assert!(stderr.contains("BLOB_ROOT"), "{stderr}"); +} diff --git a/capsule-swift/Project.swift b/capsule-swift/Project.swift index 3a23683f..ab68d6e9 100644 --- a/capsule-swift/Project.swift +++ b/capsule-swift/Project.swift @@ -474,8 +474,9 @@ private let appTarget: Target = .target( // reachable from the Security screen in either lane, so the key belongs in both. "NSFaceIDUsageDescription": // locales/: app.infoplist.face_id_usage "Capsule uses Face ID to unlock your Hidden and Recently Deleted photos.", - // Let the simulator reach a dev server on http://127.0.0.1:3000 (`mise run serve-api`, - // slice S-P7). `NSAllowsLocalNetworking` is scoped to loopback and .local — it does + // Let the simulator reach a dev server on http://127.0.0.1:3000 + // (`mise run serve-memory`). `NSAllowsLocalNetworking` is scoped to loopback and .local + // — it does // NOT weaken ATS for real servers, unlike NSAllowsArbitraryLoads. Production // deployments are HTTPS and unaffected. "NSAppTransportSecurity": ["NSAllowsLocalNetworking": true], diff --git a/capsule-web/README.md b/capsule-web/README.md index b939b147..a79642ea 100644 --- a/capsule-web/README.md +++ b/capsule-web/README.md @@ -48,8 +48,10 @@ it is not dead code. ### Prerequisites - Install [Bun](https://bun.sh). -- A running server for anything beyond empty states. **There is not one today**: the Salvo server and its `serve-api` task retired in slice `S-C59`, and the Kynos server that replaces it has no binary yet. Every screen renders its empty state; the sync store's own tests (`bun test src/data/server/`) are what exercise the data path meanwhile. - from the repo root. There is no mock gateway to fall back on. +- A running server for anything beyond empty states. `mise run serve-memory` from the repo root + starts one on the in-memory adapters — an account registers and signs in against it, and it + loses everything but the blobs when it exits. There is no mock gateway to fall back on; the + sync store's own tests (`bun test src/data/server/`) exercise the data path without a server. With no reachable server the app still builds, runs, and renders empty states, so pure UI work needs no backend. Authenticated writes are not a web surface: the diff --git a/mise.toml b/mise.toml index 6260dcc6..d4f7fb04 100644 --- a/mise.toml +++ b/mise.toml @@ -233,11 +233,49 @@ run = "cargo deny --all-features check licenses" # to point at each stage. `S-C59` retired the Salvo one; this is now the contract, singular. [tasks.openapi-kynos] description = "Dump the Kynos OpenAPI 3.2 document to capsule-server/openapi.json" -run = "cargo run -q -p capsule-server --bin gen_openapi" +run = "cargo run -q -p capsule-server -- gen-openapi" [tasks.openapi-check-kynos] description = "Verify the committed Kynos OpenAPI 3.2 document matches the server" -run = "cargo run -q -p capsule-server --bin gen_openapi -- --check" +run = "cargo run -q -p capsule-server -- gen-openapi --check" + +# ── Running a server locally ───────────────────────────────────────────────── +# +# `serve-deps` and `serve` are deliberately separate: a task that silently starts containers is +# a task that leaks them. Bring the services down with +# `podman compose -f capsule-server/compose.yaml down`. + +[tasks.serve-deps] +description = "Start Postgres 18 + Valkey 9 for the server (podman compose)" +# `podman compose` shells out to the compose provider; `docker compose -f …` accepts the same +# file. Nothing reads these services yet — the adapters are issues #402 and #403 — so this is +# for the adapters' own development and for dependabot to have a live file to track. +run = "podman compose -f capsule-server/compose.yaml up -d" + +[tasks.serve] +description = "Run the Kynos server (needs the capsule-server/.env.example environment)" +# No environment is supplied on purpose. `serve` without `VALKEY_URL` and without `--memory` +# refuses to boot and names the variable — the refusal `capsule-server/src/store/mod.rs` has +# documented since `S-C29` — and with `VALKEY_URL` set it refuses and names #403. Copy +# `capsule-server/.env.example` and export it, or use `serve-memory` below. +run = "cargo run -p capsule-server -- serve" + +[tasks.serve-memory] +description = "Run the server on the in-memory adapters (development only)" +# A server you can point a client at: register, sign in, upload, sync. The blob store is real +# and lives under `target/`; everything else is lost when the process exits. +# +# Loopback, not the `0.0.0.0` default. That default is right for a deployment behind an ingress +# and wrong here: the fallback key below is the **published** example from +# capsule-server/.env.example, so every token this mints is forgeable by anyone who has read this +# repository — and a forgeable server reachable from the office network is a different thing from +# one reachable only from your own machine. That is also why this task is `serve-memory` and not +# `serve`; set `JWT_ED25519_DER` yourself and it is used instead. +run = """ +JWT_ED25519_DER=${JWT_ED25519_DER:-MC4CAQAwBQYDK2VwBCIEIN6eTvXEL7xMZWHY8rTk7VbQSGSuRkle5MVfiiYUStLF} \ +BLOB_ROOT=${BLOB_ROOT:-./target/capsule-server-blobs} \ +cargo run -p capsule-server -- serve --memory --listen 127.0.0.1:3000 +""" # Regenerate the translated README..md files from README.md and the committed # per-locale translation data (xtask/translations/readme/). See xtask/src/translate_readme.rs.