rtb-credentials¶
rtb-credentials is the framework's secret-handling layer. It
provides 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 renders [REDACTED], memory is zeroed on drop. The crate
treats &str/String for a secret as a type error, not a style
preference.
Overview¶
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; credentials-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, …).
Design rationale¶
SecretStringeverywhere.LiteralStore::getreturnsself.secret.clone()— notSecretString::from(expose_secret().to_string())— so the secret never bounces through a bareString.secrecy0.10+ deliberately omitsSerializeforSecretStringto prevent blind round-trip leaks;CredentialRefdoesn't deriveSerializeas a result.- CI detection via
CI=trueonly. The common convention used by GitHub Actions, GitLab CI, CircleCI, Buildkite, and others. Broader detection (CI_*globs, provider-specific vars) produces false positives for developer shells. Tools wanting stricter enforcement setCI=truethemselves. - Keyring blocking calls in
spawn_blocking. Platform keyring APIs are synchronous; wrapping intokio::task::spawn_blockingkeeps the async trait honest on any runtime. Some platforms (Linux + keyutils) are fast enough to not truly block, but we assume worst case. Arc<std::io::Error>for theIovariant — soCredentialErrorcan deriveClone. Subsystems that fan errors to multiple consumers (logs, telemetry, health aggregation) avoid re-allocating per consumer.
Core types¶
CredentialStore¶
#[async_trait::async_trait]
pub trait CredentialStore: Send + Sync + 'static {
async fn get(&self, service: &str, account: &str) -> Result<SecretString, CredentialError>;
async fn set(&self, service: &str, account: &str, secret: SecretString) -> Result<(), CredentialError>;
async fn delete(&self, service: &str, account: &str) -> Result<(), CredentialError>;
}
Four built-in implementations:
| Store | Backing | Mutation | Use case |
|---|---|---|---|
KeyringStore |
keyring crate, platform-native |
supported | Production default for persistent secrets. |
EnvStore |
Process env | ReadOnly |
Env-reference lookup; set/delete unsupported (mutating env from library code is unsafe in Rust 2024). |
LiteralStore |
Single in-memory SecretString |
ReadOnly |
Tools hard-wired to one secret; test harnesses. |
MemoryStore |
HashMap<(String, String), SecretString> behind RwLock |
supported | Test fixture, exported publicly. |
CredentialRef¶
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CredentialRef {
#[serde(default)] pub env: Option<String>,
#[serde(default)] pub keychain: Option<KeychainRef>,
#[serde(default)] pub literal: Option<SecretString>,
#[serde(default)] pub fallback_env: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct KeychainRef { pub service: String, pub account: String }
Tools embed CredentialRef in their config structs:
CredentialRef: !Serialize by design
secrecy::SecretString deliberately does not implement
Serialize (blind round-trip leaks). CredentialRef inherits
the limitation — tools that need to write credentials back to
config go through an explicit "write secret" path; blanket
config re-serialisation is not supported.
Resolver¶
pub struct Resolver { /* Arc<dyn CredentialStore> */ }
impl Resolver {
pub fn new(keychain: Arc<dyn CredentialStore>) -> Self;
/// Walk env > keychain > literal (refused under CI) > fallback_env.
/// First hit wins.
pub async fn resolve(&self, cref: &CredentialRef)
-> Result<SecretString, CredentialError>;
}
The precedence chain is normative — do not reorder:
cref.env→std::env::var(name).cref.keychain→store.get(service, account).cref.literal— refused viaCredentialError::LiteralRefusedInCiwhenCI=true.cref.fallback_env→std::env::var(name).
CredentialBearing¶
pub trait CredentialBearing {
fn credentials(&self) -> Vec<(&'static str, &CredentialRef)>;
}
impl CredentialBearing for () { /* yields empty */ }
Introspection seam used by rtb-cli's v0.4 credentials subtree
to enumerate the CredentialRefs a downstream tool's config knows
about. Tools implement the trait on their typed config in five
lines:
impl CredentialBearing for MyConfig {
fn credentials(&self) -> Vec<(&'static str, &CredentialRef)> {
vec![
("anthropic", &self.anthropic.api),
("github", &self.github.token),
]
}
}
The &'static str name is the human-friendly identifier surfaced
by credentials list and accepted as the argument to
credentials add / remove / test. The trait is object-safe
(slice 2 stores a Box<dyn CredentialBearing>-erased provider on
App), and the blanket impl for () keeps existing App<()>
sites compiling unchanged.
A #[derive(CredentialBearing)] proc-macro is deferred to v0.5;
the explicit five-line impl is the v0.4 contract — see
v0.4 scope §4.1
and rtb-cli ops v0.1 §2.2.
CredentialError¶
#[derive(Debug, Clone, Error, Diagnostic)]
#[non_exhaustive]
pub enum CredentialError {
NotFound { name: String },
LiteralRefusedInCi,
Keychain(String),
ReadOnly,
Io(Arc<std::io::Error>),
}
All variants carry rtb::credentials::* diagnostic codes.
API surface¶
| Item | Kind | Since |
|---|---|---|
CredentialStore |
async trait | 0.1.0 |
KeyringStore, EnvStore, LiteralStore, MemoryStore |
structs | 0.1.0 |
CredentialRef, KeychainRef |
structs (deserialize-only) | 0.1.0 |
Resolver |
struct | 0.1.0 |
Resolver::probe |
method | 0.4.0 |
ResolutionSource, ResolutionOutcome |
enums | 0.4.0 |
CredentialError::{NotFound, LiteralRefusedInCi, Keychain, ReadOnly, Io} |
enum | 0.1.0 |
CredentialBearing |
trait + blanket impl for () |
0.4.0 |
Re-exports: SecretString, ExposeSecret |
from secrecy |
0.1.0 |
Usage patterns¶
Tool-config reference¶
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()));
Test-side injection¶
use rtb_credentials::{CredentialRef, KeychainRef, MemoryStore, Resolver, SecretString};
#[tokio::test]
async fn test_resolver_prefers_keychain_over_literal() {
let store = Arc::new(MemoryStore::new());
store.set("svc", "acct", SecretString::from("keychain-wins".to_string())).await.unwrap();
let resolver = Resolver::new(store);
let cref = CredentialRef {
keychain: Some(KeychainRef { service: "svc".into(), account: "acct".into() }),
literal: Some(SecretString::from("literal-loses".to_string())),
..CredentialRef::default()
};
let got = resolver.resolve(&cref).await.unwrap();
assert_eq!(got.expose_secret(), "keychain-wins");
}
Platform behaviour¶
Linux default is session-scoped
On Linux the default keyring backend is kernel keyutils
(keyring/linux-native) — pure Rust, no system deps, session
lifetime. This keeps workspace builds hermetic (no
libdbus-1-dev / pkg-config required).
Tools that need reboot-persistent Linux storage enable the
credentials-linux-persistent feature on the rtb umbrella
(or linux-persistent on rtb-credentials directly). That
extends keyring with sync-secret-service so the fallback
chain becomes keyutils → Secret Service.
macOS / Windows always persistent
macOS Keychain and Windows Credential Manager store cross- session by default; no feature flag needed.
Security¶
#![forbid(unsafe_code)]at the crate root.- Every public fn that touches a secret takes or returns
SecretString. DebugofSecretStringrenders[REDACTED].- Tracing spans record service/account, never the secret.
- Literal-in-CI refusal is a policy check, not a technical one —
tools that want stricter enforcement set
CI=truethemselves. - See Engineering Standards §1.2 for the full secret-handling rules.
Deferred to later versions¶
credentialsCLI subcommand inrtb-cli(get/set/delete).- OAuth flows (device / PKCE).
- Password rotation.
- Encrypted-at-rest config secrets beyond the literal value.
Consumers¶
| Crate | Uses |
|---|---|
| rtb-ai (v0.3) | AI provider API keys via CredentialRef. |
| rtb-vcs (v0.5) | GitHub/GitLab PATs via CredentialRef. |
| rtb-update (v0.2) | Signing keys for release verification (possibly). |
Testing¶
18 acceptance criteria across:
- 12 unit tests (
tests/unit.rs) — T1–T12 covering store object- safety, round-trip, NotFound, env store read + missing, literal read + ReadOnly, resolver precedence across all four legs, CI literal refusal, empty-ref NotFound, Debug redaction, and a keyring smoke test. - 6 Gherkin scenarios (
tests/features/credentials.feature).
Spec and status¶
- Status:
IMPLEMENTEDsince 0.1.0. - Spec:
docs/development/specs/2026-04-22-rtb-credentials-v0.1.md. - Source:
crates/rtb-credentials/.
Related¶
- Engineering Standards §1.2 — Secret handling.
- rtb-config — where
CredentialRefis embedded. - rtb-error — diagnostic rendering.