From 4d51bd88c3bf85755ed8aa360bc9af3b48742e75 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 22:49:18 -0400 Subject: [PATCH 1/8] feat(server): add the configuration and the composition root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `capsule-server` has never been assembled outside its own test fixture, so "every port has an adapter" and "the router builds from real ones" were claims rather than assertions. `config` reads the operator's settings once — command line over environment over default — and reports **every** fault in one message, because an operator otherwise restarts the process once per variable. What a subcommand requires is a parameter: `gc`/`purge`/`scrub` demand a blob root and deliberately no key material, so a maintenance host never needs the production token-signing key. `--config PATH` is accepted and refused, which keeps a configuration-file crate out of a domain the dependencies doc has no row for while leaving the precedence slot named. `boot::assemble` is the one composition root and the only place adapters are chosen. Selection is a two-arm match on `Backends`: `--memory` takes every deterministic in-crate adapter over a real filesystem blob store, and anything else refuses. That makes two sentences `store/mod.rs` has carried since `S-C29` true for the first time — Valkey is required, and the in-memory adapters are not a deployment profile — because until now there was no boot path to enforce either. The account ports and the second factor had no adapter at all, which would have left `register` and `login` answering their declared refusal on a development server. `auth::credential` is the Argon2id helper the Postgres adapter (#402) reuses; `auth::accounts_memory` is a real directory over it — PHC strings, the timing-equalized miss, a lockout that a password change clears — and `auth::totp` gains the deterministic store its port's three properties are all expressible over. None is the permissive credential double `tests/support/mod.rs` warns must never be linkable by a server. Refs #401, #402, #403 --- Cargo.lock | 3 + capsule-server/Cargo.toml | 38 +- capsule-server/src/auth/accounts_memory.rs | 616 ++++++++++++++ capsule-server/src/auth/credential.rs | 257 ++++++ capsule-server/src/auth/mod.rs | 30 +- capsule-server/src/auth/totp.rs | 250 +++++- capsule-server/src/boot.rs | 601 +++++++++++++ capsule-server/src/config.rs | 939 +++++++++++++++++++++ capsule-server/src/lib.rs | 10 + 9 files changed, 2727 insertions(+), 17 deletions(-) create mode 100644 capsule-server/src/auth/accounts_memory.rs create mode 100644 capsule-server/src/auth/credential.rs create mode 100644 capsule-server/src/boot.rs create mode 100644 capsule-server/src/config.rs 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-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/src/auth/accounts_memory.rs b/capsule-server/src/auth/accounts_memory.rs new file mode 100644 index 00000000..b2551b77 --- /dev/null +++ b/capsule-server/src/auth/accounts_memory.rs @@ -0,0 +1,616 @@ +//! [`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::{Mutex, MutexGuard, PoisonError}; + +use jiff::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::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. + failures: u32, +} + +impl Account { + /// Whether enough failures have accumulated to refuse a correct password. + fn locked(&self) -> bool { + self.failures >= MAX_FAILED_ATTEMPTS + } + + /// 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, + accounts: Mutex>, +} + +impl InMemoryAccounts { + /// An empty directory over `credentials`. + /// + /// 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. + pub fn new(credentials: Credentials) -> Self { + Self { + credentials, + 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) { + if let Some(held) = self.accounts().get_mut(email) { + if granted { + held.failures = 0; + } else { + held.failures = held.failures.saturating_add(1); + if held.failures == MAX_FAILED_ATTEMPTS { + tracing::warn!( + user = %held.user_id, + failures = held.failures, + "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() { + // 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, + }, + ); + 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; + tracing::info!(%user, "an account's password was replaced"); + Ok(PasswordChanged::Yes) + }) + } +} + +#[cfg(test)] +mod tests { + use jiff::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; + + const EMAIL: &str = "somebody@example.test"; + const PASSWORD: &str = "correct horse battery staple"; + + fn user() -> UserId { + UserId::new("018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e6f") + } + + /// A directory with one registered account. + async fn seeded() -> InMemoryAccounts { + let accounts = InMemoryAccounts::new(Credentials::new().expect("the platform hashes")); + assert_eq!( + accounts + .create(EMAIL, PASSWORD, &user(), Timestamp::UNIX_EPOCH) + .await + .expect("it writes"), + Registration::Created(user()) + ); + accounts + } + + #[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_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/boot.rs b/capsule-server/src/boot.rs new file mode 100644 index 00000000..8e2dbe7e --- /dev/null +++ b/capsule-server/src/boot.rs @@ -0,0 +1,601 @@ +//! [`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, + }, + /// 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, + }, +} + +/// A server, ready to serve or to sweep. +/// +/// The three things a subcommand can want out of one assembly: the application the router is +/// built with, and the two operator workers, which have no wire surface at all and therefore +/// cannot be reached through it. +#[derive(Debug)] +pub struct Assembled { + /// The application context every operation resolves its dependencies from. + pub app: App, + /// The collector's collaborators (`gc`, `purge`). + pub collection: CollectionContext, + /// The integrity scrub's collaborators (`scrub`). + pub scrub: ScrubContext, +} + +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 { + match config.backends { + Backends::Memory => memory(config).await, + // 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. + Backends::Durable => Err(BootError::AdapterUnavailable { + key: "VALKEY_URL", + issue: "#403 (Valkey) and #402 (Postgres)", + }), + } +} + +/// 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" +)] +async fn memory(config: &Config) -> Result { + let root = config + .blob_root + .as_ref() + .ok_or(BootError::Missing { key: "BLOB_ROOT" })?; + 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", + })?; + + 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(), + })?, + ); + + // 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)); + + let index = Arc::new(InMemoryAssetIndex::new()); + let uploads = Arc::new(InMemoryUploadSessions::with_default_ttl(clock.clone())); + 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 quotas = Arc::new(InMemoryQuota::new()); + let marks = Arc::new(InMemoryCollection::new()); + 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. The + // separation is structural here — the seed is a different HKDF `info` over the same input, + // so an operator cannot accidentally configure one key for both. + 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 { + // 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. + collection: CollectionContext::new( + index.clone(), + blobs.clone(), + marks, + quotas, + clock, + config.grace_window, + ), + scrub: ScrubContext::new(index, blobs, uploads), + app, + }) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::{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 { + let environment: BTreeMap = [ + ("BLOB_ROOT".to_owned(), root.display().to_string()), + ("JWT_ED25519_DER".to_owned(), EXAMPLE_DER.to_owned()), + ] + .into_iter() + .collect(); + let overrides = Overrides { + memory: true, + ..Overrides::default() + }; + Config::load(&environment, &overrides, Demands::Serve).expect("the configuration loads") + } + + #[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()), + ] + .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_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/config.rs b/capsule-server/src/config.rs new file mode 100644 index 00000000..7a587477 --- /dev/null +++ b/capsule-server/src/config.rs @@ -0,0 +1,939 @@ +//! [`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; + +/// 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 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); + + // Both are HKDF-derived from the token-signing key when unset, which is the right + // default for a single-server deployment and the reason the two variables are optional: + // an operator sets them explicitly only to rotate one independently of the token key, or + // to share an attestation identity across replicas. + let (sync_cursor_mac_key, attestation_key_seed) = match &signing_key_der { + Some(der) => ( + sync_cursor_mac_key + .or_else(|| derive::(der.expose(), CURSOR_KEY_INFO)), + attestation_key_seed.or_else(|| { + derive::(der.expose(), ATTESTATION_SEED_INFO) + }), + ), + None => (sync_cursor_mac_key, attestation_key_seed), + }; + + // ── 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 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 && valkey_url.is_none() { + faults.push(ConfigFault::Missing { key: "VALKEY_URL" }); + } + } + } + + 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, + 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 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}"); + } + + #[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 the_cursor_key_and_the_attestation_seed_are_derived_from_the_signing_key() { + 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 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..3eda14bf 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,8 @@ pub mod attestation; pub mod auth; pub mod blob; pub mod body; +pub mod boot; +pub mod config; pub mod counter; pub mod directory; pub mod discovery; From 0ee91e35fe0049d48eba55b022d88784ec5e3474 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 22:53:58 -0400 Subject: [PATCH 2/8] refactor(server)!: replace the gen_openapi bin with a capsule-server binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gen_openapi` was this crate's only executable, and the Salvo tree it replaces shipped four — `capsule-gc`, `capsule-scrub`, `capsule-keygen` and its own document dump. Four executables would each carry their own copy of the configuration loader and the composition root, which is the duplication `boot` exists to prevent, so this is one `capsule-server` binary with subcommands, as `capsule-cli` already is. `main.rs` installs error reporting and dispatches; the parsing, the log stream and every subcommand body live in `cli`, in the library, so a test asserts against the same code the binary runs. Logs go to stderr. `gen-openapi` writes a path to stdout and the operator commands will write a report there, and a subscriber sharing that stream is how a pipeline ends up parsing a log line — the failure `capsule-cli/tests/cull_round_trip.rs` works around with `RUST_LOG=off`. `mise run openapi-kynos` and `openapi-check-kynos` re-point at the subcommand. The committed `openapi.json` is untouched and `openapi-check-kynos` passes against it, which is the strongest available evidence the port preserved the document byte for byte. BREAKING CHANGE: `cargo run -p capsule-server --bin gen_openapi` is now `cargo run -p capsule-server -- gen-openapi`. Refs #401 --- capsule-server/src/bin/gen_openapi.rs | 81 -------- capsule-server/src/cli.rs | 258 ++++++++++++++++++++++++++ capsule-server/src/lib.rs | 1 + capsule-server/src/main.rs | 19 ++ mise.toml | 4 +- 5 files changed, 280 insertions(+), 83 deletions(-) delete mode 100644 capsule-server/src/bin/gen_openapi.rs create mode 100644 capsule-server/src/cli.rs create mode 100644 capsule-server/src/main.rs 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/cli.rs b/capsule-server/src/cli.rs new file mode 100644 index 00000000..cc47e259 --- /dev/null +++ b/capsule-server/src/cli.rs @@ -0,0 +1,258 @@ +//! 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::path::PathBuf; +use std::process::ExitCode; + +use clap::{Parser, Subcommand}; +use color_eyre::eyre::{Context as _, Result, bail}; +use tracing_subscriber::prelude::*; +use tracing_subscriber::{EnvFilter, fmt}; + +use crate::config::{Config, Demands, Environment, LogFormat, Overrides, ProcessEnvironment}; + +/// 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; + +/// 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, +} + +/// The things this binary does. +#[derive(Debug, Subcommand)] +pub enum Command { + /// 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::GenOpenapi { .. } => Demands::Nothing, + } + } +} + +/// 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 = Overrides { + config_file: cli.config.clone(), + ..Overrides::default() + }; + + 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 { + // 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) + } + } +} + +/// 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( + fmt::layer() + .json() + .flatten_event(true) + .with_writer(std::io::stderr), + ) + .init(), + LogFormat::Pretty => registry + .with( + fmt::layer() + .pretty() + .with_file(true) + .with_line_number(true) + .with_writer(std::io::stderr), + ) + .init(), + } +} + +/// 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, Command}; + + #[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; + assert_eq!(output, std::path::Path::new("capsule-server/openapi.json")); + assert!(!check, "writing is the default; checking is opt-in"); + } + + #[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")) + ); + } +} diff --git a/capsule-server/src/lib.rs b/capsule-server/src/lib.rs index 3eda14bf..5d018f32 100644 --- a/capsule-server/src/lib.rs +++ b/capsule-server/src/lib.rs @@ -56,6 +56,7 @@ pub mod auth; pub mod blob; pub mod body; pub mod boot; +pub mod cli; pub mod config; pub mod counter; pub mod directory; 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/mise.toml b/mise.toml index 369913c9..4fe3ee4e 100644 --- a/mise.toml +++ b/mise.toml @@ -226,11 +226,11 @@ 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" # 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. From d968878a01af21af93ceba19669ecce23b181f95 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 23:05:35 -0400 Subject: [PATCH 3/8] feat(server): add the serve subcommand and the binary smoke test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `capsule-server serve` binds, says where it landed, and drains on a termination signal. The bound address is logged at INFO and also 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 `LOG_FORMAT` can change under them would be a contract nobody wrote down. `Shutdown::signals()` covers SIGINT and SIGTERM with a second one forcing, and the drain deadline defaults to Kynos's own 25 seconds, under the usual 30-second orchestrator window. TLS stays off: `failure-modes.md` is explicit that application servers do not terminate it, so Kynos's `tls` feature is not enabled and a certificate cannot be configured by accident. `tests/binary.rs` drives the process, which is the only way to assert what a binary does. It proves the four properties an in-process client cannot: the server binds and reports its port; `capsule-sdk`'s **generated** client reaches it over TCP and reads a `server-info` record whose published signing key is the one derived from the configured private key; an account registers and signs in while a wrong password is refused; and SIGTERM drains to exit 0. The three refusal cases assert the non-zero code and the message — no `VALKEY_URL` without `--memory` names the variable, `VALKEY_URL` set names Refs #401 --- capsule-server/src/cli.rs | 162 +++++++++++++++- capsule-server/tests/binary.rs | 338 +++++++++++++++++++++++++++++++++ 2 files changed, 493 insertions(+), 7 deletions(-) create mode 100644 capsule-server/tests/binary.rs diff --git a/capsule-server/src/cli.rs b/capsule-server/src/cli.rs index cc47e259..3550ab00 100644 --- a/capsule-server/src/cli.rs +++ b/capsule-server/src/cli.rs @@ -22,14 +22,18 @@ //! 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::{Parser, Subcommand}; -use color_eyre::eyre::{Context as _, Result, bail}; +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}; +use crate::boot::{self, Assembled}; use crate::config::{Config, Demands, Environment, LogFormat, Overrides, ProcessEnvironment}; /// The exit code a configuration refusal produces. @@ -62,9 +66,41 @@ pub struct Cli { 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, + }, + /// 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 @@ -84,9 +120,27 @@ impl Command { /// What this subcommand needs from the configuration. fn demands(&self) -> Demands { match self { + Self::Serve { .. } => Demands::Serve, 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::GenOpenapi { .. } => {} + } + overrides + } } /// Parse the command line, install the log stream, and do what was asked. @@ -100,10 +154,7 @@ impl Command { pub async fn run() -> Result { let cli = Cli::parse(); let environment = ProcessEnvironment; - let overrides = Overrides { - config_file: cli.config.clone(), - ..Overrides::default() - }; + let overrides = cli.command.overrides(cli.config.clone()); install_tracing(&environment, &overrides); @@ -119,6 +170,7 @@ pub async fn run() -> Result { }; match cli.command { + Command::Serve { .. } => serve(&config).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. @@ -129,6 +181,59 @@ pub async fn run() -> Result { } } +/// 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?) +} + +/// 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 @@ -237,11 +342,54 @@ mod tests { #[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; + 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([ diff --git a/capsule-server/tests/binary.rs b/capsule-server/tests/binary.rs new file mode 100644 index 00000000..78ac3098 --- /dev/null +++ b/capsule-server/tests/binary.rs @@ -0,0 +1,338 @@ +//! 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"; + +/// 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 { + match self.child.try_wait().expect("the child is waitable") { + Some(status) => return status.code(), + None => { + 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(&mut 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(&mut 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_ne!(code, Some(0), "{stderr}"); + assert!(stderr.contains("#403"), "{stderr}"); +} + +#[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}"); +} From 2605cc3e4a92de1e9036167805b83a0c17d679ed Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 23:20:46 -0400 Subject: [PATCH 4/8] feat(server): add the gc, purge and scrub operator commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `filesystem/maintenance.md` has described these as operator-invoked commands, schedulable as jobs, since before there was a binary to invoke them from. They existed only as library functions with no entry point. Dry run is the default for the two that write, because the first thing an operator does with a collector is find out what it thinks; `--apply` opts in and the report says which posture produced it. `scrub` exits 1 on a non-empty report and mutates nothing, which is what makes it usable as a monitoring probe — and a truncated deep pass is reported as truncated, because a clean report from a pass that stopped early is the one answer a scrub must never give. Reports name what they found rather than counting it. `CollectionReport`'s own docs are why: "a count tells an operator that something happened without telling them what to look at." A scrub's findings print through their own `Debug`, so a variant added later renders as itself instead of as nothing. `boot` splits into `assemble` and `assemble_maintenance`. That is not tidiness: `config` claims `gc`/`purge`/`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 it then ignores. A maintenance host that had to hold the production signing key to sweep a directory would be a reason to put the key on a maintenance host. Both entry points build the same `Stores`, so the application and the workers never disagree about what is in the index. `tests/binary.rs` covers all three against a seeded blob root: the dry run names the unreferenced blob and leaves it, `--apply` marks it (the sweep is a later pass, and this profile's mark store does not outlive the process), the scrub exits 1 with the store byte-identical afterwards, `--deep` finds the byte mismatch a structural pass cannot see, and none of them is given a signing key. Refs #401 --- capsule-server/src/boot.rs | 185 +++++++++---- capsule-server/src/cli.rs | 456 ++++++++++++++++++++++++++++++++- capsule-server/tests/binary.rs | 180 ++++++++++++- 3 files changed, 759 insertions(+), 62 deletions(-) diff --git a/capsule-server/src/boot.rs b/capsule-server/src/boot.rs index 8e2dbe7e..a46ae8f1 100644 --- a/capsule-server/src/boot.rs +++ b/capsule-server/src/boot.rs @@ -142,21 +142,37 @@ pub enum BootError { }, } -/// A server, ready to serve or to sweep. +/// The two operator workers' collaborators. /// -/// The three things a subcommand can want out of one assembly: the application the router is -/// built with, and the two operator workers, which have no wire surface at all and therefore -/// cannot be reached through it. +/// 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 Assembled { - /// The application context every operation resolves its dependencies from. - pub app: App, +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. /// @@ -177,28 +193,118 @@ impl Assembled { /// 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).await, - // 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. - Backends::Durable => Err(BootError::AdapterUnavailable { - key: "VALKEY_URL", - issue: "#403 (Valkey) and #402 (Postgres)", - }), + 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) + } + Backends::Durable => Err(durable()), + } +} + +/// 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" )] -async fn memory(config: &Config) -> Result { - let root = config - .blob_root - .as_ref() - .ok_or(BootError::Missing { key: "BLOB_ROOT" })?; +fn memory(config: &Config, stores: Stores) -> Result { let der = config.signing_key_der.as_ref().ok_or(BootError::Missing { key: "JWT_ED25519_DER", })?; @@ -209,16 +315,16 @@ async fn memory(config: &Config) -> Result { key: "ATTESTATION_KEY_SEED", })?; - 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(), - })?, - ); + // 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 @@ -238,8 +344,6 @@ async fn memory(config: &Config) -> Result { })?; let accounts = Arc::new(InMemoryAccounts::new(credentials)); - let index = Arc::new(InMemoryAssetIndex::new()); - let uploads = Arc::new(InMemoryUploadSessions::with_default_ttl(clock.clone())); 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 @@ -250,8 +354,6 @@ async fn memory(config: &Config) -> Result { directories.clone(), clock.clone(), )); - let quotas = Arc::new(InMemoryQuota::new()); - let marks = Arc::new(InMemoryCollection::new()); 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. The @@ -358,19 +460,10 @@ async fn memory(config: &Config) -> Result { ); Ok(Assembled { - // 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. - collection: CollectionContext::new( - index.clone(), - blobs.clone(), - marks, - quotas, - clock, - config.grace_window, - ), - scrub: ScrubContext::new(index, blobs, uploads), 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), }) } diff --git a/capsule-server/src/cli.rs b/capsule-server/src/cli.rs index 3550ab00..ffd496e5 100644 --- a/capsule-server/src/cli.rs +++ b/capsule-server/src/cli.rs @@ -31,10 +31,12 @@ 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}; +use tracing_subscriber::{EnvFilter, fmt as log_fmt}; -use crate::boot::{self, Assembled}; +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. /// @@ -50,6 +52,21 @@ pub const EXIT_MISCONFIGURED: u8 = 2; /// 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)] @@ -101,6 +118,65 @@ pub enum Command { 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 @@ -121,6 +197,9 @@ impl Command { 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, } } @@ -137,6 +216,19 @@ impl Command { 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 @@ -171,6 +263,16 @@ pub async fn run() -> Result { 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. @@ -190,6 +292,16 @@ async fn assemble(config: &Config) -> Result { 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 @@ -265,7 +377,7 @@ fn install_tracing(environment: &dyn Environment, overrides: &Overrides) { match format { LogFormat::Json => registry .with( - fmt::layer() + log_fmt::layer() .json() .flatten_event(true) .with_writer(std::io::stderr), @@ -273,7 +385,7 @@ fn install_tracing(environment: &dyn Environment, overrides: &Overrides) { .init(), LogFormat::Pretty => registry .with( - fmt::layer() + log_fmt::layer() .pretty() .with_file(true) .with_line_number(true) @@ -283,6 +395,199 @@ fn install_tracing(environment: &dyn Environment, overrides: &Overrides) { } } +/// 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. +/// +/// The partial report is printed **before** the error when a pass fails part-way. `collect` +/// propagates the first store failure having applied whatever it did before it, which is safe +/// in both directions by the module's own argument — a mark is reversible and a sweep only ever +/// removed a blob confirmed unreferenced twice — but an operator still needs to know what +/// happened before it stopped. +async fn collect(config: &Config, mode: Mode) -> Result { + let maintenance = maintenance(config).await?; + let report = crate::gc::collect(&maintenance.collection, mode).await; + if let Ok(report) = &report { + print!("{}", render_collection(report, mode)); + } + report.map_err(|error| eyre!("the collection pass could not finish: {error}"))?; + Ok(ExitCode::SUCCESS) +} + +/// Drop the blob references of tombstoned assets past their retention window. +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; + if let Ok(report) = &report { + print!("{}", render_purge(report, mode)); + } + report.map_err(|error| eyre!("the retention purge could not finish: {error}"))?; + 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 @@ -329,7 +634,10 @@ fn gen_openapi(output: &PathBuf, check: bool) -> Result { mod tests { use clap::{CommandFactory as _, Parser as _}; - use super::{Cli, Command}; + use super::{ + Cli, CollectionReport, Command, Mode, PurgeReport, ScrubReport, mode, render_collection, + render_purge, render_scrub, + }; #[test] fn the_command_line_is_well_formed() { @@ -403,4 +711,142 @@ mod tests { 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/tests/binary.rs b/capsule-server/tests/binary.rs index 78ac3098..d7de660e 100644 --- a/capsule-server/tests/binary.rs +++ b/capsule-server/tests/binary.rs @@ -165,16 +165,14 @@ impl Serving { let deadline = Instant::now() + EXIT_TIMEOUT; loop { - match self.child.try_wait().expect("the child is waitable") { - Some(status) => return status.code(), - None => { - assert!( - Instant::now() < deadline, - "the server did not exit within {EXIT_TIMEOUT:?} of SIGTERM" - ); - std::thread::sleep(Duration::from_millis(25)); - } + 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)); } } } @@ -269,7 +267,7 @@ 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(&mut server(&[ + let (code, _, stderr) = run(server(&[ "serve", "--listen", EPHEMERAL, @@ -286,7 +284,7 @@ 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(&mut server(&[ + let (code, _, stderr) = run(server(&[ "serve", "--listen", EPHEMERAL, @@ -336,3 +334,163 @@ fn the_committed_openapi_document_is_reproduced_byte_for_byte() { 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_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}"); +} From 000c7509f23105f94095f4187a501678dc9318c4 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 23:26:55 -0400 Subject: [PATCH 5/8] build(server): add the compose stack, the serve tasks and the release target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mise run serve-api` and the compose stack behind it went with the Salvo tree in `S-C59`, so there has been no way to bring a server's services up and nothing to point a client at. `capsule-server/compose.yaml` is Postgres 18 and Valkey 9.0.4 — the versions dependabot is already tracking — with the retired deployment's Valkey flags carried over verbatim, because those are the flags the session and upload-session stores were sized against. Both services carry a healthcheck: `serve-deps` returns as soon as compose has started the containers, so a developer who runs `serve` immediately afterwards would otherwise race the database's own startup. There is still no object store: the filesystem `BLOB_ROOT` is the blob backend. `serve-deps` and `serve` are separate tasks, because a task that silently starts containers is a task that leaks them. `serve` supplies no environment on purpose — it refuses and names what is missing. `serve-memory` is the one that just works, and its fallback signing key is the published example: every token it mints is forgeable by anyone who has read this repository, which is exactly why that task is not `serve`. `.env.example` documents every setting, its default, and why the default is what it is. It ships in the release archive, because a release without it is a binary that refuses to start and an operator reading GitHub to find out which variables it wanted. `release.yml` builds `capsule-server` beside `capsule` and puts both in the one per-target archive: an operator wants the server and the CLI that talks to it at the same version, and two downloads is two chances to mix versions. Unix only — Windows is already best-effort for the CLI, and adding a server build to a job allowed to fail would make "did the Windows CLI ship" harder to answer. `dependabot.yml`'s three `/capsule-api` entries were watching a directory that has not existed since `S-C59`. The cargo one moves to the workspace root, the docker-compose one to `/capsule-server`, and the `docker` one goes: no Containerfile exists anywhere in the active tree, and an ecosystem pointed at an absent file is a permanent dashboard error rather than an update. Refs #401, #402, #403 --- .github/dependabot.yml | 18 ++++-- .github/workflows/release.yml | 25 ++++++-- capsule-server/.env.example | 114 ++++++++++++++++++++++++++++++++++ capsule-server/compose.yaml | 64 +++++++++++++++++++ mise.toml | 35 +++++++++++ 5 files changed, 245 insertions(+), 11 deletions(-) create mode 100644 capsule-server/.env.example create mode 100644 capsule-server/compose.yaml 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/capsule-server/.env.example b/capsule-server/.env.example new file mode 100644 index 00000000..98d11518 --- /dev/null +++ b/capsule-server/.env.example @@ -0,0 +1,114 @@ +# 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 +# +# The value below is the retired deployment's own example. It is public, it signs nothing, and a +# real deployment must replace it. +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 + +# ── Derived key material ───────────────────────────────────────────────────────────────────── +# +# Both are HKDF-SHA256-derived from JWT_ED25519_DER under separate `info` strings when unset, +# which is the right default for a single-server deployment. Set them explicitly only to rotate +# one independently of the token-signing key, or to share an attestation identity across +# replicas. +# +# SYNC_CURSOR_MAC_KEY= # base64, exactly 32 bytes +# ATTESTATION_KEY_SEED= # base64, 32 or 64 bytes (32 is expanded, domain-separated) + +# ── 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 + +# `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/compose.yaml b/capsule-server/compose.yaml new file mode 100644 index 00000000..c9e6c2d4 --- /dev/null +++ b/capsule-server/compose.yaml @@ -0,0 +1,64 @@ +# 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: + - "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: + - "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: the port is published to localhost. + 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/mise.toml b/mise.toml index 4fe3ee4e..f9fff287 100644 --- a/mise.toml +++ b/mise.toml @@ -232,6 +232,41 @@ run = "cargo run -q -p capsule-server -- gen-openapi" description = "Verify the committed Kynos OpenAPI 3.2 document matches the server" 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. +# +# The fallback key is the **published** example from capsule-server/.env.example. Every token +# this mints is forgeable by anyone who has read this repository, which is exactly 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 +""" + # 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. [tasks.translate-readme] From 082fe9dfee560c01b2ebe91a9205ebce38df0bf0 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Tue, 1 Sep 2026 23:29:06 -0400 Subject: [PATCH 6/8] docs(server): replace "there is no local server today" with how to run one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `local-development.md` said that as a known gap, and it was true. It now documents the binary, both profiles, the operator commands, where logs go and where TLS is terminated. It is deliberately explicit about what the development profile is *not*: the blob store is real and everything else lives in the process, so a restart leaves every blob an orphan the scrub will report, and `gc` can only ever mark because the collector's two-pass design needs a mark store that outlives the process. Both are consequences a developer would otherwise meet as a surprise. It is also explicit that `mise run serve` does not work yet and refuses rather than pretending, and that `serve-memory`'s fallback signing key is published — every token it mints is forgeable by anyone who has read this repository. `capsule-server/README.md`'s "no binary, no configuration loading" section becomes "Running it", and the flat "every adapter is in-memory" claim gains the qualifier it now needs: two of them live beside their ports rather than in `tests/support/`, and the distinction the port docs were drawing is between a double and an implementation. Three stale `serve-api` citations follow: `capsule-web/README.md` asserted the Kynos server "has no binary yet", and the CLI's default-endpoint comment and the Swift project's local-networking comment both named a task that retired in `S-C59`. `SLICES.md`'s three remain, and are not this change's. Refs #401 --- capsule-cli/src/remote.rs | 2 +- .../docs/development/local-development.md | 112 +++++++++++++++++- capsule-server/README.md | 52 +++++++- capsule-swift/Project.swift | 5 +- capsule-web/README.md | 6 +- 5 files changed, 163 insertions(+), 14 deletions(-) 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..73a08b89 100644 --- a/capsule-docs/src/content/docs/development/local-development.md +++ b/capsule-docs/src/content/docs/development/local-development.md @@ -52,17 +52,113 @@ 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] +``` + +### 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`. Every token it mints is forgeable by anyone who has read this +repository, which is why that task is `serve-memory` and not `serve`. 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 +mise run serve +``` + +`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. + +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. + +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 +166,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/README.md b/capsule-server/README.md index 0d443cc8..0ad2b940 100644 --- a/capsule-server/README.md +++ b/capsule-server/README.md @@ -38,6 +38,14 @@ 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. What they +lack is durability, which is what makes them a development profile rather than a deployment. + ## Running the tests ```bash @@ -60,8 +68,46 @@ 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] +``` + +`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. + +`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-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 From aaca127e38f6a1fc5800d34151bedff5ee4add4c Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 01:20:09 -0400 Subject: [PATCH 7/8] fix(server): stop deriving the attestation seed, and let a lockout decay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review repairs on the binary, configuration and operator commands. **The attestation seed is no longer derived from the token-signing key.** `attestation/mod.rs` requires the attestation key to be distinct from the operational one, so that holding the operational key does not let anything manufacture custody evidence. Deriving the seed by HKDF from `JWT_ED25519_DER` collapsed exactly that: anyone with the token key recomputes it and signs receipts. Worse, a comment claimed the separation was structural. `ATTESTATION_KEY_SEED` is now required on the durable path, the derivation survives only under `--memory` — where the whole state is discarded on exit, so a development server still comes up on one variable — and the comment says what is actually true. **A lockout now decays, because nothing else could clear it.** `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 the state. Ten failures were therefore a permanently lost account rather than a throttle. The window runs from the last counted failure and defaults to fifteen minutes, which `design/authentication.md` does not name, so it is written down here. Attempts made *during* a lockout are refused without extending it: extending would hand anyone who can reach the endpoint a way to hold somebody else's account shut. The threshold joins the window as a setting — a lockout is two numbers, and driving the ten-failure default through the login route would cost ten Argon2id verifications whose spacing under load can exceed a short window, which is a test racing its own subject. **A maintenance command is told what it actually needs.** `gc`, `purge` and `scrub` never demand `VALKEY_URL`, so naming it sent an operator to configure something that would not have helped; they need `--memory`, because they compare the index against the blob store and the in-memory index is the only one written. The README and the docs stop showing `--memory` as optional there. Also: `serve-memory` binds loopback rather than every interface, since the key it falls back to is published; the compose stack publishes Postgres and Valkey to loopback, which is what makes `--protected-mode no` a concession rather than an exposure; `.env.example` ships `JWT_ED25519_DER` commented out, so `cp .env.example .env` cannot silently produce a forgeable deployment; and the `gc`/`purge` doc comment no longer claims a partial report is printed on failure, which the library's signature does not permit — it says what the operator does see instead. `VALKEY_EXTRA_FLAGS` was checked rather than assumed: `valkey/valkey:9.0.4`'s own entrypoint ends with `exec "$@" $VALKEY_EXTRA_FLAGS`, so the flags reach the server. The compose file now records that, with the evidence. Refs #401, #402 --- .../docs/development/local-development.md | 33 ++- capsule-server/.env.example | 47 +++- capsule-server/README.md | 22 +- capsule-server/compose.yaml | 18 +- capsule-server/src/auth/accounts_memory.rs | 210 ++++++++++++++++-- capsule-server/src/boot.rs | 153 ++++++++++++- capsule-server/src/cli.rs | 38 ++-- capsule-server/src/config.rs | 175 +++++++++++++-- capsule-server/tests/binary.rs | 68 ++++++ mise.toml | 11 +- 10 files changed, 685 insertions(+), 90 deletions(-) diff --git a/capsule-docs/src/content/docs/development/local-development.md b/capsule-docs/src/content/docs/development/local-development.md index 73a08b89..a6334657 100644 --- a/capsule-docs/src/content/docs/development/local-development.md +++ b/capsule-docs/src/content/docs/development/local-development.md @@ -57,10 +57,14 @@ mise run hooks-install # installs the git hooks (hk) ```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] + 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 @@ -86,9 +90,10 @@ adapter that has been written. Two consequences worth knowing before they surpri 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`. Every token it mints is forgeable by anyone who has read this -repository, which is why that task is `serve-memory` and not `serve`. Set `JWT_ED25519_DER` -yourself and it is used instead: +`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')" \ @@ -98,8 +103,8 @@ JWT_ED25519_DER="$(openssl genpkey -algorithm ed25519 -outform DER | base64 | tr ### A configured server ```bash -cp capsule-server/.env.example capsule-server/.env # then edit it -mise run serve-deps # Postgres 18 + Valkey 9 +cp capsule-server/.env.example capsule-server/.env # then edit it: two keys are commented out +mise run serve-deps # Postgres 18 + Valkey 9, on loopback mise run serve ``` @@ -114,6 +119,14 @@ nothing could enforce until there was a boot path; with `VALKEY_URL` set it exit 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. @@ -141,6 +154,10 @@ is `pretty` in a debug build and `json` in a release one; `RUST_LOG` is the usua 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 diff --git a/capsule-server/.env.example b/capsule-server/.env.example index 98d11518..ca690611 100644 --- a/capsule-server/.env.example +++ b/capsule-server/.env.example @@ -17,9 +17,14 @@ # # openssl genpkey -algorithm ed25519 -outform DER | base64 -w 0 # -# The value below is the retired deployment's own example. It is public, it signs nothing, and a -# real deployment must replace it. -JWT_ED25519_DER=MC4CAQAwBQYDK2VwBCIEIN6eTvXEL7xMZWHY8rTk7VbQSGSuRkle5MVfiiYUStLF +# **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. @@ -73,15 +78,28 @@ VALKEY_URL=redis://127.0.0.1:6379 # spelling; this exists for a process manager that cannot add an argument. # CAPSULE_PROFILE=memory -# ── Derived key material ───────────────────────────────────────────────────────────────────── +# ── The rest of the key material ───────────────────────────────────────────────────────────── # -# Both are HKDF-SHA256-derived from JWT_ED25519_DER under separate `info` strings when unset, -# which is the right default for a single-server deployment. Set them explicitly only to rotate -# one independently of the token-signing key, or to share an attestation identity across -# replicas. +# 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= # base64, 32 or 64 bytes (32 is expanded, domain-separated) +# +# **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' +# +# base64, 32 or 64 bytes; 32 is expanded to 64, domain-separated. +ATTESTATION_KEY_SEED=$(CHANGE_ME) # ── The protocol window ────────────────────────────────────────────────────────────────────── # @@ -104,6 +122,17 @@ VALKEY_URL=redis://127.0.0.1:6379 # 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 diff --git a/capsule-server/README.md b/capsule-server/README.md index 0ad2b940..7d7f373d 100644 --- a/capsule-server/README.md +++ b/capsule-server/README.md @@ -43,8 +43,10 @@ and `auth::totp`'s `InMemoryTotp`. The account ports' docs say a double in `src/ 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. What they -lack is durability, which is what makes them a development profile rather than a deployment. +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 @@ -79,10 +81,14 @@ 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] + 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 @@ -90,6 +96,12 @@ default — and reports **every** fault in one message, because an operator othe 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. diff --git a/capsule-server/compose.yaml b/capsule-server/compose.yaml index c9e6c2d4..60013ef1 100644 --- a/capsule-server/compose.yaml +++ b/capsule-server/compose.yaml @@ -21,7 +21,10 @@ services: postgres: image: docker.io/library/postgres:18 ports: - - "5432:5432" + # 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. @@ -42,14 +45,23 @@ services: valkey: image: docker.io/valkey/valkey:9.0.4 ports: - - "6379:6379" + # 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: the port is published to localhost. + # `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"] diff --git a/capsule-server/src/auth/accounts_memory.rs b/capsule-server/src/auth/accounts_memory.rs index b2551b77..3d148b23 100644 --- a/capsule-server/src/auth/accounts_memory.rs +++ b/capsule-server/src/auth/accounts_memory.rs @@ -40,9 +40,9 @@ //! than describing how to do it. use std::collections::BTreeMap; -use std::sync::{Mutex, MutexGuard, PoisonError}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; -use jiff::Timestamp; +use jiff::{SignedDuration, Timestamp}; use super::credential::{CredentialError, Credentials}; use super::directory::{AccountDirectory, Authentication, DirectoryError, DirectoryFuture}; @@ -50,7 +50,7 @@ use super::profile::{ AccountProfiles, PasswordChange, PasswordChanged, ProfileRecord, ProfileUpdate, }; use super::registry::{AccountRegistry, Registration}; -use crate::store::UserId; +use crate::store::{Clock, UserId}; /// How many consecutive failures put an account into [`Authentication::Locked`]. /// @@ -73,14 +73,28 @@ struct Account { display_name: Option, /// When it was created. created_at: Timestamp, - /// Consecutive failed credential presentations. + /// 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 failures have accumulated to refuse a correct password. - fn locked(&self) -> bool { - self.failures >= MAX_FAILED_ATTEMPTS + /// 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. @@ -104,18 +118,32 @@ impl Account { #[derive(Debug)] pub struct InMemoryAccounts { credentials: Credentials, + clock: Arc, + lockout_attempts: u32, + lockout_window: SignedDuration, accounts: Mutex>, } impl InMemoryAccounts { - /// An empty directory over `credentials`. + /// 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. - pub fn new(credentials: Credentials) -> Self { + /// 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()), } } @@ -156,18 +184,32 @@ impl InMemoryAccounts { /// /// 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; - } else { - held.failures = held.failures.saturating_add(1); - if held.failures == MAX_FAILED_ATTEMPTS { - tracing::warn!( - user = %held.user_id, - failures = held.failures, - "an account reached the failed-attempt ceiling and is locked out" - ); - } + 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" + ); } } } @@ -188,7 +230,7 @@ impl InMemoryAccounts { self.credentials.absorb_miss(password); return Ok(Authentication::Refused); }; - if held.locked() { + 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); @@ -266,6 +308,7 @@ impl AccountRegistry for InMemoryAccounts { display_name: None, created_at: at, failures: 0, + last_failure_at: None, }, ); tracing::info!(%user, "an account was created in the in-memory directory"); @@ -317,6 +360,7 @@ impl PasswordChange for InMemoryAccounts { // 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) }) @@ -325,13 +369,16 @@ impl PasswordChange for InMemoryAccounts { #[cfg(test)] mod tests { - use jiff::Timestamp; + 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"; @@ -340,9 +387,17 @@ mod tests { UserId::new("018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e6f") } - /// A directory with one registered account. - async fn seeded() -> InMemoryAccounts { - let accounts = InMemoryAccounts::new(Credentials::new().expect("the platform hashes")); + /// 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) @@ -353,6 +408,18 @@ mod tests { 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. @@ -446,6 +513,101 @@ mod tests { ); } + #[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; diff --git a/capsule-server/src/boot.rs b/capsule-server/src/boot.rs index a46ae8f1..9552433e 100644 --- a/capsule-server/src/boot.rs +++ b/capsule-server/src/boot.rs @@ -131,6 +131,19 @@ pub enum BootError { /// 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 @@ -218,7 +231,15 @@ pub async fn assemble_maintenance(config: &Config) -> Result Err(durable()), + // **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)", + }), } } @@ -342,7 +363,12 @@ fn memory(config: &Config, stores: Stores) -> Result { let credentials = Credentials::new().map_err(|error| BootError::Credentials { detail: error.detail, })?; - let accounts = Arc::new(InMemoryAccounts::new(credentials)); + 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()); @@ -356,9 +382,13 @@ fn memory(config: &Config, stores: Stores) -> Result { )); 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. The - // separation is structural here — the seed is a different HKDF `info` over the same input, - // so an operator cannot accidentally configure one key for both. + // 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), @@ -471,19 +501,27 @@ fn memory(config: &Config, stores: Stores) -> Result { mod tests { use std::collections::BTreeMap; - use super::{BootError, assemble}; + 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 { - let environment: BTreeMap = [ + 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() @@ -491,6 +529,31 @@ mod tests { 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 @@ -548,6 +611,15 @@ mod tests { ("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(); @@ -661,6 +733,73 @@ mod tests { 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 diff --git a/capsule-server/src/cli.rs b/capsule-server/src/cli.rs index ffd496e5..136af3c5 100644 --- a/capsule-server/src/cli.rs +++ b/capsule-server/src/cli.rs @@ -405,29 +405,37 @@ fn mode(apply: bool) -> Mode { /// Sweep blobs nothing references any more. /// -/// The partial report is printed **before** the error when a pass fails part-way. `collect` -/// propagates the first store failure having applied whatever it did before it, which is safe -/// in both directions by the module's own argument — a mark is reversible and a sweep only ever -/// removed a blob confirmed unreferenced twice — but an operator still needs to know what -/// happened before it stopped. +/// # 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; - if let Ok(report) = &report { - print!("{}", render_collection(report, mode)); - } - report.map_err(|error| eyre!("the collection pass could not finish: {error}"))?; + 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; - if let Ok(report) = &report { - print!("{}", render_purge(report, mode)); - } - report.map_err(|error| eyre!("the retention purge could not finish: {error}"))?; + 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) } diff --git a/capsule-server/src/config.rs b/capsule-server/src/config.rs index 7a587477..806f8640 100644 --- a/capsule-server/src/config.rs +++ b/capsule-server/src/config.rs @@ -58,6 +58,29 @@ 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; @@ -276,6 +299,10 @@ pub struct Config { 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. @@ -384,20 +411,31 @@ impl Config { decode_fixed::(env, "SYNC_CURSOR_MAC_KEY", &mut faults); let attestation_key_seed = decode_seed(env, &mut faults); - // Both are HKDF-derived from the token-signing key when unset, which is the right - // default for a single-server deployment and the reason the two variables are optional: - // an operator sets them explicitly only to rotate one independently of the token key, or - // to share an attestation identity across replicas. - let (sync_cursor_mac_key, attestation_key_seed) = match &signing_key_der { - Some(der) => ( - sync_cursor_mac_key - .or_else(|| derive::(der.expose(), CURSOR_KEY_INFO)), - attestation_key_seed.or_else(|| { - derive::(der.expose(), ATTESTATION_SEED_INFO) - }), + // 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, ), - None => (sync_cursor_mac_key, attestation_key_seed), - }; + Backends::Durable => None, + }); // ── Protocol window ───────────────────────────────────────────────────────────── let protocol_max = env @@ -420,6 +458,32 @@ impl Config { .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), @@ -474,8 +538,19 @@ impl Config { // 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 && valkey_url.is_none() { - faults.push(ConfigFault::Missing { key: "VALKEY_URL" }); + 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", + }); + } } } } @@ -494,6 +569,8 @@ impl Config { protocol_min, protocol_max, grace_window, + lockout_window, + lockout_attempts, shutdown_timeout, max_connections, log_format, @@ -634,6 +711,8 @@ fn derive(secret: &[u8], info: &[u8]) -> Option<[u8; N]> { 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`. @@ -720,6 +799,15 @@ mod tests { 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] @@ -767,8 +855,35 @@ mod tests { 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"); @@ -792,6 +907,36 @@ mod tests { ); } + #[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(); diff --git a/capsule-server/tests/binary.rs b/capsule-server/tests/binary.rs index d7de660e..5c883d2f 100644 --- a/capsule-server/tests/binary.rs +++ b/capsule-server/tests/binary.rs @@ -44,6 +44,14 @@ use capsule_server::store::SystemClock; /// 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"; @@ -292,11 +300,53 @@ fn a_durable_backend_refuses_with_the_issue_that_will_honour_it() { &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 @@ -488,6 +538,24 @@ fn the_operator_commands_need_no_key_material() { } } +#[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}"); + } +} + #[test] fn an_operator_command_without_a_blob_root_refuses_by_name() { let (code, _, stderr) = run(&mut server(&["scrub", "--memory"])); diff --git a/mise.toml b/mise.toml index f9fff287..5bc7ae58 100644 --- a/mise.toml +++ b/mise.toml @@ -258,13 +258,16 @@ 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. # -# The fallback key is the **published** example from capsule-server/.env.example. Every token -# this mints is forgeable by anyone who has read this repository, which is exactly why this task -# is `serve-memory` and not `serve` — set `JWT_ED25519_DER` yourself and it is used instead. +# 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 +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 From 459e8af3605d6c706c0d0ebeca2474309cb113fd Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Wed, 2 Sep 2026 02:16:55 -0400 Subject: [PATCH 8/8] fix(server): comment out the attestation seed placeholder in the template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repair commit added `ATTESTATION_KEY_SEED` to `.env.example` uncommented, with `$(CHANGE_ME)` as the placeholder, while `JWT_ED25519_DER` beside it was commented out. Both halves of that are wrong. 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 the characters `$(CHANGE_ME)` become the *value*. That is present-but-malformed rather than absent, so `decode_seed` raises `ConfigFault::Invalid` and stops `capsule-server gc|purge|scrub`, which are built to need no key material at all and which no `Demands` arm can excuse it for. Sourced by bash it fails the other way, printing `CHANGE_ME: command not found`. So the line is commented out exactly as the token-signing key is, and the placeholder is plain text. `local-development.md` said "two keys are commented out", which was false when it was written and is now true — it says which two and why, and records that nothing in the template is a shell expression. Two cases in `tests/binary.rs` parse the shipped template rather than restating it: one asserts no uncommented value carries `$` or a backtick, which is the defect's general form and catches it anywhere in the file; the other runs `scrub` under exactly the settings the template ships uncommented and asserts it exits 0 without naming `ATTESTATION_KEY_SEED`. Both were confirmed to fail against the reintroduced placeholder before being kept. Refs #401 --- .../docs/development/local-development.md | 12 ++- capsule-server/.env.example | 12 ++- capsule-server/tests/binary.rs | 76 +++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/capsule-docs/src/content/docs/development/local-development.md b/capsule-docs/src/content/docs/development/local-development.md index a6334657..ddcecf48 100644 --- a/capsule-docs/src/content/docs/development/local-development.md +++ b/capsule-docs/src/content/docs/development/local-development.md @@ -103,11 +103,21 @@ JWT_ED25519_DER="$(openssl genpkey -algorithm ed25519 -outform DER | base64 | tr ### A configured server ```bash -cp capsule-server/.env.example capsule-server/.env # then edit it: two keys are commented out +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). diff --git a/capsule-server/.env.example b/capsule-server/.env.example index ca690611..8fa7239c 100644 --- a/capsule-server/.env.example +++ b/capsule-server/.env.example @@ -98,8 +98,18 @@ VALKEY_URL=redis://127.0.0.1:6379 # # 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=$(CHANGE_ME) +# ATTESTATION_KEY_SEED=replace-with-your-own-base64-seed # ── The protocol window ────────────────────────────────────────────────────────────────────── # diff --git a/capsule-server/tests/binary.rs b/capsule-server/tests/binary.rs index 5c883d2f..8f99fb36 100644 --- a/capsule-server/tests/binary.rs +++ b/capsule-server/tests/binary.rs @@ -556,6 +556,82 @@ fn an_operator_command_without_memory_is_told_that_and_not_about_valkey() { } } +/// 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"]));