Proof-of-concept proving the secure-storage exit criterion from docs/architecture/proof-of-concept-plan.md §2: "Secret write/read/delete works through platform secure storage." Implements a typed SecretStorageRepository trait per ADR-006 (SecureStore + per-platform adapters) and a Linux adapter (the only adapter in PoC scope) that supports both equivalent Linux backends per SysRS-053/SysRS-162: Secret Service (libsecret) and kernel keyutils. The audit test suite covers: SS-AUD-001 identity secret absent from local DB (raw file scan) SS-AUD-002 server password absent from local DB SS-AUD-003 secrets absent from logs (Secret newtype redaction) SS-AUD-005 failure returns safe typed error (NotFound) SS-AUD-006 delete removes entry SS-TC-003 Linux round-trip set/get/delete Verified on 2026-05-13 against the local keyutils backend (cargo test runs need 'keyctl session -' to provide a valid session keyring under non-interactive shells, documented in the spike README). The CLI driver additionally observed a real locked gnome-keyring collection and exercised the typed-error → fallback path live. Surfaced finding for the decision register: DEC-013 does not pin a Linux secure-storage backend policy. Both Secret Service and keyutils are 'equivalent' per the requirements; production code needs an owner ruling. Out of scope: Windows DPAPI, macOS/iOS Keychain, Android Keystore, SS-AUD-004 (covered by diagnostics-redaction spike), SS-AUD-007/008 (process / migration items). Authority: PoC plan §2, ADR-006, SDD-078, SRS-091..095, SysRS-158..162. Not product code; not promoted into chanora_storage.
207 lines
7.4 KiB
Rust
207 lines
7.4 KiB
Rust
//! `SecretStorageRepository` trait + Linux Secret Service adapter.
|
|
//!
|
|
//! Maps to SDD-078 (`SecretStorageRepository shall persist secrets only
|
|
//! through platform secure storage service interfaces`) and ADR-006
|
|
//! (`SecureStore trait and per-platform adapters`).
|
|
|
|
use std::fmt;
|
|
|
|
use zeroize::Zeroize;
|
|
|
|
/// Trait every platform adapter implements.
|
|
///
|
|
/// The API is intentionally narrow: a secret is identified by a stable
|
|
/// `name` (e.g. `"identity.primary"`, `"server.<bookmark-id>.password"`)
|
|
/// and treated as an opaque UTF-8 byte sequence. The trait does **not**
|
|
/// expose handles, paths, or backend-specific types — the
|
|
/// `chanora_storage` crate must not leak those upward (SAD-067).
|
|
pub trait SecretStorageRepository: Send + Sync {
|
|
/// Store (or replace) a secret under `name`. The implementation
|
|
/// must not write `secret` to plaintext disk, logs, or diagnostic
|
|
/// exports (SS-AUD-001..004).
|
|
fn set(&self, name: &str, secret: &Secret) -> Result<(), SecureStoreError>;
|
|
|
|
/// Retrieve a secret previously stored under `name`.
|
|
///
|
|
/// Returns `Err(SecureStoreError::NotFound)` if no entry exists.
|
|
fn get(&self, name: &str) -> Result<Secret, SecureStoreError>;
|
|
|
|
/// Remove a secret. Returns `Err(SecureStoreError::NotFound)` if
|
|
/// it was already absent. Idempotent variants are intentionally
|
|
/// not provided here; callers must handle `NotFound`.
|
|
fn delete(&self, name: &str) -> Result<(), SecureStoreError>;
|
|
}
|
|
|
|
/// Typed error DTO. Production code (`chanora_bridge`) will widen this
|
|
/// when more failure modes are observed; the PoC only needs to prove
|
|
/// SS-AUD-005 (safe error mapping).
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum SecureStoreError {
|
|
#[error("secret not found")]
|
|
NotFound,
|
|
#[error("platform secure storage unavailable: {0}")]
|
|
Unavailable(String),
|
|
#[error("platform secure storage backend rejected the operation: {0}")]
|
|
Backend(String),
|
|
}
|
|
|
|
/// Owned, zeroed-on-drop secret material.
|
|
///
|
|
/// `Debug` and `Display` deliberately do **not** print the inner bytes
|
|
/// — this is part of the SS-AUD-003 (no-secrets-in-logs) defense in
|
|
/// depth. Tests in this crate rely on this behavior.
|
|
#[derive(Clone, Zeroize)]
|
|
#[zeroize(drop)]
|
|
pub struct Secret(Vec<u8>);
|
|
|
|
impl Secret {
|
|
pub fn from_utf8(s: impl Into<String>) -> Self {
|
|
Self(s.into().into_bytes())
|
|
}
|
|
|
|
pub fn from_bytes(b: impl Into<Vec<u8>>) -> Self {
|
|
Self(b.into())
|
|
}
|
|
|
|
pub fn as_bytes(&self) -> &[u8] {
|
|
&self.0
|
|
}
|
|
|
|
pub fn as_str(&self) -> Result<&str, std::str::Utf8Error> {
|
|
std::str::from_utf8(&self.0)
|
|
}
|
|
|
|
pub fn len(&self) -> usize {
|
|
self.0.len()
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.0.is_empty()
|
|
}
|
|
}
|
|
|
|
impl fmt::Debug for Secret {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
write!(f, "Secret(<{} bytes redacted>)", self.0.len())
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for Secret {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
// Same redaction shape as Debug.
|
|
write!(f, "<redacted>")
|
|
}
|
|
}
|
|
|
|
impl PartialEq for Secret {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
// Constant-time-ish compare via fixed length-first then byte compare.
|
|
// The PoC does not promise true constant-time semantics.
|
|
self.0 == other.0
|
|
}
|
|
}
|
|
|
|
// ---------- Linux adapter ----------
|
|
|
|
#[cfg(target_os = "linux")]
|
|
pub mod linux {
|
|
use super::*;
|
|
use keyring::Entry;
|
|
|
|
/// Which Linux backend to use.
|
|
///
|
|
/// Both are acceptable under SysRS-053 / SysRS-162 ("Secret Service,
|
|
/// libsecret, or equivalent"):
|
|
///
|
|
/// * `SecretService`: D-Bus Secret Service (gnome-keyring, kwallet,
|
|
/// KeePassXC, etc.). Preferred on interactive desktop sessions.
|
|
/// * `Keyutils`: kernel session keyring (`add_key(2)` /
|
|
/// `request_key(2)`). Always available on Linux, no D-Bus
|
|
/// dependency, but secrets live only for the session lifetime.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum LinuxBackend {
|
|
/// Default user keyring backend selected by the `keyring` crate
|
|
/// at compile time. With the features this PoC enables, this
|
|
/// is Secret Service.
|
|
Default,
|
|
/// Force the kernel keyutils backend. Useful for headless
|
|
/// environments and for production code paths where no
|
|
/// graphical session is available.
|
|
Keyutils,
|
|
}
|
|
|
|
/// Linux adapter using the `keyring` crate. The choice of backend
|
|
/// is made at construction so the rest of the application can
|
|
/// remain backend-agnostic.
|
|
pub struct LinuxSecureStore {
|
|
service: String,
|
|
backend: LinuxBackend,
|
|
}
|
|
|
|
impl LinuxSecureStore {
|
|
/// Construct an adapter backed by the default `keyring`
|
|
/// credential store (Secret Service with the features compiled
|
|
/// in this PoC).
|
|
pub fn new(service: impl Into<String>) -> Self {
|
|
Self { service: service.into(), backend: LinuxBackend::Default }
|
|
}
|
|
|
|
/// Construct an adapter backed by the kernel keyutils session
|
|
/// keyring. Production code may select this when no D-Bus
|
|
/// session is available.
|
|
pub fn new_keyutils(service: impl Into<String>) -> Self {
|
|
Self { service: service.into(), backend: LinuxBackend::Keyutils }
|
|
}
|
|
|
|
fn entry(&self, name: &str) -> Result<Entry, SecureStoreError> {
|
|
let result = match self.backend {
|
|
LinuxBackend::Default => Entry::new(&self.service, name),
|
|
LinuxBackend::Keyutils => {
|
|
let cred = keyring::keyutils::KeyutilsCredential::new_with_target(
|
|
None,
|
|
&self.service,
|
|
name,
|
|
)
|
|
.map_err(|e| SecureStoreError::Unavailable(format!("{e}")))?;
|
|
Ok(Entry::new_with_credential(Box::new(cred)))
|
|
}
|
|
};
|
|
result.map_err(|e| SecureStoreError::Unavailable(format!("{e}")))
|
|
}
|
|
}
|
|
|
|
impl SecretStorageRepository for LinuxSecureStore {
|
|
fn set(&self, name: &str, secret: &Secret) -> Result<(), SecureStoreError> {
|
|
let entry = self.entry(name)?;
|
|
let s = secret.as_str().map_err(|e| {
|
|
SecureStoreError::Backend(format!("secret is not valid utf-8: {e}"))
|
|
})?;
|
|
entry
|
|
.set_password(s)
|
|
.map_err(|e| SecureStoreError::Backend(format!("{e}")))
|
|
}
|
|
|
|
fn get(&self, name: &str) -> Result<Secret, SecureStoreError> {
|
|
let entry = self.entry(name)?;
|
|
match entry.get_password() {
|
|
Ok(s) => Ok(Secret::from_utf8(s)),
|
|
Err(keyring::Error::NoEntry) => Err(SecureStoreError::NotFound),
|
|
Err(e) => Err(SecureStoreError::Backend(format!("{e}"))),
|
|
}
|
|
}
|
|
|
|
fn delete(&self, name: &str) -> Result<(), SecureStoreError> {
|
|
let entry = self.entry(name)?;
|
|
match entry.delete_credential() {
|
|
Ok(()) => Ok(()),
|
|
Err(keyring::Error::NoEntry) => Err(SecureStoreError::NotFound),
|
|
Err(e) => Err(SecureStoreError::Backend(format!("{e}"))),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Back-compat type alias — older code in this PoC referred to
|
|
/// `SecretServiceAdapter` before the backend choice existed.
|
|
pub type SecretServiceAdapter = LinuxSecureStore;
|
|
}
|