From f75c892a7fb0f5a323f3b9b2336aab22617d8397 Mon Sep 17 00:00:00 2001 From: Santiago Date: Sat, 8 Aug 2026 19:49:16 -0300 Subject: [PATCH 1/6] feat(stelae): authenticate a registry with basic credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transport spoke bearer-token-or-anonymous, which is the shape GHCR wanted. The registry stele distribution is heading for authenticates every request with an HTTP Basic pair instead: access stays free and identity-less, and is still credentialed. `Options` now carries the credentials, and the caller decides them. `Auth::from_env` still owns the environment grammar — the crate owns the variable names — but nothing calls it behind a caller's back: which identity a program authenticates as is that program's policy. Two sources, one rule, resolved once in `dolos_snapshot::registry::auth`. A consumer's published read-only pair comes from `[stelae.registry]` in dolos.toml, which `dolos init` seeds from a single constants site (empty until the registry that issues it exists, and a commented template in the generated file until then). A publisher's full-access pair comes from STELAE_REGISTRY_USER/_PASSWORD and overrides it, so a node carrying the read-only pair can publish without having it removed first. A token and a pair set together is a refusal naming both, not a precedence puzzle, and so is half a pair. The e2e harnesses are rebound rather than duplicated: every registry they spawn is behind htpasswd and every transport they open carries the pair. Both server families are configured unconditionally — distribution reads REGISTRY_AUTH_* from the environment and zot reads /etc/zot/config.json, and neither notices the other's — so there is no per-image branch. New tests assert that the registry really does refuse an unauthenticated request, which is also what keeps the fixture honest: a server it could not put behind htpasswd would run anonymous with everything else passing. A 401 read as absence would silently restart a publisher's history chain, so the refusal is checked against a server that really answers 401. Verified: the three registry suites against registry:2, registry:3 and zot v2.1.20; `cargo test --workspace --all-targets`; `cargo test --workspace --all-features` less the three service crates; clippy clean; `cargo tree -p stelae -e normal --all-features` still matches no dolos package. Co-Authored-By: Claude Opus 5 (1M context) --- adrs/004_stelae_snapshots.md | 17 +- crates/core/src/config.rs | 77 +++++ crates/snapshot/src/registry.rs | 183 ++++++++++- crates/snapshot/tests/registry_fixture/mod.rs | 126 ++++++- crates/snapshot/tests/restore_registry.rs | 57 ++++ crates/stelae/src/lib.rs | 31 ++ crates/stelae/src/oci.rs | 310 +++++++++++++++++- crates/stelae/tests/oci.rs | 164 ++++++++- docs/content/configuration/schema.mdx | 43 +++ src/bin/dolos/bootstrap/stelae.rs | 4 +- src/bin/dolos/init.rs | 106 +++++- src/bin/dolos/snapshot/publish.rs | 10 +- 12 files changed, 1090 insertions(+), 38 deletions(-) diff --git a/adrs/004_stelae_snapshots.md b/adrs/004_stelae_snapshots.md index a00db41e7..20a2d3a7e 100644 --- a/adrs/004_stelae_snapshots.md +++ b/adrs/004_stelae_snapshots.md @@ -240,7 +240,16 @@ The arithmetic is counted in layers, because layers are what the ceiling counts: #### What the transport requires of its host - **A process that opens a registry client must have installed a process-default rustls `CryptoProvider` first.** The transport ships no crypto backend of its own (`reqwest/rustls-no-provider`): the backend the client library would otherwise pick, `aws-lc-rs`, wants `cmake` on every build machine — the dependency this workspace already goes out of its way to avoid — so it stays out of the tree and the choice of provider moves to the program. In Dolos, `main()` installs `ring`. Omitting the install is a panic when the registry client opens, not a link error. -- **Authentication is a bearer token from `STELAE_REGISTRY_TOKEN`, or anonymous.** Read once, when the registry client opens. Nothing else: registry credentials are a publisher's concern with a policy of its own. +- **Authentication is the host's decision, in one of three shapes.** The client is opened with credentials its caller supplies — anonymous, a bearer token, or an HTTP Basic pair — and never goes looking on its own: which identity a program authenticates as is that program's policy. What the protocol owns is the environment grammar, because it owns the variable names: + + | Variable | Shape | + | --- | --- | + | `STELAE_REGISTRY_TOKEN` | bearer token | + | `STELAE_REGISTRY_USER` + `STELAE_REGISTRY_PASSWORD` | Basic pair | + + An empty value is unset. A token and a pair set together is a **refusal**, not a precedence rule, and so is half a pair: an operator who exported both meant one of them, and a client that guessed would authenticate as an identity nobody chose — which on a registry whose credentials carry different capabilities is the difference between a publish and a 403 nobody can explain. + + Anonymous remains legitimate and is what a genuinely public repository wants. It is not what a registry that authenticates every request wants, and that is the deployment Dolos is heading for: read access to a stele repository is free and identity-less, and still credentialed. ### Code layout @@ -286,8 +295,14 @@ download_url = "https://…" # legacy, kept working, deprecated in docs source = "oci://ghcr.io/txpipe/dolos-snapshots/mainnet" # new, takes precedence require_signatures = 0 # k-of-n enforcement trusted_keys = ["ed25519:…"] # mirrors mithril genesis_key style + +[stelae.registry] # the registry's published read-only pair +user = "…" # seeded by `dolos init`; overridden by +password = "…" # STELAE_REGISTRY_USER/_PASSWORD ``` +The read-only pair is a **published secret and belongs in the file**: it is what makes stele distribution free and identity-less while still authenticated, and `dolos init` seeds it so a fresh node restores from the official registry with nothing exported. A publisher's full-access pair is a real secret and belongs in the environment or a secret manager, never here — which is why the environment overrides the file rather than the other way round, so a node carrying the read-only pair can publish without having it removed first. + ### Publisher pipeline 1. Restore the publisher node from the previous stele (self-hosting delta pull; first run via Mithril). diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 89d839745..263775651 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -699,6 +699,80 @@ pub struct SnapshotConfig { pub download_url: String, } +/// The official stele registry's published read-only credentials. +/// +/// **Deliberately a published secret**, and the only one this project has: +/// stele distribution is free and identity-less, but never unrestricted — the +/// registry authenticates every request, and the pair below is what a consumer +/// authenticates with. It is seeded into `dolos.toml` by `dolos init` so that a +/// fresh node pulls from the official registry out of the box; the pair +/// therefore gates out-of-band tooling and nothing else. +/// +/// Empty until the registry that issues it exists. Filling it is a one-line +/// change *here* and nowhere else, which is the reason this constant is a +/// constant rather than a literal at the seeding site. +pub const OFFICIAL_REGISTRY_CREDENTIALS: Option<(&str, &str)> = None; + +/// `[stelae]` — how this node reaches a stele registry. +/// +/// One section carrying one credential pair, and that is the whole of the +/// consumer surface: a node restoring from a stele repository needs to +/// authenticate, and nothing more about a registry belongs in a node's +/// configuration. +#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)] +pub struct StelaeConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub registry: Option, +} + +impl StelaeConfig { + /// What `dolos init` seeds, from [`OFFICIAL_REGISTRY_CREDENTIALS`]. + /// + /// Empty while that constant is, which is why it is a constructor rather + /// than a `Default` impl: a caller reading this name knows it is asking for + /// the official registry specifically, and gets the honest answer when + /// there is not one yet. + pub fn official() -> Self { + Self { + registry: OFFICIAL_REGISTRY_CREDENTIALS.map(|(user, password)| StelaeRegistryConfig { + user: user.to_owned(), + password: password.to_owned(), + }), + } + } + + pub fn is_default(&self) -> bool { + self.registry.is_none() + } +} + +/// `[stelae.registry]` — the credentials a stele registry is read with. +/// +/// Read credentials only. A publisher's full-access pair is a secret and lives +/// in the environment (`STELAE_REGISTRY_USER` / `STELAE_REGISTRY_PASSWORD`) or +/// in a secret manager, never in a file that gets committed alongside a node's +/// other settings — and the environment overrides what is here, so a publisher +/// running on a node that carries the read-only pair does not have to remove it +/// first. +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct StelaeRegistryConfig { + pub user: String, + pub password: String, +} + +/// Names the user and never the password. +/// +/// `RootConfig` is printed in diagnostics; a derived `Debug` here would put the +/// pair in whatever a bug report happens to include. +impl std::fmt::Debug for StelaeRegistryConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StelaeRegistryConfig") + .field("user", &self.user) + .field("password", &"") + .finish() + } +} + #[derive(Serialize, Deserialize, Clone)] pub struct OuroborosConfig { pub listen_path: PathBuf, @@ -1143,6 +1217,9 @@ pub struct RootConfig { pub snapshot: Option, + #[serde(default, skip_serializing_if = "StelaeConfig::is_default")] + pub stelae: StelaeConfig, + pub chain: ChainConfig, #[serde(default, skip_serializing_if = "LoggingConfig::is_default")] diff --git a/crates/snapshot/src/registry.rs b/crates/snapshot/src/registry.rs index d4e37e43e..61e374efd 100644 --- a/crates/snapshot/src/registry.rs +++ b/crates/snapshot/src/registry.rs @@ -76,13 +76,21 @@ //! protocol takes the string and validates it. An operator naming `epoch-500` //! is naming a sequence, so that is what [`Point`] parses to, and the round //! trip back to a tag goes through the profile like every other one. +//! +//! ## Who the client authenticates as +//! +//! A registry that charges nothing for reads may still refuse an unidentified +//! one, so both directions carry credentials, from two different places: a +//! publisher's full-access pair out of the environment, a consumer's published +//! read-only pair out of `dolos.toml`. [`auth`] is the one place that resolves +//! them, in one rule, for both directions. use std::{cell::Cell, collections::BTreeMap}; -use dolos_core::{ArchiveStore, IndexStore, StateStore}; +use dolos_core::{config::StelaeRegistryConfig, ArchiveStore, IndexStore, StateStore}; use stelae::{ inscription::{HistoryEntry, Inscription, LayerDescriptor}, - oci::{Options, Registry, Stele, Transfer}, + oci::{Auth, Options, Registry, Stele, Transfer}, Digest, SteleReader as _, }; @@ -211,19 +219,65 @@ where /// or a mirror inside a cluster, and for nothing that is reachable from outside /// one. /// +/// `configured` is the read-only pair a node carries in `[stelae.registry]`, +/// which the environment overrides — see [`auth`]. +/// /// **Never call any of this from inside an async context.** The transport owns /// a current-thread runtime and enters it with `block_on`; `stelae::oci`'s /// module documentation states the rule and the reason. -pub fn open(repository: &Repository, insecure: bool) -> Result { +pub fn open( + repository: &Repository, + insecure: bool, + configured: Option<&StelaeRegistryConfig>, +) -> Result { Ok(Registry::open( repository, Options { insecure, scratch_dir: None, + auth: auth(configured)?, }, )?) } +/// The credentials a registry client authenticates with: the environment's, or +/// the node's configured pair, or nobody. +/// +/// **One resolution for both directions**, and that is the point. A publish +/// takes its full-access pair from `STELAE_REGISTRY_USER` / +/// `STELAE_REGISTRY_PASSWORD` (or a bearer token from +/// `STELAE_REGISTRY_TOKEN`); a restore takes the published read-only pair from +/// `[stelae.registry]` in `dolos.toml`. Those are two *sources*, not two rules, +/// and the rule is: +/// +/// - **the environment wins.** A publisher's credentials are a secret and never +/// enter a configuration file, so the environment is the only place they can +/// come from — and a node that already carries the read-only pair must not +/// have to have it removed before it can publish. +/// - **what is configured is the fallback**, which is what makes a fresh +/// `dolos init` pull from the official registry with nothing exported. +/// - **neither is anonymous**, which is what a genuinely public repository +/// wants and what a credentialed one answers with a 401. +/// +/// The environment naming two kinds of credential at once is a refusal rather +/// than a third precedence rule; `stelae::oci::Auth::from_env` raises it and +/// says which variables to unset. +pub fn auth(configured: Option<&StelaeRegistryConfig>) -> Result { + let from_env = Auth::from_env()?; + + if !from_env.is_anonymous() { + return Ok(from_env); + } + + Ok(match configured { + Some(credentials) => Auth::Basic { + user: credentials.user.clone(), + password: credentials.password.clone(), + }, + None => Auth::Anonymous, + }) +} + /// What a publish into a repository did. #[derive(Debug, Clone)] pub struct Published { @@ -897,4 +951,127 @@ mod tests { scope, } } + + // ----------------------------------------------------------------------- + // Which credentials a client ends up with + // ----------------------------------------------------------------------- + + /// The environment is process-wide, so these run one at a time. + static ENV: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// Run `body` with exactly these registry variables set, and put the + /// process environment back afterwards. + fn with_env( + token: Option<&str>, + user: Option<&str>, + password: Option<&str>, + body: impl FnOnce() -> T, + ) -> T { + use stelae::oci::{PASSWORD_ENV, TOKEN_ENV, USER_ENV}; + + let _guard = ENV.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + + let previous: Vec<(&str, Option)> = [TOKEN_ENV, USER_ENV, PASSWORD_ENV] + .into_iter() + .map(|name| (name, std::env::var(name).ok())) + .collect(); + + for (name, value) in [ + (TOKEN_ENV, token), + (USER_ENV, user), + (PASSWORD_ENV, password), + ] { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + + let outcome = body(); + + for (name, value) in previous { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + + outcome + } + + fn configured() -> StelaeRegistryConfig { + StelaeRegistryConfig { + user: "dolos-reader".to_owned(), + password: "published".to_owned(), + } + } + + /// The restore path: the pair `dolos init` seeded is what a client + /// authenticates with when nothing is exported. + #[test] + fn a_configured_pair_is_used_when_the_environment_is_silent() { + with_env(None, None, None, || { + assert_eq!( + auth(Some(&configured())).unwrap(), + Auth::Basic { + user: "dolos-reader".to_owned(), + password: "published".to_owned(), + } + ); + + // And a node that configured nothing stays anonymous rather than + // inventing an identity. + assert_eq!(auth(None).unwrap(), Auth::Anonymous); + }); + } + + /// The publish path, and the precedence that makes it work on a node that + /// already carries the read-only pair. + #[test] + fn the_environment_overrides_a_configured_pair() { + with_env(None, Some("publisher"), Some("full-access"), || { + assert_eq!( + auth(Some(&configured())).unwrap(), + Auth::Basic { + user: "publisher".to_owned(), + password: "full-access".to_owned(), + }, + "a publisher must not have to strip the read-only pair out of \ + dolos.toml before it can publish", + ); + }); + + // A bearer token overrides it too: the environment is the source, and + // which shape it names is the environment's business. + with_env(Some("ghp_x"), None, None, || { + assert_eq!( + auth(Some(&configured())).unwrap(), + Auth::Bearer("ghp_x".to_owned()) + ); + }); + } + + /// The refusal reaches this far rather than being resolved on the way. + /// + /// A configured pair is not a tie-breaker for an ambiguous environment: an + /// operator who exported both a token and a pair gets told so, whatever is + /// in `dolos.toml`. + #[test] + fn an_ambiguous_environment_is_refused_even_with_a_configured_pair() { + with_env( + Some("ghp_x"), + Some("publisher"), + Some("full-access"), + || { + for configured in [None, Some(configured())] { + let err = auth(configured.as_ref()).unwrap_err(); + + assert!( + matches!(err, Error::Stelae(stelae::Error::AmbiguousRegistryAuth)), + "{err:?}" + ); + } + }, + ); + } } diff --git a/crates/snapshot/tests/registry_fixture/mod.rs b/crates/snapshot/tests/registry_fixture/mod.rs index 574172707..1324aaf36 100644 --- a/crates/snapshot/tests/registry_fixture/mod.rs +++ b/crates/snapshot/tests/registry_fixture/mod.rs @@ -16,9 +16,29 @@ // binary does not reach look dead to it. They are not. #![allow(dead_code)] +use dolos_core::config::StelaeRegistryConfig; use dolos_snapshot::registry; use stelae::oci::Registry; +/// The credentials the fixture's registry demands, and the ones these suites +/// hand `registry::open` as a node's configured pair. +/// +/// A test credential, not a secret: it exists for the length of one container. +/// The bcrypt hash below is this pair, and `distribution` accepts no other +/// hash algorithm in an htpasswd file. +pub const USER: &str = "stelae"; +pub const PASSWORD: &str = "stelae-fixture"; + +const HTPASSWD: &str = "stelae:$2y$05$1Hb22zONvzLAj4WaYl34/uDWF5rDgQkS9MoewgRvsTlsNrusMYTW6\n"; + +/// The node-side half of the same pair. +pub fn credentials() -> StelaeRegistryConfig { + StelaeRegistryConfig { + user: USER.to_owned(), + password: PASSWORD.to_owned(), + } +} + /// Install `ring` as the process-default crypto provider. /// /// `stelae::oci` documents this as the caller's job — the transport is built @@ -42,9 +62,18 @@ fn install_crypto_provider() { /// A container running an OCI Distribution server, removed when this is /// dropped. +/// +/// It demands Basic credentials, because the registry these suites exist to +/// stand in for does: access to a stele repository is free and identity-less, +/// and still authenticated. An anonymous fixture would leave the credential +/// plumbing in `registry::open` exercised by unit tests alone. pub struct Fixture { container: String, port: u16, + /// The htpasswd file — and, for a registry that reads a file rather than + /// the environment, the configuration naming it. Held so it outlives the + /// container that has it mounted. + _auth: tempfile::TempDir, } impl Fixture { @@ -54,15 +83,18 @@ impl Fixture { let image = std::env::var("STELAE_TEST_REGISTRY_IMAGE").unwrap_or_else(|_| "registry:2".to_owned()); + let auth = auth_dir(); + + let mut args: Vec = ["run", "--detach", "--rm", "--publish", "127.0.0.1::5000"] + .iter() + .map(|arg| (*arg).to_owned()) + .collect(); + + args.extend(auth_args(auth.path())); + args.push(image.clone()); + let run = std::process::Command::new("docker") - .args([ - "run", - "--detach", - "--rm", - "--publish", - "127.0.0.1::5000", - &image, - ]) + .args(&args) .output() .expect("docker is required to run the registry tests"); @@ -86,10 +118,15 @@ impl Fixture { .and_then(|port| port.trim().parse::().ok()) .unwrap_or_else(|| panic!("no published port in {mapped:?}")); - let fixture = Self { container, port }; + let fixture = Self { + container, + port, + _auth: auth, + }; + fixture.wait_until_ready(); - eprintln!("registry: {image} on 127.0.0.1:{port}"); + eprintln!("registry: {image} on 127.0.0.1:{port}, basic auth as {USER:?}"); fixture } @@ -147,12 +184,22 @@ impl Fixture { /// A fresh transport per call, even for a name already opened: the pending /// layers and the transfer counters live in the transport, and a test /// comparing two publishes wants two of them. + /// + /// Through `registry::open` with the pair as a *configured* one, so these + /// suites exercise the path a restoring node takes — credentials off + /// `[stelae.registry]` in `dolos.toml` — rather than a transport assembled + /// beside it. pub fn repository(&self, name: &str) -> Registry { + self.repository_as(name, Some(&credentials())) + } + + /// The same, with whatever credentials a caller wants to try. + pub fn repository_as(&self, name: &str, configured: Option<&StelaeRegistryConfig>) -> Registry { let repository = format!("oci://127.0.0.1:{}/{name}", self.port) .parse() .expect("the fixture named a usable repository"); - registry::open(&repository, true).unwrap() + registry::open(&repository, true, configured).unwrap() } } @@ -163,3 +210,60 @@ impl Drop for Fixture { .output(); } } + +/// An htpasswd file, plus the configuration a registry that wants one in a file +/// rather than in the environment reads. +/// +/// Returned as a directory the caller holds: the container has both mounted, +/// and a `TempDir` dropped early would unlink them out from under it. +pub fn auth_dir() -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("a temporary directory for the htpasswd file"); + + std::fs::write(dir.path().join("htpasswd"), HTPASSWD).expect("writing the htpasswd file"); + std::fs::write(dir.path().join("zot.json"), ZOT_CONFIG).expect("writing the zot config"); + + dir +} + +/// The `docker run` arguments that make a registry demand [`USER`]/[`PASSWORD`]. +/// +/// **Both configurations, unconditionally, and no per-image branch.** The two +/// server families this suite is pointed at read their auth from different +/// places and each ignores the other's: `distribution` reads `REGISTRY_AUTH_*` +/// out of the environment and never opens `/etc/zot/config.json`, while `zot` +/// reads that file and knows nothing about `REGISTRY_*`. Applying both is +/// therefore not a guess about which image is running — it is the union of two +/// settings that cannot collide. +/// +/// A registry that reads neither would run anonymous, which is why every suite +/// that uses this fixture also asserts that credentials are actually required. +pub fn auth_args(dir: &std::path::Path) -> Vec { + let path = |name: &str| dir.join(name).display().to_string(); + + vec![ + "--volume".to_owned(), + format!("{}:/auth/htpasswd:ro", path("htpasswd")), + "--volume".to_owned(), + format!("{}:/etc/zot/config.json:ro", path("zot.json")), + "--env".to_owned(), + "REGISTRY_AUTH=htpasswd".to_owned(), + "--env".to_owned(), + "REGISTRY_AUTH_HTPASSWD_REALM=stelae".to_owned(), + "--env".to_owned(), + "REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd".to_owned(), + ] +} + +/// zot's whole configuration, which is a file or nothing — it has no +/// environment equivalent, and the image's own default has no auth in it. +const ZOT_CONFIG: &str = r#"{ + "distSpecVersion": "1.1.1", + "storage": { "rootDirectory": "/var/lib/registry" }, + "http": { + "address": "0.0.0.0", + "port": "5000", + "auth": { "htpasswd": { "path": "/auth/htpasswd" } } + }, + "log": { "level": "warn" } +} +"#; diff --git a/crates/snapshot/tests/restore_registry.rs b/crates/snapshot/tests/restore_registry.rs index 828713327..a58d8ad54 100644 --- a/crates/snapshot/tests/restore_registry.rs +++ b/crates/snapshot/tests/restore_registry.rs @@ -27,6 +27,11 @@ //! 4. **A point names a stele.** `epoch-N` resolves to that sequence and //! `latest` to the newest, which is what makes a repository holding a //! history restorable at any of them. +//! 5. **The pair in `dolos.toml` is what opens the repository.** The fixture's +//! registry demands Basic credentials, and every restore above reaches it +//! through `registry::open` with the pair as a node's *configured* one — so +//! the four properties are all evidence for this fifth. `a_node_authenticates_ +//! with_its_configured_pair` states it directly, from both sides. //! //! ## Why the interruption is a layer boundary //! @@ -514,6 +519,58 @@ fn a_point_that_names_no_stele_is_refused() { ); } +/// The credentials `[stelae.registry]` carries are what a restore authenticates +/// with — and a node carrying none does not get in. +/// +/// This is the consumer half of the access policy the registry exists under: +/// pulling a stele costs nothing and identifies nobody, and is still refused +/// without a credential. What a fresh `dolos init` seeds is exactly the pair +/// this test hands `registry::open`. +/// +/// The negative half doubles as the fixture's own honesty check: a registry +/// this fixture did not manage to put behind htpasswd would run anonymous, and +/// every other test in this file would pass unchanged. +#[test] +#[ignore] +fn a_node_authenticates_with_its_configured_pair() { + let fixture = Fixture::spawn(); + let node = Node::build(); + + let repository = fixture.repository("dolos/credentialed"); + node.publish(&repository, &node.first); + + // With the pair a node carries in its configuration, the stele resolves. + let configured = registry_fixture::credentials(); + let reader = fixture.repository_as("dolos/credentialed", Some(&configured)); + + let stele = Point::Latest.pull(&reader).unwrap(); + println!("with the configured pair: {stele:?}"); + + // Without it, and with the wrong one, the repository refuses — and the + // refusal is an error rather than an empty repository. `latest` reading a + // 401 as absence is what would let a publisher restart a history chain + // against a registry that merely did not recognise it. + let wrong = dolos_core::config::StelaeRegistryConfig { + user: registry_fixture::USER.to_owned(), + password: "not-the-password".to_owned(), + }; + + for (who, credentials) in [("no credentials", None), ("the wrong pair", Some(&wrong))] { + let refused = fixture.repository_as("dolos/credentialed", credentials); + + let err = Point::Latest + .pull(&refused) + .expect_err("the registry answered an unauthenticated request"); + + println!("{who}: {err}"); + + assert!( + refused.latest(&dolos_snapshot::DolosProfile).is_err(), + "{who}: a refusal read as an empty repository", + ); + } +} + // --------------------------------------------------------------------------- // The interruption // --------------------------------------------------------------------------- diff --git a/crates/stelae/src/lib.rs b/crates/stelae/src/lib.rs index 17717ee15..d89255b3e 100644 --- a/crates/stelae/src/lib.rs +++ b/crates/stelae/src/lib.rs @@ -279,6 +279,37 @@ pub enum Error { #[error("{value:?} is not an OCI repository: {reason}")] InvalidRepository { value: String, reason: String }, + /// The environment names two sets of registry credentials at once. + /// + /// Refused rather than resolved by precedence. An operator who exported + /// both a bearer token and a Basic pair meant one of them, and a client + /// that quietly picked would authenticate as an identity nobody chose — + /// which, on a registry where the two credentials carry different + /// capabilities, is the difference between a publish and a 403 nobody can + /// explain. + #[cfg(feature = "oci")] + #[error( + "{} and {}/{} are both set; registry credentials come from one of the two and \ + which one was meant is not something to guess at — unset the one you did not mean", + crate::oci::TOKEN_ENV, + crate::oci::USER_ENV, + crate::oci::PASSWORD_ENV + )] + AmbiguousRegistryAuth, + + /// Half of a Basic credential pair. + /// + /// A user with no password (or the reverse) is a typo or a secret that + /// never reached the process. Sending the half that arrived would + /// authenticate as somebody the operator did not name, so it is refused + /// where it is read. + #[cfg(feature = "oci")] + #[error("{set} is set without {missing}; basic registry credentials are a pair")] + IncompleteRegistryAuth { + set: &'static str, + missing: &'static str, + }, + /// Anything the registry client reported. #[cfg(feature = "oci")] #[error("registry error: {0}")] diff --git a/crates/stelae/src/oci.rs b/crates/stelae/src/oci.rs index 54dc2c238..46eb447d0 100644 --- a/crates/stelae/src/oci.rs +++ b/crates/stelae/src/oci.rs @@ -99,9 +99,21 @@ //! //! ## Authentication //! -//! Anonymous, or a bearer token read from [`TOKEN_ENV`]. Nothing else: registry -//! credentials are a publisher's concern with a policy of their own, and a -//! transport that has no secrets policy should not invent one. +//! Anonymous, a bearer token, or a Basic credential pair — whichever the caller +//! puts in [`Options::auth`]. The transport does not go looking: which +//! credentials a program authenticates with is that program's policy, and a +//! transport that reached into the environment behind its caller's back would +//! be making that policy for it. +//! +//! What the transport *does* own is the environment grammar, because it owns +//! the variable names: [`Auth::from_env`] reads [`TOKEN_ENV`] and the +//! [`USER_ENV`]/[`PASSWORD_ENV`] pair and answers with the same value a caller +//! would otherwise assemble. A host that wants credentials from the environment +//! calls it; a host that reads them from a configuration file does not. +//! +//! Both shapes set at once is a **refusal**, not a precedence rule. An operator +//! who exported a token and a pair meant one of them, and a transport that +//! silently picked would authenticate as an identity nobody chose. use std::{ collections::BTreeMap, @@ -135,10 +147,119 @@ use crate::{ /// Environment variable holding a bearer token for the registry. /// -/// Read once, when a [`Registry`] is opened. Absent means anonymous, which is -/// what a public read-only repository wants. +/// Read by [`Auth::from_env`], and by nothing else in this crate. pub const TOKEN_ENV: &str = "STELAE_REGISTRY_TOKEN"; +/// Environment variable holding the user half of a Basic credential pair. +pub const USER_ENV: &str = "STELAE_REGISTRY_USER"; + +/// Environment variable holding the password half of a Basic credential pair. +pub const PASSWORD_ENV: &str = "STELAE_REGISTRY_PASSWORD"; + +/// How a [`Registry`] authenticates. +/// +/// The three shapes `oci-client` implements, named here rather than re-exported +/// so that a caller assembling credentials does not have to depend on the +/// registry client this transport happens to be built on. +#[derive(Clone, Default, PartialEq, Eq)] +pub enum Auth { + /// No credentials. What a genuinely public repository wants, and what a + /// registry that authenticates every request will answer with a 401. + #[default] + Anonymous, + /// A bearer token, as GHCR and the token-exchange registries issue. + Bearer(String), + /// A user and password, sent as HTTP Basic. What a registry fronted by + /// htpasswd — or by a Worker checking a credential table — expects. + Basic { user: String, password: String }, +} + +/// Says which shape it is and never what is in it. +/// +/// A transport is held in structures that get logged and printed in error +/// context; a derived `Debug` would put a publisher's password in the first +/// backtrace anybody pastes into an issue. +impl std::fmt::Debug for Auth { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Anonymous => f.write_str("Anonymous"), + Self::Bearer(_) => f.write_str("Bearer()"), + Self::Basic { user, .. } => f + .debug_struct("Basic") + .field("user", user) + .field("password", &"") + .finish(), + } + } +} + +impl Auth { + /// The credentials the environment names, or [`Auth::Anonymous`] if it + /// names none. + /// + /// The grammar is this crate's because the variable names are: [`TOKEN_ENV`] + /// for a bearer token, [`USER_ENV`] and [`PASSWORD_ENV`] for a pair. An + /// empty value counts as unset, so `STELAE_REGISTRY_TOKEN=` in a stale + /// shell profile does not authenticate as the empty token. + /// + /// Two refusals, and both are operator mistakes worth a sentence rather + /// than a rule: + /// + /// - **a token and a pair together** — two answers to one question, and + /// which was meant is not something to guess at; + /// - **half a pair** — a user with no password, or the other way round, is + /// a typo or a secret that failed to reach the process, and sending the + /// half that arrived would authenticate as somebody the operator did not + /// name. + pub fn from_env() -> Result { + let read = |name: &str| match std::env::var(name) { + Ok(value) if !value.is_empty() => Some(value), + _ => None, + }; + + let token = read(TOKEN_ENV); + let user = read(USER_ENV); + let password = read(PASSWORD_ENV); + + if token.is_some() && (user.is_some() || password.is_some()) { + return Err(Error::AmbiguousRegistryAuth); + } + + match (user, password) { + (Some(user), Some(password)) => Ok(Self::Basic { user, password }), + (Some(_), None) => Err(Error::IncompleteRegistryAuth { + set: USER_ENV, + missing: PASSWORD_ENV, + }), + (None, Some(_)) => Err(Error::IncompleteRegistryAuth { + set: PASSWORD_ENV, + missing: USER_ENV, + }), + (None, None) => Ok(match token { + Some(token) => Self::Bearer(token), + None => Self::Anonymous, + }), + } + } + + /// Whether these credentials name anybody. + /// + /// The question a caller layering sources asks — "did the environment say + /// anything, or should I fall back to what was configured?" — so it is + /// answered here rather than by every caller matching on the variant. + pub fn is_anonymous(&self) -> bool { + matches!(self, Self::Anonymous) + } + + fn to_registry_auth(&self) -> RegistryAuth { + match self { + Self::Anonymous => RegistryAuth::Anonymous, + Self::Bearer(token) => RegistryAuth::Bearer(token.clone()), + Self::Basic { user, password } => RegistryAuth::Basic(user.clone(), password.clone()), + } + } +} + /// Annotation naming a layer's profile-defined kind. /// /// The three annotation keys below are the specification's: ADR-004's "OCI @@ -217,6 +338,15 @@ pub struct Options { /// compressed, and the platform temporary directory is not always on the /// volume with room for sixteen of them. pub scratch_dir: Option, + + /// How to authenticate, decided by the caller. + /// + /// Defaults to [`Auth::Anonymous`]. [`Auth::from_env`] is here for a host + /// that wants the environment's answer, but it is the host that asks: a + /// transport reading credentials on its caller's behalf would be choosing + /// that program's credential policy for it, and this one has no standing + /// to. + pub auth: Auth, } /// A repository an operator named, as `oci://HOST/PATH`. @@ -377,9 +507,9 @@ impl Registry { /// parses it into a [`Repository`] and hands that over; nothing outside /// this module needs to know where the host ends. /// - /// Builds the current-thread runtime the whole transport runs on. Reads - /// [`TOKEN_ENV`] once, here, so a token never has to be threaded through a - /// profile's call stack. + /// Builds the current-thread runtime the whole transport runs on, and + /// stores the credentials [`Options::auth`] carries so they never have to + /// be threaded through a profile's call stack. /// /// # Panics /// @@ -409,10 +539,7 @@ impl Registry { crate::MOVING_TAG.to_owned(), ); - let auth = match std::env::var(TOKEN_ENV) { - Ok(token) if !token.is_empty() => RegistryAuth::Bearer(token), - _ => RegistryAuth::Anonymous, - }; + let auth = options.auth.to_registry_auth(); let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -1543,4 +1670,163 @@ mod tests { })); assert!(!is_absent(&OciDistributionError::GenericError(None))); } + + // --------------------------------------------------------------------- + // Credentials, out of the environment + // --------------------------------------------------------------------- + + /// The environment is process-wide, so these run one at a time. + static ENV: Mutex<()> = Mutex::new(()); + + /// Run `body` with exactly `token`, `user` and `password` set, and the + /// process environment put back afterwards. + /// + /// Restoring is not politeness: `cargo test` runs every test in this binary + /// in one process, and a leaked `STELAE_REGISTRY_TOKEN` would be read by + /// whatever ran next. + fn with_env( + token: Option<&str>, + user: Option<&str>, + password: Option<&str>, + body: impl FnOnce() -> T, + ) -> T { + let _guard = ENV.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + + let previous: Vec<(&str, Option)> = [TOKEN_ENV, USER_ENV, PASSWORD_ENV] + .into_iter() + .map(|name| (name, std::env::var(name).ok())) + .collect(); + + for (name, value) in [ + (TOKEN_ENV, token), + (USER_ENV, user), + (PASSWORD_ENV, password), + ] { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + + let outcome = body(); + + for (name, value) in previous { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + + outcome + } + + #[test] + fn the_environment_names_a_token_a_pair_or_nobody() { + with_env(None, None, None, || { + assert_eq!(Auth::from_env().unwrap(), Auth::Anonymous); + }); + + with_env(Some("ghp_x"), None, None, || { + assert_eq!(Auth::from_env().unwrap(), Auth::Bearer("ghp_x".to_owned())); + }); + + with_env(None, Some("reader"), Some("hunter2"), || { + assert_eq!( + Auth::from_env().unwrap(), + Auth::Basic { + user: "reader".to_owned(), + password: "hunter2".to_owned(), + } + ); + }); + + // An empty value is unset. A stale `export STELAE_REGISTRY_TOKEN=` in a + // shell profile should leave a client anonymous rather than have it + // authenticate as the empty token. + with_env(Some(""), Some(""), Some(""), || { + assert_eq!(Auth::from_env().unwrap(), Auth::Anonymous); + }); + } + + /// A token and a pair together is a refusal, and the message names every + /// variable involved so an operator knows which one to unset. + #[test] + fn a_token_and_a_pair_together_are_refused() { + with_env(Some("ghp_x"), Some("reader"), Some("hunter2"), || { + let err = Auth::from_env().unwrap_err(); + + assert!(matches!(err, Error::AmbiguousRegistryAuth), "{err:?}"); + + let message = err.to_string(); + assert!(message.contains(TOKEN_ENV), "{message}"); + assert!(message.contains(USER_ENV), "{message}"); + assert!(message.contains(PASSWORD_ENV), "{message}"); + }); + + // Either half of the pair is enough to make it ambiguous. The operator + // set two kinds of credential; that one of them is incomplete is not a + // reason to silently prefer the other. + for (user, password) in [(Some("reader"), None), (None, Some("hunter2"))] { + with_env(Some("ghp_x"), user, password, || { + assert!(matches!( + Auth::from_env().unwrap_err(), + Error::AmbiguousRegistryAuth + )); + }); + } + } + + #[test] + fn half_a_pair_is_refused() { + with_env(None, Some("reader"), None, || { + let err = Auth::from_env().unwrap_err(); + + assert!( + matches!(&err, Error::IncompleteRegistryAuth { set, missing } + if *set == USER_ENV && *missing == PASSWORD_ENV), + "{err:?}" + ); + }); + + with_env(None, None, Some("hunter2"), || { + let err = Auth::from_env().unwrap_err(); + + assert!( + matches!(&err, Error::IncompleteRegistryAuth { set, missing } + if *set == PASSWORD_ENV && *missing == USER_ENV), + "{err:?}" + ); + }); + } + + /// A password never reaches a log through this type. + /// + /// [`Options`] derives `Debug` and error context is printed freely, so this + /// redaction is what stands between a publisher's credentials and the first + /// backtrace anybody pastes into an issue. + #[test] + fn credentials_are_redacted_in_debug_output() { + let basic = Auth::Basic { + user: "reader".to_owned(), + password: "hunter2".to_owned(), + }; + + let printed = format!("{basic:?}"); + assert!(printed.contains("reader"), "{printed}"); + assert!(!printed.contains("hunter2"), "{printed}"); + + let printed = format!("{:?}", Auth::Bearer("ghp_x".to_owned())); + assert!(!printed.contains("ghp_x"), "{printed}"); + + // And through the structure a caller actually holds, which is where it + // would leak from. + let printed = format!( + "{:?}", + Options { + auth: basic, + ..Default::default() + } + ); + assert!(!printed.contains("hunter2"), "{printed}"); + } } diff --git a/crates/stelae/tests/oci.rs b/crates/stelae/tests/oci.rs index 0d5208ff6..47e6e2814 100644 --- a/crates/stelae/tests/oci.rs +++ b/crates/stelae/tests/oci.rs @@ -24,6 +24,19 @@ //! the same suite can be pointed at another implementation — which is the only //! way to find out whether a given registry accepts an OCI 1.1 `artifactType`. //! +//! ## The fixture demands credentials +//! +//! Every registry this suite spawns is behind htpasswd, and every transport it +//! opens carries the pair. That is not incidental hardening: the registry this +//! transport is aimed at authenticates every request — access to a stele +//! repository is free and identity-less, and still credentialed — so a suite +//! that only ever spoke to an anonymous server would prove the round trip +//! against a server unlike the one it runs against. +//! +//! [`credentials_are_required`] is what keeps that honest. A server the fixture +//! does not know how to configure would run anonymous and every other test here +//! would pass regardless; that one fails instead, and says so. +//! //! ## Running them over TLS //! //! The fixture speaks plaintext by default, which is enough for everything @@ -63,7 +76,7 @@ use stelae::{ frame::{encode, CanonicalCbor, Limits}, inscription::LayerDescriptor, oci::{ - build_manifest, manifest_bytes, read_manifest, Options, Registry, Transfer, + build_manifest, manifest_bytes, read_manifest, Auth, Options, Registry, Transfer, DIFF_ID_ANNOTATION, KIND_ANNOTATION, SCOPE_ANNOTATION, }, Compression, Digest, Error, HistoryEntry, Inscription, LayerDigests, LayerSpec, Profile, @@ -558,6 +571,30 @@ fn install_crypto_provider() { }); } +/// The credentials the fixture's registry demands. +/// +/// A test credential, not a secret: it lives as long as one container. [`HTPASSWD`] +/// is the bcrypt encoding of this pair — `distribution` accepts no other hash +/// algorithm in an htpasswd file — so the two move together or not at all. +const USER: &str = "stelae"; +const PASSWORD: &str = "stelae-fixture"; + +const HTPASSWD: &str = "stelae:$2y$05$1Hb22zONvzLAj4WaYl34/uDWF5rDgQkS9MoewgRvsTlsNrusMYTW6\n"; + +/// zot's whole configuration, which is a file or nothing: it has no environment +/// equivalent, and the image's own default carries no auth. +const ZOT_CONFIG: &str = r#"{ + "distSpecVersion": "1.1.1", + "storage": { "rootDirectory": "/var/lib/registry" }, + "http": { + "address": "0.0.0.0", + "port": "5000", + "auth": { "htpasswd": { "path": "/auth/htpasswd" } } + }, + "log": { "level": "warn" } +} +"#; + /// A container running an OCI Distribution server, removed when this is /// dropped. /// @@ -568,6 +605,9 @@ struct Fixture { container: String, port: u16, tls: bool, + /// The htpasswd file and the configuration naming it, held so they outlive + /// the container that has them mounted. + _auth: tempfile::TempDir, } impl Fixture { @@ -578,6 +618,7 @@ impl Fixture { std::env::var("STELAE_TEST_REGISTRY_IMAGE").unwrap_or_else(|_| "registry:2".to_owned()); let tls = Tls::from_env(); + let auth = auth_dir(); let mut args: Vec = ["run", "--detach", "--rm", "--publish", "127.0.0.1::5000"] .iter() @@ -588,6 +629,7 @@ impl Fixture { args.extend(tls.docker_args()); } + args.extend(auth_args(auth.path())); args.push(image.clone()); let run = std::process::Command::new("docker") @@ -619,12 +661,13 @@ impl Fixture { container, port, tls: tls.is_some(), + _auth: auth, }; fixture.wait_until_ready(); eprintln!( - "registry: {image} on {}, {}", + "registry: {image} on {}, {}, basic auth as {USER:?}", fixture.address(), if fixture.tls { "TLS" } else { "plaintext" } ); @@ -681,10 +724,23 @@ impl Fixture { } /// Every transport in this file is built here, so that whether the fixture - /// is speaking TLS is decided in exactly one place. A test that assembled - /// its own [`Options`] to set a scratch directory would keep working - /// against a plaintext fixture and quietly send `http://` at a TLS one. + /// is speaking TLS — and which credentials it presents — is decided in + /// exactly one place. A test that assembled its own [`Options`] to set a + /// scratch directory would keep working against a plaintext fixture and + /// quietly send `http://` at a TLS one. fn registry_staging_in(&self, repository: &str, scratch_dir: Option) -> Registry { + self.registry_as(repository, scratch_dir, self.credentials()) + } + + /// The pair the fixture's registry accepts. + fn credentials(&self) -> Auth { + Auth::Basic { + user: USER.to_owned(), + password: PASSWORD.to_owned(), + } + } + + fn registry_as(&self, repository: &str, scratch_dir: Option, auth: Auth) -> Registry { Registry::open( &format!("oci://{}/{repository}", self.address()) .parse() @@ -692,12 +748,57 @@ impl Fixture { Options { insecure: !self.tls, scratch_dir, + auth, }, ) .unwrap() } } +/// An htpasswd file, plus the configuration a registry that wants one in a file +/// rather than in the environment reads. +/// +/// Returned as a directory the caller holds: the container has both mounted, +/// and a `TempDir` dropped early would unlink them out from under it. +fn auth_dir() -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("a temporary directory for the htpasswd file"); + + std::fs::write(dir.path().join("htpasswd"), HTPASSWD).expect("writing the htpasswd file"); + std::fs::write(dir.path().join("zot.json"), ZOT_CONFIG).expect("writing the zot config"); + + dir +} + +/// The `docker run` arguments that make a registry demand [`USER`]/[`PASSWORD`]. +/// +/// **Both configurations, unconditionally, and no per-image branch.** The two +/// server families this suite is pointed at read their auth from different +/// places and each ignores the other's: `distribution` reads `REGISTRY_AUTH_*` +/// out of the environment and never opens `/etc/zot/config.json`, while `zot` +/// reads that file and knows nothing about `REGISTRY_*`. Applying both is +/// therefore not a guess about which image is running — it is the union of two +/// settings that cannot collide. +/// +/// A registry that reads neither would run anonymous, which every other test +/// here would be perfectly happy with. [`credentials_are_required`] is what +/// notices. +fn auth_args(dir: &std::path::Path) -> Vec { + let path = |name: &str| dir.join(name).display().to_string(); + + vec![ + "--volume".to_owned(), + format!("{}:/auth/htpasswd:ro", path("htpasswd")), + "--volume".to_owned(), + format!("{}:/etc/zot/config.json:ro", path("zot.json")), + "--env".to_owned(), + "REGISTRY_AUTH=htpasswd".to_owned(), + "--env".to_owned(), + "REGISTRY_AUTH_HTPASSWD_REALM=stelae".to_owned(), + "--env".to_owned(), + "REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd".to_owned(), + ] +} + impl Drop for Fixture { fn drop(&mut self) { let _ = std::process::Command::new("docker") @@ -1403,6 +1504,59 @@ fn latest_is_absent_until_something_is_published() { assert!(empty.latest(&ToyProfile).unwrap().is_none()); } +/// The registry the fixture spawns actually demands credentials, and a refusal +/// is never read as absence. +/// +/// Two claims, and the second is the one with teeth. `Registry::latest` turns +/// "no such manifest" into `None`, and a publisher reads `None` as "nothing to +/// chain to" and starts a fresh history — so a 401 widening into absence would +/// silently restart the attestation chain against a registry that simply did +/// not recognise the caller. `is_absent` is written not to, and this is that +/// claim against a server that really answers 401 rather than against a +/// hand-built error value. +/// +/// The first claim is what keeps the rest of this file honest: the fixture +/// configures htpasswd for the two server families it knows, and a registry +/// that read neither would run anonymous with every other test here passing +/// exactly as before. This one fails instead — which, for an operator pointing +/// `STELAE_TEST_REGISTRY_IMAGE` at a fourth implementation, is the fixture +/// saying it does not know how to make that one ask for credentials. +#[test] +#[ignore = "spawns a registry"] +fn credentials_are_required() { + let _serial = exclusive(); + + let fixture = Fixture::spawn(); + + // The pair the fixture configured reads the repository, which is the + // baseline every other test in this file rests on. + let allowed = fixture.registry("stelae/credentials"); + assert!(allowed.latest(&ToyProfile).unwrap().is_none()); + + for (who, auth) in [ + ("anonymous", Auth::Anonymous), + ( + "the wrong password", + Auth::Basic { + user: USER.to_owned(), + password: "not-the-password".to_owned(), + }, + ), + ] { + let refused = fixture.registry_as("stelae/credentials", None, auth); + + let err = refused + .latest(&ToyProfile) + .expect_err("the registry answered an unauthenticated request"); + + println!("{who}: {err}"); + + // And a publish through this transport is refused rather than starting + // a chain, which is the consequence that matters. + assert!(refused.pull_latest(&ToyProfile).is_err(), "{who}"); + } +} + /// A layer cannot be carried forward into a repository that does not hold its /// blob. /// diff --git a/docs/content/configuration/schema.mdx b/docs/content/configuration/schema.mdx index 1b7af645d..2910203b1 100644 --- a/docs/content/configuration/schema.mdx +++ b/docs/content/configuration/schema.mdx @@ -72,6 +72,10 @@ genesis_key = "5b3...45d" # redacted [snapshot] download_url = "https://example.com/snapshot.tar.zst" +[stelae.registry] +user = "dolos" +password = "published-read-only-credential" + ``` Each section of the toml controls a different aspect of Dolos' processes. The rest of this document describes in detail each of these sections. @@ -394,6 +398,45 @@ The `snapshot` section controls bootstrap from a snapshot archive. - `download_url`: URL of the snapshot archive to download. +## `stelae` section + +The `stelae` section controls how this node reaches a **stele registry** — the OCI +repositories `dolos bootstrap stelae --source oci://…` restores from and +`dolos snapshot publish --repo oci://…` publishes into. + +### `stelae.registry` section + +| property | type | example | +| -------- | ------ | ------- | +| user | string | "dolos" | +| password | string | "published-read-only-credential" | + +- `user`, `password`: the credentials the registry is read with, sent as HTTP Basic. + +A registry may charge nothing for reads and still refuse an unidentified one. The +pair that belongs here is the **published read-only credential** such a registry +hands out: it is not a secret, it is what makes free, identity-less access +possible without leaving the registry unrestricted. `dolos init` seeds it, so a +freshly initialized node restores from the official registry without an operator +having to find a credential first. Omit the section entirely to connect +anonymously, which is what a genuinely public repository wants. + +### Registry credentials in the environment + +Three variables override what the section carries, and are where a **publisher's** +credentials belong — those are real secrets and never go in this file: + +| variable | shape | +| -------- | ----- | +| `STELAE_REGISTRY_USER` + `STELAE_REGISTRY_PASSWORD` | an HTTP Basic pair | +| `STELAE_REGISTRY_TOKEN` | a bearer token, for a registry that issues them | + +An empty value counts as unset. Setting a token and a pair at the same time is an +error rather than a precedence puzzle — Dolos refuses to open the registry and +names both — and so is setting one half of the pair without the other. Because +the environment wins, a node that carries the read-only pair in `dolos.toml` can +publish by exporting the full pair, with nothing to remove first. + ## `logging` section The `logging` section controls the logging options to define the level of details to output. diff --git a/src/bin/dolos/bootstrap/stelae.rs b/src/bin/dolos/bootstrap/stelae.rs index 562525454..ef350f5bb 100644 --- a/src/bin/dolos/bootstrap/stelae.rs +++ b/src/bin/dolos/bootstrap/stelae.rs @@ -205,7 +205,9 @@ fn restore_repo( ) -> miette::Result<()> { let node = Node::open(config)?; - let registry = registry::open(repo, insecure) + // The published read-only pair a fresh `dolos init` seeds, which the + // environment overrides — `dolos_snapshot::registry::auth` owns the rule. + let registry = registry::open(repo, insecure, config.stelae.registry.as_ref()) .into_diagnostic() .context("opening the repository")?; diff --git a/src/bin/dolos/init.rs b/src/bin/dolos/init.rs index 44e69522a..bf3f30ec4 100644 --- a/src/bin/dolos/init.rs +++ b/src/bin/dolos/init.rs @@ -3,8 +3,8 @@ use dolos_cardano::{include, mutable_slots}; use dolos_core::{ config::{ CardanoConfig, ChainConfig, GenesisConfig, GrpcConfig, MinibfConfig, MinikupoConfig, - MithrilConfig, PeerConfig, RelayConfig, RootConfig, StorageConfig, StorageVersion, - TrpConfig, UpstreamConfig, + MithrilConfig, PeerConfig, RelayConfig, RootConfig, StelaeConfig, StorageConfig, + StorageVersion, TrpConfig, UpstreamConfig, }, Genesis, }; @@ -352,6 +352,13 @@ impl Default for ConfigEditor { upstream: From::from(&KnownNetwork::CardanoMainnet), mithril: Some(From::from(&KnownNetwork::CardanoMainnet)), snapshot: Default::default(), + // Seeded, so a node created here restores from the official + // stele registry without an operator having to find a + // credential first. Only on a *fresh* config: an existing + // `dolos.toml` keeps whatever it carries, because a section + // an operator removed and one that predates the field look + // the same from here, and overwriting would undo the first. + stelae: StelaeConfig::official(), storage: StorageConfig { version: StorageVersion::V3, ..Default::default() @@ -704,10 +711,14 @@ impl ConfigEditor { } fn save(self, path: &Path) -> miette::Result<()> { - let config = toml::to_string_pretty(&self.0) + let mut config = toml::to_string_pretty(&self.0) .into_diagnostic() .context("serializing config toml")?; + if self.0.stelae.registry.is_none() { + config.push_str(STELAE_REGISTRY_TEMPLATE); + } + std::fs::write(path, config) .into_diagnostic() .context("saving config file")?; @@ -716,6 +727,29 @@ impl ConfigEditor { } } +/// The stelae registry section, written commented out when there is no pair to +/// seed. +/// +/// A comment rather than nothing. The section is how a node authenticates +/// against a stele registry, and an operator pointing `dolos bootstrap stelae` +/// at one — the official registry before its credentials ship, or a private one +/// — should find the shape of it in the file they already have. Serde cannot +/// emit a comment, so this is appended after serialization; it is written only +/// when the section itself is absent, so it never sits next to a real one +/// contradicting it. +const STELAE_REGISTRY_TEMPLATE: &str = "\ +# Credentials for the stele registry `dolos bootstrap stelae` reads from. +# Registries that charge nothing for reads may still refuse an unidentified +# one; this is the published read-only pair such a registry hands out. The +# environment overrides it — STELAE_REGISTRY_USER and STELAE_REGISTRY_PASSWORD, +# or STELAE_REGISTRY_TOKEN for a registry that issues bearer tokens — which is +# where a publisher's own full-access credentials belong. +# +# [stelae.registry] +# user = \"\" +# password = \"\" +"; + pub fn run( config: miette::Result, args: &Args, @@ -746,3 +780,69 @@ pub fn run( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// A freshly initialized node carries the stelae registry section. + /// + /// Two shapes, one test, because which one is written depends on a + /// constant: the section itself once `OFFICIAL_REGISTRY_CREDENTIALS` is + /// filled, the commented template until then. Both have to parse — a + /// generated `dolos.toml` that `dolos daemon` cannot read is worse than one + /// that says nothing about registries — and this is what will still be true + /// on the day the constant is filled in. + #[test] + fn a_fresh_config_carries_the_stelae_registry_section() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("dolos.toml"); + + ConfigEditor::default().save(&path).unwrap(); + + let written = std::fs::read_to_string(&path).unwrap(); + println!("{written}"); + + assert!(written.contains("[stelae.registry]"), "{written}"); + + // The environment is named beside it either way: it is where a + // publisher's credentials go, and the section is where an operator + // looks for that fact. + assert!(written.contains("STELAE_REGISTRY_USER"), "{written}"); + + let parsed: RootConfig = toml::from_str(&written).expect("the generated config parses"); + assert_eq!(parsed.stelae, StelaeConfig::official()); + } + + /// A config that already carries a pair keeps it, and gets no commented + /// template contradicting it. + #[test] + fn a_configured_pair_is_written_rather_than_commented() { + use dolos_core::config::StelaeRegistryConfig; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("dolos.toml"); + + let mut editor = ConfigEditor::default(); + editor.0.stelae.registry = Some(StelaeRegistryConfig { + user: "dolos".to_owned(), + password: "published".to_owned(), + }); + + editor.save(&path).unwrap(); + + let written = std::fs::read_to_string(&path).unwrap(); + + assert!(written.contains("[stelae.registry]"), "{written}"); + assert!( + !written.contains("# [stelae.registry]"), + "the template was written next to a real section: {written}" + ); + + let parsed: RootConfig = toml::from_str(&written).unwrap(); + let registry = parsed.stelae.registry.expect("the pair round-trips"); + + assert_eq!(registry.user, "dolos"); + assert_eq!(registry.password, "published"); + } +} diff --git a/src/bin/dolos/snapshot/publish.rs b/src/bin/dolos/snapshot/publish.rs index 993c668d7..20490d0bd 100644 --- a/src/bin/dolos/snapshot/publish.rs +++ b/src/bin/dolos/snapshot/publish.rs @@ -159,7 +159,7 @@ pub fn run(config: &RootConfig, args: &Args) -> miette::Result<()> { } match (&args.repo, &args.output_dir) { - (Some(repo), _) => to_repository(args, repo, &plan, &stores), + (Some(repo), _) => to_repository(config, args, repo, &plan, &stores), (None, Some(dir)) => to_directory(args, dir, &plan, &stores), // The required `destination` group already refuses this. (None, None) => unreachable!("one of --output-dir and --repo is required"), @@ -207,12 +207,18 @@ fn to_directory( /// this stele was inherited rather than built, and how much of it moved. Both /// are numbers the code counted, not an inference from a duration. fn to_repository( + config: &RootConfig, args: &Args, repo: &Repository, plan: &export::Plan, stores: &crate::common::Stores, ) -> miette::Result<()> { - let registry = registry::open(repo, args.insecure) + // A publisher's credentials come from `STELAE_REGISTRY_USER` / + // `STELAE_REGISTRY_PASSWORD`, which override anything configured. The + // configured pair is still handed over: it is read-only, so authenticating + // with it fails the push at the registry rather than a step earlier — which + // is the honest place for "these credentials cannot publish" to be said. + let registry = registry::open(repo, args.insecure, config.stelae.registry.as_ref()) .into_diagnostic() .context("opening the repository")?; From 69b7a21b42ccca41b7e10a61da282725ea64cbfd Mon Sep 17 00:00:00 2001 From: Santiago Date: Sat, 8 Aug 2026 19:52:59 -0300 Subject: [PATCH 2/6] style: wrap comments the way nightly rustfmt does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rustfmt.toml` turns on `wrap_comments`, which only nightly applies — the CI job runs `cargo +nightly fmt --all -- --check` for exactly that reason, and stable had nothing to say about these. Two lines are reworded rather than rewrapped: a doc comment that nightly would have split an identifier across, and one it would have split `dolos init` across. Co-Authored-By: Claude Opus 5 (1M context) --- crates/snapshot/src/registry.rs | 4 ++-- crates/snapshot/tests/registry_fixture/mod.rs | 3 ++- crates/snapshot/tests/restore_registry.rs | 4 ++-- crates/stelae/src/oci.rs | 9 +++++---- crates/stelae/tests/oci.rs | 10 ++++++---- 5 files changed, 17 insertions(+), 13 deletions(-) diff --git a/crates/snapshot/src/registry.rs b/crates/snapshot/src/registry.rs index 61e374efd..8ccb08605 100644 --- a/crates/snapshot/src/registry.rs +++ b/crates/snapshot/src/registry.rs @@ -254,8 +254,8 @@ pub fn open( /// enter a configuration file, so the environment is the only place they can /// come from — and a node that already carries the read-only pair must not /// have to have it removed before it can publish. -/// - **what is configured is the fallback**, which is what makes a fresh -/// `dolos init` pull from the official registry with nothing exported. +/// - **what is configured is the fallback**, which is what lets a node created +/// by `dolos init` pull from the official registry with nothing exported. /// - **neither is anonymous**, which is what a genuinely public repository /// wants and what a credentialed one answers with a 401. /// diff --git a/crates/snapshot/tests/registry_fixture/mod.rs b/crates/snapshot/tests/registry_fixture/mod.rs index 1324aaf36..ae6ce6286 100644 --- a/crates/snapshot/tests/registry_fixture/mod.rs +++ b/crates/snapshot/tests/registry_fixture/mod.rs @@ -225,7 +225,8 @@ pub fn auth_dir() -> tempfile::TempDir { dir } -/// The `docker run` arguments that make a registry demand [`USER`]/[`PASSWORD`]. +/// The `docker run` arguments that make a registry demand +/// [`USER`]/[`PASSWORD`]. /// /// **Both configurations, unconditionally, and no per-image branch.** The two /// server families this suite is pointed at read their auth from different diff --git a/crates/snapshot/tests/restore_registry.rs b/crates/snapshot/tests/restore_registry.rs index a58d8ad54..6a029483c 100644 --- a/crates/snapshot/tests/restore_registry.rs +++ b/crates/snapshot/tests/restore_registry.rs @@ -30,8 +30,8 @@ //! 5. **The pair in `dolos.toml` is what opens the repository.** The fixture's //! registry demands Basic credentials, and every restore above reaches it //! through `registry::open` with the pair as a node's *configured* one — so -//! the four properties are all evidence for this fifth. `a_node_authenticates_ -//! with_its_configured_pair` states it directly, from both sides. +//! the four properties are all evidence for this fifth. The test named for +//! it states the same thing directly, from both sides. //! //! ## Why the interruption is a layer boundary //! diff --git a/crates/stelae/src/oci.rs b/crates/stelae/src/oci.rs index 46eb447d0..2f6d9051e 100644 --- a/crates/stelae/src/oci.rs +++ b/crates/stelae/src/oci.rs @@ -197,10 +197,11 @@ impl Auth { /// The credentials the environment names, or [`Auth::Anonymous`] if it /// names none. /// - /// The grammar is this crate's because the variable names are: [`TOKEN_ENV`] - /// for a bearer token, [`USER_ENV`] and [`PASSWORD_ENV`] for a pair. An - /// empty value counts as unset, so `STELAE_REGISTRY_TOKEN=` in a stale - /// shell profile does not authenticate as the empty token. + /// The grammar is this crate's because the variable names are: + /// [`TOKEN_ENV`] for a bearer token, [`USER_ENV`] and [`PASSWORD_ENV`] + /// for a pair. An empty value counts as unset, so + /// `STELAE_REGISTRY_TOKEN=` in a stale shell profile does not + /// authenticate as the empty token. /// /// Two refusals, and both are operator mistakes worth a sentence rather /// than a rule: diff --git a/crates/stelae/tests/oci.rs b/crates/stelae/tests/oci.rs index 47e6e2814..aff2c0731 100644 --- a/crates/stelae/tests/oci.rs +++ b/crates/stelae/tests/oci.rs @@ -573,9 +573,10 @@ fn install_crypto_provider() { /// The credentials the fixture's registry demands. /// -/// A test credential, not a secret: it lives as long as one container. [`HTPASSWD`] -/// is the bcrypt encoding of this pair — `distribution` accepts no other hash -/// algorithm in an htpasswd file — so the two move together or not at all. +/// A test credential, not a secret: it lives as long as one container. +/// [`HTPASSWD`] is the bcrypt encoding of this pair — `distribution` accepts no +/// other hash algorithm in an htpasswd file — so the two move together or not +/// at all. const USER: &str = "stelae"; const PASSWORD: &str = "stelae-fixture"; @@ -769,7 +770,8 @@ fn auth_dir() -> tempfile::TempDir { dir } -/// The `docker run` arguments that make a registry demand [`USER`]/[`PASSWORD`]. +/// The `docker run` arguments that make a registry demand +/// [`USER`]/[`PASSWORD`]. /// /// **Both configurations, unconditionally, and no per-image branch.** The two /// server families this suite is pointed at read their auth from different From 304c5cc11219c0327bf46922759f6f2d6e697f91 Mon Sep 17 00:00:00 2001 From: Santiago Date: Sat, 8 Aug 2026 22:02:51 -0300 Subject: [PATCH 3/6] refactor(config): compile the registry password in, seed only the user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections to the shape `dolos init` writes. The generated `dolos.toml` no longer carries a commented-out template. Serde cannot emit comments, so it was appended after serialization — a mechanism this repo uses nowhere else, for a section the schema page already documents. And the password is no longer seeded into the file. `dolos init` writes `[stelae.registry]` with the official registry's `user` and nothing else; `StelaeRegistryConfig::password()` falls back to a compiled-in constant when the file names none. A password copied into every generated config is a password that has to be found again in every one of them, whereas a compiled-in default rotates with a release. A node pointing at a private registry sets `password` and keeps its own; the environment still overrides both. `OFFICIAL_REGISTRY_USER` and `OFFICIAL_REGISTRY_PASSWORD` are the two constants the registry deployment fills, still one site, still empty here — so today init writes no section at all rather than one naming nobody. With them set the file reads: [stelae.registry] user = "dolos" Co-Authored-By: Claude Opus 5 (1M context) --- adrs/004_stelae_snapshots.md | 11 ++- crates/core/src/config.rs | 75 +++++++++++----- crates/snapshot/src/registry.rs | 31 ++++++- crates/snapshot/tests/registry_fixture/mod.rs | 2 +- crates/snapshot/tests/restore_registry.rs | 2 +- docs/content/configuration/schema.mdx | 20 +++-- src/bin/dolos/init.rs | 86 +++++++------------ 7 files changed, 133 insertions(+), 94 deletions(-) diff --git a/adrs/004_stelae_snapshots.md b/adrs/004_stelae_snapshots.md index 20a2d3a7e..0a76f24b1 100644 --- a/adrs/004_stelae_snapshots.md +++ b/adrs/004_stelae_snapshots.md @@ -296,12 +296,15 @@ source = "oci://ghcr.io/txpipe/dolos-snapshots/mainnet" # new, takes precedence require_signatures = 0 # k-of-n enforcement trusted_keys = ["ed25519:…"] # mirrors mithril genesis_key style -[stelae.registry] # the registry's published read-only pair -user = "…" # seeded by `dolos init`; overridden by -password = "…" # STELAE_REGISTRY_USER/_PASSWORD +[stelae.registry] # who this node reads the registry as +user = "…" # seeded by `dolos init` +# password = "…" # optional; omitted means the official + # registry's, compiled into the binary ``` -The read-only pair is a **published secret and belongs in the file**: it is what makes stele distribution free and identity-less while still authenticated, and `dolos init` seeds it so a fresh node restores from the official registry with nothing exported. A publisher's full-access pair is a real secret and belongs in the environment or a secret manager, never here — which is why the environment overrides the file rather than the other way round, so a node carrying the read-only pair can publish without having it removed first. +The official registry's read-only password is a **published secret**: it is what makes stele distribution free and identity-less while still authenticated. It is compiled into the binary rather than seeded into the file, so `dolos init` writes a `user` and no password and a rotation reaches every node that takes a release, instead of having to be found again in every generated `dolos.toml`. A node pointing at a private registry sets `password` and gets its own. + +A publisher's full-access pair is a real secret and belongs in the environment or a secret manager, never in this file — which is why the environment overrides both the file and the compiled-in default, so a node carrying the read-only user can publish without being edited first. ### Publisher pipeline diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 263775651..9effd5187 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -699,26 +699,37 @@ pub struct SnapshotConfig { pub download_url: String, } -/// The official stele registry's published read-only credentials. +/// The official stele registry's published read-only user. +/// +/// Written into `dolos.toml` by `dolos init`, so a generated config says which +/// identity it reads the registry as. +/// +/// Empty until the registry that issues it exists. +pub const OFFICIAL_REGISTRY_USER: &str = ""; + +/// The password that goes with [`OFFICIAL_REGISTRY_USER`], compiled in rather +/// than written to `dolos.toml`. /// /// **Deliberately a published secret**, and the only one this project has: /// stele distribution is free and identity-less, but never unrestricted — the -/// registry authenticates every request, and the pair below is what a consumer -/// authenticates with. It is seeded into `dolos.toml` by `dolos init` so that a -/// fresh node pulls from the official registry out of the box; the pair -/// therefore gates out-of-band tooling and nothing else. +/// registry authenticates every request, and this is what a consumer +/// authenticates with. So it gates out-of-band tooling and nothing else. /// -/// Empty until the registry that issues it exists. Filling it is a one-line -/// change *here* and nowhere else, which is the reason this constant is a -/// constant rather than a literal at the seeding site. -pub const OFFICIAL_REGISTRY_CREDENTIALS: Option<(&str, &str)> = None; +/// Compiled in rather than seeded into the file because a password copied into +/// every generated `dolos.toml` is a password that has to be found again in +/// every one of them. Here, a rotation reaches every node that takes the +/// release; a node that overrode it in its own config keeps its override. +/// +/// Empty until the registry exists. Filling both constants is a two-line change +/// *here* and nowhere else, which is why they are constants rather than +/// literals at the sites that use them. +pub const OFFICIAL_REGISTRY_PASSWORD: &str = ""; /// `[stelae]` — how this node reaches a stele registry. /// -/// One section carrying one credential pair, and that is the whole of the -/// consumer surface: a node restoring from a stele repository needs to -/// authenticate, and nothing more about a registry belongs in a node's -/// configuration. +/// One section carrying one credential, and that is the whole of the consumer +/// surface: a node restoring from a stele repository needs to authenticate, and +/// nothing more about a registry belongs in a node's configuration. #[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)] pub struct StelaeConfig { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -726,17 +737,17 @@ pub struct StelaeConfig { } impl StelaeConfig { - /// What `dolos init` seeds, from [`OFFICIAL_REGISTRY_CREDENTIALS`]. + /// What `dolos init` seeds: the official registry's user, and no password. /// - /// Empty while that constant is, which is why it is a constructor rather - /// than a `Default` impl: a caller reading this name knows it is asking for - /// the official registry specifically, and gets the honest answer when - /// there is not one yet. + /// Empty while [`OFFICIAL_REGISTRY_USER`] is, which is why it is a + /// constructor rather than a `Default` impl: a caller reading this name + /// knows it is asking for the official registry specifically, and gets the + /// honest answer when there is not one yet. pub fn official() -> Self { Self { - registry: OFFICIAL_REGISTRY_CREDENTIALS.map(|(user, password)| StelaeRegistryConfig { - user: user.to_owned(), - password: password.to_owned(), + registry: (!OFFICIAL_REGISTRY_USER.is_empty()).then(|| StelaeRegistryConfig { + user: OFFICIAL_REGISTRY_USER.to_owned(), + password: None, }), } } @@ -752,12 +763,30 @@ impl StelaeConfig { /// in the environment (`STELAE_REGISTRY_USER` / `STELAE_REGISTRY_PASSWORD`) or /// in a secret manager, never in a file that gets committed alongside a node's /// other settings — and the environment overrides what is here, so a publisher -/// running on a node that carries the read-only pair does not have to remove it +/// running on a node that carries the read-only user does not have to remove it /// first. #[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct StelaeRegistryConfig { pub user: String, - pub password: String, + + /// Omitted by `dolos init`, and by anything reading the official registry: + /// see [`StelaeRegistryConfig::password`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password: Option, +} + +impl StelaeRegistryConfig { + /// The password to send: the one configured, or + /// [`OFFICIAL_REGISTRY_PASSWORD`]. + /// + /// The fallback is what lets a generated config name a user and no secret. + /// A private registry sets `password` and gets its own; the official one is + /// answered by the binary. + pub fn password(&self) -> &str { + self.password + .as_deref() + .unwrap_or(OFFICIAL_REGISTRY_PASSWORD) + } } /// Names the user and never the password. diff --git a/crates/snapshot/src/registry.rs b/crates/snapshot/src/registry.rs index 8ccb08605..104f85c8e 100644 --- a/crates/snapshot/src/registry.rs +++ b/crates/snapshot/src/registry.rs @@ -272,7 +272,10 @@ pub fn auth(configured: Option<&StelaeRegistryConfig>) -> Result { Ok(match configured { Some(credentials) => Auth::Basic { user: credentials.user.clone(), - password: credentials.password.clone(), + // Through the accessor, not the field: a config that names a user + // and no password means the official registry's, which is compiled + // in rather than written into every generated `dolos.toml`. + password: credentials.password().to_owned(), }, None => Auth::Anonymous, }) @@ -1002,10 +1005,34 @@ mod tests { fn configured() -> StelaeRegistryConfig { StelaeRegistryConfig { user: "dolos-reader".to_owned(), - password: "published".to_owned(), + password: Some("published".to_owned()), } } + /// A section naming a user and no password authenticates with the + /// compiled-in one. + /// + /// This is the shape `dolos init` writes, so it is the shape the official + /// registry is actually reached in: the file says who, the binary says + /// with what. + #[test] + fn a_seeded_user_takes_the_compiled_in_password() { + let seeded = StelaeRegistryConfig { + user: "dolos-reader".to_owned(), + password: None, + }; + + with_env(None, None, None, || { + assert_eq!( + auth(Some(&seeded)).unwrap(), + Auth::Basic { + user: "dolos-reader".to_owned(), + password: dolos_core::config::OFFICIAL_REGISTRY_PASSWORD.to_owned(), + } + ); + }); + } + /// The restore path: the pair `dolos init` seeded is what a client /// authenticates with when nothing is exported. #[test] diff --git a/crates/snapshot/tests/registry_fixture/mod.rs b/crates/snapshot/tests/registry_fixture/mod.rs index ae6ce6286..b7e86656e 100644 --- a/crates/snapshot/tests/registry_fixture/mod.rs +++ b/crates/snapshot/tests/registry_fixture/mod.rs @@ -35,7 +35,7 @@ const HTPASSWD: &str = "stelae:$2y$05$1Hb22zONvzLAj4WaYl34/uDWF5rDgQkS9MoewgRvsT pub fn credentials() -> StelaeRegistryConfig { StelaeRegistryConfig { user: USER.to_owned(), - password: PASSWORD.to_owned(), + password: Some(PASSWORD.to_owned()), } } diff --git a/crates/snapshot/tests/restore_registry.rs b/crates/snapshot/tests/restore_registry.rs index 6a029483c..bb59c28e3 100644 --- a/crates/snapshot/tests/restore_registry.rs +++ b/crates/snapshot/tests/restore_registry.rs @@ -552,7 +552,7 @@ fn a_node_authenticates_with_its_configured_pair() { // against a registry that merely did not recognise it. let wrong = dolos_core::config::StelaeRegistryConfig { user: registry_fixture::USER.to_owned(), - password: "not-the-password".to_owned(), + password: Some("not-the-password".to_owned()), }; for (who, credentials) in [("no credentials", None), ("the wrong pair", Some(&wrong))] { diff --git a/docs/content/configuration/schema.mdx b/docs/content/configuration/schema.mdx index 2910203b1..5b2be59fa 100644 --- a/docs/content/configuration/schema.mdx +++ b/docs/content/configuration/schema.mdx @@ -74,7 +74,6 @@ download_url = "https://example.com/snapshot.tar.zst" [stelae.registry] user = "dolos" -password = "published-read-only-credential" ``` @@ -409,17 +408,20 @@ repositories `dolos bootstrap stelae --source oci://…` restores from and | property | type | example | | -------- | ------ | ------- | | user | string | "dolos" | -| password | string | "published-read-only-credential" | +| password | string | "a-private-registry-credential" | -- `user`, `password`: the credentials the registry is read with, sent as HTTP Basic. +- `user`: the identity the registry is read as, sent as HTTP Basic. +- `password`: optional. Omitted, Dolos uses the official registry's published + read-only password, which is compiled into the binary. A registry may charge nothing for reads and still refuse an unidentified one. The -pair that belongs here is the **published read-only credential** such a registry -hands out: it is not a secret, it is what makes free, identity-less access -possible without leaving the registry unrestricted. `dolos init` seeds it, so a -freshly initialized node restores from the official registry without an operator -having to find a credential first. Omit the section entirely to connect -anonymously, which is what a genuinely public repository wants. +credential the official registry hands out is not a secret — it is what makes +free, identity-less access possible without leaving the registry unrestricted — +but it is not written into your config either: `dolos init` seeds the `user` and +leaves the password to the binary, so a rotation reaches every node that takes a +release instead of having to be found again in every generated file. Set +`password` to point at a registry of your own. Omit the section entirely to +connect anonymously, which is what a genuinely public repository wants. ### Registry credentials in the environment diff --git a/src/bin/dolos/init.rs b/src/bin/dolos/init.rs index bf3f30ec4..f490a1ea3 100644 --- a/src/bin/dolos/init.rs +++ b/src/bin/dolos/init.rs @@ -711,14 +711,10 @@ impl ConfigEditor { } fn save(self, path: &Path) -> miette::Result<()> { - let mut config = toml::to_string_pretty(&self.0) + let config = toml::to_string_pretty(&self.0) .into_diagnostic() .context("serializing config toml")?; - if self.0.stelae.registry.is_none() { - config.push_str(STELAE_REGISTRY_TEMPLATE); - } - std::fs::write(path, config) .into_diagnostic() .context("saving config file")?; @@ -727,29 +723,6 @@ impl ConfigEditor { } } -/// The stelae registry section, written commented out when there is no pair to -/// seed. -/// -/// A comment rather than nothing. The section is how a node authenticates -/// against a stele registry, and an operator pointing `dolos bootstrap stelae` -/// at one — the official registry before its credentials ship, or a private one -/// — should find the shape of it in the file they already have. Serde cannot -/// emit a comment, so this is appended after serialization; it is written only -/// when the section itself is absent, so it never sits next to a real one -/// contradicting it. -const STELAE_REGISTRY_TEMPLATE: &str = "\ -# Credentials for the stele registry `dolos bootstrap stelae` reads from. -# Registries that charge nothing for reads may still refuse an unidentified -# one; this is the published read-only pair such a registry hands out. The -# environment overrides it — STELAE_REGISTRY_USER and STELAE_REGISTRY_PASSWORD, -# or STELAE_REGISTRY_TOKEN for a registry that issues bearer tokens — which is -# where a publisher's own full-access credentials belong. -# -# [stelae.registry] -# user = \"\" -# password = \"\" -"; - pub fn run( config: miette::Result, args: &Args, @@ -785,16 +758,16 @@ pub fn run( mod tests { use super::*; - /// A freshly initialized node carries the stelae registry section. + /// A freshly initialized node carries the stelae registry section the + /// official registry needs, and no password. /// - /// Two shapes, one test, because which one is written depends on a - /// constant: the section itself once `OFFICIAL_REGISTRY_CREDENTIALS` is - /// filled, the commented template until then. Both have to parse — a - /// generated `dolos.toml` that `dolos daemon` cannot read is worse than one - /// that says nothing about registries — and this is what will still be true - /// on the day the constant is filled in. + /// The section appears once `OFFICIAL_REGISTRY_USER` names one; until then + /// there is no user to write and the file says nothing. Either way the + /// generated config has to parse and to round-trip to exactly what + /// `StelaeConfig::official()` is, which is what will still hold on the day + /// the constants are filled in. #[test] - fn a_fresh_config_carries_the_stelae_registry_section() { + fn a_fresh_config_seeds_the_official_registry_and_no_password() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("dolos.toml"); @@ -803,22 +776,24 @@ mod tests { let written = std::fs::read_to_string(&path).unwrap(); println!("{written}"); - assert!(written.contains("[stelae.registry]"), "{written}"); - - // The environment is named beside it either way: it is where a - // publisher's credentials go, and the section is where an operator - // looks for that fact. - assert!(written.contains("STELAE_REGISTRY_USER"), "{written}"); + // The password is compiled in, so a generated config never carries one + // however the constants are set. + assert!(!written.contains("password"), "{written}"); let parsed: RootConfig = toml::from_str(&written).expect("the generated config parses"); assert_eq!(parsed.stelae, StelaeConfig::official()); + + if let Some(registry) = &parsed.stelae.registry { + assert_eq!(registry.user, dolos_core::config::OFFICIAL_REGISTRY_USER); + assert_eq!(registry.password, None); + } } - /// A config that already carries a pair keeps it, and gets no commented - /// template contradicting it. + /// A password an operator wrote is kept; one they did not is the official + /// registry's, out of the binary. #[test] - fn a_configured_pair_is_written_rather_than_commented() { - use dolos_core::config::StelaeRegistryConfig; + fn a_configured_password_overrides_the_compiled_in_one() { + use dolos_core::config::{StelaeRegistryConfig, OFFICIAL_REGISTRY_PASSWORD}; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("dolos.toml"); @@ -826,23 +801,26 @@ mod tests { let mut editor = ConfigEditor::default(); editor.0.stelae.registry = Some(StelaeRegistryConfig { user: "dolos".to_owned(), - password: "published".to_owned(), + password: Some("a-private-registry".to_owned()), }); editor.save(&path).unwrap(); let written = std::fs::read_to_string(&path).unwrap(); - assert!(written.contains("[stelae.registry]"), "{written}"); - assert!( - !written.contains("# [stelae.registry]"), - "the template was written next to a real section: {written}" - ); let parsed: RootConfig = toml::from_str(&written).unwrap(); - let registry = parsed.stelae.registry.expect("the pair round-trips"); + let registry = parsed.stelae.registry.expect("the section round-trips"); assert_eq!(registry.user, "dolos"); - assert_eq!(registry.password, "published"); + assert_eq!(registry.password(), "a-private-registry"); + + // And the same user with the password left out falls back. + let defaulted = StelaeRegistryConfig { + user: "dolos".to_owned(), + password: None, + }; + + assert_eq!(defaulted.password(), OFFICIAL_REGISTRY_PASSWORD); } } From 2507ee1e374ae7a85e8c3c3863e3a2a9acd7b58d Mon Sep 17 00:00:00 2001 From: Santiago Date: Sun, 9 Aug 2026 10:08:31 -0300 Subject: [PATCH 4/6] refactor(stelae): stop the protocol crate sourcing credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stelae` had no business naming an environment variable. A protocol library that reads one is choosing its host's credential policy, and publishing the name freezes that choice into its API: every program embedding the transport inherits three `STELAE_*` variables it never asked for and cannot rename. The justification in the code was circular — the crate owned the names because they started with STELAE. So `TOKEN_ENV`, `USER_ENV`, `PASSWORD_ENV`, `Auth::from_env` and the two refusal variants leave `stelae` entirely; the crate no longer references `std::env` at all. What stays is the interface: `Options::auth` takes an `Auth` the caller constructs. `dolos_snapshot::registry::open` takes an `Auth` too rather than a config type, so the profile crate does not source credentials either. The names, the precedence and both refusals land in `dolos::common::stele_registry_auth` — the binary whose deployment they actually describe. It reads `&StelaeConfig` and the environment, and the refusals become plain miette errors, which is also why the CLI now says: Error: × STELAE_REGISTRY_TOKEN and STELAE_REGISTRY_USER/… are both set; … rather than the same sentence three times through two error types. ADR-004 follows: the protocol section states that credentials arrive from the caller and names no variable and no configuration key. The variable table moves to "CLI and configuration", where it is Dolos's answer rather than the format's. Co-Authored-By: Claude Opus 5 (1M context) --- adrs/004_stelae_snapshots.md | 20 +- crates/snapshot/src/registry.rs | 220 +------------ crates/snapshot/tests/registry_fixture/mod.rs | 23 +- crates/snapshot/tests/restore_registry.rs | 41 +-- crates/stelae/src/lib.rs | 31 -- crates/stelae/src/oci.rs | 232 ++------------ src/bin/dolos/bootstrap/stelae.rs | 9 +- src/bin/dolos/common.rs | 290 +++++++++++++++++- src/bin/dolos/snapshot/publish.rs | 6 +- 9 files changed, 381 insertions(+), 491 deletions(-) diff --git a/adrs/004_stelae_snapshots.md b/adrs/004_stelae_snapshots.md index 0a76f24b1..b46ed367a 100644 --- a/adrs/004_stelae_snapshots.md +++ b/adrs/004_stelae_snapshots.md @@ -240,14 +240,7 @@ The arithmetic is counted in layers, because layers are what the ceiling counts: #### What the transport requires of its host - **A process that opens a registry client must have installed a process-default rustls `CryptoProvider` first.** The transport ships no crypto backend of its own (`reqwest/rustls-no-provider`): the backend the client library would otherwise pick, `aws-lc-rs`, wants `cmake` on every build machine — the dependency this workspace already goes out of its way to avoid — so it stays out of the tree and the choice of provider moves to the program. In Dolos, `main()` installs `ring`. Omitting the install is a panic when the registry client opens, not a link error. -- **Authentication is the host's decision, in one of three shapes.** The client is opened with credentials its caller supplies — anonymous, a bearer token, or an HTTP Basic pair — and never goes looking on its own: which identity a program authenticates as is that program's policy. What the protocol owns is the environment grammar, because it owns the variable names: - - | Variable | Shape | - | --- | --- | - | `STELAE_REGISTRY_TOKEN` | bearer token | - | `STELAE_REGISTRY_USER` + `STELAE_REGISTRY_PASSWORD` | Basic pair | - - An empty value is unset. A token and a pair set together is a **refusal**, not a precedence rule, and so is half a pair: an operator who exported both meant one of them, and a client that guessed would authenticate as an identity nobody chose — which on a registry whose credentials carry different capabilities is the difference between a publish and a 403 nobody can explain. +- **Authentication is the host's decision, in one of three shapes.** The client is opened with credentials its caller supplies — anonymous, a bearer token, or an HTTP Basic pair — and never sources them itself. Which identity a program authenticates as is that program's credential policy, and where it keeps its credentials is that program's deployment: a protocol library that read an environment variable would be deciding both on its host's behalf, and naming the variable would freeze that decision into a published API. **So this specification names no environment variable and no configuration key**, and `stelae::oci::Options::auth` is the whole of the interface. Dolos's own answer is under "CLI and configuration" below. Anonymous remains legitimate and is what a genuinely public repository wants. It is not what a registry that authenticates every request wants, and that is the deployment Dolos is heading for: read access to a stele repository is free and identity-less, and still credentialed. @@ -304,7 +297,16 @@ user = "…" # seeded by `dolos init` The official registry's read-only password is a **published secret**: it is what makes stele distribution free and identity-less while still authenticated. It is compiled into the binary rather than seeded into the file, so `dolos init` writes a `user` and no password and a rotation reaches every node that takes a release, instead of having to be found again in every generated `dolos.toml`. A node pointing at a private registry sets `password` and gets its own. -A publisher's full-access pair is a real secret and belongs in the environment or a secret manager, never in this file — which is why the environment overrides both the file and the compiled-in default, so a node carrying the read-only user can publish without being edited first. +A publisher's full-access pair is a real secret and belongs in the environment or a secret manager, never in this file, so `dolos` reads three variables of its own: + +| Variable | Shape | +| --- | --- | +| `STELAE_REGISTRY_TOKEN` | bearer token | +| `STELAE_REGISTRY_USER` + `STELAE_REGISTRY_PASSWORD` | Basic pair | + +An empty value is unset. The environment overrides both the file and the compiled-in default, so a node carrying the read-only user can publish without being edited first. A token and a pair set together is a **refusal**, not a precedence rule, and so is half a pair: an operator who exported both meant one of them, and a client that guessed would authenticate as an identity nobody chose — which on a registry whose credentials carry different capabilities is the difference between a publish and a 403 nobody can explain. + +These are Dolos's variables and Dolos's rule, resolved in `dolos::common::stele_registry_auth`, which hands the answer to the transport as a value. Another host embedding `stelae` names its own, or none. ### Publisher pipeline diff --git a/crates/snapshot/src/registry.rs b/crates/snapshot/src/registry.rs index 104f85c8e..58491146b 100644 --- a/crates/snapshot/src/registry.rs +++ b/crates/snapshot/src/registry.rs @@ -80,17 +80,17 @@ //! ## Who the client authenticates as //! //! A registry that charges nothing for reads may still refuse an unidentified -//! one, so both directions carry credentials, from two different places: a -//! publisher's full-access pair out of the environment, a consumer's published -//! read-only pair out of `dolos.toml`. [`auth`] is the one place that resolves -//! them, in one rule, for both directions. +//! one, so both directions carry credentials — and this module takes them as an +//! argument rather than finding them. Which identity a Dolos node authenticates +//! as is the node's policy, not the profile's: the `dolos` binary reads its own +//! configuration and its own environment and hands the answer to [`open`]. use std::{cell::Cell, collections::BTreeMap}; -use dolos_core::{config::StelaeRegistryConfig, ArchiveStore, IndexStore, StateStore}; +use dolos_core::{ArchiveStore, IndexStore, StateStore}; use stelae::{ inscription::{HistoryEntry, Inscription, LayerDescriptor}, - oci::{Auth, Options, Registry, Stele, Transfer}, + oci::{Options, Registry, Stele, Transfer}, Digest, SteleReader as _, }; @@ -101,7 +101,11 @@ use stelae::{ /// usable — the distribution grammar lives with the client that defines it. /// The profile is the only thing in `dolos` that reaches into `stelae`, here as /// everywhere else. -pub use stelae::oci::{Repository, SCHEME}; +/// +/// [`Auth`] rides along for the same reason: a host resolving its own +/// credentials should not have to name the protocol crate to say what it +/// resolved them to. +pub use stelae::oci::{Auth, Repository, SCHEME}; use crate::{ export::{self, Plan, Predecessor}, @@ -219,68 +223,25 @@ where /// or a mirror inside a cluster, and for nothing that is reachable from outside /// one. /// -/// `configured` is the read-only pair a node carries in `[stelae.registry]`, -/// which the environment overrides — see [`auth`]. +/// `auth` is who to authenticate as, decided by the caller. A node resolves it +/// from its own configuration and environment; nothing here goes looking, for +/// the reason `stelae::oci` states one layer down and this crate has no more +/// standing to override than that one does. /// /// **Never call any of this from inside an async context.** The transport owns /// a current-thread runtime and enters it with `block_on`; `stelae::oci`'s /// module documentation states the rule and the reason. -pub fn open( - repository: &Repository, - insecure: bool, - configured: Option<&StelaeRegistryConfig>, -) -> Result { +pub fn open(repository: &Repository, insecure: bool, auth: Auth) -> Result { Ok(Registry::open( repository, Options { insecure, scratch_dir: None, - auth: auth(configured)?, + auth, }, )?) } -/// The credentials a registry client authenticates with: the environment's, or -/// the node's configured pair, or nobody. -/// -/// **One resolution for both directions**, and that is the point. A publish -/// takes its full-access pair from `STELAE_REGISTRY_USER` / -/// `STELAE_REGISTRY_PASSWORD` (or a bearer token from -/// `STELAE_REGISTRY_TOKEN`); a restore takes the published read-only pair from -/// `[stelae.registry]` in `dolos.toml`. Those are two *sources*, not two rules, -/// and the rule is: -/// -/// - **the environment wins.** A publisher's credentials are a secret and never -/// enter a configuration file, so the environment is the only place they can -/// come from — and a node that already carries the read-only pair must not -/// have to have it removed before it can publish. -/// - **what is configured is the fallback**, which is what lets a node created -/// by `dolos init` pull from the official registry with nothing exported. -/// - **neither is anonymous**, which is what a genuinely public repository -/// wants and what a credentialed one answers with a 401. -/// -/// The environment naming two kinds of credential at once is a refusal rather -/// than a third precedence rule; `stelae::oci::Auth::from_env` raises it and -/// says which variables to unset. -pub fn auth(configured: Option<&StelaeRegistryConfig>) -> Result { - let from_env = Auth::from_env()?; - - if !from_env.is_anonymous() { - return Ok(from_env); - } - - Ok(match configured { - Some(credentials) => Auth::Basic { - user: credentials.user.clone(), - // Through the accessor, not the field: a config that names a user - // and no password means the official registry's, which is compiled - // in rather than written into every generated `dolos.toml`. - password: credentials.password().to_owned(), - }, - None => Auth::Anonymous, - }) -} - /// What a publish into a repository did. #[derive(Debug, Clone)] pub struct Published { @@ -954,151 +915,4 @@ mod tests { scope, } } - - // ----------------------------------------------------------------------- - // Which credentials a client ends up with - // ----------------------------------------------------------------------- - - /// The environment is process-wide, so these run one at a time. - static ENV: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - /// Run `body` with exactly these registry variables set, and put the - /// process environment back afterwards. - fn with_env( - token: Option<&str>, - user: Option<&str>, - password: Option<&str>, - body: impl FnOnce() -> T, - ) -> T { - use stelae::oci::{PASSWORD_ENV, TOKEN_ENV, USER_ENV}; - - let _guard = ENV.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); - - let previous: Vec<(&str, Option)> = [TOKEN_ENV, USER_ENV, PASSWORD_ENV] - .into_iter() - .map(|name| (name, std::env::var(name).ok())) - .collect(); - - for (name, value) in [ - (TOKEN_ENV, token), - (USER_ENV, user), - (PASSWORD_ENV, password), - ] { - match value { - Some(value) => std::env::set_var(name, value), - None => std::env::remove_var(name), - } - } - - let outcome = body(); - - for (name, value) in previous { - match value { - Some(value) => std::env::set_var(name, value), - None => std::env::remove_var(name), - } - } - - outcome - } - - fn configured() -> StelaeRegistryConfig { - StelaeRegistryConfig { - user: "dolos-reader".to_owned(), - password: Some("published".to_owned()), - } - } - - /// A section naming a user and no password authenticates with the - /// compiled-in one. - /// - /// This is the shape `dolos init` writes, so it is the shape the official - /// registry is actually reached in: the file says who, the binary says - /// with what. - #[test] - fn a_seeded_user_takes_the_compiled_in_password() { - let seeded = StelaeRegistryConfig { - user: "dolos-reader".to_owned(), - password: None, - }; - - with_env(None, None, None, || { - assert_eq!( - auth(Some(&seeded)).unwrap(), - Auth::Basic { - user: "dolos-reader".to_owned(), - password: dolos_core::config::OFFICIAL_REGISTRY_PASSWORD.to_owned(), - } - ); - }); - } - - /// The restore path: the pair `dolos init` seeded is what a client - /// authenticates with when nothing is exported. - #[test] - fn a_configured_pair_is_used_when_the_environment_is_silent() { - with_env(None, None, None, || { - assert_eq!( - auth(Some(&configured())).unwrap(), - Auth::Basic { - user: "dolos-reader".to_owned(), - password: "published".to_owned(), - } - ); - - // And a node that configured nothing stays anonymous rather than - // inventing an identity. - assert_eq!(auth(None).unwrap(), Auth::Anonymous); - }); - } - - /// The publish path, and the precedence that makes it work on a node that - /// already carries the read-only pair. - #[test] - fn the_environment_overrides_a_configured_pair() { - with_env(None, Some("publisher"), Some("full-access"), || { - assert_eq!( - auth(Some(&configured())).unwrap(), - Auth::Basic { - user: "publisher".to_owned(), - password: "full-access".to_owned(), - }, - "a publisher must not have to strip the read-only pair out of \ - dolos.toml before it can publish", - ); - }); - - // A bearer token overrides it too: the environment is the source, and - // which shape it names is the environment's business. - with_env(Some("ghp_x"), None, None, || { - assert_eq!( - auth(Some(&configured())).unwrap(), - Auth::Bearer("ghp_x".to_owned()) - ); - }); - } - - /// The refusal reaches this far rather than being resolved on the way. - /// - /// A configured pair is not a tie-breaker for an ambiguous environment: an - /// operator who exported both a token and a pair gets told so, whatever is - /// in `dolos.toml`. - #[test] - fn an_ambiguous_environment_is_refused_even_with_a_configured_pair() { - with_env( - Some("ghp_x"), - Some("publisher"), - Some("full-access"), - || { - for configured in [None, Some(configured())] { - let err = auth(configured.as_ref()).unwrap_err(); - - assert!( - matches!(err, Error::Stelae(stelae::Error::AmbiguousRegistryAuth)), - "{err:?}" - ); - } - }, - ); - } } diff --git a/crates/snapshot/tests/registry_fixture/mod.rs b/crates/snapshot/tests/registry_fixture/mod.rs index b7e86656e..b276c7f1e 100644 --- a/crates/snapshot/tests/registry_fixture/mod.rs +++ b/crates/snapshot/tests/registry_fixture/mod.rs @@ -16,9 +16,8 @@ // binary does not reach look dead to it. They are not. #![allow(dead_code)] -use dolos_core::config::StelaeRegistryConfig; use dolos_snapshot::registry; -use stelae::oci::Registry; +use stelae::oci::{Auth, Registry}; /// The credentials the fixture's registry demands, and the ones these suites /// hand `registry::open` as a node's configured pair. @@ -31,11 +30,11 @@ pub const PASSWORD: &str = "stelae-fixture"; const HTPASSWD: &str = "stelae:$2y$05$1Hb22zONvzLAj4WaYl34/uDWF5rDgQkS9MoewgRvsTlsNrusMYTW6\n"; -/// The node-side half of the same pair. -pub fn credentials() -> StelaeRegistryConfig { - StelaeRegistryConfig { +/// The same pair, as the value a host hands `registry::open`. +pub fn credentials() -> Auth { + Auth::Basic { user: USER.to_owned(), - password: Some(PASSWORD.to_owned()), + password: PASSWORD.to_owned(), } } @@ -185,21 +184,19 @@ impl Fixture { /// layers and the transfer counters live in the transport, and a test /// comparing two publishes wants two of them. /// - /// Through `registry::open` with the pair as a *configured* one, so these - /// suites exercise the path a restoring node takes — credentials off - /// `[stelae.registry]` in `dolos.toml` — rather than a transport assembled - /// beside it. + /// Through `registry::open`, credentials and all, so these suites exercise + /// the call a node makes rather than a transport assembled beside it. pub fn repository(&self, name: &str) -> Registry { - self.repository_as(name, Some(&credentials())) + self.repository_as(name, credentials()) } /// The same, with whatever credentials a caller wants to try. - pub fn repository_as(&self, name: &str, configured: Option<&StelaeRegistryConfig>) -> Registry { + pub fn repository_as(&self, name: &str, auth: Auth) -> Registry { let repository = format!("oci://127.0.0.1:{}/{name}", self.port) .parse() .expect("the fixture named a usable repository"); - registry::open(&repository, true, configured).unwrap() + registry::open(&repository, true, auth).unwrap() } } diff --git a/crates/snapshot/tests/restore_registry.rs b/crates/snapshot/tests/restore_registry.rs index bb59c28e3..37781094e 100644 --- a/crates/snapshot/tests/restore_registry.rs +++ b/crates/snapshot/tests/restore_registry.rs @@ -27,11 +27,12 @@ //! 4. **A point names a stele.** `epoch-N` resolves to that sequence and //! `latest` to the newest, which is what makes a repository holding a //! history restorable at any of them. -//! 5. **The pair in `dolos.toml` is what opens the repository.** The fixture's -//! registry demands Basic credentials, and every restore above reaches it -//! through `registry::open` with the pair as a node's *configured* one — so -//! the four properties are all evidence for this fifth. The test named for -//! it states the same thing directly, from both sides. +//! 5. **Credentials are what opens the repository.** The fixture's registry +//! demands Basic credentials, and every restore above reaches it through +//! `registry::open` carrying them — so the four properties are all evidence +//! for this fifth. The test named for it states the same thing directly, +//! from both sides. Where a node's credentials *come from* is the `dolos` +//! binary's decision and is tested there. //! //! ## Why the interruption is a layer boundary //! @@ -62,7 +63,7 @@ use registry_fixture::Fixture; use stelae::{ frame::Limits, inscription::LayerDescriptor, - oci::{Registry, Stele}, + oci::{Auth, Registry, Stele}, plan::RestoreProgress, transport::BlobIndex, Digest, LayerReader, Profile, SteleReader, @@ -519,43 +520,45 @@ fn a_point_that_names_no_stele_is_refused() { ); } -/// The credentials `[stelae.registry]` carries are what a restore authenticates -/// with — and a node carrying none does not get in. +/// The credentials handed to `registry::open` are what a restore authenticates +/// with — and a node handing none does not get in. /// /// This is the consumer half of the access policy the registry exists under: /// pulling a stele costs nothing and identifies nobody, and is still refused -/// without a credential. What a fresh `dolos init` seeds is exactly the pair -/// this test hands `registry::open`. +/// without a credential. Where a node's credentials come from is the `dolos` +/// binary's business and is tested there; what is tested here is that they +/// reach the wire and decide the outcome. /// /// The negative half doubles as the fixture's own honesty check: a registry /// this fixture did not manage to put behind htpasswd would run anonymous, and /// every other test in this file would pass unchanged. #[test] #[ignore] -fn a_node_authenticates_with_its_configured_pair() { +fn a_node_authenticates_with_the_credentials_it_was_given() { let fixture = Fixture::spawn(); let node = Node::build(); let repository = fixture.repository("dolos/credentialed"); node.publish(&repository, &node.first); - // With the pair a node carries in its configuration, the stele resolves. - let configured = registry_fixture::credentials(); - let reader = fixture.repository_as("dolos/credentialed", Some(&configured)); + let reader = fixture.repository_as("dolos/credentialed", registry_fixture::credentials()); let stele = Point::Latest.pull(&reader).unwrap(); - println!("with the configured pair: {stele:?}"); + println!("with the right credentials: {stele:?}"); - // Without it, and with the wrong one, the repository refuses — and the + // Without them, and with the wrong ones, the repository refuses — and the // refusal is an error rather than an empty repository. `latest` reading a // 401 as absence is what would let a publisher restart a history chain // against a registry that merely did not recognise it. - let wrong = dolos_core::config::StelaeRegistryConfig { + let wrong = Auth::Basic { user: registry_fixture::USER.to_owned(), - password: Some("not-the-password".to_owned()), + password: "not-the-password".to_owned(), }; - for (who, credentials) in [("no credentials", None), ("the wrong pair", Some(&wrong))] { + for (who, credentials) in [ + ("no credentials", Auth::Anonymous), + ("the wrong pair", wrong), + ] { let refused = fixture.repository_as("dolos/credentialed", credentials); let err = Point::Latest diff --git a/crates/stelae/src/lib.rs b/crates/stelae/src/lib.rs index d89255b3e..17717ee15 100644 --- a/crates/stelae/src/lib.rs +++ b/crates/stelae/src/lib.rs @@ -279,37 +279,6 @@ pub enum Error { #[error("{value:?} is not an OCI repository: {reason}")] InvalidRepository { value: String, reason: String }, - /// The environment names two sets of registry credentials at once. - /// - /// Refused rather than resolved by precedence. An operator who exported - /// both a bearer token and a Basic pair meant one of them, and a client - /// that quietly picked would authenticate as an identity nobody chose — - /// which, on a registry where the two credentials carry different - /// capabilities, is the difference between a publish and a 403 nobody can - /// explain. - #[cfg(feature = "oci")] - #[error( - "{} and {}/{} are both set; registry credentials come from one of the two and \ - which one was meant is not something to guess at — unset the one you did not mean", - crate::oci::TOKEN_ENV, - crate::oci::USER_ENV, - crate::oci::PASSWORD_ENV - )] - AmbiguousRegistryAuth, - - /// Half of a Basic credential pair. - /// - /// A user with no password (or the reverse) is a typo or a secret that - /// never reached the process. Sending the half that arrived would - /// authenticate as somebody the operator did not name, so it is refused - /// where it is read. - #[cfg(feature = "oci")] - #[error("{set} is set without {missing}; basic registry credentials are a pair")] - IncompleteRegistryAuth { - set: &'static str, - missing: &'static str, - }, - /// Anything the registry client reported. #[cfg(feature = "oci")] #[error("registry error: {0}")] diff --git a/crates/stelae/src/oci.rs b/crates/stelae/src/oci.rs index 2f6d9051e..59ea19e00 100644 --- a/crates/stelae/src/oci.rs +++ b/crates/stelae/src/oci.rs @@ -100,20 +100,19 @@ //! ## Authentication //! //! Anonymous, a bearer token, or a Basic credential pair — whichever the caller -//! puts in [`Options::auth`]. The transport does not go looking: which -//! credentials a program authenticates with is that program's policy, and a -//! transport that reached into the environment behind its caller's back would -//! be making that policy for it. +//! puts in [`Options::auth`]. That is the whole of it: [`Auth`] is a value the +//! caller constructs and hands over. //! -//! What the transport *does* own is the environment grammar, because it owns -//! the variable names: [`Auth::from_env`] reads [`TOKEN_ENV`] and the -//! [`USER_ENV`]/[`PASSWORD_ENV`] pair and answers with the same value a caller -//! would otherwise assemble. A host that wants credentials from the environment -//! calls it; a host that reads them from a configuration file does not. +//! **Where those credentials came from is not this crate's business, and it has +//! no way to ask.** A protocol library that read an environment variable would +//! be deciding its host's credential policy for it, and naming the variable +//! would freeze that decision into a published API — a program embedding this +//! transport gets no say in either. So a host reads its own environment, its +//! own configuration file, its own secret manager, or all three in whatever +//! order it has decided, and the answer arrives here as an [`Auth`]. //! -//! Both shapes set at once is a **refusal**, not a precedence rule. An operator -//! who exported a token and a pair meant one of them, and a transport that -//! silently picked would authenticate as an identity nobody chose. +//! In Dolos that host is the `dolos` binary; `dolos::common` holds the +//! variables and the precedence between them. use std::{ collections::BTreeMap, @@ -145,22 +144,14 @@ use crate::{ Digest, Error, ARTIFACT_TYPE, INSCRIPTION_MEDIA_TYPE, MANIFEST_SIZE_LIMIT, }; -/// Environment variable holding a bearer token for the registry. -/// -/// Read by [`Auth::from_env`], and by nothing else in this crate. -pub const TOKEN_ENV: &str = "STELAE_REGISTRY_TOKEN"; - -/// Environment variable holding the user half of a Basic credential pair. -pub const USER_ENV: &str = "STELAE_REGISTRY_USER"; - -/// Environment variable holding the password half of a Basic credential pair. -pub const PASSWORD_ENV: &str = "STELAE_REGISTRY_PASSWORD"; - /// How a [`Registry`] authenticates. /// /// The three shapes `oci-client` implements, named here rather than re-exported /// so that a caller assembling credentials does not have to depend on the -/// registry client this transport happens to be built on. +/// registry client this transport happens to be built on. Constructing one is +/// the caller's whole side of the arrangement: this crate never sources +/// credentials, so there is no `from_env` here and no variable name for a host +/// to inherit. #[derive(Clone, Default, PartialEq, Eq)] pub enum Auth { /// No credentials. What a genuinely public repository wants, and what a @@ -194,60 +185,11 @@ impl std::fmt::Debug for Auth { } impl Auth { - /// The credentials the environment names, or [`Auth::Anonymous`] if it - /// names none. - /// - /// The grammar is this crate's because the variable names are: - /// [`TOKEN_ENV`] for a bearer token, [`USER_ENV`] and [`PASSWORD_ENV`] - /// for a pair. An empty value counts as unset, so - /// `STELAE_REGISTRY_TOKEN=` in a stale shell profile does not - /// authenticate as the empty token. - /// - /// Two refusals, and both are operator mistakes worth a sentence rather - /// than a rule: - /// - /// - **a token and a pair together** — two answers to one question, and - /// which was meant is not something to guess at; - /// - **half a pair** — a user with no password, or the other way round, is - /// a typo or a secret that failed to reach the process, and sending the - /// half that arrived would authenticate as somebody the operator did not - /// name. - pub fn from_env() -> Result { - let read = |name: &str| match std::env::var(name) { - Ok(value) if !value.is_empty() => Some(value), - _ => None, - }; - - let token = read(TOKEN_ENV); - let user = read(USER_ENV); - let password = read(PASSWORD_ENV); - - if token.is_some() && (user.is_some() || password.is_some()) { - return Err(Error::AmbiguousRegistryAuth); - } - - match (user, password) { - (Some(user), Some(password)) => Ok(Self::Basic { user, password }), - (Some(_), None) => Err(Error::IncompleteRegistryAuth { - set: USER_ENV, - missing: PASSWORD_ENV, - }), - (None, Some(_)) => Err(Error::IncompleteRegistryAuth { - set: PASSWORD_ENV, - missing: USER_ENV, - }), - (None, None) => Ok(match token { - Some(token) => Self::Bearer(token), - None => Self::Anonymous, - }), - } - } - /// Whether these credentials name anybody. /// - /// The question a caller layering sources asks — "did the environment say - /// anything, or should I fall back to what was configured?" — so it is - /// answered here rather than by every caller matching on the variant. + /// The question a host layering credential sources asks — "did that one say + /// anything, or do I fall through to the next?" — so it is answered here + /// rather than by every host matching on the variant. pub fn is_anonymous(&self) -> bool { matches!(self, Self::Anonymous) } @@ -340,13 +282,11 @@ pub struct Options { /// volume with room for sixteen of them. pub scratch_dir: Option, - /// How to authenticate, decided by the caller. + /// How to authenticate, decided entirely by the caller. /// - /// Defaults to [`Auth::Anonymous`]. [`Auth::from_env`] is here for a host - /// that wants the environment's answer, but it is the host that asks: a - /// transport reading credentials on its caller's behalf would be choosing - /// that program's credential policy for it, and this one has no standing - /// to. + /// Defaults to [`Auth::Anonymous`]. Nothing in this crate sources + /// credentials — see the module documentation for why that is a boundary + /// rather than an omission. pub auth: Auth, } @@ -1672,134 +1612,6 @@ mod tests { assert!(!is_absent(&OciDistributionError::GenericError(None))); } - // --------------------------------------------------------------------- - // Credentials, out of the environment - // --------------------------------------------------------------------- - - /// The environment is process-wide, so these run one at a time. - static ENV: Mutex<()> = Mutex::new(()); - - /// Run `body` with exactly `token`, `user` and `password` set, and the - /// process environment put back afterwards. - /// - /// Restoring is not politeness: `cargo test` runs every test in this binary - /// in one process, and a leaked `STELAE_REGISTRY_TOKEN` would be read by - /// whatever ran next. - fn with_env( - token: Option<&str>, - user: Option<&str>, - password: Option<&str>, - body: impl FnOnce() -> T, - ) -> T { - let _guard = ENV.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); - - let previous: Vec<(&str, Option)> = [TOKEN_ENV, USER_ENV, PASSWORD_ENV] - .into_iter() - .map(|name| (name, std::env::var(name).ok())) - .collect(); - - for (name, value) in [ - (TOKEN_ENV, token), - (USER_ENV, user), - (PASSWORD_ENV, password), - ] { - match value { - Some(value) => std::env::set_var(name, value), - None => std::env::remove_var(name), - } - } - - let outcome = body(); - - for (name, value) in previous { - match value { - Some(value) => std::env::set_var(name, value), - None => std::env::remove_var(name), - } - } - - outcome - } - - #[test] - fn the_environment_names_a_token_a_pair_or_nobody() { - with_env(None, None, None, || { - assert_eq!(Auth::from_env().unwrap(), Auth::Anonymous); - }); - - with_env(Some("ghp_x"), None, None, || { - assert_eq!(Auth::from_env().unwrap(), Auth::Bearer("ghp_x".to_owned())); - }); - - with_env(None, Some("reader"), Some("hunter2"), || { - assert_eq!( - Auth::from_env().unwrap(), - Auth::Basic { - user: "reader".to_owned(), - password: "hunter2".to_owned(), - } - ); - }); - - // An empty value is unset. A stale `export STELAE_REGISTRY_TOKEN=` in a - // shell profile should leave a client anonymous rather than have it - // authenticate as the empty token. - with_env(Some(""), Some(""), Some(""), || { - assert_eq!(Auth::from_env().unwrap(), Auth::Anonymous); - }); - } - - /// A token and a pair together is a refusal, and the message names every - /// variable involved so an operator knows which one to unset. - #[test] - fn a_token_and_a_pair_together_are_refused() { - with_env(Some("ghp_x"), Some("reader"), Some("hunter2"), || { - let err = Auth::from_env().unwrap_err(); - - assert!(matches!(err, Error::AmbiguousRegistryAuth), "{err:?}"); - - let message = err.to_string(); - assert!(message.contains(TOKEN_ENV), "{message}"); - assert!(message.contains(USER_ENV), "{message}"); - assert!(message.contains(PASSWORD_ENV), "{message}"); - }); - - // Either half of the pair is enough to make it ambiguous. The operator - // set two kinds of credential; that one of them is incomplete is not a - // reason to silently prefer the other. - for (user, password) in [(Some("reader"), None), (None, Some("hunter2"))] { - with_env(Some("ghp_x"), user, password, || { - assert!(matches!( - Auth::from_env().unwrap_err(), - Error::AmbiguousRegistryAuth - )); - }); - } - } - - #[test] - fn half_a_pair_is_refused() { - with_env(None, Some("reader"), None, || { - let err = Auth::from_env().unwrap_err(); - - assert!( - matches!(&err, Error::IncompleteRegistryAuth { set, missing } - if *set == USER_ENV && *missing == PASSWORD_ENV), - "{err:?}" - ); - }); - - with_env(None, None, Some("hunter2"), || { - let err = Auth::from_env().unwrap_err(); - - assert!( - matches!(&err, Error::IncompleteRegistryAuth { set, missing } - if *set == PASSWORD_ENV && *missing == USER_ENV), - "{err:?}" - ); - }); - } - /// A password never reaches a log through this type. /// /// [`Options`] derives `Debug` and error context is printed freely, so this diff --git a/src/bin/dolos/bootstrap/stelae.rs b/src/bin/dolos/bootstrap/stelae.rs index ef350f5bb..d7f3b9a48 100644 --- a/src/bin/dolos/bootstrap/stelae.rs +++ b/src/bin/dolos/bootstrap/stelae.rs @@ -205,9 +205,12 @@ fn restore_repo( ) -> miette::Result<()> { let node = Node::open(config)?; - // The published read-only pair a fresh `dolos init` seeds, which the - // environment overrides — `dolos_snapshot::registry::auth` owns the rule. - let registry = registry::open(repo, insecure, config.stelae.registry.as_ref()) + // Resolved here rather than inside the transport: which identity this node + // reads a registry as is the node's policy, and `crate::common` is where + // this program keeps its own. + let auth = crate::common::stele_registry_auth(&config.stelae)?; + + let registry = registry::open(repo, insecure, auth) .into_diagnostic() .context("opening the repository")?; diff --git a/src/bin/dolos/common.rs b/src/bin/dolos/common.rs index 5de335b1c..b49ee8b25 100644 --- a/src/bin/dolos/common.rs +++ b/src/bin/dolos/common.rs @@ -1,5 +1,8 @@ -use dolos_core::config::{ChainConfig, GenesisConfig, LoggingConfig, RootConfig, TelemetryConfig}; +use dolos_core::config::{ + ChainConfig, GenesisConfig, LoggingConfig, RootConfig, StelaeConfig, TelemetryConfig, +}; use dolos_core::BootstrapExt; +use dolos_snapshot::registry::Auth; use miette::{Context as _, IntoDiagnostic}; use opentelemetry::trace::TracerProvider as _; use opentelemetry_otlp::WithExportConfig as _; @@ -61,6 +64,94 @@ pub fn load_config( s.build()?.try_deserialize() } +/// Environment variable holding a bearer token for a stele registry. +/// +/// These three are **this program's**, not the protocol's. `stelae` takes +/// credentials as a value and never sources them: a library that read an +/// environment variable would be choosing its host's credential policy, and +/// naming the variable would freeze that choice into a published API. So the +/// names live here, in the binary whose deployment they describe, and so does +/// the precedence between them. +pub const STELE_REGISTRY_TOKEN_ENV: &str = "STELAE_REGISTRY_TOKEN"; + +/// The user half of a Basic credential pair for a stele registry. +pub const STELE_REGISTRY_USER_ENV: &str = "STELAE_REGISTRY_USER"; + +/// The password half of a Basic credential pair for a stele registry. +pub const STELE_REGISTRY_PASSWORD_ENV: &str = "STELAE_REGISTRY_PASSWORD"; + +/// Who this node authenticates to a stele registry as. +/// +/// Two sources, one rule. A publish takes its full-access pair from +/// [`STELE_REGISTRY_USER_ENV`] / [`STELE_REGISTRY_PASSWORD_ENV`] (or a token +/// from [`STELE_REGISTRY_TOKEN_ENV`]); a restore takes the published read-only +/// user from `[stelae.registry]` in `dolos.toml`. Those are two *sources*, not +/// two rules: +/// +/// - **the environment wins.** A publisher's credentials are a secret and never +/// enter a configuration file, so the environment is the only place they can +/// come from — and a node that already carries the read-only user must not +/// have to be edited before it can publish. +/// - **what is configured is the fallback**, which is what lets a node created +/// by `dolos init` pull from the official registry with nothing exported. +/// - **neither is anonymous**, which is what a genuinely public repository +/// wants and what a credentialed one answers with a 401. +/// +/// Two refusals, because both are operator mistakes worth a sentence rather +/// than a precedence rule: a token and a pair set together, and half a pair. An +/// operator who exported both meant one of them, and a client that guessed +/// would authenticate as an identity nobody chose — which on a registry whose +/// credentials carry different capabilities is the difference between a publish +/// and a 403 nobody can explain. +pub fn stele_registry_auth(config: &StelaeConfig) -> miette::Result { + let read = |name: &str| match std::env::var(name) { + // An empty value is unset, so a stale `export STELAE_REGISTRY_TOKEN=` + // in a shell profile leaves a node anonymous rather than + // authenticating it as the empty token. + Ok(value) if !value.is_empty() => Some(value), + _ => None, + }; + + let token = read(STELE_REGISTRY_TOKEN_ENV); + let user = read(STELE_REGISTRY_USER_ENV); + let password = read(STELE_REGISTRY_PASSWORD_ENV); + + if token.is_some() && (user.is_some() || password.is_some()) { + miette::bail!( + "{STELE_REGISTRY_TOKEN_ENV} and \ + {STELE_REGISTRY_USER_ENV}/{STELE_REGISTRY_PASSWORD_ENV} are both set; registry \ + credentials come from one of the two and which one was meant is not something to \ + guess at — unset the one you did not mean" + ); + } + + let half = |set: &str, missing: &str| { + miette::miette!("{set} is set without {missing}; basic registry credentials are a pair") + }; + + match (user, password) { + (Some(user), Some(password)) => return Ok(Auth::Basic { user, password }), + (Some(_), None) => return Err(half(STELE_REGISTRY_USER_ENV, STELE_REGISTRY_PASSWORD_ENV)), + (None, Some(_)) => return Err(half(STELE_REGISTRY_PASSWORD_ENV, STELE_REGISTRY_USER_ENV)), + (None, None) => {} + } + + if let Some(token) = token { + return Ok(Auth::Bearer(token)); + } + + Ok(match &config.registry { + Some(credentials) => Auth::Basic { + user: credentials.user.clone(), + // Through the accessor, not the field: a config that names a user + // and no password means the official registry's, which is compiled + // in rather than written into every generated `dolos.toml`. + password: credentials.password().to_owned(), + }, + None => Auth::Anonymous, + }) +} + pub fn setup_domain(config: &RootConfig) -> miette::Result { let stores = open_data_stores(config).map_err(|e| match e { Error::WalError(WalError::IncompatibleVersion { found, expected }) => miette::miette!( @@ -320,3 +411,200 @@ pub fn cleanup_data(config: &RootConfig) -> Result<(), std::io::Error> { } Ok(()) } + +#[cfg(test)] +mod tests { + use dolos_core::config::{StelaeRegistryConfig, OFFICIAL_REGISTRY_PASSWORD}; + + use super::*; + + /// The environment is process-wide, so these run one at a time. + static ENV: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// Run `body` with exactly these registry variables set, and put the + /// process environment back afterwards. + /// + /// Restoring is not politeness: `cargo test` runs every test in this binary + /// in one process, and a leaked `STELAE_REGISTRY_TOKEN` would be read by + /// whatever ran next. + fn with_env( + token: Option<&str>, + user: Option<&str>, + password: Option<&str>, + body: impl FnOnce() -> T, + ) -> T { + let _guard = ENV.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + + let names = [ + STELE_REGISTRY_TOKEN_ENV, + STELE_REGISTRY_USER_ENV, + STELE_REGISTRY_PASSWORD_ENV, + ]; + + let previous: Vec<(&str, Option)> = names + .into_iter() + .map(|name| (name, std::env::var(name).ok())) + .collect(); + + let apply = |values: [Option<&str>; 3]| { + for (name, value) in names.into_iter().zip(values) { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + }; + + apply([token, user, password]); + + let outcome = body(); + + for (name, value) in previous { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + + outcome + } + + /// A `[stelae]` section carrying `registry`, or carrying nothing. + fn config(registry: Option) -> StelaeConfig { + StelaeConfig { registry } + } + + fn reader() -> StelaeRegistryConfig { + StelaeRegistryConfig { + user: "dolos-reader".to_owned(), + password: Some("published".to_owned()), + } + } + + /// The restore path: what `dolos init` seeded is what a node authenticates + /// with when nothing is exported. + #[test] + fn a_configured_user_is_used_when_the_environment_is_silent() { + with_env(None, None, None, || { + assert_eq!( + stele_registry_auth(&config(Some(reader()))).unwrap(), + Auth::Basic { + user: "dolos-reader".to_owned(), + password: "published".to_owned(), + } + ); + + // A user with no password is the seeded shape: the file says who, + // the binary says with what. + let seeded = StelaeRegistryConfig { + user: "dolos-reader".to_owned(), + password: None, + }; + + assert_eq!( + stele_registry_auth(&config(Some(seeded))).unwrap(), + Auth::Basic { + user: "dolos-reader".to_owned(), + password: OFFICIAL_REGISTRY_PASSWORD.to_owned(), + } + ); + + // And a node that configured nothing stays anonymous rather than + // inventing an identity. + assert_eq!(stele_registry_auth(&config(None)).unwrap(), Auth::Anonymous); + }); + } + + /// The publish path, and the precedence that makes it work on a node that + /// already carries the read-only user. + #[test] + fn the_environment_overrides_what_is_configured() { + with_env(None, Some("publisher"), Some("full-access"), || { + assert_eq!( + stele_registry_auth(&config(Some(reader()))).unwrap(), + Auth::Basic { + user: "publisher".to_owned(), + password: "full-access".to_owned(), + }, + "a publisher must not have to edit dolos.toml before it can publish", + ); + }); + + // A bearer token overrides it too: the environment is the source, and + // which shape it names is the environment's business. + with_env(Some("ghp_x"), None, None, || { + assert_eq!( + stele_registry_auth(&config(Some(reader()))).unwrap(), + Auth::Bearer("ghp_x".to_owned()) + ); + }); + + // An empty value is unset. A stale `export STELAE_REGISTRY_TOKEN=` in a + // shell profile should not authenticate as the empty token. + with_env(Some(""), Some(""), Some(""), || { + assert_eq!(stele_registry_auth(&config(None)).unwrap(), Auth::Anonymous); + }); + } + + /// Two kinds of credential at once is a refusal, and the message names + /// every variable involved so an operator knows which to unset. + /// + /// A configured user is not a tie-breaker for it: the operator's mistake is + /// in the environment, and what is in `dolos.toml` cannot resolve it. + #[test] + fn a_token_and_a_pair_together_are_refused() { + with_env( + Some("ghp_x"), + Some("publisher"), + Some("full-access"), + || { + for configured in [None, Some(reader())] { + let err = stele_registry_auth(&config(configured)).unwrap_err(); + let message = err.to_string(); + + assert!(message.contains(STELE_REGISTRY_TOKEN_ENV), "{message}"); + assert!(message.contains(STELE_REGISTRY_USER_ENV), "{message}"); + assert!(message.contains(STELE_REGISTRY_PASSWORD_ENV), "{message}"); + } + }, + ); + + // Either half of the pair is enough to make it ambiguous. That one of + // them is incomplete is not a reason to silently prefer the other. + for (user, password) in [(Some("publisher"), None), (None, Some("full-access"))] { + with_env(Some("ghp_x"), user, password, || { + assert!(stele_registry_auth(&config(None)).is_err()); + }); + } + } + + /// Half a pair is a typo or a secret that never reached the process, and + /// sending the half that arrived would authenticate as somebody the + /// operator did not name. + #[test] + fn half_a_pair_is_refused() { + for (user, password, set, missing) in [ + ( + Some("publisher"), + None, + STELE_REGISTRY_USER_ENV, + STELE_REGISTRY_PASSWORD_ENV, + ), + ( + None, + Some("full-access"), + STELE_REGISTRY_PASSWORD_ENV, + STELE_REGISTRY_USER_ENV, + ), + ] { + with_env(None, user, password, || { + let message = stele_registry_auth(&config(Some(reader()))) + .unwrap_err() + .to_string(); + + assert!(message.contains(set), "{message}"); + assert!(message.contains(missing), "{message}"); + }); + } + } +} diff --git a/src/bin/dolos/snapshot/publish.rs b/src/bin/dolos/snapshot/publish.rs index 20490d0bd..b0d3af83b 100644 --- a/src/bin/dolos/snapshot/publish.rs +++ b/src/bin/dolos/snapshot/publish.rs @@ -215,10 +215,12 @@ fn to_repository( ) -> miette::Result<()> { // A publisher's credentials come from `STELAE_REGISTRY_USER` / // `STELAE_REGISTRY_PASSWORD`, which override anything configured. The - // configured pair is still handed over: it is read-only, so authenticating + // configured user is still the fallback: it is read-only, so authenticating // with it fails the push at the registry rather than a step earlier — which // is the honest place for "these credentials cannot publish" to be said. - let registry = registry::open(repo, args.insecure, config.stelae.registry.as_ref()) + let auth = crate::common::stele_registry_auth(&config.stelae)?; + + let registry = registry::open(repo, args.insecure, auth) .into_diagnostic() .context("opening the repository")?; From 78611df14c6189c148aa80a3e5f88ee1409d32e0 Mon Sep 17 00:00:00 2001 From: Santiago Date: Sun, 9 Aug 2026 10:53:21 -0300 Subject: [PATCH 5/6] refactor(config): reach registry credentials the way every other setting is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dolos already has one answer to "how does an operator override a setting from the environment": `load_config` layers `config::Environment` with the `DOLOS` prefix over the whole of `RootConfig`, and the docs teach it (`DOLOS_UPSTREAM_PEER_ADDRESS` and friends). Nothing in the workspace uses clap's declarative `env`, and before the OCI transport landed nothing read `std::env::var` outside a test. So the three `STELAE_REGISTRY_*` variables were a second mechanism for something the first already did. `DOLOS_STELAE_REGISTRY_USER` and `DOLOS_STELAE_REGISTRY_PASSWORD` work today with no code at all — they did before this commit, which is what makes the hand-rolled reader redundant rather than merely inconsistent. Breaking, deliberately: `STELAE_REGISTRY_TOKEN`, `STELAE_REGISTRY_USER` and `STELAE_REGISTRY_PASSWORD` are gone with no alias. `[stelae.registry]` gains `token` so the bearer case is reachable on the same route, and `user`/`password` become optional so the section can name one identity or the other. `stele_registry_auth` is now a pure function of that section; the two refusals survive as validation over the resolved configuration — `token` with `user`, and `password` with no `user`. A test pins the mapping itself, building the same `config::Environment` source `load_config` does. A rename of either field, or a change to the prefix or separator, would otherwise fail silently at run time: the override would stop applying and a publisher would authenticate as the read-only user. Production code is back to zero hand-rolled environment reads. Co-Authored-By: Claude Opus 5 (1M context) --- adrs/004_stelae_snapshots.md | 17 +- crates/core/src/config.rs | 48 +++- docs/content/configuration/schema.mdx | 31 +- src/bin/dolos/common.rs | 395 +++++++++++--------------- src/bin/dolos/init.rs | 14 +- 5 files changed, 232 insertions(+), 273 deletions(-) diff --git a/adrs/004_stelae_snapshots.md b/adrs/004_stelae_snapshots.md index b46ed367a..b9be6c2a3 100644 --- a/adrs/004_stelae_snapshots.md +++ b/adrs/004_stelae_snapshots.md @@ -293,20 +293,23 @@ trusted_keys = ["ed25519:…"] # mirrors mithril genesis_key style user = "…" # seeded by `dolos init` # password = "…" # optional; omitted means the official # registry's, compiled into the binary +# token = "…" # a bearer registry instead; excludes `user` ``` The official registry's read-only password is a **published secret**: it is what makes stele distribution free and identity-less while still authenticated. It is compiled into the binary rather than seeded into the file, so `dolos init` writes a `user` and no password and a rotation reaches every node that takes a release, instead of having to be found again in every generated `dolos.toml`. A node pointing at a private registry sets `password` and gets its own. -A publisher's full-access pair is a real secret and belongs in the environment or a secret manager, never in this file, so `dolos` reads three variables of its own: +A publisher's full-access pair is a real secret and belongs in the environment or a secret manager, never in this file. **Dolos introduces no environment variable for it**: `RootConfig` is already loaded through a `config::Environment` layer with the `DOLOS` prefix, so a publisher exports -| Variable | Shape | -| --- | --- | -| `STELAE_REGISTRY_TOKEN` | bearer token | -| `STELAE_REGISTRY_USER` + `STELAE_REGISTRY_PASSWORD` | Basic pair | +```sh +DOLOS_STELAE_REGISTRY_USER=… +DOLOS_STELAE_REGISTRY_PASSWORD=… +``` + +and the override applies by the same mechanism and with the same precedence as every other setting. That is the whole of the environment story — nothing in Dolos reads a registry credential by hand, which is what keeps one answer to "where does configuration come from" rather than two. A node carrying the read-only user in `dolos.toml` therefore publishes with nothing to remove first. -An empty value is unset. The environment overrides both the file and the compiled-in default, so a node carrying the read-only user can publish without being edited first. A token and a pair set together is a **refusal**, not a precedence rule, and so is half a pair: an operator who exported both meant one of them, and a client that guessed would authenticate as an identity nobody chose — which on a registry whose credentials carry different capabilities is the difference between a publish and a 403 nobody can explain. +Two shapes are refusals rather than precedence rules, checked once the configuration has resolved: `token` together with `user`, and `password` with no `user`. An operator who supplied two identities meant one of them, and a client that guessed would authenticate as one nobody chose — which on a registry whose credentials carry different capabilities is the difference between a publish and a 403 nobody can explain. -These are Dolos's variables and Dolos's rule, resolved in `dolos::common::stele_registry_auth`, which hands the answer to the transport as a value. Another host embedding `stelae` names its own, or none. +The resolution is `dolos::common::stele_registry_auth`, a pure function of `[stelae.registry]` that hands the answer to the transport as a value. Another host embedding `stelae` decides its own credential sources, and this specification constrains none of them. ### Publisher pipeline diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 9effd5187..79b65b4ed 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -746,8 +746,8 @@ impl StelaeConfig { pub fn official() -> Self { Self { registry: (!OFFICIAL_REGISTRY_USER.is_empty()).then(|| StelaeRegistryConfig { - user: OFFICIAL_REGISTRY_USER.to_owned(), - password: None, + user: Some(OFFICIAL_REGISTRY_USER.to_owned()), + ..Default::default() }), } } @@ -757,22 +757,37 @@ impl StelaeConfig { } } -/// `[stelae.registry]` — the credentials a stele registry is read with. +/// `[stelae.registry]` — the credentials a stele registry is reached with. /// -/// Read credentials only. A publisher's full-access pair is a secret and lives -/// in the environment (`STELAE_REGISTRY_USER` / `STELAE_REGISTRY_PASSWORD`) or -/// in a secret manager, never in a file that gets committed alongside a node's -/// other settings — and the environment overrides what is here, so a publisher -/// running on a node that carries the read-only user does not have to remove it -/// first. -#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] +/// **Every field is settable from the environment as `DOLOS_STELAE_REGISTRY_*`, +/// and that is not a feature this section implements.** `RootConfig` is loaded +/// through a `config::Environment` layer with the `DOLOS` prefix, so a +/// publisher exports `DOLOS_STELAE_REGISTRY_USER` and +/// `DOLOS_STELAE_REGISTRY_PASSWORD` the same way any other setting is +/// overridden, and the precedence is the one the whole configuration already +/// has. Nothing in Dolos reads a registry credential out of the environment by +/// hand. +/// +/// That is what keeps a publisher's real secret out of the file while a +/// consumer's published user stays in it. +#[derive(Serialize, Deserialize, Clone, Default, PartialEq, Eq)] pub struct StelaeRegistryConfig { - pub user: String, + /// The identity to authenticate as, sent with [a + /// password](StelaeRegistryConfig::password) as HTTP Basic. + /// + /// Seeded by `dolos init`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user: Option, /// Omitted by `dolos init`, and by anything reading the official registry: /// see [`StelaeRegistryConfig::password`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub password: Option, + + /// A bearer token, for a registry that issues them rather than accepting a + /// pair. Mutually exclusive with `user`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token: Option, } impl StelaeRegistryConfig { @@ -789,15 +804,18 @@ impl StelaeRegistryConfig { } } -/// Names the user and never the password. +/// Names the user and never a secret. /// -/// `RootConfig` is printed in diagnostics; a derived `Debug` here would put the -/// pair in whatever a bug report happens to include. +/// `RootConfig` is printed in diagnostics; a derived `Debug` here would put a +/// publisher's credentials in whatever a bug report happens to include. impl std::fmt::Debug for StelaeRegistryConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let redacted = |value: &Option| value.as_ref().map(|_| ""); + f.debug_struct("StelaeRegistryConfig") .field("user", &self.user) - .field("password", &"") + .field("password", &redacted(&self.password)) + .field("token", &redacted(&self.token)) .finish() } } diff --git a/docs/content/configuration/schema.mdx b/docs/content/configuration/schema.mdx index 5b2be59fa..320046f0f 100644 --- a/docs/content/configuration/schema.mdx +++ b/docs/content/configuration/schema.mdx @@ -409,10 +409,13 @@ repositories `dolos bootstrap stelae --source oci://…` restores from and | -------- | ------ | ------- | | user | string | "dolos" | | password | string | "a-private-registry-credential" | +| token | string | "ghp_…" | - `user`: the identity the registry is read as, sent as HTTP Basic. - `password`: optional. Omitted, Dolos uses the official registry's published read-only password, which is compiled into the binary. +- `token`: a bearer token, for a registry that issues them rather than accepting + a pair. Mutually exclusive with `user`. A registry may charge nothing for reads and still refuse an unidentified one. The credential the official registry hands out is not a secret — it is what makes @@ -423,21 +426,25 @@ release instead of having to be found again in every generated file. Set `password` to point at a registry of your own. Omit the section entirely to connect anonymously, which is what a genuinely public repository wants. -### Registry credentials in the environment +Setting `token` and `user` at the same time is an error rather than a precedence +puzzle — Dolos refuses to open the registry and names both — and so is setting +`password` with no `user`. -Three variables override what the section carries, and are where a **publisher's** -credentials belong — those are real secrets and never go in this file: +#### A publisher's credentials -| variable | shape | -| -------- | ----- | -| `STELAE_REGISTRY_USER` + `STELAE_REGISTRY_PASSWORD` | an HTTP Basic pair | -| `STELAE_REGISTRY_TOKEN` | a bearer token, for a registry that issues them | +Those are real secrets and never belong in this file. Export them instead, using +the same `DOLOS_` environment overrides that apply to [every other +setting](./introduction#environmental-variables): -An empty value counts as unset. Setting a token and a pair at the same time is an -error rather than a precedence puzzle — Dolos refuses to open the registry and -names both — and so is setting one half of the pair without the other. Because -the environment wins, a node that carries the read-only pair in `dolos.toml` can -publish by exporting the full pair, with nothing to remove first. +```sh +export DOLOS_STELAE_REGISTRY_USER=publisher +export DOLOS_STELAE_REGISTRY_PASSWORD=… +dolos snapshot publish --repo oci://… +``` + +There is nothing registry-specific about that: the environment layer overrides +the file for every field, so a node that carries the read-only `user` in +`dolos.toml` can publish with nothing to remove first. ## `logging` section diff --git a/src/bin/dolos/common.rs b/src/bin/dolos/common.rs index b49ee8b25..9490b7334 100644 --- a/src/bin/dolos/common.rs +++ b/src/bin/dolos/common.rs @@ -64,92 +64,64 @@ pub fn load_config( s.build()?.try_deserialize() } -/// Environment variable holding a bearer token for a stele registry. -/// -/// These three are **this program's**, not the protocol's. `stelae` takes -/// credentials as a value and never sources them: a library that read an -/// environment variable would be choosing its host's credential policy, and -/// naming the variable would freeze that choice into a published API. So the -/// names live here, in the binary whose deployment they describe, and so does -/// the precedence between them. -pub const STELE_REGISTRY_TOKEN_ENV: &str = "STELAE_REGISTRY_TOKEN"; - -/// The user half of a Basic credential pair for a stele registry. -pub const STELE_REGISTRY_USER_ENV: &str = "STELAE_REGISTRY_USER"; - -/// The password half of a Basic credential pair for a stele registry. -pub const STELE_REGISTRY_PASSWORD_ENV: &str = "STELAE_REGISTRY_PASSWORD"; - /// Who this node authenticates to a stele registry as. /// -/// Two sources, one rule. A publish takes its full-access pair from -/// [`STELE_REGISTRY_USER_ENV`] / [`STELE_REGISTRY_PASSWORD_ENV`] (or a token -/// from [`STELE_REGISTRY_TOKEN_ENV`]); a restore takes the published read-only -/// user from `[stelae.registry]` in `dolos.toml`. Those are two *sources*, not -/// two rules: +/// A pure function of `[stelae.registry]`, and deliberately nothing more. +/// **Dolos reads no environment variable of its own here**, because it does not +/// have to: `load_config` layers `config::Environment` with the `DOLOS` prefix +/// over every setting, so `DOLOS_STELAE_REGISTRY_USER` and +/// `DOLOS_STELAE_REGISTRY_PASSWORD` already override what the file says, by the +/// same mechanism and with the same precedence as every other field. A second, +/// hand-rolled set of variables would be a second answer to a question the +/// configuration has already answered — and this binary has no other. /// -/// - **the environment wins.** A publisher's credentials are a secret and never -/// enter a configuration file, so the environment is the only place they can -/// come from — and a node that already carries the read-only user must not -/// have to be edited before it can publish. -/// - **what is configured is the fallback**, which is what lets a node created -/// by `dolos init` pull from the official registry with nothing exported. -/// - **neither is anonymous**, which is what a genuinely public repository -/// wants and what a credentialed one answers with a 401. +/// So the two sources the operator sees are the two the configuration has: a +/// consumer's published user in `dolos.toml`, and a publisher's real +/// credentials exported into the environment and never written down. /// /// Two refusals, because both are operator mistakes worth a sentence rather -/// than a precedence rule: a token and a pair set together, and half a pair. An -/// operator who exported both meant one of them, and a client that guessed -/// would authenticate as an identity nobody chose — which on a registry whose -/// credentials carry different capabilities is the difference between a publish -/// and a 403 nobody can explain. +/// than a precedence rule: +/// +/// - **a token and a user together** — two identities, and which was meant is +/// not something to guess at. On a registry whose credentials carry different +/// capabilities, guessing is the difference between a publish and a 403 +/// nobody can explain. +/// - **a password with no user** — a secret that arrived with nobody to be. It +/// is a typo or half an export, and the half that arrived cannot be sent on +/// its own. pub fn stele_registry_auth(config: &StelaeConfig) -> miette::Result { - let read = |name: &str| match std::env::var(name) { - // An empty value is unset, so a stale `export STELAE_REGISTRY_TOKEN=` - // in a shell profile leaves a node anonymous rather than - // authenticating it as the empty token. - Ok(value) if !value.is_empty() => Some(value), - _ => None, + let Some(registry) = &config.registry else { + return Ok(Auth::Anonymous); }; - let token = read(STELE_REGISTRY_TOKEN_ENV); - let user = read(STELE_REGISTRY_USER_ENV); - let password = read(STELE_REGISTRY_PASSWORD_ENV); - - if token.is_some() && (user.is_some() || password.is_some()) { + if registry.token.is_some() && registry.user.is_some() { miette::bail!( - "{STELE_REGISTRY_TOKEN_ENV} and \ - {STELE_REGISTRY_USER_ENV}/{STELE_REGISTRY_PASSWORD_ENV} are both set; registry \ - credentials come from one of the two and which one was meant is not something to \ - guess at — unset the one you did not mean" + "[stelae.registry] sets both `token` and `user`; a registry client authenticates as \ + one identity and which one was meant is not something to guess at — drop the one \ + you did not mean, or unset DOLOS_STELAE_REGISTRY_TOKEN / DOLOS_STELAE_REGISTRY_USER" ); } - let half = |set: &str, missing: &str| { - miette::miette!("{set} is set without {missing}; basic registry credentials are a pair") - }; - - match (user, password) { - (Some(user), Some(password)) => return Ok(Auth::Basic { user, password }), - (Some(_), None) => return Err(half(STELE_REGISTRY_USER_ENV, STELE_REGISTRY_PASSWORD_ENV)), - (None, Some(_)) => return Err(half(STELE_REGISTRY_PASSWORD_ENV, STELE_REGISTRY_USER_ENV)), - (None, None) => {} - } - - if let Some(token) = token { - return Ok(Auth::Bearer(token)); + if let Some(token) = ®istry.token { + return Ok(Auth::Bearer(token.clone())); } - Ok(match &config.registry { - Some(credentials) => Auth::Basic { - user: credentials.user.clone(), - // Through the accessor, not the field: a config that names a user + match ®istry.user { + Some(user) => Ok(Auth::Basic { + user: user.clone(), + // Through the accessor, not the field: a section that names a user // and no password means the official registry's, which is compiled // in rather than written into every generated `dolos.toml`. - password: credentials.password().to_owned(), - }, - None => Auth::Anonymous, - }) + password: registry.password().to_owned(), + }), + // A password with nobody to be. Anonymous would be the quiet answer and + // the wrong one: the operator supplied a secret and it would go unused. + None if registry.password.is_some() => miette::bail!( + "[stelae.registry] sets `password` with no `user`; basic registry credentials are a \ + pair" + ), + None => Ok(Auth::Anonymous), + } } pub fn setup_domain(config: &RootConfig) -> miette::Result { @@ -418,193 +390,148 @@ mod tests { use super::*; - /// The environment is process-wide, so these run one at a time. - static ENV: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - /// Run `body` with exactly these registry variables set, and put the - /// process environment back afterwards. - /// - /// Restoring is not politeness: `cargo test` runs every test in this binary - /// in one process, and a leaked `STELAE_REGISTRY_TOKEN` would be read by - /// whatever ran next. - fn with_env( - token: Option<&str>, - user: Option<&str>, - password: Option<&str>, - body: impl FnOnce() -> T, - ) -> T { - let _guard = ENV.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); - - let names = [ - STELE_REGISTRY_TOKEN_ENV, - STELE_REGISTRY_USER_ENV, - STELE_REGISTRY_PASSWORD_ENV, - ]; - - let previous: Vec<(&str, Option)> = names - .into_iter() - .map(|name| (name, std::env::var(name).ok())) - .collect(); - - let apply = |values: [Option<&str>; 3]| { - for (name, value) in names.into_iter().zip(values) { - match value { - Some(value) => std::env::set_var(name, value), - None => std::env::remove_var(name), - } - } - }; - - apply([token, user, password]); - - let outcome = body(); - - for (name, value) in previous { - match value { - Some(value) => std::env::set_var(name, value), - None => std::env::remove_var(name), - } - } - - outcome - } - - /// A `[stelae]` section carrying `registry`, or carrying nothing. fn config(registry: Option) -> StelaeConfig { StelaeConfig { registry } } - fn reader() -> StelaeRegistryConfig { + fn basic(user: &str, password: Option<&str>) -> StelaeRegistryConfig { StelaeRegistryConfig { - user: "dolos-reader".to_owned(), - password: Some("published".to_owned()), + user: Some(user.to_owned()), + password: password.map(str::to_owned), + token: None, } } - /// The restore path: what `dolos init` seeded is what a node authenticates - /// with when nothing is exported. + /// The three shapes `[stelae.registry]` can name, and the one it names by + /// saying nothing. #[test] - fn a_configured_user_is_used_when_the_environment_is_silent() { - with_env(None, None, None, || { - assert_eq!( - stele_registry_auth(&config(Some(reader()))).unwrap(), - Auth::Basic { - user: "dolos-reader".to_owned(), - password: "published".to_owned(), - } - ); + fn the_section_names_a_user_a_token_or_nobody() { + assert_eq!(stele_registry_auth(&config(None)).unwrap(), Auth::Anonymous); - // A user with no password is the seeded shape: the file says who, - // the binary says with what. - let seeded = StelaeRegistryConfig { + assert_eq!( + stele_registry_auth(&config(Some(basic("dolos-reader", Some("published"))))).unwrap(), + Auth::Basic { user: "dolos-reader".to_owned(), - password: None, - }; + password: "published".to_owned(), + } + ); - assert_eq!( - stele_registry_auth(&config(Some(seeded))).unwrap(), - Auth::Basic { - user: "dolos-reader".to_owned(), - password: OFFICIAL_REGISTRY_PASSWORD.to_owned(), - } - ); + let bearer = StelaeRegistryConfig { + token: Some("ghp_x".to_owned()), + ..Default::default() + }; - // And a node that configured nothing stays anonymous rather than - // inventing an identity. - assert_eq!(stele_registry_auth(&config(None)).unwrap(), Auth::Anonymous); - }); + assert_eq!( + stele_registry_auth(&config(Some(bearer))).unwrap(), + Auth::Bearer("ghp_x".to_owned()) + ); } - /// The publish path, and the precedence that makes it work on a node that - /// already carries the read-only user. + /// A user with no password is the shape `dolos init` seeds: the file says + /// who, the binary says with what. #[test] - fn the_environment_overrides_what_is_configured() { - with_env(None, Some("publisher"), Some("full-access"), || { - assert_eq!( - stele_registry_auth(&config(Some(reader()))).unwrap(), - Auth::Basic { - user: "publisher".to_owned(), - password: "full-access".to_owned(), - }, - "a publisher must not have to edit dolos.toml before it can publish", - ); - }); - - // A bearer token overrides it too: the environment is the source, and - // which shape it names is the environment's business. - with_env(Some("ghp_x"), None, None, || { - assert_eq!( - stele_registry_auth(&config(Some(reader()))).unwrap(), - Auth::Bearer("ghp_x".to_owned()) - ); - }); - - // An empty value is unset. A stale `export STELAE_REGISTRY_TOKEN=` in a - // shell profile should not authenticate as the empty token. - with_env(Some(""), Some(""), Some(""), || { - assert_eq!(stele_registry_auth(&config(None)).unwrap(), Auth::Anonymous); - }); + fn a_seeded_user_takes_the_compiled_in_password() { + assert_eq!( + stele_registry_auth(&config(Some(basic("dolos-reader", None)))).unwrap(), + Auth::Basic { + user: "dolos-reader".to_owned(), + password: OFFICIAL_REGISTRY_PASSWORD.to_owned(), + } + ); } - /// Two kinds of credential at once is a refusal, and the message names - /// every variable involved so an operator knows which to unset. - /// - /// A configured user is not a tie-breaker for it: the operator's mistake is - /// in the environment, and what is in `dolos.toml` cannot resolve it. #[test] - fn a_token_and_a_pair_together_are_refused() { - with_env( - Some("ghp_x"), - Some("publisher"), - Some("full-access"), - || { - for configured in [None, Some(reader())] { - let err = stele_registry_auth(&config(configured)).unwrap_err(); - let message = err.to_string(); - - assert!(message.contains(STELE_REGISTRY_TOKEN_ENV), "{message}"); - assert!(message.contains(STELE_REGISTRY_USER_ENV), "{message}"); - assert!(message.contains(STELE_REGISTRY_PASSWORD_ENV), "{message}"); - } - }, - ); + fn two_identities_at_once_are_refused() { + let both = StelaeRegistryConfig { + user: Some("dolos-reader".to_owned()), + password: None, + token: Some("ghp_x".to_owned()), + }; - // Either half of the pair is enough to make it ambiguous. That one of - // them is incomplete is not a reason to silently prefer the other. - for (user, password) in [(Some("publisher"), None), (None, Some("full-access"))] { - with_env(Some("ghp_x"), user, password, || { - assert!(stele_registry_auth(&config(None)).is_err()); - }); - } + let message = stele_registry_auth(&config(Some(both))) + .unwrap_err() + .to_string(); + + assert!(message.contains("token"), "{message}"); + assert!(message.contains("user"), "{message}"); } - /// Half a pair is a typo or a secret that never reached the process, and - /// sending the half that arrived would authenticate as somebody the - /// operator did not name. + /// A password with nobody to be. Anonymous would be the quiet answer and + /// the wrong one: the operator supplied a secret and it would go unused. #[test] - fn half_a_pair_is_refused() { - for (user, password, set, missing) in [ - ( - Some("publisher"), - None, - STELE_REGISTRY_USER_ENV, - STELE_REGISTRY_PASSWORD_ENV, - ), - ( - None, - Some("full-access"), - STELE_REGISTRY_PASSWORD_ENV, - STELE_REGISTRY_USER_ENV, - ), - ] { - with_env(None, user, password, || { - let message = stele_registry_auth(&config(Some(reader()))) - .unwrap_err() - .to_string(); - - assert!(message.contains(set), "{message}"); - assert!(message.contains(missing), "{message}"); - }); + fn a_password_with_no_user_is_refused() { + let orphan = StelaeRegistryConfig { + password: Some("full-access".to_owned()), + ..Default::default() + }; + + let message = stele_registry_auth(&config(Some(orphan))) + .unwrap_err() + .to_string(); + + assert!(message.contains("password"), "{message}"); + assert!(message.contains("user"), "{message}"); + } + + /// The environment reaches this section by the same route as every other + /// setting, and *this* is the assertion that says so. + /// + /// It is the whole of Dolos's registry-credential environment story — a + /// publisher exports `DOLOS_STELAE_REGISTRY_USER` and + /// `DOLOS_STELAE_REGISTRY_PASSWORD` and nothing in this binary reads them — + /// so it is worth pinning rather than trusting. The source is built exactly + /// as [`load_config`] builds it; only the file layers are left off, because + /// those would make the test depend on the working directory. + /// + /// What would break it is a rename of either field or a change to the + /// prefix or separator, and all three are silent failures at run time: the + /// override would simply stop applying, and a publisher would authenticate + /// as the read-only user. + #[test] + fn the_dolos_environment_prefix_reaches_the_registry_section() { + #[derive(serde::Deserialize)] + struct Root { + stelae: StelaeConfig, } + + // Process-wide, so this test owns these three names for its duration. + // Nothing else in this binary reads them. + static ENV: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _guard = ENV.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + + let names = [ + "DOLOS_STELAE_REGISTRY_USER", + "DOLOS_STELAE_REGISTRY_PASSWORD", + "DOLOS_STELAE_REGISTRY_TOKEN", + ]; + + let previous: Vec> = names.iter().map(|n| std::env::var(n).ok()).collect(); + + std::env::set_var("DOLOS_STELAE_REGISTRY_USER", "publisher"); + std::env::set_var("DOLOS_STELAE_REGISTRY_PASSWORD", "full-access"); + std::env::remove_var("DOLOS_STELAE_REGISTRY_TOKEN"); + + let built: Root = ::config::Config::builder() + .add_source(::config::Environment::with_prefix("DOLOS").separator("_")) + .build() + .expect("the environment source builds") + .try_deserialize() + .expect("DOLOS_STELAE_REGISTRY_* deserializes into [stelae.registry]"); + + for (name, value) in names.iter().zip(previous) { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + + assert_eq!( + stele_registry_auth(&built.stelae).unwrap(), + Auth::Basic { + user: "publisher".to_owned(), + password: "full-access".to_owned(), + }, + "the DOLOS_ prefix no longer reaches [stelae.registry]", + ); } } diff --git a/src/bin/dolos/init.rs b/src/bin/dolos/init.rs index f490a1ea3..8a4c283a6 100644 --- a/src/bin/dolos/init.rs +++ b/src/bin/dolos/init.rs @@ -784,7 +784,10 @@ mod tests { assert_eq!(parsed.stelae, StelaeConfig::official()); if let Some(registry) = &parsed.stelae.registry { - assert_eq!(registry.user, dolos_core::config::OFFICIAL_REGISTRY_USER); + assert_eq!( + registry.user.as_deref(), + Some(dolos_core::config::OFFICIAL_REGISTRY_USER) + ); assert_eq!(registry.password, None); } } @@ -800,8 +803,9 @@ mod tests { let mut editor = ConfigEditor::default(); editor.0.stelae.registry = Some(StelaeRegistryConfig { - user: "dolos".to_owned(), + user: Some("dolos".to_owned()), password: Some("a-private-registry".to_owned()), + token: None, }); editor.save(&path).unwrap(); @@ -812,13 +816,13 @@ mod tests { let parsed: RootConfig = toml::from_str(&written).unwrap(); let registry = parsed.stelae.registry.expect("the section round-trips"); - assert_eq!(registry.user, "dolos"); + assert_eq!(registry.user.as_deref(), Some("dolos")); assert_eq!(registry.password(), "a-private-registry"); // And the same user with the password left out falls back. let defaulted = StelaeRegistryConfig { - user: "dolos".to_owned(), - password: None, + user: Some("dolos".to_owned()), + ..Default::default() }; assert_eq!(defaulted.password(), OFFICIAL_REGISTRY_PASSWORD); From a3e51f2e46a3847d389b14768e58200e0ede1e8f Mon Sep 17 00:00:00 2001 From: Santiago Date: Sun, 9 Aug 2026 12:50:16 -0300 Subject: [PATCH 6/6] refactor(init): keep the official registry with the other hardcoded defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read-only user and password the official stele registry is reached with sat in `dolos-core`, next to the config types. Every other default a generated `dolos.toml` points at — the Demeter and CF relay addresses, the Mithril aggregators and their genesis keys — lives on `KnownNetwork` in the binary, and this belongs there with them. `StelaeRegistryConfig::password()` is what made the old placement wrong rather than merely inconsistent: it put the answer to "which registry is ours" on the config type itself, so a library shipped an opinion about a specific deployment. The type now carries the shape and none of the values. The seed is `init::official_stelae()`, and the runtime fallback for a section naming a user and no password resolves in `stele_registry_auth`, which is where the rest of the credential policy already is — reaching into `init` the way `doctor` already reaches into it for `KnownNetwork`. The tests split on the same line: `init` asserts the file round-trip, `common` asserts what a passwordless section authenticates with. Co-Authored-By: Claude Opus 5 (1M context) --- crates/core/src/config.rs | 67 +++++----------------------- src/bin/dolos/common.rs | 14 +++--- src/bin/dolos/init.rs | 92 ++++++++++++++++++++++++++++----------- 3 files changed, 85 insertions(+), 88 deletions(-) diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 79b65b4ed..127f6e36b 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -699,37 +699,18 @@ pub struct SnapshotConfig { pub download_url: String, } -/// The official stele registry's published read-only user. -/// -/// Written into `dolos.toml` by `dolos init`, so a generated config says which -/// identity it reads the registry as. -/// -/// Empty until the registry that issues it exists. -pub const OFFICIAL_REGISTRY_USER: &str = ""; - -/// The password that goes with [`OFFICIAL_REGISTRY_USER`], compiled in rather -/// than written to `dolos.toml`. -/// -/// **Deliberately a published secret**, and the only one this project has: -/// stele distribution is free and identity-less, but never unrestricted — the -/// registry authenticates every request, and this is what a consumer -/// authenticates with. So it gates out-of-band tooling and nothing else. -/// -/// Compiled in rather than seeded into the file because a password copied into -/// every generated `dolos.toml` is a password that has to be found again in -/// every one of them. Here, a rotation reaches every node that takes the -/// release; a node that overrode it in its own config keeps its override. -/// -/// Empty until the registry exists. Filling both constants is a two-line change -/// *here* and nowhere else, which is why they are constants rather than -/// literals at the sites that use them. -pub const OFFICIAL_REGISTRY_PASSWORD: &str = ""; - /// `[stelae]` — how this node reaches a stele registry. /// /// One section carrying one credential, and that is the whole of the consumer /// surface: a node restoring from a stele repository needs to authenticate, and /// nothing more about a registry belongs in a node's configuration. +/// +/// **Which registry is the official one, and what it is read as, is not decided +/// here.** That is a hardcoded default of the same kind as a network's relay +/// address or its Mithril aggregator, and it lives where those live: beside +/// `KnownNetwork` in the binary, which is both what seeds a generated +/// `dolos.toml` and what answers for a section naming a user and no password. +/// This crate carries the shape and none of the values. #[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)] pub struct StelaeConfig { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -737,21 +718,6 @@ pub struct StelaeConfig { } impl StelaeConfig { - /// What `dolos init` seeds: the official registry's user, and no password. - /// - /// Empty while [`OFFICIAL_REGISTRY_USER`] is, which is why it is a - /// constructor rather than a `Default` impl: a caller reading this name - /// knows it is asking for the official registry specifically, and gets the - /// honest answer when there is not one yet. - pub fn official() -> Self { - Self { - registry: (!OFFICIAL_REGISTRY_USER.is_empty()).then(|| StelaeRegistryConfig { - user: Some(OFFICIAL_REGISTRY_USER.to_owned()), - ..Default::default() - }), - } - } - pub fn is_default(&self) -> bool { self.registry.is_none() } @@ -779,8 +745,9 @@ pub struct StelaeRegistryConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub user: Option, - /// Omitted by `dolos init`, and by anything reading the official registry: - /// see [`StelaeRegistryConfig::password`]. + /// Omitted by `dolos init`, and by anything reading the official registry — + /// the binary supplies that one rather than copying it into every generated + /// file. Set it for a registry that is not the official one. #[serde(default, skip_serializing_if = "Option::is_none")] pub password: Option, @@ -790,20 +757,6 @@ pub struct StelaeRegistryConfig { pub token: Option, } -impl StelaeRegistryConfig { - /// The password to send: the one configured, or - /// [`OFFICIAL_REGISTRY_PASSWORD`]. - /// - /// The fallback is what lets a generated config name a user and no secret. - /// A private registry sets `password` and gets its own; the official one is - /// answered by the binary. - pub fn password(&self) -> &str { - self.password - .as_deref() - .unwrap_or(OFFICIAL_REGISTRY_PASSWORD) - } -} - /// Names the user and never a secret. /// /// `RootConfig` is printed in diagnostics; a derived `Debug` here would put a diff --git a/src/bin/dolos/common.rs b/src/bin/dolos/common.rs index 9490b7334..0d92e453e 100644 --- a/src/bin/dolos/common.rs +++ b/src/bin/dolos/common.rs @@ -109,10 +109,13 @@ pub fn stele_registry_auth(config: &StelaeConfig) -> miette::Result { match ®istry.user { Some(user) => Ok(Auth::Basic { user: user.clone(), - // Through the accessor, not the field: a section that names a user - // and no password means the official registry's, which is compiled - // in rather than written into every generated `dolos.toml`. - password: registry.password().to_owned(), + // A user and no password means the official registry's, which is + // compiled in beside the rest of the hardcoded defaults rather than + // written into every generated `dolos.toml`. + password: registry + .password + .clone() + .unwrap_or_else(|| crate::init::OFFICIAL_REGISTRY_PASSWORD.to_owned()), }), // A password with nobody to be. Anonymous would be the quiet answer and // the wrong one: the operator supplied a secret and it would go unused. @@ -386,9 +389,10 @@ pub fn cleanup_data(config: &RootConfig) -> Result<(), std::io::Error> { #[cfg(test)] mod tests { - use dolos_core::config::{StelaeRegistryConfig, OFFICIAL_REGISTRY_PASSWORD}; + use dolos_core::config::StelaeRegistryConfig; use super::*; + use crate::init::OFFICIAL_REGISTRY_PASSWORD; fn config(registry: Option) -> StelaeConfig { StelaeConfig { registry } diff --git a/src/bin/dolos/init.rs b/src/bin/dolos/init.rs index 8a4c283a6..297a1fcde 100644 --- a/src/bin/dolos/init.rs +++ b/src/bin/dolos/init.rs @@ -3,8 +3,8 @@ use dolos_cardano::{include, mutable_slots}; use dolos_core::{ config::{ CardanoConfig, ChainConfig, GenesisConfig, GrpcConfig, MinibfConfig, MinikupoConfig, - MithrilConfig, PeerConfig, RelayConfig, RootConfig, StelaeConfig, StorageConfig, - StorageVersion, TrpConfig, UpstreamConfig, + MithrilConfig, PeerConfig, RelayConfig, RootConfig, StelaeConfig, StelaeRegistryConfig, + StorageConfig, StorageVersion, TrpConfig, UpstreamConfig, }, Genesis, }; @@ -189,6 +189,54 @@ impl From<&KnownNetwork> for MithrilConfig { } } +/// The official stele registry's published read-only user. +/// +/// A hardcoded default of the same kind as the relay addresses and the Mithril +/// aggregators above, and here for the same reason: this module is where a +/// generated `dolos.toml` gets everything it points at. Written into the file, +/// so a config says which identity it reads the registry as. +/// +/// Empty until the registry that issues it exists. +const OFFICIAL_REGISTRY_USER: &str = ""; + +/// The password that goes with [`OFFICIAL_REGISTRY_USER`], compiled in rather +/// than written to `dolos.toml`. +/// +/// **Deliberately a published secret**, and the only one this project has: +/// stele distribution is free and identity-less, but never unrestricted — the +/// registry authenticates every request, and this is what a consumer +/// authenticates with. So it gates out-of-band tooling and nothing else. +/// +/// Compiled in rather than seeded into the file because a password copied into +/// every generated `dolos.toml` is a password that has to be found again in +/// every one of them. Here, a rotation reaches every node that takes the +/// release; a node that overrode it in its own config keeps its override. +/// +/// That makes this the one default here with a runtime reader as well as an +/// init-time one: [`crate::common::stele_registry_auth`] answers a section that +/// names a user and no password with it, reaching into this module the way +/// `doctor` reaches into it for [`KnownNetwork`] rather than keeping a second +/// account of what the defaults are. +/// +/// Empty until the registry exists. Filling both constants is a two-line change +/// here and nowhere else. +pub const OFFICIAL_REGISTRY_PASSWORD: &str = ""; + +/// `[stelae]` as a generated config carries it: the official registry's user, +/// and no password. +/// +/// Empty while [`OFFICIAL_REGISTRY_USER`] is, which is why it is a function +/// rather than a `Default` impl on the config type — it asks for the official +/// registry specifically, and gives the honest answer while there is not one. +fn official_stelae() -> StelaeConfig { + StelaeConfig { + registry: (!OFFICIAL_REGISTRY_USER.is_empty()).then(|| StelaeRegistryConfig { + user: Some(OFFICIAL_REGISTRY_USER.to_owned()), + ..Default::default() + }), + } +} + #[derive(Debug, Clone)] pub enum HistoryPrunningOptions { Keep1Day, @@ -358,7 +406,7 @@ impl Default for ConfigEditor { // `dolos.toml` keeps whatever it carries, because a section // an operator removed and one that predates the field look // the same from here, and overwriting would undo the first. - stelae: StelaeConfig::official(), + stelae: official_stelae(), storage: StorageConfig { version: StorageVersion::V3, ..Default::default() @@ -761,11 +809,11 @@ mod tests { /// A freshly initialized node carries the stelae registry section the /// official registry needs, and no password. /// - /// The section appears once `OFFICIAL_REGISTRY_USER` names one; until then - /// there is no user to write and the file says nothing. Either way the + /// The section appears once [`OFFICIAL_REGISTRY_USER`] names one; until + /// then there is no user to write and the file says nothing. Either way the /// generated config has to parse and to round-trip to exactly what - /// `StelaeConfig::official()` is, which is what will still hold on the day - /// the constants are filled in. + /// [`official_stelae`] is, which is what will still hold on the day the + /// constants are filled in. #[test] fn a_fresh_config_seeds_the_official_registry_and_no_password() { let dir = tempfile::tempdir().unwrap(); @@ -781,23 +829,23 @@ mod tests { assert!(!written.contains("password"), "{written}"); let parsed: RootConfig = toml::from_str(&written).expect("the generated config parses"); - assert_eq!(parsed.stelae, StelaeConfig::official()); + assert_eq!(parsed.stelae, official_stelae()); if let Some(registry) = &parsed.stelae.registry { - assert_eq!( - registry.user.as_deref(), - Some(dolos_core::config::OFFICIAL_REGISTRY_USER) - ); + assert_eq!(registry.user.as_deref(), Some(OFFICIAL_REGISTRY_USER)); assert_eq!(registry.password, None); } } - /// A password an operator wrote is kept; one they did not is the official - /// registry's, out of the binary. + /// A password an operator wrote is kept, so a private registry is + /// configurable in the file the same command generates. + /// + /// Only the round trip is asserted here. What a section with *no* password + /// authenticates with is [`crate::common::stele_registry_auth`]'s question, + /// and is answered by its tests against + /// [`OFFICIAL_REGISTRY_PASSWORD`]. #[test] - fn a_configured_password_overrides_the_compiled_in_one() { - use dolos_core::config::{StelaeRegistryConfig, OFFICIAL_REGISTRY_PASSWORD}; - + fn a_configured_password_survives_the_round_trip() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("dolos.toml"); @@ -817,14 +865,6 @@ mod tests { let registry = parsed.stelae.registry.expect("the section round-trips"); assert_eq!(registry.user.as_deref(), Some("dolos")); - assert_eq!(registry.password(), "a-private-registry"); - - // And the same user with the password left out falls back. - let defaulted = StelaeRegistryConfig { - user: Some("dolos".to_owned()), - ..Default::default() - }; - - assert_eq!(defaulted.password(), OFFICIAL_REGISTRY_PASSWORD); + assert_eq!(registry.password.as_deref(), Some("a-private-registry")); } }