agent/lib/src/creds.rs
//! Upstream credentials for the continuous mirror — named in RON, held in a vault.
//!
//! A continuous mirror of a Nexus / Artifactory upstream has to authenticate
//! every time it wakes up, so its credential must survive a restart. Until now
//! the agent took `--username` / `--password` on the command line and kept them
//! nowhere: a restart meant a human, and a scripted restart meant the password in
//! a process table. This module gives a repository's upstream credential a
//! **place to be named** in holger's RON config and a **place to be read from**
//! at boot — `nornir-vault` (edda), the at-rest-encrypted local store that
//! already exists. Nothing here re-implements a vault.
//!
//! ## The shape in RON
//!
//! ```ron
//! (
//! ron_vault_provider: Pin,
//! ron_repositories: [
//! (
//! ron_name: "nexus-mirror",
//! ron_credential: Vault(
//! ron_vault_path: "/var/lib/holger/agent.vault",
//! ron_entry: "nexus-mirror",
//! ),
//! ),
//! ( ron_name: "public-mirror", ron_credential: Anonymous ),
//! ],
//! )
//! ```
//!
//! A repository names *where* its credential lives — a vault path plus an entry
//! name — and never the credential itself.
//!
//! ## A literal secret is refused at boot, by name
//!
//! The literal forms (`Basic` / `Bearer` / `Mtls`) still **exist in the parse
//! surface**, and that is deliberate: a shape that simply failed to deserialize
//! would tell the operator "unknown variant", not "you put a password in a config
//! file". So they parse — but every secret-bearing field deserializes into
//! [`LiteralSecret`], which reads the value and **throws the bytes away**, and
//! [`UpstreamCredsConfig::refuse_literal_secrets`] then refuses the whole config
//! naming the repository and the offending fields. This is the house pattern:
//! refuse rather than half-enforce — the same call `ScanError::NoWasmHost`
//! (`plugin/host/src/registry.rs`) and `Caps::path_mapping`
//! (`server/lib/src/group.rs`) make.
//!
//! Because the literal value is discarded at deserialize time, there is no copy
//! of it anywhere for the refusal, a log line, a metric label or a rendered
//! config to leak. See `password_never_reaches_config_error_or_audit`.
//!
//! ## What happens at boot
//!
//! | condition | outcome |
//! |---|---|
//! | vault file absent | [`CredsError::VaultMissing`] — refuse to boot |
//! | vault will not unlock (wrong PIN / tampered) | [`CredsError::VaultLocked`] with attempts remaining |
//! | vault locked out | [`CredsError::VaultLockedOut`] |
//! | named entry absent | [`CredsError::EntryAbsent`] naming repository + entry |
//! | entry is the wrong shape / mislabelled | [`CredsError::EntryWrongShape`] |
//!
//! On success every named credential is decoded into a [`Credential`] held in
//! memory ([`nornir_vault::SecretString`], zeroized on drop) for the process
//! lifetime, and the vault is locked again — the DEK does not stay resident.
//!
//! ## Tier gate
//!
//! This module is compiled only under the `online` feature, the same gate the
//! network connectors sit behind. The airgap tiers (`embedded`, `mini`) link no
//! connector and therefore no vault: nothing here widens the zero-egress floor.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use zeroize::{Zeroize, ZeroizeOnDrop};
use nornir_vault::{PinProvider, SecretEntry, SecretString, Vault, VaultError, VaultKeyProvider};
/// The non-secret label a registry-credentials `Opaque` entry carries in the
/// vault. The label is bound into the entry's AEAD AAD, so it cannot be changed
/// after the fact without failing the tag — which makes it a real guard against
/// pointing a mirror at an entry that was stored for something else.
pub const REGISTRY_CREDS_LABEL: &str = "registry-creds";
// ---------------------------------------------------------------------------
// The literal-secret sink
// ---------------------------------------------------------------------------
/// A secret written **literally** into the RON config.
///
/// It deserializes from any value and keeps *nothing*. Its only purpose is to let
/// the boot gate see the shape and refuse it by name; there is deliberately no
/// accessor, because the bytes were never retained. `Debug`/`Serialize` render
/// `***`.
#[derive(Clone, Copy, Default, PartialEq, Eq)]
pub struct LiteralSecret;
impl core::fmt::Debug for LiteralSecret {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str("***")
}
}
impl<'de> Deserialize<'de> for LiteralSecret {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
// Read the value and drop it on the floor.
serde::de::IgnoredAny::deserialize(d)?;
Ok(LiteralSecret)
}
}
impl Serialize for LiteralSecret {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str("***")
}
}
// ---------------------------------------------------------------------------
// The RON config
// ---------------------------------------------------------------------------
/// Which [`VaultKeyProvider`] opens the agent's vault. A config choice, not a
/// secret: the PIN itself is supplied in-process by the caller (an operator
/// prompt), never by this file and never by an environment variable.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum VaultProvider {
/// Argon2id over an operator PIN.
#[default]
Pin,
/// The OS keyring (Secret Service). Needs the `vault-os-keyring` feature.
OsKeyring {
/// Keyring service name.
ron_service: String,
/// Keyring account name.
ron_account: String,
},
}
/// How one repository's upstream credential is named.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum CredentialRef {
/// The upstream needs no credential.
Anonymous,
/// The credential lives in a vault entry. **The supported form.**
Vault {
/// Path to the `nornir-vault` file.
ron_vault_path: String,
/// The entry name inside that vault.
ron_entry: String,
},
/// REFUSED AT BOOT — a literal basic credential. Parses only so the refusal
/// can name it; the values are discarded (see [`LiteralSecret`]).
Basic {
/// Discarded.
ron_username: LiteralSecret,
/// Discarded.
ron_password: LiteralSecret,
},
/// REFUSED AT BOOT — a literal bearer token.
Bearer {
/// Discarded.
ron_token: LiteralSecret,
},
/// REFUSED AT BOOT — a literal mTLS identity.
Mtls {
/// Discarded.
ron_cert_pem: LiteralSecret,
/// Discarded.
ron_key_pem: LiteralSecret,
},
}
impl CredentialRef {
/// The RON variant name, for a refusal message.
pub fn form(&self) -> &'static str {
match self {
CredentialRef::Anonymous => "Anonymous",
CredentialRef::Vault { .. } => "Vault",
CredentialRef::Basic { .. } => "Basic",
CredentialRef::Bearer { .. } => "Bearer",
CredentialRef::Mtls { .. } => "Mtls",
}
}
/// The secret-bearing fields this form writes into the config file. Empty for
/// the two accepted forms — non-empty means "refuse at boot".
pub fn literal_fields(&self) -> &'static [&'static str] {
match self {
CredentialRef::Anonymous | CredentialRef::Vault { .. } => &[],
CredentialRef::Basic { .. } => &["ron_username", "ron_password"],
CredentialRef::Bearer { .. } => &["ron_token"],
CredentialRef::Mtls { .. } => &["ron_cert_pem", "ron_key_pem"],
}
}
}
/// One repository and the credential it names.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryCredential {
/// The repository name — the key the mirror looks itself up by.
pub ron_name: String,
/// Where its upstream credential comes from.
pub ron_credential: CredentialRef,
}
/// The agent's upstream-credentials config, parsed from RON.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpstreamCredsConfig {
/// Which provider opens the vault.
#[serde(default)]
pub ron_vault_provider: VaultProvider,
/// One entry per repository that has an upstream.
#[serde(default)]
pub ron_repositories: Vec<RepositoryCredential>,
}
impl UpstreamCredsConfig {
/// Parse RON.
///
/// GOTCHA: a `ron` parse error's text can quote the *offending value* — which
/// for a malformed credential line is the password itself. So only the
/// position is kept; the parser's own message is deliberately dropped. The
/// accepted/refused shapes above all parse, so a real config never lands here
/// for a credential reason.
pub fn parse(text: &str) -> Result<Self, CredsError> {
ron::from_str::<Self>(text).map_err(|e| CredsError::Parse {
line: e.span.start.line,
col: e.span.start.col,
})
}
/// Refuse, by name, every repository that wrote a literal secret into the
/// config. Called by [`CredentialStore::load`] before anything is opened.
pub fn refuse_literal_secrets(&self) -> Result<(), CredsError> {
let offenders: Vec<LiteralOffence> = self
.ron_repositories
.iter()
.filter(|r| !r.ron_credential.literal_fields().is_empty())
.map(|r| LiteralOffence {
repository: r.ron_name.clone(),
form: r.ron_credential.form(),
fields: r.ron_credential.literal_fields(),
})
.collect();
if offenders.is_empty() {
Ok(())
} else {
Err(CredsError::LiteralSecretInConfig { offenders })
}
}
}
/// One repository that wrote a literal secret into the RON. Carries the
/// repository name and the field names — never a value.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct LiteralOffence {
/// The repository that did it.
pub repository: String,
/// The RON variant it used.
pub form: &'static str,
/// The secret-bearing field names in that variant.
pub fields: &'static [&'static str],
}
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
/// Everything that stops the agent from booting with credentials.
///
/// Every variant is secret-free by construction: it carries repository names,
/// vault paths, entry names and counters. Its `Debug` and `Display` are safe to
/// log, and `password_never_reaches_config_error_or_audit` holds that line.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum CredsError {
/// The RON did not parse. Position only — see [`UpstreamCredsConfig::parse`].
Parse {
/// 1-based line.
line: usize,
/// 1-based column.
col: usize,
},
/// One or more repositories wrote a literal secret into the config.
LiteralSecretInConfig {
/// Who, and which fields.
offenders: Vec<LiteralOffence>,
},
/// The vault file a repository names does not exist.
VaultMissing {
/// The repository that names it.
repository: String,
/// The path that is not there.
vault: PathBuf,
},
/// The vault file exists but is not a vault / could not be read.
VaultUnreadable {
/// The repository that names it.
repository: String,
/// The path.
vault: PathBuf,
},
/// The vault would not unlock with the configured provider.
VaultLocked {
/// The path.
vault: PathBuf,
/// Attempts left before the lockout trips.
attempts_remaining: u32,
},
/// The vault has locked out after too many failed attempts.
VaultLockedOut {
/// The path.
vault: PathBuf,
},
/// The provider offered does not match how the vault was provisioned.
ProviderMismatch {
/// The path.
vault: PathBuf,
},
/// The named entry is not in the vault.
EntryAbsent {
/// The repository that names it.
repository: String,
/// The vault.
vault: PathBuf,
/// The entry name that is not there.
entry: String,
},
/// The entry is present but is not usable as an upstream credential.
EntryWrongShape {
/// The repository that names it.
repository: String,
/// The entry name.
entry: String,
/// What is wrong — a fixed phrase, never a value.
detail: &'static str,
},
/// The configured provider is not compiled into this build.
ProviderUnavailable {
/// Which one.
method: &'static str,
/// The cargo feature that would supply it.
feature: &'static str,
},
/// A [`VaultProvider::Pin`] config was loaded without an operator PIN.
PinNotSupplied,
}
impl core::fmt::Display for CredsError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
CredsError::Parse { line, col } => {
write!(f, "upstream-credentials RON is malformed at {line}:{col} (the parser's own message is withheld: it can quote the offending value)")
}
CredsError::LiteralSecretInConfig { offenders } => {
write!(f, "refusing to boot: a literal secret is written into the upstream-credentials RON. ")?;
for (i, o) in offenders.iter().enumerate() {
if i > 0 {
f.write_str("; ")?;
}
write!(
f,
"repository {:?} uses {}({}) ",
o.repository,
o.form,
o.fields.join(", ")
)?;
}
write!(f, "— name a vault entry instead: Vault(ron_vault_path: \"…\", ron_entry: \"…\")")
}
CredsError::VaultMissing { repository, vault } => write!(
f,
"refusing to boot: repository {repository:?} names vault {} and it does not exist",
vault.display()
),
CredsError::VaultUnreadable { repository, vault } => write!(
f,
"refusing to boot: repository {repository:?} names vault {} and it is not a readable vault file",
vault.display()
),
CredsError::VaultLocked { vault, attempts_remaining } => write!(
f,
"refusing to boot: vault {} did not unlock ({attempts_remaining} attempt(s) remaining before lockout)",
vault.display()
),
CredsError::VaultLockedOut { vault } => write!(
f,
"refusing to boot: vault {} is locked out after too many failed attempts",
vault.display()
),
CredsError::ProviderMismatch { vault } => write!(
f,
"refusing to boot: vault {} was provisioned with a different auth method than ron_vault_provider names",
vault.display()
),
CredsError::EntryAbsent { repository, vault, entry } => write!(
f,
"refusing to boot: repository {repository:?} names entry {entry:?} in vault {}, which holds no such entry",
vault.display()
),
CredsError::EntryWrongShape { repository, entry, detail } => write!(
f,
"refusing to boot: repository {repository:?} names entry {entry:?}, which {detail}"
),
CredsError::ProviderUnavailable { method, feature } => write!(
f,
"refusing to boot: ron_vault_provider names {method}, which this build does not carry (rebuild with --features {feature})"
),
CredsError::PinNotSupplied => f.write_str(
"refusing to boot: ron_vault_provider is Pin but no PIN was supplied to the loader",
),
}
}
}
impl std::error::Error for CredsError {}
// ---------------------------------------------------------------------------
// The in-memory credential
// ---------------------------------------------------------------------------
/// One upstream credential, held in memory for the process lifetime.
///
/// Every secret half is a [`SecretString`]: no `Serialize`, no `Display`, no
/// revealing `Debug`, zeroized on drop.
#[derive(Clone)]
pub enum Credential {
/// No credential — an anonymous upstream.
Anonymous,
/// HTTP basic.
Basic {
/// The account name (not a secret, but still never logged from here).
username: String,
/// The password / API key.
password: SecretString,
},
/// A bearer token (Artifactory access token, OIDC bearer).
Bearer(SecretString),
/// An mTLS client identity.
Mtls {
/// Client leaf certificate, PEM.
cert_pem: SecretString,
/// Matching private key, PEM.
key_pem: SecretString,
},
}
impl core::fmt::Debug for Credential {
/// Redacted — prints the kind, never a byte of the credential (not even the
/// username, which would narrow a guess).
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "Credential::{}(***)", self.kind())
}
}
impl Credential {
/// The non-secret kind name — what a log line or an audit record may say.
pub fn kind(&self) -> &'static str {
match self {
Credential::Anonymous => "Anonymous",
Credential::Basic { .. } => "Basic",
Credential::Bearer(_) => "Bearer",
Credential::Mtls { .. } => "Mtls",
}
}
}
/// The secret-free record of one loaded credential — the audit / `state_json`
/// view, the analogue of `nornir_vault::VaultState`. Names only.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CredentialSummary {
/// The repository.
pub repository: String,
/// The vault it came from, if any.
pub vault: Option<String>,
/// The entry name inside that vault, if any.
pub entry: Option<String>,
/// The credential kind (`Basic` / `Bearer` / `Mtls` / `Anonymous`).
pub kind: &'static str,
}
// ---------------------------------------------------------------------------
// The vault wire form of a registry credential
// ---------------------------------------------------------------------------
/// The plaintext an `Opaque`/`registry-creds` entry holds, as RON:
/// `(username: "…", password: "…")`. Zeroized on drop so the decode leaves no
/// copy behind once the halves have moved into [`SecretString`]s.
#[derive(Deserialize, Zeroize, ZeroizeOnDrop)]
struct RegistryCredsWire {
username: String,
password: String,
}
/// Build the vault entry a mirror expects for a basic upstream credential.
///
/// The counterpart of the decode in [`CredentialStore::load`]; store it with
/// `vault.set_with_meta(name, entry, Some(REGISTRY_CREDS_LABEL), Some(stamp))`.
pub fn registry_creds_entry(username: &str, password: &str) -> SecretEntry {
// ron's own escaping is what makes a password with a quote in it round-trip.
let ron_text = format!(
"(username: {}, password: {})",
ron_quote(username),
ron_quote(password)
);
let entry = SecretEntry::opaque(ron_text.as_bytes().to_vec());
let mut ron_text = ron_text;
ron_text.zeroize();
entry
}
/// Minimal RON string literal quoting — `"` and `\` escaped.
fn ron_quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
_ => out.push(c),
}
}
out.push('"');
out
}
// ---------------------------------------------------------------------------
// Providers
// ---------------------------------------------------------------------------
/// Build the [`VaultKeyProvider`] the config names.
///
/// `pin` is supplied **in-process** by the caller — an operator prompt, a
/// systemd credential handed to the process, an interactive unlock. It is never
/// read from this config and never from an environment variable.
pub fn provider_for(
cfg: &VaultProvider,
pin: Option<&str>,
) -> Result<Box<dyn VaultKeyProvider>, CredsError> {
match cfg {
VaultProvider::Pin => {
let pin = pin.ok_or(CredsError::PinNotSupplied)?;
Ok(Box::new(PinProvider::new(pin)))
}
#[cfg(feature = "vault-os-keyring")]
VaultProvider::OsKeyring {
ron_service,
ron_account,
} => Ok(Box::new(nornir_vault::OsKeyringProvider::new(
ron_service.clone(),
ron_account.clone(),
))),
#[cfg(not(feature = "vault-os-keyring"))]
VaultProvider::OsKeyring { .. } => Err(CredsError::ProviderUnavailable {
method: "OsKeyring",
feature: "vault-os-keyring",
}),
}
}
// ---------------------------------------------------------------------------
// The store
// ---------------------------------------------------------------------------
/// Every repository's upstream credential, resolved once at boot and held for
/// the process lifetime.
///
/// `Debug` prints names and kinds only.
#[derive(Default)]
pub struct CredentialStore {
by_repository: BTreeMap<String, Credential>,
provenance: BTreeMap<String, (Option<String>, Option<String>)>,
}
impl core::fmt::Debug for CredentialStore {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("CredentialStore")
.field("repositories", &self.summary())
.finish()
}
}
impl CredentialStore {
/// Parse the RON, refuse literal secrets, then open every named vault once,
/// decode every named entry, and lock the vaults again.
pub fn load(text: &str, provider: &dyn VaultKeyProvider) -> Result<Self, CredsError> {
let cfg = UpstreamCredsConfig::parse(text)?;
Self::from_config(&cfg, provider)
}
/// [`load`](Self::load) from an already-parsed config.
pub fn from_config(
cfg: &UpstreamCredsConfig,
provider: &dyn VaultKeyProvider,
) -> Result<Self, CredsError> {
cfg.refuse_literal_secrets()?;
let mut store = CredentialStore::default();
// One open+unlock per distinct vault path, however many repositories
// share it — the DEK is unwrapped once, not per repository.
let mut vaults: BTreeMap<PathBuf, Vault> = BTreeMap::new();
for repo in &cfg.ron_repositories {
match &repo.ron_credential {
CredentialRef::Anonymous => {
store
.by_repository
.insert(repo.ron_name.clone(), Credential::Anonymous);
store.provenance.insert(repo.ron_name.clone(), (None, None));
}
CredentialRef::Vault {
ron_vault_path,
ron_entry,
} => {
let path = PathBuf::from(ron_vault_path);
if !vaults.contains_key(&path) {
let v = open_and_unlock(&path, &repo.ron_name, provider)?;
vaults.insert(path.clone(), v);
}
let vault = vaults.get(&path).expect("just inserted");
let cred = decode_entry(vault, &path, &repo.ron_name, ron_entry)?;
store.by_repository.insert(repo.ron_name.clone(), cred);
store.provenance.insert(
repo.ron_name.clone(),
(Some(ron_vault_path.clone()), Some(ron_entry.clone())),
);
}
// Unreachable: refuse_literal_secrets ran first.
_ => unreachable!("literal secrets are refused before this point"),
}
}
// Credentials are copied out; the DEK and the decrypted cache go away.
for v in vaults.values_mut() {
v.lock();
}
Ok(store)
}
/// The credential for `repository`, if the config named one.
pub fn get(&self, repository: &str) -> Option<&Credential> {
self.by_repository.get(repository)
}
/// The repositories this store carries.
pub fn repositories(&self) -> Vec<&str> {
self.by_repository.keys().map(String::as_str).collect()
}
/// The secret-free audit / `state_json` view.
pub fn summary(&self) -> Vec<CredentialSummary> {
self.by_repository
.iter()
.map(|(name, cred)| {
let (vault, entry) = self.provenance.get(name).cloned().unwrap_or((None, None));
CredentialSummary {
repository: name.clone(),
vault,
entry,
kind: cred.kind(),
}
})
.collect()
}
/// Build a Nexus connector for `repository` with the credential held here.
pub fn nexus_connector(
&self,
repository: &str,
url: String,
) -> anyhow::Result<connector_nexus::NexusConnector> {
use connector_nexus::NexusConnector;
match self.require(repository)? {
Credential::Anonymous => NexusConnector::new(url, None, None),
Credential::Basic { username, password } => NexusConnector::new(
url,
Some(username.clone()),
Some(password.expose().to_string()),
),
Credential::Bearer(token) => {
NexusConnector::with_oidc_token(url, token.expose().to_string())
}
Credential::Mtls { cert_pem, key_pem } => NexusConnector::with_mtls(
url,
cert_pem.expose().as_bytes(),
key_pem.expose().as_bytes(),
),
}
}
/// Build an Artifactory connector for `repository` with the credential held
/// here.
pub fn artifactory_connector(
&self,
repository: &str,
url: String,
) -> anyhow::Result<connector_artifactory::ArtifactoryConnector> {
use connector_artifactory::ArtifactoryConnector;
match self.require(repository)? {
Credential::Anonymous => ArtifactoryConnector::new(url, None, None),
Credential::Basic { username, password } => ArtifactoryConnector::new(
url,
Some(username.clone()),
Some(password.expose().to_string()),
),
Credential::Bearer(token) => {
ArtifactoryConnector::with_oidc_token(url, token.expose().to_string())
}
Credential::Mtls { cert_pem, key_pem } => ArtifactoryConnector::with_mtls(
url,
cert_pem.expose().as_bytes(),
key_pem.expose().as_bytes(),
),
}
}
fn require(&self, repository: &str) -> anyhow::Result<&Credential> {
self.by_repository.get(repository).ok_or_else(|| {
anyhow::anyhow!("no upstream credential configured for repository {repository:?}")
})
}
}
fn open_and_unlock(
path: &Path,
repository: &str,
provider: &dyn VaultKeyProvider,
) -> Result<Vault, CredsError> {
if !path.exists() {
return Err(CredsError::VaultMissing {
repository: repository.to_string(),
vault: path.to_path_buf(),
});
}
let mut vault = Vault::open(path).map_err(|_| CredsError::VaultUnreadable {
repository: repository.to_string(),
vault: path.to_path_buf(),
})?;
vault.unlock(provider).map_err(|e| match e {
VaultError::Unlock { remaining } => CredsError::VaultLocked {
vault: path.to_path_buf(),
attempts_remaining: remaining,
},
VaultError::LockedOut => CredsError::VaultLockedOut {
vault: path.to_path_buf(),
},
VaultError::MethodMismatch { .. } => CredsError::ProviderMismatch {
vault: path.to_path_buf(),
},
_ => CredsError::VaultUnreadable {
repository: repository.to_string(),
vault: path.to_path_buf(),
},
})?;
Ok(vault)
}
fn decode_entry(
vault: &Vault,
path: &Path,
repository: &str,
entry: &str,
) -> Result<Credential, CredsError> {
let found = vault
.get(entry)
.map_err(|_| CredsError::VaultUnreadable {
repository: repository.to_string(),
vault: path.to_path_buf(),
})?
.ok_or_else(|| CredsError::EntryAbsent {
repository: repository.to_string(),
vault: path.to_path_buf(),
entry: entry.to_string(),
})?;
match found {
SecretEntry::SparkToken(t) => Ok(Credential::Bearer(SecretString::new(t.expose()))),
SecretEntry::MtlsClientIdentity { cert_pem, key_pem } => Ok(Credential::Mtls {
cert_pem: SecretString::new(cert_pem.expose()),
key_pem: SecretString::new(key_pem.expose()),
}),
SecretEntry::Opaque(bytes) => {
// The label is bound into the entry's AAD, so it cannot have been
// changed after the fact. Insist on it when one is set: a blob stored
// for something else is not an upstream credential.
if let Some(label) = vault.entry_label(entry) {
if label != REGISTRY_CREDS_LABEL {
return Err(CredsError::EntryWrongShape {
repository: repository.to_string(),
entry: entry.to_string(),
detail: "is labelled for something other than registry credentials",
});
}
}
let text = core::str::from_utf8(bytes.as_slice()).map_err(|_| {
CredsError::EntryWrongShape {
repository: repository.to_string(),
entry: entry.to_string(),
detail: "is not UTF-8 registry-credentials RON",
}
})?;
// The ron error is discarded, not rendered: it can quote the password.
let wire: RegistryCredsWire =
ron::from_str(text).map_err(|_| CredsError::EntryWrongShape {
repository: repository.to_string(),
entry: entry.to_string(),
detail: "does not parse as (username: \"…\", password: \"…\")",
})?;
Ok(Credential::Basic {
username: wire.username.clone(),
password: SecretString::new(wire.password.as_str()),
})
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
const PIN: &str = "4913";
/// The canary. If this string ever shows up in a rendered config, an error,
/// a Debug print or an audit record, the wiring leaked.
const PASSWORD: &str = "korrekt-hast-batteri-haftklammer";
fn tmpdir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!(
"holger-creds-{tag}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
std::fs::create_dir_all(&d).unwrap();
d
}
/// A vault at `path` holding `entry` = basic creds for `PASSWORD`.
fn provision(path: &Path, entry: &str) {
let mut v = Vault::create(path, &PinProvider::new(PIN)).unwrap();
v.set_with_meta(
entry,
registry_creds_entry("mirror-bot", PASSWORD),
Some(REGISTRY_CREDS_LABEL),
Some("2026-09-20T00:00:00Z"),
)
.unwrap();
v.lock();
}
fn vault_ron(path: &Path, entry: &str) -> String {
format!(
r#"(
ron_vault_provider: Pin,
ron_repositories: [
( ron_name: "nexus-mirror", ron_credential: Vault(
ron_vault_path: {:?}, ron_entry: {:?} ) ),
( ron_name: "public", ron_credential: Anonymous ),
],
)"#,
path.to_string_lossy(),
entry
)
}
const LITERAL_RON: &str = r#"(
ron_repositories: [
( ron_name: "nexus-mirror", ron_credential: Basic(
ron_username: "mirror-bot",
ron_password: "korrekt-hast-batteri-haftklammer" ) ),
],
)"#;
#[test]
fn a_vault_reference_is_the_shape_a_repository_writes() {
let dir = tmpdir("shape");
let path = dir.join("agent.vault");
provision(&path, "nexus-mirror");
let store =
CredentialStore::load(&vault_ron(&path, "nexus-mirror"), &PinProvider::new(PIN))
.unwrap();
match store.get("nexus-mirror").unwrap() {
Credential::Basic { username, password } => {
assert_eq!(username, "mirror-bot");
assert_eq!(password.expose(), PASSWORD);
}
other => panic!("expected basic, got {other:?}"),
}
assert!(matches!(store.get("public"), Some(Credential::Anonymous)));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_literal_secret_in_the_ron_is_refused_by_name() {
let err = CredentialStore::load(LITERAL_RON, &PinProvider::new(PIN)).unwrap_err();
let CredsError::LiteralSecretInConfig { ref offenders } = err else {
panic!("expected a literal-secret refusal, got {err:?}");
};
assert_eq!(offenders.len(), 1);
assert_eq!(offenders[0].repository, "nexus-mirror");
assert_eq!(offenders[0].form, "Basic");
assert_eq!(offenders[0].fields, &["ron_username", "ron_password"]);
// …and the refusal names the repository out loud.
assert!(err.to_string().contains("nexus-mirror"));
assert!(err.to_string().contains("ron_password"));
}
#[test]
fn literal_bearer_and_mtls_are_refused_too() {
for (ron, form, field) in [
(
r#"( ron_repositories: [ ( ron_name: "a", ron_credential: Bearer( ron_token: "tok" ) ) ] )"#,
"Bearer",
"ron_token",
),
(
r#"( ron_repositories: [ ( ron_name: "a", ron_credential: Mtls( ron_cert_pem: "c", ron_key_pem: "k" ) ) ] )"#,
"Mtls",
"ron_key_pem",
),
] {
let err = CredentialStore::load(ron, &PinProvider::new(PIN)).unwrap_err();
let CredsError::LiteralSecretInConfig { offenders } = &err else {
panic!("expected refusal for {form}, got {err:?}");
};
assert_eq!(offenders[0].form, form);
assert!(offenders[0].fields.contains(&field));
}
}
/// THE LEAK TEST. A password written literally into the RON must not survive
/// into anything a human or a machine ever reads: not the parsed config, not
/// its rendered (serialized) form, not its `Debug`, not the refusal's
/// `Display` or `Debug`, and not the audit record.
#[test]
fn password_never_reaches_config_error_or_audit() {
let cfg = UpstreamCredsConfig::parse(LITERAL_RON).unwrap();
// 1. The parsed config kept nothing.
let rendered = ron::ser::to_string(&cfg).unwrap();
assert!(
!rendered.contains(PASSWORD),
"rendered config leaked: {rendered}"
);
assert!(
rendered.contains("***"),
"expected the redaction marker: {rendered}"
);
let debugged = format!("{cfg:?}");
assert!(
!debugged.contains(PASSWORD),
"config Debug leaked: {debugged}"
);
let as_json = serde_json::to_string(&cfg).unwrap();
assert!(!as_json.contains(PASSWORD), "json render leaked: {as_json}");
// 2. The error path kept nothing.
let err = cfg.refuse_literal_secrets().unwrap_err();
assert!(!format!("{err}").contains(PASSWORD), "error Display leaked");
assert!(!format!("{err:?}").contains(PASSWORD), "error Debug leaked");
// 3. A loaded (vault-backed) credential does not leak through Debug or
// the audit record either.
let dir = tmpdir("leak");
let path = dir.join("agent.vault");
provision(&path, "nexus-mirror");
let store =
CredentialStore::load(&vault_ron(&path, "nexus-mirror"), &PinProvider::new(PIN))
.unwrap();
let store_debug = format!("{store:?}");
assert!(
!store_debug.contains(PASSWORD),
"store Debug leaked: {store_debug}"
);
let cred_debug = format!("{:?}", store.get("nexus-mirror").unwrap());
assert_eq!(cred_debug, "Credential::Basic(***)");
let audit = serde_json::to_string(&store.summary()).unwrap();
assert!(!audit.contains(PASSWORD), "audit record leaked: {audit}");
assert!(
audit.contains("nexus-mirror"),
"audit lost the repository name"
);
assert!(audit.contains("\"kind\":\"Basic\""), "audit lost the kind");
// 4. The vault's own UI view is clean (the invariant we lean on).
let v = Vault::open(&path).unwrap();
let state = serde_json::to_string(&v.state()).unwrap();
assert!(!state.contains(PASSWORD), "VaultState leaked: {state}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_missing_vault_refuses_to_boot() {
let dir = tmpdir("missing");
let path = dir.join("not-here.vault");
let err = CredentialStore::load(&vault_ron(&path, "nexus-mirror"), &PinProvider::new(PIN))
.unwrap_err();
let CredsError::VaultMissing { repository, .. } = &err else {
panic!("expected VaultMissing, got {err:?}");
};
assert_eq!(repository, "nexus-mirror");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_vault_that_will_not_unlock_refuses_to_boot_with_attempts_remaining() {
let dir = tmpdir("locked");
let path = dir.join("agent.vault");
provision(&path, "nexus-mirror");
let err =
CredentialStore::load(&vault_ron(&path, "nexus-mirror"), &PinProvider::new("0000"))
.unwrap_err();
let CredsError::VaultLocked {
attempts_remaining, ..
} = &err
else {
panic!("expected VaultLocked, got {err:?}");
};
assert!(*attempts_remaining < u32::MAX);
assert!(err.to_string().contains("did not unlock"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn an_absent_entry_refuses_to_boot_naming_repository_and_entry() {
let dir = tmpdir("absent");
let path = dir.join("agent.vault");
provision(&path, "some-other-entry");
let err = CredentialStore::load(&vault_ron(&path, "nexus-mirror"), &PinProvider::new(PIN))
.unwrap_err();
let CredsError::EntryAbsent {
repository, entry, ..
} = &err
else {
panic!("expected EntryAbsent, got {err:?}");
};
assert_eq!(repository, "nexus-mirror");
assert_eq!(entry, "nexus-mirror");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_mislabelled_entry_is_not_an_upstream_credential() {
let dir = tmpdir("mislabel");
let path = dir.join("agent.vault");
let mut v = Vault::create(&path, &PinProvider::new(PIN)).unwrap();
v.set_with_meta(
"nexus-mirror",
registry_creds_entry("mirror-bot", PASSWORD),
Some("git-ssh-key"),
None,
)
.unwrap();
v.lock();
let err = CredentialStore::load(&vault_ron(&path, "nexus-mirror"), &PinProvider::new(PIN))
.unwrap_err();
assert!(matches!(err, CredsError::EntryWrongShape { .. }), "{err:?}");
assert!(!format!("{err}").contains(PASSWORD));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_spark_token_entry_becomes_a_bearer_credential() {
let dir = tmpdir("bearer");
let path = dir.join("agent.vault");
let mut v = Vault::create(&path, &PinProvider::new(PIN)).unwrap();
v.set("art", SecretEntry::SparkToken(SecretString::new(PASSWORD)))
.unwrap();
v.lock();
let ron = format!(
r#"( ron_repositories: [ ( ron_name: "art", ron_credential: Vault(
ron_vault_path: {:?}, ron_entry: "art" ) ) ] )"#,
path.to_string_lossy()
);
let store = CredentialStore::load(&ron, &PinProvider::new(PIN)).unwrap();
match store.get("art").unwrap() {
Credential::Bearer(t) => assert_eq!(t.expose(), PASSWORD),
other => panic!("expected bearer, got {other:?}"),
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_credential_reaches_the_nexus_and_artifactory_constructors() {
let dir = tmpdir("connectors");
let path = dir.join("agent.vault");
provision(&path, "nexus-mirror");
let store =
CredentialStore::load(&vault_ron(&path, "nexus-mirror"), &PinProvider::new(PIN))
.unwrap();
let nexus = store
.nexus_connector("nexus-mirror", "https://nexus.example.com".into())
.unwrap();
match &nexus.auth {
connector_nexus::ConnectorAuth::Basic { username, password } => {
assert_eq!(username, "mirror-bot");
assert_eq!(password, PASSWORD);
}
other => panic!("expected basic, got {other:?}"),
}
// …and the connector's own Debug does not print it.
assert!(!format!("{:?}", nexus.auth).contains(PASSWORD));
let art = store
.artifactory_connector("public", "https://art.example.com".into())
.unwrap();
assert!(matches!(
art.auth,
connector_artifactory::ConnectorAuth::None
));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_password_with_quotes_round_trips_through_the_vault() {
let dir = tmpdir("quotes");
let path = dir.join("agent.vault");
let nasty = "a\"b\\c\nd";
let mut v = Vault::create(&path, &PinProvider::new(PIN)).unwrap();
v.set_with_meta(
"r",
registry_creds_entry("u", nasty),
Some(REGISTRY_CREDS_LABEL),
None,
)
.unwrap();
v.lock();
let ron = format!(
r#"( ron_repositories: [ ( ron_name: "r", ron_credential: Vault(
ron_vault_path: {:?}, ron_entry: "r" ) ) ] )"#,
path.to_string_lossy()
);
let store = CredentialStore::load(&ron, &PinProvider::new(PIN)).unwrap();
match store.get("r").unwrap() {
Credential::Basic { password, .. } => assert_eq!(password.expose(), nasty),
other => panic!("expected basic, got {other:?}"),
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn an_os_keyring_provider_is_named_but_not_linked_by_default() {
let cfg = VaultProvider::OsKeyring {
ron_service: "holger".into(),
ron_account: "mirror".into(),
};
let got = provider_for(&cfg, None);
#[cfg(not(feature = "vault-os-keyring"))]
assert!(matches!(
got,
Err(CredsError::ProviderUnavailable {
method: "OsKeyring",
..
})
));
#[cfg(feature = "vault-os-keyring")]
assert!(got.is_ok());
}
#[test]
fn a_pin_provider_without_a_pin_refuses_rather_than_reading_the_environment() {
assert!(matches!(
provider_for(&VaultProvider::Pin, None),
Err(CredsError::PinNotSupplied)
));
assert!(provider_for(&VaultProvider::Pin, Some(PIN)).is_ok());
}
}