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.
230 lines
7.4 KiB
Rust
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)));
|
|
}
|