rtb-credentials 0.6.4

OS keychain credential storage. Part of the phpboyscout Rust toolkit.
Documentation
---
title: rtb-credentials
description: OS keychain credential storage with precedence-aware resolution.
---

# `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`](https://crates.io/crates/secrecy): `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](https://rust.phpboyscout.uk);
extracted from — and battle-tested by —
[rust-tool-base](https://gitlab.com/phpboyscout/rust-tool-base).

## Where to start

| If you want to | Go to |
|---|---|
| Get something working from nothing | [Tutorial: resolve your first credential]tutorials/resolve-your-first-credential.md |
| Solve a specific task | [How-to guides]how-to/index.md |
| Look up a key, error or default | [Reference]reference/index.md |
| Understand why it works this way | [Explanation]explanation/index.md |
| Know what it will not do | [What this crate does not do]explanation/limitations.md |

## 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:

1. `cref.env` → `std::env::var(name)`.
2. `cref.keychain` → `store.get(service, account)`.
3. `cref.literal` — refused via `CredentialError::LiteralRefusedInCi`
   when `CI` is exactly `true`.
4. `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](reference/resolver.md).

## Public API

```rust
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`]https://crates.io/crates/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 `CredentialRef`s 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](https://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](reference/cargo-features.md).

## Security

- `#![forbid(unsafe_code)]` at the crate root.
- Every public fn that touches a secret takes or returns `SecretString`.
- `Debug` of a `SecretString` replaces the value with
  `SecretBox<str>([REDACTED])`.
- `CredentialRef` cannot 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 `CI` to anything other than
  `true`, and tools wanting stricter enforcement set `CI=true`
  themselves. See
  [why literal secrets are refused in CI]explanation/refusing-literals-in-ci.md.

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`](explanation/secret-handling.md).

## 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]explanation/precedence-chain.md**
  — 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`]explanation/secret-handling.md**
  — what the type prevents that a `String` does not, and why it can be
  deserialised but never serialised.
- **[Why literal secrets are refused in CI]explanation/refusing-literals-in-ci.md**
  — 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]explanation/linux-keychain-persistence.md**
  — the build-hermeticity trade behind choosing kernel keyutils, and
  what `linux-persistent` actually 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](explanation/limitations.md); 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.
- **`KeyringStore` requires 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](https://phpboyscout.uk/topics/rust/)** collects
everything written about it, ordered so you can start at the beginning rather
than newest-first.

!!! tip "Ask phpbotscout"

    ![phpbotscout](https://phpboyscout.uk/images/projects/logo-phpbotscout.png){ width="84" align=left style="border-radius:10px;margin-right:1rem" }

    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.

    [Join the Discord](https://discord.gg/mQzGbmGyzZ){ .md-button .md-button--primary }