Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion adrs/004_stelae_snapshots.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,9 @@ 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 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.

### Code layout

Expand Down Expand Up @@ -286,8 +288,29 @@ 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] # who this node reads the registry as
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. **Dolos introduces no environment variable for it**: `RootConfig` is already loaded through a `config::Environment` layer with the `DOLOS` prefix, so a publisher exports

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

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.

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

1. Restore the publisher node from the previous stele (self-hosting delta pull; first run via Mithril).
Expand Down
124 changes: 124 additions & 0 deletions crates/core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,127 @@ 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.
#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)]
pub struct StelaeConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub registry: Option<StelaeRegistryConfig>,
}

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()
}
}

/// `[stelae.registry]` — the credentials a stele registry is reached with.
///
/// **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 {
/// 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<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<String>,

/// 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<String>,
}

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)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

/// Names the user and never a secret.
///
/// `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<String>| value.as_ref().map(|_| "<redacted>");

f.debug_struct("StelaeRegistryConfig")
.field("user", &self.user)
.field("password", &redacted(&self.password))
.field("token", &redacted(&self.token))
.finish()
}
}

#[derive(Serialize, Deserialize, Clone)]
pub struct OuroborosConfig {
pub listen_path: PathBuf,
Expand Down Expand Up @@ -1143,6 +1264,9 @@ pub struct RootConfig {

pub snapshot: Option<SnapshotConfig>,

#[serde(default, skip_serializing_if = "StelaeConfig::is_default")]
pub stelae: StelaeConfig,

pub chain: ChainConfig,

#[serde(default, skip_serializing_if = "LoggingConfig::is_default")]
Expand Down
22 changes: 20 additions & 2 deletions crates/snapshot/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@
//! 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 — 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};

Expand All @@ -93,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},
Expand Down Expand Up @@ -211,15 +223,21 @@ where
/// or a mirror inside a cluster, and for nothing that is reachable from outside
/// one.
///
/// `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) -> Result<Registry, Error> {
pub fn open(repository: &Repository, insecure: bool, auth: Auth) -> Result<Registry, Error> {
Ok(Registry::open(
repository,
Options {
insecure,
scratch_dir: None,
auth,
},
)?)
}
Expand Down
Loading
Loading