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:
cref.env→std::env::var(name).cref.keychain→store.get(service, account).cref.literal— refused viaCredentialError::LiteralRefusedInCiwhenCIis exactlytrue.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 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. Debugof aSecretStringreplaces the value withSecretBox<str>([REDACTED]).CredentialRefcannot 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
CIto anything other thantrue, and tools wanting stricter enforcement setCI=truethemselves. 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:
- Why the precedence chain is ordered this way — why an environment variable beats a stored keychain entry, why there are two environment legs at opposite ends of the chain, and why the order cannot be configured.
- Why every secret is a
SecretString— what the type prevents that aStringdoes not, and why it can be deserialised but never serialised. - Why literal secrets are refused in CI — what the refusal protects against, and why detection is one exact string rather than anything cleverer.
- Why Linux keychain storage is session-scoped by default
— the build-hermeticity trade behind choosing kernel keyutils, and
what
linux-persistentactually buys.
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.
KeyringStorerequires 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

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.