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:
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 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. 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 uses the Secret Service:
why the Secret Service and not kernel keyutils, what it costs at build
and run time, and why
linux-persistentno longer does anything.
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.