Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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