Credential stores¶
A credential store is a backend that can retrieve, and sometimes write,
a secret identified by a service and an account pair.
The CredentialStore 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>;
}
The trait is object-safe, and the intended way to hold one is
Arc<dyn CredentialStore> — that is exactly what Resolver::new
takes.
The Send + Sync + 'static bound is part of the contract, not an
implementation detail: a store is expected to be shared across tasks and
threads for the life of the process.
Every method is async even though several implementations do no I/O at
all. Platform keychain APIs are blocking, so the trait has to be async
for the keychain implementation to avoid stalling an executor; the
in-memory implementations pay an unused async for uniformity.
Which methods each built-in store supports¶
| Store | get |
set |
delete |
Backed by |
|---|---|---|---|---|
MemoryStore |
yes | yes | yes | HashMap behind an RwLock |
KeyringStore |
yes | yes | yes | platform keychain via the keyring crate |
EnvStore |
yes | ReadOnly |
ReadOnly |
process environment |
LiteralStore |
yes | ReadOnly |
ReadOnly |
one fixed secret |
EnvStore and LiteralStore are read-only by design, and their set
and delete return CredentialError::ReadOnly rather than failing
silently or panicking.
MemoryStore — read-write, in-process¶
Keyed on the (service, account) pair. Both strings are used; neither
is ignored.
geton an absent key returnsNotFoundwith the name"{service}/{account}".setinserts or overwrites.deleteremoves the key. Deleting an entry that is not present is not an error — it returnsOk(()).- If the internal
RwLockis poisoned by a panic in another thread, every method returnsCredentialError::Keychainwith the message"in-memory lock poisoned". Note the variant isKeychaindespite no keychain being involved; there is no dedicated variant for lock poisoning.
MemoryStore is exported from the crate root, not hidden behind a
test feature, so downstream crates can use it in their own test
suites.
Contents do not survive the process. There is no persistence, no snapshotting and no capacity limit.
KeyringStore — the platform keychain¶
Delegates to keyring::Entry, constructed fresh per call from the
service and account strings. KeyringStore::new() is const and
holds no handles; nothing touches the platform keychain until the first
get, set or delete.
Error mapping:
| Condition | Returns |
|---|---|
entry absent (keyring::Error::NoEntry) on get |
NotFound { name: "{service}/{account}" } |
any other keyring error on get or set |
Keychain(String) carrying the backend message |
delete on an absent entry |
Ok(()) — treated as success |
any other keyring error on delete |
Keychain(String) |
| the blocking task fails to join | Keychain("join error: …") |
KeyringStore requires a Tokio runtime¶
Each method wraps its blocking keyring call in
tokio::task::spawn_blocking. That function panics when called outside
a Tokio runtime context.
KeyringStore therefore cannot be used on async-std, smol or any
other executor. Calling it there panics rather than returning an error,
so it is not something a Result can catch. The crate depends on
tokio with the full feature unconditionally; there is no
runtime-agnostic build.
The other three stores do no spawn_blocking and are runtime-agnostic.
EnvStore — account is the variable name and service is ignored¶
EnvStore::get ignores its service argument entirely and treats
account as the name of an environment variable.
let store = EnvStore::new();
// reads $MYTOOL_TOKEN; the "unused" argument is discarded
let secret = store.get("unused", "MYTOOL_TOKEN").await?;
This is the crate's sharpest edge. Called with the conventional
argument order used everywhere else — get("mytool", "anthropic") —
EnvStore looks for a variable named anthropic, not one named
mytool, and returns NotFound naming anthropic.
A missing variable returns NotFound with the variable name as name.
A variable that is set but empty is a hit, returning an empty
SecretString: std::env::var succeeds on an empty value, and the
store does not check for it.
set and delete return ReadOnly. This is not an oversight —
mutating process environment variables is unsound across threads and
requires unsafe in Rust 2024, so the crate declines to offer it.
LiteralStore — one secret, both arguments ignored¶
Constructed with a single SecretString, which get returns for any
service and account:
let store = LiteralStore::new(SecretString::from("baked-in".to_string()));
store.get("anything", "anywhere").await?; // -> "baked-in"
get never returns NotFound. set and delete return ReadOnly.
get returns self.secret.clone(). Cloning a SecretString produces
another zeroed-on-drop container rather than exposing the value through
a plain String, so the secret does not leave the protected type on the
way out.
Its intended use is a test harness or a tool hard-wired to exactly one
credential. As a Resolver keychain backend it makes every keychain
lookup succeed with the same value, which is rarely what production
wants.
Debug on a store never prints a secret¶
MemoryStore and LiteralStore implement Debug by hand, both using
finish_non_exhaustive(), so neither renders the secrets it holds:
EnvStore and KeyringStore derive Debug and are unit structs
holding no data.