Files
lightcraft/crates/credentials/src/lib.rs
T
Andrew FrahnandClaude Opus 5.5 5fe7835d8e P1.7: dac-net HTTP(S) client and dac-credentials secret storage
dac-net (L0, MIT, written fresh): rustls + RustCrypto, GET/HEAD/POST/PUT/PATCH/DELETE, JSON, streaming multipart with progress, resumable SHA-256-verified downloads, connect/stall timeouts, cancellation, redirects (credentials never cross origins, no https->http), gzip, HTTP proxy (CONNECT), brand User-Agent, per-connection trust (extra CA, pinned fingerprints, trust-on-first-use hook). Tests use in-process servers only.

dac-credentials (L0): Secret Service over zbus on Linux/BSD; macOS/Windows return Unsupported (no safe pure-Rust path); Argon2id + XChaCha20-Poly1305 encrypted-file fallback. Secret redacts Debug/Display; tests grep captured logs for secrets.

Sources: RFC 9110/9112 (HTTP), RFC 9106 (Argon2), RFC 8439 + XChaCha draft, freedesktop Secret Service API 0.2; own design otherwise.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 11:29:22 +11:00

119 lines
4.3 KiB
Rust

//! Where account secrets (API keys, tokens, passwords) live: never in the catalog, settings,
//! logs, MCP output or crash reports.
//!
//! - [`os_store`]: the platform keychain, where it is reachable in pure Rust without `unsafe`.
//! Linux and the BSDs use the freedesktop Secret Service (GNOME Keyring, KWallet) over D-Bus
//! with `zbus`. macOS Keychain and Windows Credential Manager need FFI, which this workspace
//! keeps out of product crates (see AGENTS.md: `unsafe` lives only in `crates/sysmem`), so there
//! [`os_store`] answers [`CredError::Unsupported`] and callers use the file store. The platform
//! code is isolated in `platform/`, so a safe backend can drop in later.
//! - [`FileStore`]: a file encrypted with XChaCha20-Poly1305 under a key derived from the user's
//! passphrase with Argon2id. Wrong passphrases and tampering are detected, never "decrypted"
//! to garbage; writes are atomic.
//!
//! [`Secret`] holds a secret in memory: its `Debug`/`Display` never show the value and the memory
//! is zeroed on drop. Read it with [`Secret::expose`] only where it is sent.
//!
//! Native only: on wasm32 this crate is empty.
#![cfg(not(target_arch = "wasm32"))]
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod file;
mod platform;
pub use file::FileStore;
use zeroize::Zeroizing;
/// A secret value. Never printed: `{:?}` and `{}` show `<redacted>`.
#[derive(Clone, PartialEq, Eq)]
pub struct Secret(Zeroizing<String>);
impl Secret {
pub fn new(value: impl Into<String>) -> Secret {
Secret(Zeroizing::new(value.into()))
}
/// The value, for the one place that sends it.
pub fn expose(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Secret(<redacted>)")
}
}
impl std::fmt::Display for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("<redacted>")
}
}
/// Which secret: a service (`"immich"`) and an account within it (e.g. `server URL + user`).
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Key {
pub service: String,
pub account: String,
}
impl Key {
pub fn new(service: &str, account: &str) -> Key {
Key { service: service.to_string(), account: account.to_string() }
}
}
/// Why a secret could not be read or written. Messages never contain secrets.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CredError {
/// No keychain backend on this platform: use [`FileStore`].
Unsupported(&'static str),
/// The keychain exists but can't be reached (no session bus, no Secret Service running…).
Unavailable(String),
/// The keychain is locked and unlocking needs a prompt.
Locked,
/// The passphrase does not open the file (or the file was altered).
WrongPassphrase,
/// The file is not a credentials file of a known format.
Corrupt(String),
Io(String),
}
impl std::fmt::Display for CredError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CredError::Unsupported(e) => write!(f, "no system keychain available: {e}"),
CredError::Unavailable(e) => write!(f, "the system keychain can't be reached: {e}"),
CredError::Locked => write!(f, "the system keychain is locked; unlock it and try again"),
CredError::WrongPassphrase => write!(f, "wrong passphrase, or the credentials file was altered"),
CredError::Corrupt(e) => write!(f, "unreadable credentials file: {e}"),
CredError::Io(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for CredError {}
/// A place to keep secrets.
pub trait SecretStore: Send + Sync {
/// Short description for settings ("Secret Service", "encrypted file").
fn name(&self) -> &'static str;
fn get(&self, key: &Key) -> Result<Option<Secret>, CredError>;
/// Store or replace.
fn set(&self, key: &Key, secret: &Secret) -> Result<(), CredError>;
/// `true` when something was deleted.
fn delete(&self, key: &Key) -> Result<bool, CredError>;
}
/// The platform keychain, or why there is none.
pub fn os_store() -> Result<Box<dyn SecretStore>, CredError> {
platform::open()
}
#[cfg(test)]
mod tests;