Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
20 changes: 19 additions & 1 deletion adrs/004_stelae_snapshots.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -286,8 +295,17 @@ 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
```

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

1. Restore the publisher node from the previous stele (self-hosting delta pull; first run via Mithril).
Expand Down
106 changes: 106 additions & 0 deletions crates/core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,109 @@ 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: OFFICIAL_REGISTRY_USER.to_owned(),
password: None,
}),
}
}

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 user does not have to remove it
/// first.
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct StelaeRegistryConfig {
pub user: 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>,
}

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 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", &"<redacted>")
.finish()
}
}

#[derive(Serialize, Deserialize, Clone)]
pub struct OuroborosConfig {
pub listen_path: PathBuf,
Expand Down Expand Up @@ -1143,6 +1246,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
Loading
Loading