Skip to content

rtb-credentials

Secret-handling layer for CLI tools: the CredentialStore async trait, four built-in stores, a Resolver that walks the canonical precedence chain, and CredentialRef — the deserialise-only config shape tool authors embed in their typed configs.

Secrets cross every boundary as secrecy::SecretString: Debug replaces the value with SecretBox<str>([REDACTED]), and memory is zeroed on drop. The crate treats &str/String for a secret as a type error, not a style preference.

Part of the phpboyscout Rust toolkit; extracted from — and battle-tested by — rust-tool-base.

Where to start

If you want to Go to
Get something working from nothing Tutorial: resolve your first credential
Solve a specific task How-to guides
Look up a key, error or default Reference
Understand why it works this way Explanation
Know what it will not do What this crate does not do

Storage modes

Three storage modes are supported:

Mode Lives in Notes
Env var Process environment (shell profile, CI secret injection) Recommended default.
OS keychain Platform-native (macOS Keychain / Linux keyutils / Windows Credential Manager) Linux default is pure-Rust kernel keyutils; the linux-persistent feature adds D-Bus Secret Service.
Literal Config file Legacy. Refused under CI=true.

Resolver walks all three in order, plus an ecosystem-default env-var fallback (ANTHROPIC_API_KEY, GITHUB_TOKEN, …). The chain is fixed in code and cannot be reordered, disabled or extended:

  1. cref.envstd::env::var(name).
  2. cref.keychainstore.get(service, account).
  3. cref.literal — refused via CredentialError::LiteralRefusedInCi when CI is exactly true.
  4. cref.fallback_envstd::env::var(name).

First hit wins. Two details that catch people out: a refused literal stops the walk rather than falling through to fallback_env, and a keychain error that is not a clean "not found" aborts the whole resolution. Both are covered in the resolver reference.

Public API

use rtb_credentials::{CredentialRef, CredentialStore, KeyringStore, Resolver};
use std::sync::Arc;

#[derive(serde::Deserialize)]
struct MyCfg {
    anthropic: CredentialRef,
}

let cfg: MyCfg = /* ... */;

let store: Arc<dyn CredentialStore> = Arc::new(KeyringStore::new());
let resolver = Resolver::new(store);

let api_key = resolver.resolve(&cfg.anthropic).await?;
use secrecy::ExposeSecret;
request.header("authorization", format!("Bearer {}", api_key.expose_secret()));
Item Purpose
CredentialStore Async trait: get / set / delete, all in SecretString.
KeyringStore Platform-native keychain via the keyring crate. Blocking calls wrapped in tokio::task::spawn_blocking, so it requires a Tokio runtime and panics on any other.
EnvStore Read-only lookup against the process environment. Takes the variable name as its account argument and ignores service.
LiteralStore Read-only single in-memory secret, returned for any service/account; test harnesses.
MemoryStore HashMap-backed store behind RwLock — exported test fixture.
CredentialRef Deserialise-only config shape (Serialize deliberately not derived — no blind round-trip leaks).
KeychainRef Service/account pair. Holds no secret, so it derives Serialize as well as Deserialize.
Resolver (+ probe, ResolutionSource, ResolutionOutcome) Walks env > keychain > literal > fallback_env; probe reports which leg would win without exposing the secret.
CredentialBearing Introspection seam: a typed config enumerates its CredentialRefs for credentials list-style tooling. Object-safe; blanket impl for ().
CredentialError thiserror + miette::Diagnostic, Clone-able (Io wraps Arc<std::io::Error>).
Re-exports SecretString, ExposeSecret from secrecy.

Full API reference: docs.rs/rtb-credentials.

Platform behaviour and the keychain opt-in

On Linux the default keyring backend is kernel keyutils (keyring/linux-native) — pure Rust, no system deps, session-scoped persistence. This keeps builds hermetic: no libdbus-1-dev / pkg-config required.

Tools that need reboot-persistent Linux storage enable the linux-persistent Cargo feature, which extends keyring with the freedesktop Secret Service (D-Bus) backend. Building with it requires pkg-config and libdbus-1-dev on the host.

macOS Keychain and Windows Credential Manager store cross-session by default; no feature flag needed.

There is no feature that removes keychain support. keyring and its macOS, Windows and Linux keyutils backends are compiled into every build; the only feature this crate has, linux-persistent, adds a backend. A build that must provably contain no keychain code cannot get there by turning a feature off — it has to not depend on rtb-credentials. See Cargo features.

Security

  • #![forbid(unsafe_code)] at the crate root.
  • Every public fn that touches a secret takes or returns SecretString.
  • Debug of a SecretString replaces the value with SecretBox<str>([REDACTED]).
  • CredentialRef cannot be serialised, so a blanket "save the config struct" cannot write a resolved secret to disk.
  • Literal-in-CI refusal is a policy check, not a technical one — it is trivially overridden by setting CI to anything other than true, and tools wanting stricter enforcement set CI=true themselves. See why literal secrets are refused in CI.

What the crate does not protect: once expose_secret() has been called the returned &str is an ordinary string and can be logged like any other. Catching secrets that have already escaped into free-form text is a separate job — see why every secret is a SecretString.

Why it is built this way

Each of these has a page that makes the argument in full:

What this crate does not do

Worth reading before you design around it. The full list is on what this crate does not do; the ones that catch people most often:

  • The resolution order cannot be changed. No key, builder or feature reorders it.
  • A literal plus a fallback fails in CI rather than using the fallback, even when the fallback variable is set.
  • A keychain backend error aborts resolution instead of falling through to lower-precedence sources. Only a clean "not found" continues the chain.
  • KeyringStore requires Tokio specifically — it panics on any other async runtime.
  • No caching, expiry, rotation or validation. The crate retrieves a stored static secret and nothing more.

Further reading

The blog carries a curated route through this subject: Rust, and what survived the port collects everything written about it, ordered so you can start at the beginning rather than newest-first.

Ask phpbotscout

phpbotscout

He answers questions about the projects over on the Discord, citing the docs where they already cover it, and offering to raise an issue where they don't. Bring a bug, an idea, or a questionable engineering decision.

Join the Discord