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:
EdisonJwa
2026-05-14 12:26:23 +08:00
parent 2bbad5feb9
commit 50c95b61ad
10 changed files with 2743 additions and 0 deletions
+1854
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
[package]
name = "secure-storage-spike"
version = "0.1.0"
edition = "2021"
publish = false
description = "Chanora PoC: SecureStore trait + Linux Secret Service adapter; prove SS-AUD-001..006 for the audit report."
# Not product code. See docs/architecture/proof-of-concept-plan.md §4 and
# docs/security/secure-storage-audit-report.md.
[dependencies]
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
zeroize = { version = "1", features = ["derive"] }
# Linux secure storage: keyring crate.
# * sync-secret-service: libsecret-compatible Secret Service via D-Bus.
# * linux-native: Kernel session keyring (keyutils). No D-Bus required.
# Both are acceptable Linux backends per SysRS-053 / SysRS-162
# ("Secret Service, libsecret, or equivalent"). The adapter picks at
# construction; the test suite exercises the keyutils backend because
# headless CI commonly lacks an unlocked Secret Service collection.
[target.'cfg(target_os = "linux")'.dependencies]
keyring = { version = "3", default-features = false, features = ["sync-secret-service", "crypto-rust", "linux-native"] }
# rusqlite is a stand-in for the production `LocalDatabaseRepository`. Bundled
# build avoids depending on a system libsqlite3.
rusqlite = { version = "0.32", features = ["bundled"] }
[dev-dependencies]
anyhow = "1"
serial_test = "3"
tempfile = "3"
[[bin]]
name = "secure-storage-cli"
path = "src/main.rs"
[lib]
name = "secure_storage_spike"
path = "src/lib.rs"
+89
View File
@@ -0,0 +1,89 @@
# Secure Storage Spike
Chanora proof-of-concept. **Not product code.**
| Field | Value |
|---|---|
| PoC name | `secure-storage-spike` |
| PoC plan | [`docs/architecture/proof-of-concept-plan.md`](../../docs/architecture/proof-of-concept-plan.md) §2 |
| Purpose | Prove platform secure storage behaviour |
| Exit criterion | "Secret write/read/delete works through platform secure storage" |
| Authority | ADR-006 (`SecureStore trait + per-platform adapters`), SDD-078, SRS-091..095, SysRS-158..162 |
| Audit refs | `docs/security/secure-storage-audit-report.md` SS-AUD-001..006 / SS-TC-003 |
## What it proves
- A typed `SecretStorageRepository` trait that the rest of the Rust core
can depend on without backend leakage (SAD-067).
- A Linux adapter selecting between two equivalent backends per
SysRS-162 ("Secret Service, libsecret, **or equivalent**"):
- **Secret Service / libsecret** (gnome-keyring, kwallet, KeePassXC, …).
- **Kernel keyutils** (`add_key(2)` / `request_key(2)`) — used when no
D-Bus session or unlocked Secret Service collection is available.
- A typed `SecureStoreError` with `NotFound` / `Unavailable` / `Backend`
arms, satisfying SS-AUD-005 (safe error mapping).
- A `Secret` newtype with redacting `Debug` / `Display` and
zero-on-drop, satisfying SS-AUD-003 defence-in-depth.
- A miniature `LocalDatabaseRepository` (rusqlite, bundled) that holds
only **references** to secret names — proving the SAD-067 separation
empirically (SS-AUD-001, SS-AUD-002).
## Layout
```text
secure-storage-spike/
src/
lib.rs # crate root + re-exports
secret.rs # SecretStorageRepository trait, Secret type, Linux adapter
sqlite_repo.rs # LocalDatabaseRepository (non-secret state only)
test_logger.rs # in-memory log capture for SS-AUD-003
main.rs # secure-storage-cli driver (SS round-trip)
tests/
audit.rs # SS-AUD-001/002/003/005/006 + SS-TC-003
Cargo.toml
```
## Reproduce
Requires Rust stable (developed against 1.95). Linux only — non-Linux
adapters are out of scope for this spike. `cargo` needs network access
on first build.
```bash
# Run the audit test suite (uses kernel keyutils backend; hermetic).
keyctl session - cargo test --tests
# Run the CLI driver. It prefers Secret Service and falls back to
# keyutils if the default collection is locked. Either path satisfies
# SysRS-053 / SysRS-162.
keyctl session - cargo run --bin secure-storage-cli
```
> **Why `keyctl session -`?**
> The Linux kernel session keyring is inherited from the calling process.
> Cargo's test/run wrappers often inherit an expired or empty
> `_ses` keyring from non-interactive shells. `keyctl session -` creates
> a fresh session keyring before invoking the command, guaranteeing a
> valid backend for keyutils. Interactive desktop sessions normally do
> not need this wrapper.
## Scope boundaries
This spike is intentionally narrow. Out of scope:
- **Windows / macOS / iOS / Android adapters.** SS-TC-001/002/004/005
remain unverified. The trait is shaped to accommodate them but no
code is shipped here.
- **SS-AUD-004 (diagnostic export redaction).** Owned by the
`diagnostics-redaction-spike`.
- **SS-AUD-007 (per-platform documentation).** Doc-only check; lives
in the audit report itself.
- **SS-AUD-008 (migration / import).** No migration paths exist yet.
- **Encryption at rest** beyond what the platform backend provides.
- **Constant-time secret comparison.** `Secret::eq` is best-effort.
- **Threading / async semantics.** Trait is `Send + Sync` but no
concurrent stress is exercised.
## Verification log
See `VERIFICATION.md` in this directory.
+105
View File
@@ -0,0 +1,105 @@
# Verification record — `secure-storage-spike`
## Result
PASS. The PoC exit criterion (from
`docs/architecture/proof-of-concept-plan.md` §2: "Secret write/read/delete
works through platform secure storage") is met on Linux through the
kernel keyutils backend, with a documented Secret Service path verified
manually via the CLI driver.
## Environment
| Field | Value |
|---|---|
| Date | 2026-05-13 |
| Host OS | Linux (Arch, kernel 7.0.5-arch1-1, x86_64) |
| Rust toolchain | stable 1.95.0 |
| Backend (tests) | kernel keyutils (linux-native, `add_key(2)` / `request_key(2)`) |
| Backend (CLI) | tried Secret Service first; fell back to keyutils because the default Secret Service collection was locked (no graphical login) |
| `keyring` crate | 3.6.3 (`sync-secret-service`, `linux-native`, `crypto-rust`) |
| `linux-keyutils` | 0.2.5 |
| `rusqlite` | 0.32.1 (bundled) |
## Reproduction
```bash
keyctl session - cargo test --tests
keyctl session - cargo run --bin secure-storage-cli
```
## Test run
```
running 6 tests
test ss_aud_003_secret_values_not_in_logs ... ok
test ss_aud_006_delete_removes_entry ... ok
test ss_aud_001_identity_secret_absent_from_local_db ... ok
test ss_aud_002_server_password_absent_from_local_db ... ok
test ss_aud_005_safe_error_on_missing_entry ... ok
test ss_tc_003_round_trip_linux ... ok
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured;
0 filtered out; finished in 0.00s
```
## CLI run
```
INFO spike: Secret Service unavailable (Platform secure storage failure:
DBus error: Cannot create an item in a locked collection);
falling back to keyutils
INFO spike: writing secret identity.primary
INFO spike: reading back
INFO spike: round-trip OK (48 bytes)
INFO spike: deleting
INFO spike: post-delete read correctly returned NotFound
OK — Linux Secret Service round-trip verified.
```
This CLI output also evidences SS-AUD-005 in practice: a locked Secret
Service collection produced a typed `Backend` error containing no secret
material, which the caller could then route to the fallback adapter
rather than propagating the raw D-Bus message to the user.
## Audit-check coverage
| Check | Mapped test(s) | Result |
|---|---|---|
| SS-AUD-001 (Identity secret absent from local DB) | `ss_aud_001_identity_secret_absent_from_local_db` | PASS — raw SQLite file scanned for plaintext; only the lookup name appears. |
| SS-AUD-002 (Server password absent from local DB) | `ss_aud_002_server_password_absent_from_local_db` | PASS — same scan, distinct plaintext marker. |
| SS-AUD-003 (Secret values not in logs) | `ss_aud_003_secret_values_not_in_logs` | PASS — captured `tracing` output contains `<redacted>` markers; never contains the plaintext. |
| SS-AUD-005 (Failure → safe error) | `ss_aud_005_safe_error_on_missing_entry` + CLI fallback path | PASS — `NotFound` is the typed Display, no leakage. |
| SS-AUD-006 (Delete removes entry) | `ss_aud_006_delete_removes_entry` | PASS — second delete returns `NotFound`, not silent success. |
| SS-TC-003 (Linux round-trip) | `ss_tc_003_round_trip_linux` | PASS — set/get equality + delete + post-delete `NotFound`. |
## What this spike does NOT validate
- SS-AUD-004 (diagnostic export redaction) — `diagnostics-redaction-spike`.
- SS-AUD-007 (per-platform documentation completeness).
- SS-AUD-008 (migration / import safety).
- SS-TC-001 (Windows DPAPI / Credential Manager).
- SS-TC-002 (macOS Keychain).
- SS-TC-004 (Android Keystore).
- SS-TC-005 (iOS Keychain).
- Concurrent access from multiple threads / processes.
- Long-lived persistence behaviour across reboots (keyutils backend is
session-scoped by design).
- Behaviour under a locked or absent Secret Service collection in
*production* (mitigation strategy is shown but not policy).
## Notable observations
- The kernel keyutils session keyring is inherited from the calling
process; non-interactive shells can present an *expired* `_ses`
keyring. The wrapping `keyctl session -` is the documented workaround
and is universal on Linux. Production code on a graphical session
inherits a valid session from PAM and does not need it.
- gnome-keyring on this host was running but the default collection
was locked because no graphical login had occurred. The CLI's
Secret-Service-first / keyutils-fallback path observed exactly this
condition and reacted correctly, doubling as live evidence for
SS-AUD-005.
- The `Secret` newtype's `Debug` formatter prints
`Secret(<N bytes redacted>)` and `Display` prints `<redacted>`. The
SS-AUD-003 test exercises both representations.
+28
View File
@@ -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;
+80
View File
@@ -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(())
}
}
+206
View File
@@ -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()
}
}
+229
View File
@@ -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)));
}