rtb-credentials v0.1 — Credential storage and precedence resolution¶
Status: IMPLEMENTED — 12 unit + 6 BDD acceptance criteria all
green on first implementation run.
Target crate: rtb-credentials
Feeds: rtb-ai (API tokens), rtb-vcs (PAT/OAuth), rtb-update
(signed-artefact keys — later).
Parent contract: §9.3 of the framework
spec and the credential-
storage policy in CLAUDE.md § Credential Storage.
1. Motivation¶
Downstream tools need tokens (AI API keys, GitHub PATs, GitLab tokens, …) resolvable from three places, with a documented precedence:
- Environment variable —
{provider}.api.envpoints at the env-var name. Read-through on every access. - OS keychain —
{provider}.api.keychainholds a service/account pair. Looked up via the platform-native keyring. - Literal —
{provider}.api.keyholds the raw secret in config. Legacy. Refused underCI=true. - Fallback — a tool-provided fallback env var (e.g.
ANTHROPIC_API_KEY).
rtb-credentials ships the types and traits that encode this precedence
and the keyring backend. Secrets cross every boundary as
secrecy::SecretString — Debug renders [REDACTED]; memory is
zeroed on drop.
2. Scope boundaries (explicit)¶
In scope for v0.1¶
CredentialStoreasync trait:get,set,delete.- Built-in stores:
KeyringStore— platform-native via thekeyringcrate. Compiled regardless of features (keyring's Linux default islinux-nativekeyutils, no system deps — see thechore(credentials)commit).EnvStore— reads from process env.LiteralStore— holds a literal in memory. For tests/CI.MemoryStore— in-memoryHashMap. Useful for testing downstream crates without touching the OS keychain.CredentialRef— the precedence-aware reference carried in config: typed as{ env: Option<String>, keychain: Option<KeychainRef>, literal: Option<SecretString>, fallback_env: Option<String> }.Resolverwith the canonical precedence implementation —Resolver::new(store).resolve(&CredentialRef) -> Result<SecretString>.CredentialErrorwithmiette::Diagnostic.
Deferred to later versions¶
credentialssubcommand (get/set/deleteat the CLI) — belongs inrtb-cliv0.2+.- OAuth flows (device/PKCE) —
rtb-author grouped with rtb-vcs. - Password rotation.
- Encrypted at-rest config secrets beyond the literal value.
doctorhealth check variants (placeholders exist in rtb-cli; this spec focuses on the store API).
3. Public API¶
3.1 Crate root¶
pub use secrecy::{ExposeSecret, SecretString};
pub use store::{
CredentialStore, EnvStore, KeyringStore, LiteralStore, MemoryStore,
};
pub use reference::{CredentialRef, KeychainRef};
pub use resolver::Resolver;
pub use error::CredentialError;
pub mod error;
pub mod reference;
pub mod resolver;
pub mod store;
3.2 CredentialStore¶
#[async_trait::async_trait]
pub trait CredentialStore: Send + Sync + 'static {
/// Retrieve a secret by `service`/`account`. Returns
/// `CredentialError::NotFound` when the store does not carry it.
async fn get(&self, service: &str, account: &str) -> Result<SecretString, CredentialError>;
/// Store (or overwrite) a secret at `service`/`account`.
async fn set(&self, service: &str, account: &str, secret: SecretString)
-> Result<(), CredentialError>;
/// Delete a secret. Missing entries are not an error.
async fn delete(&self, service: &str, account: &str) -> Result<(), CredentialError>;
}
Each store below implements this.
3.3 Built-in stores¶
KeyringStore— thin wrapper overkeyring::Entry. On Linux uses the kernel keyutils backend (per our workspace config); macOS uses Keychain; Windows uses Credential Manager.EnvStore—getreadsstd::env::var(account)(service ignored, or used as the env-var prefix when set).LiteralStore— constructed with an exact secret;getignores service/account. Useful when the entire tool ships a single token.MemoryStore—HashMap<(String, String), SecretString>behind aRwLock. Test fixture.
3.4 CredentialRef and the precedence chain¶
#[derive(Debug, Clone, Default, serde::Deserialize)]
// Note: `Serialize` is deliberately NOT derived — secrecy 0.10+ has
// no `Serialize` for SecretString. Tools that want to write credentials
// back to config must route through an explicit "write secret" helper.
pub struct CredentialRef {
/// Name of an env var to read the secret from.
#[serde(default)]
pub env: Option<String>,
/// OS-keychain lookup.
#[serde(default)]
pub keychain: Option<KeychainRef>,
/// Literal secret in config. Rejected when `CI=true`.
#[serde(default)]
pub literal: Option<SecretString>,
/// Ecosystem-default env var fallback (e.g. `ANTHROPIC_API_KEY`).
#[serde(default)]
pub fallback_env: Option<String>,
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct KeychainRef {
pub service: String,
pub account: String,
}
SecretString already implements Deserialize/Serialize behind
feature-gated paths; the workspace dep enables what we need.
3.5 Resolver¶
pub struct Resolver {
keychain: Arc<dyn CredentialStore>,
}
impl Resolver {
pub fn new(keychain: Arc<dyn CredentialStore>) -> Self;
/// Walk the precedence chain and return the first hit:
///
/// 1. `cref.env` → `std::env::var`
/// 2. `cref.keychain` → `keychain.get(service, account)`
/// 3. `cref.literal` (rejected when `CI=true`)
/// 4. `cref.fallback_env` → `std::env::var`
///
/// Returns `CredentialError::NotFound` if every step misses.
pub async fn resolve(&self, cref: &CredentialRef) -> Result<SecretString, CredentialError>;
}
3.6 CredentialError¶
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[non_exhaustive]
pub enum CredentialError {
#[error("credential not found: {name}")]
#[diagnostic(code(rtb::credentials::not_found))]
NotFound { name: String },
#[error("literal credential is refused in CI environments")]
#[diagnostic(
code(rtb::credentials::literal_refused),
help("set CI=false locally, or move the secret to a keychain/env var"),
)]
LiteralRefusedInCi,
#[error("keychain backend error: {0}")]
#[diagnostic(code(rtb::credentials::keychain))]
Keychain(String),
#[error("I/O error: {0}")]
#[diagnostic(code(rtb::credentials::io))]
Io(#[from] std::io::Error),
}
4. Acceptance criteria¶
4.1 Unit tests (T#)¶
- T1 —
CredentialStoreis object-safe —Arc<dyn CredentialStore>compiles. - T2 —
MemoryStoreround-trips — set then get returns the same secret; delete removes it. - T3 —
MemoryStore::geton missing entry returnsNotFound. - T4 —
EnvStore::getreads fromstd::env::var. - T5 —
EnvStoreNotFound — missing env var yieldsNotFound. - T6 —
LiteralStore::getreturns the constant regardless of service/account. - T7 —
LiteralStore::set/deleteare no-ops returningOk(constant is immutable). - T8 —
Resolver::resolveprecedence — with all four fields set,envwins; removing env, keychain wins; removing keychain, literal wins; removing literal, fallback env wins. - T9 —
Resolver::resolverefuses literal in CI — settingCI=true, literal-only ref returnsLiteralRefusedInCi. - T10 —
Resolver::resolveempty ref yieldsNotFound. - T11 —
SecretStringDebug redaction —format!("{secret:?}")does not contain the secret's bytes. - T12 —
KeyringStorecompiles and constructs on the host platform. A smokegetof a known-missing entry returnsNotFound(not a keyring error).
4.2 Gherkin scenarios (S#)¶
File: crates/rtb-credentials/tests/features/credentials.feature.
- S1 — LiteralStore round-trip — secret is retrievable, Debug is redacted.
- S2 — MemoryStore set-then-get — standard KV behaviour.
- S3 — Resolver env-over-literal precedence — env set and literal set; env wins.
- S4 — Resolver keychain-over-literal — env missing, keychain populated, literal set; keychain wins.
- S5 — Missing credential surfaces NotFound — empty
CredentialRef. - S6 — Literal refused under CI —
CI=true+ literal-only ref =LiteralRefusedInCidiagnostic.
5. Security & operational requirements¶
#![forbid(unsafe_code)].- Every public function that touches a secret takes or returns
SecretString.&strorStringfor a secret is a compile error by type rather than linting. DebugonSecretStringrenders[REDACTED](secrecy crate guarantee).LiteralStorestores its secret inSecretStringso drops zero.Resolver::resolvereads env vars after checkingCI; the literal path is gated even when env lookup would have matched.- No logging of secret material.
tracingspans around resolve recordservice/account, never the secret.
6. Non-goals (explicit)¶
- No TUI prompts for first-time set. That's
rtb-cli'sinit/credentialssubcommand's job. - No cross-store sync. Every store is independent.
- No async-on-Windows keyring — keyring crate handles platform
differences; we wrap blocking calls in
tokio::task::spawn_blockinginsideKeyringStore.
7. Rollout plan¶
- Land spec + tests + impl in one
feat(credentials)commit. - Future
rtb-ai/rtb-vcswork picks upCredentialRefas the standard config shape for token fields.
8. Open questions¶
- O1 —
CredentialStore::set/deleteonEnvStore. Env-var mutation from library code is a poor idea —set_varisunsafein Rust 2024 for soundness reasons. Proposed:EnvStore::set/deletereturn an error variantCredentialError::ReadOnly(add to the enum). Users that want to mutate env vars do it explicitly in their own code. - O2 — Async or sync
CredentialStore? keyring v3 is blocking-only. Wrapping inspawn_blockingkeeps the public API async without forcing an executor on sync callers. Proposed: async trait, implementations usespawn_blockinginternally. Callers that are strictly sync canblock_onat the edge. - O3 —
CredentialRefname field. NotFound errors carry aname— what should it be? Proposed: the resolved reference'sfallback_envname if set, else"<unnamed credential>". A future builder could let users tag the ref with a diagnostic name.