Files
chanora/poc/secure-storage-spike/src/secret.rs
T
EdisonJwa 50c95b61ad feat(poc/storage): add secure-storage spike (Linux)
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.
2026-05-14 12:26:23 +08:00

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;
}