Skip to content

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

  • SecretString everywhere. LiteralStore::get returns self.secret.clone() — not SecretString::from(expose_secret().to_string()) — so the secret never bounces through a bare String. secrecy 0.10+ deliberately omits Serialize for SecretString to prevent blind round-trip leaks; CredentialRef doesn't derive Serialize as a result.
  • CI detection via CI=true only. 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 set CI=true themselves.
  • Keyring blocking calls in spawn_blocking. Platform keyring APIs are synchronous; wrapping in tokio::task::spawn_blocking keeps 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 the Io variant — so CredentialError can derive Clone. 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:

#[derive(Deserialize)]
struct AnthropicCfg {
    api: CredentialRef,
}

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:

  1. cref.envstd::env::var(name).
  2. cref.keychainstore.get(service, account).
  3. cref.literal — refused via CredentialError::LiteralRefusedInCi when CI=true.
  4. cref.fallback_envstd::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.
  • Debug of SecretString renders [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=true themselves.
  • See Engineering Standards §1.2 for the full secret-handling rules.

Deferred to later versions

  • credentials CLI subcommand in rtb-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