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