Skip to content

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.

  • get on an absent key returns NotFound with the name "{service}/{account}".
  • set inserts or overwrites.
  • delete removes the key. Deleting an entry that is not present is not an error — it returns Ok(()).
  • If the internal RwLock is poisoned by a panic in another thread, every method returns CredentialError::Keychain with the message "in-memory lock poisoned". Note the variant is Keychain despite 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.

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

MemoryStore { .. }
LiteralStore { .. }

EnvStore and KeyringStore derive Debug and are unit structs holding no data.