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,229 @@
|
||||
//! Audit tests mapped to `docs/security/secure-storage-audit-report.md`.
|
||||
//!
|
||||
//! Coverage:
|
||||
//! * SS-AUD-001 — Identity secret is not stored in local DB.
|
||||
//! * SS-AUD-002 — Server password is not stored in local DB.
|
||||
//! * SS-AUD-003 — Secret values are not written to application logs.
|
||||
//! * SS-AUD-005 — Secure storage failure returns safe error.
|
||||
//! * SS-AUD-006 — Secret deletion removes secure-storage entry.
|
||||
//! * SS-TC-003 — Linux: write / read / delete round-trip.
|
||||
//!
|
||||
//! NOT covered (out of scope for this PoC):
|
||||
//! * SS-AUD-004 (diagnostic export redaction) — owned by
|
||||
//! `diagnostics-redaction-spike`.
|
||||
//! * SS-AUD-007/008 — process-level documentation / migration paths.
|
||||
//! * SS-TC-001/002/004/005 — non-Linux platforms.
|
||||
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use std::sync::Once;
|
||||
|
||||
use secure_storage_spike::{
|
||||
secret::{linux::LinuxSecureStore, Secret},
|
||||
test_logger::CapturedLog,
|
||||
LocalDatabaseRepository, SecretStorageRepository, SecureStoreError,
|
||||
};
|
||||
use serial_test::serial;
|
||||
use tracing::{info, subscriber::DefaultGuard, Level};
|
||||
|
||||
const SERVICE: &str = "app.chanora.poc.secure-storage-tests";
|
||||
|
||||
static INIT: Once = Once::new();
|
||||
|
||||
/// Hermetic backend selection.
|
||||
///
|
||||
/// On Linux the trait has two equivalent backends (SysRS-053 / SysRS-162:
|
||||
/// "Secret Service, libsecret, **or equivalent**"). The test suite uses
|
||||
/// the kernel keyutils backend so it is deterministic and does not
|
||||
/// depend on an unlocked Secret Service collection. The CLI driver
|
||||
/// `src/main.rs` continues to exercise the Secret Service path.
|
||||
fn store() -> LinuxSecureStore {
|
||||
LinuxSecureStore::new_keyutils(SERVICE)
|
||||
}
|
||||
|
||||
/// One-time guard so missing kernel keyutils support is reported clearly
|
||||
/// rather than as a generic backend error.
|
||||
fn ensure_backend_or_skip() -> bool {
|
||||
INIT.call_once(|| {});
|
||||
// keyutils is a Linux-kernel feature; if it's compiled out or
|
||||
// disabled, every set() call will fail with `Unavailable`. We can't
|
||||
// easily probe without writing, so we rely on the per-test errors.
|
||||
true
|
||||
}
|
||||
|
||||
/// Install a per-test tracing subscriber that captures everything.
|
||||
fn capture_logs() -> (CapturedLog, DefaultGuard) {
|
||||
let cap = CapturedLog::new();
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_max_level(Level::TRACE)
|
||||
.with_writer(cap.clone())
|
||||
.without_time()
|
||||
.with_ansi(false)
|
||||
.finish();
|
||||
let guard = tracing::subscriber::set_default(subscriber);
|
||||
(cap, guard)
|
||||
}
|
||||
|
||||
// Distinctive plaintext so a grep in test artifacts unambiguously
|
||||
// identifies a leak.
|
||||
const IDENTITY_SECRET: &str = "CHANORA_POC_IDENTITY_SECRET_DO_NOT_LEAK_b8f1a4";
|
||||
const SERVER_PASSWORD: &str = "CHANORA_POC_SERVER_PASSWORD_DO_NOT_LEAK_2f7c01";
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn ss_tc_003_round_trip_linux() {
|
||||
if !ensure_backend_or_skip() {
|
||||
return;
|
||||
}
|
||||
let store = store();
|
||||
let name = "ss-tc-003.identity.primary";
|
||||
let _ = store.delete(name); // ensure clean slate
|
||||
|
||||
let s = Secret::from_utf8(IDENTITY_SECRET);
|
||||
store.set(name, &s).expect("set");
|
||||
let got = store.get(name).expect("get");
|
||||
assert_eq!(s, got, "round-trip mismatch");
|
||||
|
||||
store.delete(name).expect("delete");
|
||||
assert!(matches!(store.get(name), Err(SecureStoreError::NotFound)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn ss_aud_001_identity_secret_absent_from_local_db() {
|
||||
if !ensure_backend_or_skip() {
|
||||
return;
|
||||
}
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let db_path = tmp.path().join("chanora.sqlite");
|
||||
let db = LocalDatabaseRepository::open(&db_path).expect("open");
|
||||
|
||||
// Store identity secret via the secure store, by name only in the DB.
|
||||
let store = store();
|
||||
let identity_ref = "ss-aud-001.identity.primary";
|
||||
let _ = store.delete(identity_ref);
|
||||
store
|
||||
.set(identity_ref, &Secret::from_utf8(IDENTITY_SECRET))
|
||||
.expect("set");
|
||||
|
||||
db.insert_bookmark("Vigorous", "cn.teamspeak.app", identity_ref)
|
||||
.expect("insert");
|
||||
|
||||
// Force the DB to flush and inspect the file bytes.
|
||||
drop(db);
|
||||
let bytes = LocalDatabaseRepository::raw_db_bytes(&db_path).unwrap();
|
||||
assert!(
|
||||
!bytes.windows(IDENTITY_SECRET.len()).any(|w| w == IDENTITY_SECRET.as_bytes()),
|
||||
"identity secret plaintext found inside local SQLite file"
|
||||
);
|
||||
|
||||
// The reference (= the lookup name) is allowed to be in the DB.
|
||||
assert!(bytes.windows(identity_ref.len()).any(|w| w == identity_ref.as_bytes()));
|
||||
|
||||
// Cleanup.
|
||||
store.delete(identity_ref).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn ss_aud_002_server_password_absent_from_local_db() {
|
||||
if !ensure_backend_or_skip() {
|
||||
return;
|
||||
}
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let db_path = tmp.path().join("chanora.sqlite");
|
||||
let db = LocalDatabaseRepository::open(&db_path).expect("open");
|
||||
|
||||
let store = store();
|
||||
let pw_ref = "ss-aud-002.server.42.password";
|
||||
let _ = store.delete(pw_ref);
|
||||
store
|
||||
.set(pw_ref, &Secret::from_utf8(SERVER_PASSWORD))
|
||||
.expect("set");
|
||||
|
||||
db.insert_bookmark("Some Server", "ts.example.invalid", pw_ref)
|
||||
.expect("insert");
|
||||
|
||||
drop(db);
|
||||
let bytes = LocalDatabaseRepository::raw_db_bytes(&db_path).unwrap();
|
||||
assert!(
|
||||
!bytes.windows(SERVER_PASSWORD.len()).any(|w| w == SERVER_PASSWORD.as_bytes()),
|
||||
"server password plaintext found inside local SQLite file"
|
||||
);
|
||||
|
||||
store.delete(pw_ref).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn ss_aud_003_secret_values_not_in_logs() {
|
||||
if !ensure_backend_or_skip() {
|
||||
return;
|
||||
}
|
||||
let (cap, _guard) = capture_logs();
|
||||
|
||||
let store = store();
|
||||
let name = "ss-aud-003.identity.primary";
|
||||
let _ = store.delete(name);
|
||||
|
||||
let secret = Secret::from_utf8(IDENTITY_SECRET);
|
||||
|
||||
// The kinds of things a developer is likely to log accidentally.
|
||||
info!(target: "spike", "writing identity secret name={} value={:?}", name, secret);
|
||||
store.set(name, &secret).expect("set");
|
||||
|
||||
let got = store.get(name).expect("get");
|
||||
info!(target: "spike", "round-trip ok display=`{}` debug=`{:?}` len={}", got, got, got.len());
|
||||
|
||||
store.delete(name).ok();
|
||||
|
||||
let log_output = cap.snapshot();
|
||||
assert!(
|
||||
!log_output.contains(IDENTITY_SECRET),
|
||||
"identity secret plaintext leaked into logs:\n{log_output}"
|
||||
);
|
||||
// Sanity: redaction marker present.
|
||||
assert!(log_output.contains("<redacted>") || log_output.contains("redacted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn ss_aud_005_safe_error_on_missing_entry() {
|
||||
if !ensure_backend_or_skip() {
|
||||
return;
|
||||
}
|
||||
let store = store();
|
||||
let name = "ss-aud-005.never-stored";
|
||||
let _ = store.delete(name);
|
||||
|
||||
let err = store.get(name).err().expect("expected NotFound");
|
||||
// The error must not embed any secret-like content; for `NotFound`
|
||||
// we additionally check the Display representation is generic.
|
||||
let msg = format!("{err}");
|
||||
assert!(matches!(err, SecureStoreError::NotFound));
|
||||
assert!(msg.contains("not found"));
|
||||
assert!(!msg.contains(IDENTITY_SECRET));
|
||||
assert!(!msg.contains(SERVER_PASSWORD));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn ss_aud_006_delete_removes_entry() {
|
||||
if !ensure_backend_or_skip() {
|
||||
return;
|
||||
}
|
||||
let store = store();
|
||||
let name = "ss-aud-006.identity.primary";
|
||||
|
||||
let _ = store.delete(name);
|
||||
store
|
||||
.set(name, &Secret::from_utf8(IDENTITY_SECRET))
|
||||
.expect("set");
|
||||
assert!(store.get(name).is_ok());
|
||||
|
||||
store.delete(name).expect("delete");
|
||||
assert!(matches!(store.get(name), Err(SecureStoreError::NotFound)));
|
||||
|
||||
// Second delete must fail with NotFound, never silently succeed.
|
||||
assert!(matches!(store.delete(name), Err(SecureStoreError::NotFound)));
|
||||
}
|
||||
Reference in New Issue
Block a user