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 rust-tool-base, whose rtb crate still pulls it in behind its default-on credentials feature.

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 Secret Service / Windows Credential Manager) On Linux, needs a D-Bus session bus and a keyring daemon at run time.
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.env → std::env::var(name).
  2. cref.keychain → store.get(service, account).
  3. cref.literal, refused via CredentialError::LiteralRefusedInCi when CI is exactly true.
  4. cref.fallback_env → std::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 keyring-core and its per-platform stores. 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 as a test fixture.
CredentialRef Deserialise-only config shape. Serialize is deliberately not derived, so a blind round trip cannot leak a secret.
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

Platform Keychain backend Persists across reboots
macOS Keychain yes
Windows Credential Manager yes
Linux and other Unix freedesktop Secret Service over D-Bus yes

On Linux the Secret Service needs a D-Bus session bus and a running provider such as GNOME Keyring or KWallet. Headless servers, containers and CI runners usually have neither, and keychain calls fail there; use an environment variable instead.

The D-Bus client, libdbus, is compiled from source and linked statically. The build host needs a C compiler for the target (for a musl target, musl-gcc), but no libdbus-1-dev or pkg-config, and the built binary needs no libdbus-1 at run time.

There is no feature that removes keychain support, and no backend choice to make. The linux-persistent feature is kept so existing manifests still resolve, but it changes nothing: Linux storage already persists. A build that must provably contain no keychain code 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