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:
Generated
+1854
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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