Files
chanora/poc/secure-storage-spike/tests/audit.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

230 lines
7.4 KiB
Rust

//! 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)));
}