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.
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
//! Chanora PoC — secure storage spike.
|
||||
//!
|
||||
//! Authority:
|
||||
//! * `docs/architecture/proof-of-concept-plan.md` §2 — Secure storage spike.
|
||||
//! * `docs/security/secure-storage-audit-report.md` — audit checks
|
||||
//! SS-AUD-001..008 and test cases SS-TC-001..005.
|
||||
//! * `docs/architecture/sysdes.md` ADR-006 — "SecureStore trait and
|
||||
//! per-platform adapters".
|
||||
//! * SRS-091..095, SysRS-158..162.
|
||||
//!
|
||||
//! This crate models the production shape, in miniature:
|
||||
//!
|
||||
//! ┌─────────────────────────┐ ┌─────────────────────────────────┐
|
||||
//! │ LocalDatabaseRepository │ │ SecretStorageRepository (trait) │
|
||||
//! │ (rusqlite, non-secret) │ │ ── per-platform adapters ── │
|
||||
//! └─────────────────────────┘ └─────────────────────────────────┘
|
||||
//! SRS-089 / SDD-077 SRS-092 / SDD-078 / ADR-006
|
||||
//!
|
||||
//! Scope: Linux adapter only (Secret Service via the `keyring` crate).
|
||||
//! Other platforms (Windows DPAPI, macOS/iOS Keychain, Android Keystore)
|
||||
//! are out of scope here and live in their own spikes/adapters.
|
||||
|
||||
pub mod secret;
|
||||
pub mod sqlite_repo;
|
||||
pub mod test_logger;
|
||||
|
||||
pub use secret::{SecretStorageRepository, SecureStoreError};
|
||||
pub use sqlite_repo::LocalDatabaseRepository;
|
||||
@@ -0,0 +1,80 @@
|
||||
//! CLI driver — exercises the full secret round-trip against the real
|
||||
//! platform secure storage.
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use secure_storage_spike::secret::Secret;
|
||||
use secure_storage_spike::SecretStorageRepository;
|
||||
use tracing::info;
|
||||
|
||||
const SERVICE: &str = "app.chanora.poc.secure-storage";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
if let Err(e) = run() {
|
||||
eprintln!("error: {e}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
eprintln!("This PoC currently only ships a Linux adapter.");
|
||||
return Err("unsupported platform".into());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use secure_storage_spike::secret::linux::LinuxSecureStore;
|
||||
|
||||
// Try Secret Service first (preferred per ADR-006); fall back to
|
||||
// keyutils if the default collection is locked or unavailable.
|
||||
let store: Box<dyn SecretStorageRepository> = {
|
||||
let ss = LinuxSecureStore::new(SERVICE);
|
||||
let probe = ss.set("__chanora_probe__", &Secret::from_utf8("probe"));
|
||||
match probe {
|
||||
Ok(()) => {
|
||||
let _ = ss.delete("__chanora_probe__");
|
||||
info!(target: "spike", "using Secret Service backend");
|
||||
Box::new(ss)
|
||||
}
|
||||
Err(e) => {
|
||||
info!(target: "spike", "Secret Service unavailable ({e}); falling back to keyutils");
|
||||
Box::new(LinuxSecureStore::new_keyutils(SERVICE))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let name = "identity.primary";
|
||||
|
||||
info!(target: "spike", "writing secret {}", name);
|
||||
let original = Secret::from_utf8("MG0DAgeAAgEgAiAIXJBlj1hQbaH0Eq0DuLlCmH8bl+veTAO2");
|
||||
store.set(name, &original)?;
|
||||
|
||||
info!(target: "spike", "reading back");
|
||||
let round_trip = store.get(name)?;
|
||||
assert_eq!(original, round_trip, "round-trip mismatch");
|
||||
info!(target: "spike", "round-trip OK ({} bytes)", round_trip.len());
|
||||
|
||||
info!(target: "spike", "deleting");
|
||||
store.delete(name)?;
|
||||
match store.get(name) {
|
||||
Err(secure_storage_spike::SecureStoreError::NotFound) => {
|
||||
info!(target: "spike", "post-delete read correctly returned NotFound");
|
||||
}
|
||||
Ok(_) => return Err("secret still readable after delete".into()),
|
||||
Err(e) => return Err(format!("unexpected error after delete: {e}").into()),
|
||||
}
|
||||
|
||||
println!("OK — Linux Secret Service round-trip verified.");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! `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;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Minimal `LocalDatabaseRepository` stand-in (SDD-077). Holds *non-secret*
|
||||
//! state only — bookmarks, in this PoC. Used by the audit tests to prove
|
||||
//! SS-AUD-001 / SS-AUD-002: a secret stored via the secure-storage
|
||||
//! adapter never appears in the SQLite file.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LocalDbError {
|
||||
#[error("sqlite error: {0}")]
|
||||
Sqlite(#[from] rusqlite::Error),
|
||||
}
|
||||
|
||||
pub struct LocalDatabaseRepository {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl LocalDatabaseRepository {
|
||||
pub fn open(path: &Path) -> Result<Self, LocalDbError> {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_name TEXT NOT NULL,
|
||||
server_host TEXT NOT NULL,
|
||||
identity_ref TEXT NOT NULL
|
||||
);",
|
||||
)?;
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
/// Insert a bookmark. `identity_ref` is a *reference* (e.g. a stable
|
||||
/// secret name like `identity.primary`), **not** the secret itself.
|
||||
/// This is the SAD-067 separation in action.
|
||||
pub fn insert_bookmark(
|
||||
&self,
|
||||
server_name: &str,
|
||||
server_host: &str,
|
||||
identity_ref: &str,
|
||||
) -> Result<i64, LocalDbError> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO bookmarks (server_name, server_host, identity_ref) VALUES (?1, ?2, ?3)",
|
||||
params![server_name, server_host, identity_ref],
|
||||
)?;
|
||||
Ok(self.conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// For the audit: read the raw bytes of the underlying SQLite file
|
||||
/// so a test can grep for forbidden plaintext (SS-AUD-001).
|
||||
pub fn raw_db_bytes(path: &Path) -> std::io::Result<Vec<u8>> {
|
||||
std::fs::read(path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! In-memory log capture, used by the audit tests to assert that
|
||||
//! `tracing` output contains no secret material (SS-AUD-003).
|
||||
//!
|
||||
//! Production code will use the redaction policy defined in
|
||||
//! `docs/security/diagnostic-redaction-audit-report.md` and the
|
||||
//! `chanora_diagnostics` crate. This PoC is intentionally simpler:
|
||||
//! it captures *all* log output verbatim and lets the test grep it.
|
||||
|
||||
use std::io::{self, Write};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CapturedLog {
|
||||
buf: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl CapturedLog {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Snapshot the captured bytes as a UTF-8 string (lossy if there's
|
||||
/// any non-UTF-8 — we never expect non-UTF-8 from `tracing`).
|
||||
pub fn snapshot(&self) -> String {
|
||||
let buf = self.buf.lock().unwrap();
|
||||
String::from_utf8_lossy(&buf).into_owned()
|
||||
}
|
||||
|
||||
pub fn writer(&self) -> CapturedLogWriter {
|
||||
CapturedLogWriter { buf: self.buf.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CapturedLogWriter {
|
||||
buf: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl Write for CapturedLogWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
let mut g = self.buf.lock().unwrap();
|
||||
g.extend_from_slice(buf);
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturedLog {
|
||||
type Writer = CapturedLogWriter;
|
||||
fn make_writer(&'a self) -> Self::Writer {
|
||||
self.writer()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user