feat(secrets): add qbz-secrets crate for device-bound secret wrapping

Foundation for the offline-cache hardening story. The offline cache
needs to persist per-track AES content keys to disk, but storing them
plaintext would make copying the SQLite DB enough to decrypt the
cached audio on another machine.

qbz-secrets provides a uniform wrap/unwrap API backed by:

1. **OS keyring** (preferred): libsecret/gnome-keyring on Linux,
   Keychain on macOS, DPAPI on Windows. A 256-bit master AES key
   lives inside the OS secure store; all wraps go through it. This
   is the gold-standard device binding used by commercial music
   apps (Spotify, Apple Music, Tidal) for the same class of data.

2. **HKDF fallback**: when the OS keyring is not reachable (headless
   setups — Raspberry Pi, Docker, the future qbzd daemon on a
   server), the master key is derived on demand via HKDF-SHA256
   over service_name + machine_id + a persistent per-install UUID.
   Weaker than keyring (reversible by anyone with filesystem access
   to both sources) but still device-bound against cross-machine
   copy attacks, and lets daemon/headless variants work at all.

Every wrap uses AES-256-GCM with a fresh random 12-byte nonce. The
on-disk envelope carries a version byte + backend marker + nonce +
ciphertext+tag, so (a) we detect tampering via GCM, (b) we refuse to
silently unwrap a blob produced by a backend we can't reach, and (c)
we have a forward-compat path for future key rotation.

4 unit tests cover: small-secret round-trip, 16-byte content-key
round-trip (the exact CMAF use case), GCM tamper detection, and
nonce-randomness (two wraps of the same input differ).

This crate intentionally has zero QBZ dependencies — it's a generic
primitive. Next commits wire it into the offline cache schema + the
DB migration.

cc
This commit is contained in:
vicrodh committed 2026-04-17 00:53:01 -06:00
1 parent 391e797c9a
commit 1743c9e5ec
9 files changed
+729

No files matched your search

+81
View File
@@ -8,6 +8,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
@@ -19,6 +29,20 @@ dependencies = [
"cpufeatures 0.2.17",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -884,6 +908,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
@@ -1433,6 +1458,16 @@ dependencies = [
"wasip3",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "glob"
version = "0.3.3"
@@ -2518,6 +2553,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.3.3"
@@ -2676,6 +2717,18 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
@@ -3010,6 +3063,23 @@ dependencies = [
"tokio",
]
[[package]]
name = "qbz-secrets"
version = "0.1.0"
dependencies = [
"aes-gcm",
"base64",
"hkdf",
"keyring",
"log",
"rand 0.9.3",
"serde",
"sha2",
"tempfile",
"thiserror 2.0.18",
"uuid",
]
[[package]]
name = "qbzd"
version = "0.1.9"
@@ -4652,6 +4722,16 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -4702,6 +4782,7 @@ checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
dependencies = [
"getrandom 0.4.2",
"js-sys",
"serde_core",
"wasm-bindgen",
]
+1
View File
@@ -12,6 +12,7 @@ members = [
"qbz-library",
"qbz-cast",
"qbz-cmaf",
"qbz-secrets",
"qconnect-core",
"qconnect-protocol",
"qconnect-transport-ws",
+38
View File
@@ -0,0 +1,38 @@
[package]
name = "qbz-secrets"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Device-bound secret wrapping for QBZ (offline cache keys, tokens)"
[dependencies]
# AES-256-GCM for the at-rest wrap/unwrap
aes-gcm = "0.10"
# KDF for the fallback path when OS keyring isn't available
hkdf = "0.12"
sha2 = "0.10"
# RNG for nonces + install UUID generation
rand = "0.9"
# OS keyring backends: libsecret/secret-service (Linux), Keychain (macOS),
# DPAPI (Windows). Default features on v3 already pick the right backend
# per platform; if it fails at runtime we fall through to the KDF path.
keyring = "3"
# UUID for the persistent install identifier (salt component for fallback)
uuid = { version = "1", features = ["v4", "serde"] }
# Serde for the on-disk wrapper blob envelope
serde = { workspace = true }
# Base64 for keyring-roundtripped master key (secret-service expects a UTF-8 string)
base64 = { workspace = true }
# Errors
thiserror = { workspace = true }
log = { workspace = true }
[dev-dependencies]
tempfile = "3"
+169
View File
@@ -0,0 +1,169 @@
//! Backend selection and runtime dispatch.
//!
//! At open time we try to use the OS keyring first. Success = we store
//! (or rotate-in) a 32-byte master key inside the keyring and use it for
//! AES-256-GCM wraps. Failure (for any reason) = we fall back to HKDF
//! over device identifiers.
//!
//! The backend discriminator is baked into every wrapped blob so a blob
//! produced by one backend can't be silently decrypted by another (and
//! so that if a user later gains keyring access, we don't accidentally
//! ignore their existing KDF-wrapped data).
use std::path::Path;
use hkdf::Hkdf;
use rand::RngCore;
use sha2::Sha256;
use crate::cipher::{unwrap_with_key, wrap_with_key};
use crate::error::SecretError;
use crate::install_id;
const MASTER_KEY_LEN: usize = 32;
const KEYRING_ENTRY_NAME: &str = "master-key-v1";
const HKDF_INFO: &[u8] = b"qbz-secrets master-key derivation v1";
const BACKEND_MARKER_KEYRING: u8 = 0;
const BACKEND_MARKER_KDF: u8 = 1;
/// Which backend is active at runtime. Useful for diagnostics / UI.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BackendKind {
/// Master key lives in the OS keyring. Moving the offline cache to
/// another machine makes it unreadable. Gold-standard device binding.
Keyring,
/// Master key is derived on the fly from `machine-id` + a persistent
/// per-install UUID. Still device-bound, but reversible by anyone
/// with filesystem access to both sources. Used when the OS keyring
/// is unavailable (headless daemon, Pi-like setups).
KdfFallback,
}
pub struct Backend {
kind: BackendKind,
master_key: [u8; MASTER_KEY_LEN],
}
impl Backend {
pub fn new(service_name: &str, storage_dir: &Path) -> Result<Self, SecretError> {
// Try keyring first.
match try_open_keyring(service_name) {
Ok(master_key) => {
log::info!("[qbz-secrets] Using OS keyring backend");
return Ok(Self {
kind: BackendKind::Keyring,
master_key,
});
}
Err(e) => {
log::warn!(
"[qbz-secrets] OS keyring unavailable ({}) — falling back to KDF-derived key",
e
);
}
}
// Fallback: derive from device identifiers.
let master_key = derive_fallback_key(service_name, storage_dir)?;
Ok(Self {
kind: BackendKind::KdfFallback,
master_key,
})
}
pub fn kind(&self) -> BackendKind {
self.kind
}
pub fn wrap(&self, plaintext: &[u8]) -> Result<Vec<u8>, SecretError> {
let marker = match self.kind {
BackendKind::Keyring => BACKEND_MARKER_KEYRING,
BackendKind::KdfFallback => BACKEND_MARKER_KDF,
};
wrap_with_key(&self.master_key, marker, plaintext)
}
pub fn unwrap(&self, wrapped: &[u8]) -> Result<Vec<u8>, SecretError> {
let expected = match self.kind {
BackendKind::Keyring => BACKEND_MARKER_KEYRING,
BackendKind::KdfFallback => BACKEND_MARKER_KDF,
};
unwrap_with_key(&self.master_key, expected, wrapped)
}
}
/// Read (or create and store) the master key from the OS keyring.
fn try_open_keyring(service_name: &str) -> Result<[u8; MASTER_KEY_LEN], SecretError> {
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use keyring::Entry;
let entry = Entry::new(service_name, KEYRING_ENTRY_NAME)
.map_err(|e| SecretError::Keyring(format!("Entry::new: {}", e)))?;
match entry.get_password() {
Ok(existing_b64) => {
let bytes = B64
.decode(existing_b64.trim())
.map_err(|e| SecretError::Keyring(format!("base64 decode: {}", e)))?;
if bytes.len() != MASTER_KEY_LEN {
return Err(SecretError::Keyring(format!(
"keyring entry has wrong length ({} bytes, expected {})",
bytes.len(),
MASTER_KEY_LEN
)));
}
let mut out = [0u8; MASTER_KEY_LEN];
out.copy_from_slice(&bytes);
Ok(out)
}
Err(keyring::Error::NoEntry) => {
let mut key = [0u8; MASTER_KEY_LEN];
rand::rng().fill_bytes(&mut key);
let encoded = B64.encode(key);
entry
.set_password(&encoded)
.map_err(|e| SecretError::Keyring(format!("set_password: {}", e)))?;
log::info!(
"[qbz-secrets] Generated fresh 256-bit master key and stored in OS keyring"
);
Ok(key)
}
Err(e) => Err(SecretError::Keyring(format!("get_password: {}", e))),
}
}
fn derive_fallback_key(
service_name: &str,
storage_dir: &Path,
) -> Result<[u8; MASTER_KEY_LEN], SecretError> {
// Assemble salt inputs. Any component can be missing; we still
// derive a usable key as long as at least one is present (the
// install UUID is guaranteed after first run).
let install_uuid = install_id::load_or_create(storage_dir).map_err(SecretError::Io)?;
let machine = install_id::machine_id().unwrap_or_default();
// The "IKM" is the concatenation of service name + machine id +
// install uuid. None of these are secrets; the security comes from
// the fact that all three need to be present on the same filesystem
// to reconstruct them.
let mut ikm: Vec<u8> = Vec::with_capacity(service_name.len() + machine.len() + 64);
ikm.extend_from_slice(service_name.as_bytes());
ikm.push(0);
ikm.extend_from_slice(machine.as_bytes());
ikm.push(0);
ikm.extend_from_slice(install_uuid.as_bytes());
// HKDF-SHA256 with a fixed salt (info carries the version).
let hk = Hkdf::<Sha256>::new(None, &ikm);
let mut out = [0u8; MASTER_KEY_LEN];
hk.expand(HKDF_INFO, &mut out)
.map_err(|e| SecretError::Other(format!("HKDF expand: {}", e)))?;
log::info!(
"[qbz-secrets] Derived 256-bit master key via HKDF (machine-id-present={})",
!machine.is_empty()
);
Ok(out)
}
+42
View File
@@ -0,0 +1,42 @@
//! AES-256-GCM authenticated encryption primitive shared by both backends.
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use rand::RngCore;
use crate::envelope::{WrappedSecret, NONCE_LEN};
use crate::error::SecretError;
pub(crate) fn wrap_with_key(
key: &[u8; 32],
backend_marker: u8,
plaintext: &[u8],
) -> Result<Vec<u8>, SecretError> {
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
let mut nonce_bytes = [0u8; NONCE_LEN];
rand::rng().fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext)
.map_err(|e| SecretError::Cipher(e.to_string()))?;
Ok(WrappedSecret::build(backend_marker, &nonce_bytes, &ciphertext))
}
pub(crate) fn unwrap_with_key(
key: &[u8; 32],
expected_marker: u8,
wrapped: &[u8],
) -> Result<Vec<u8>, SecretError> {
let parsed = WrappedSecret::parse(wrapped)?;
if parsed.backend_marker != expected_marker {
return Err(SecretError::BackendMismatch);
}
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
let nonce = Nonce::from_slice(parsed.nonce);
cipher
.decrypt(nonce, parsed.ciphertext)
.map_err(|e| SecretError::Cipher(e.to_string()))
}
+64
View File
@@ -0,0 +1,64 @@
//! On-disk envelope format for wrapped secrets.
//!
//! Layout (bytes):
//!
//! ```text
//! [0] version (u8) — currently always 1
//! [1] backend marker — 0 = keyring, 1 = kdf-fallback
//! [2..14] nonce (12 bytes) — random per wrap
//! [14..] ciphertext+tag — AES-256-GCM output
//! ```
//!
//! The backend marker is informational so we can refuse to unwrap a blob
//! that was produced by a backend the current process cannot reach (e.g.
//! a keyring-wrapped blob on a machine where the keyring is gone).
use crate::error::SecretError;
pub(crate) const ENVELOPE_VERSION: u8 = 1;
pub(crate) const HEADER_LEN: usize = 2 + 12; // version + backend + nonce
pub(crate) const NONCE_LEN: usize = 12;
/// Internal view of a wrapped secret blob. `WrappedSecret` is the user-
/// facing type re-exported from `lib.rs` for ergonomics in diagnostics.
#[derive(Debug)]
pub struct WrappedSecret<'a> {
pub version: u8,
pub backend_marker: u8,
pub nonce: &'a [u8],
pub ciphertext: &'a [u8],
}
impl<'a> WrappedSecret<'a> {
pub fn parse(bytes: &'a [u8]) -> Result<Self, SecretError> {
if bytes.len() < HEADER_LEN + 16 /* GCM tag */ {
return Err(SecretError::Malformed(format!(
"wrapped blob too short ({} bytes)",
bytes.len()
)));
}
let version = bytes[0];
if version != ENVELOPE_VERSION {
return Err(SecretError::Malformed(format!(
"unsupported envelope version {}",
version
)));
}
Ok(Self {
version,
backend_marker: bytes[1],
nonce: &bytes[2..HEADER_LEN],
ciphertext: &bytes[HEADER_LEN..],
})
}
pub fn build(backend_marker: u8, nonce: &[u8], ciphertext: &[u8]) -> Vec<u8> {
assert_eq!(nonce.len(), NONCE_LEN, "nonce must be 12 bytes");
let mut out = Vec::with_capacity(HEADER_LEN + ciphertext.len());
out.push(ENVELOPE_VERSION);
out.push(backend_marker);
out.extend_from_slice(nonce);
out.extend_from_slice(ciphertext);
out
}
}
+25
View File
@@ -0,0 +1,25 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SecretError {
#[error("no backend available — keyring failed and KDF fallback could not initialize: {0}")]
NoBackend(String),
#[error("keyring error: {0}")]
Keyring(String),
#[error("cipher error: {0}")]
Cipher(String),
#[error("wrapped blob is malformed: {0}")]
Malformed(String),
#[error("wrapped blob was produced by a backend the current process cannot use")]
BackendMismatch,
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("unexpected: {0}")]
Other(String),
}
+119
View File
@@ -0,0 +1,119 @@
//! Persistent per-install UUID used as salt for the KDF fallback backend.
//!
//! The file is **not** a secret — it's a salt component. Writing it in
//! plaintext is fine. Its purpose is to ensure that two installations on
//! the same machine (e.g. system account + root) derive different keys,
//! and that moving the install directory to a different machine breaks
//! the derivation (because `machine-id` changes too).
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use uuid::Uuid;
const INSTALL_ID_FILENAME: &str = "install-id";
pub(crate) fn load_or_create(storage_dir: &Path) -> std::io::Result<Uuid> {
let path = storage_dir.join(INSTALL_ID_FILENAME);
if let Ok(contents) = fs::read_to_string(&path) {
if let Ok(uuid) = Uuid::parse_str(contents.trim()) {
return Ok(uuid);
}
log::warn!(
"[qbz-secrets] Corrupt install-id at {:?}, regenerating",
path
);
}
fs::create_dir_all(storage_dir)?;
let uuid = Uuid::new_v4();
let tmp_path: PathBuf = path.with_extension("tmp");
{
let mut f = fs::File::create(&tmp_path)?;
f.write_all(uuid.to_string().as_bytes())?;
f.sync_all()?;
}
fs::rename(&tmp_path, &path)?;
log::info!("[qbz-secrets] Generated fresh install-id at {:?}", path);
Ok(uuid)
}
/// Read the OS machine identifier. Uses platform-appropriate sources;
/// returns `None` if nothing reliable is available. A missing machine-id
/// is acceptable — the KDF falls back to just the install UUID, which is
/// still device-bound because the install directory is per-machine.
pub(crate) fn machine_id() -> Option<String> {
#[cfg(target_os = "linux")]
{
for candidate in &["/etc/machine-id", "/var/lib/dbus/machine-id"] {
if let Ok(contents) = fs::read_to_string(candidate) {
let trimmed = contents.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
None
}
#[cfg(target_os = "macos")]
{
// IOPlatformUUID via ioreg; parse the "IOPlatformUUID" = "..." line
use std::process::Command;
let output = Command::new("ioreg")
.args(["-rd1", "-c", "IOPlatformExpertDevice"])
.output()
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if let Some(idx) = line.find("IOPlatformUUID") {
let tail = &line[idx..];
if let Some(start) = tail.find('"').map(|i| i + 1) {
let rest = &tail[start..];
if let Some(end_off) = rest.find('"') {
return Some(rest[..end_off].to_string());
}
if let Some(end_off) = rest[rest.find('"').map(|i| i + 1).unwrap_or(0)..]
.find('"')
{
return Some(rest[..end_off].to_string());
}
}
}
}
None
}
#[cfg(target_os = "windows")]
{
// HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid
use std::process::Command;
let output = Command::new("reg")
.args([
"query",
"HKLM\\SOFTWARE\\Microsoft\\Cryptography",
"/v",
"MachineGuid",
])
.output()
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if line.contains("MachineGuid") {
return line
.split_whitespace()
.last()
.map(str::to_string)
.filter(|s| !s.is_empty());
}
}
None
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
None
}
}
+190
View File
@@ -0,0 +1,190 @@
//! Device-bound secret wrapping for QBZ.
//!
//! This crate exists to give QBZ a single, uniform way to wrap small
//! secrets (AES keys, session tokens, OAuth refresh material) so they
//! can be persisted to disk without giving an attacker with filesystem
//! access everything they need to use those secrets on another machine.
//!
//! # Why
//!
//! The immediate consumer is the offline music cache. Qobuz streams each
//! track with CMAF, where a per-track AES key is derived at runtime from
//! session material. To make "save for offline" work we must store each
//! track's content key somewhere. Storing it plaintext in SQLite next to
//! the encrypted audio files would defeat the purpose — copying the DB +
//! files is enough to play them anywhere. So we wrap the key.
//!
//! # Design
//!
//! Two backends, same API:
//!
//! 1. **OS keyring** (preferred): the master AES-256 key lives inside the
//! OS secure store — libsecret/gnome-keyring on Linux, Keychain on
//! macOS, DPAPI on Windows. This is the "gold standard" device binding
//! used by commercial music apps.
//! 2. **KDF fallback** (headless): when the OS keyring is not reachable
//! (typical on Raspberry Pi / server / Docker), the master key is
//! derived on the fly via HKDF-SHA256 over `machine-id` + a persistent
//! per-install UUID + a constant salt. This is weaker than the keyring
//! path because anyone with filesystem access to `machine-id` and the
//! install directory can reconstruct the key, but it still defeats
//! naive copy-paste attacks across machines and it lets the daemon
//! variant of QBZ work without a desktop session behind it.
//!
//! Both backends produce and consume the same on-disk [`WrappedSecret`]
//! envelope, so the caller never needs to care which path was used.
//!
//! # Threat model
//!
//! What this protects against:
//!
//! - Copying the offline cache directory (or the whole SQLite DB) to
//! another machine. On the new machine the keyring entry is missing
//! (or machine-id differs), so the wrapped keys can't be unwrapped.
//! - Casual inspection of the DB. Keys are not recoverable by reading
//! the wrapped blob alone.
//!
//! What this does **not** protect against:
//!
//! - A determined attacker on the same machine with shell access and
//! the ability to invoke QBZ: they can always ask QBZ itself to
//! decrypt, which is the right property (same threat model as every
//! local DRM).
//! - Someone re-compiling QBZ with telemetry on the decrypted bytes —
//! that's a source-modification attack, not a cryptographic one, and
//! it's outside the scope of at-rest wrapping.
//!
//! # API
//!
//! ```rust,ignore
//! use qbz_secrets::SecretBox;
//!
//! // Open once per app, at startup. service_name scopes the key inside
//! // the OS keyring and is part of the HKDF salt for the fallback path.
//! let vault = SecretBox::open("qbz", storage_dir).await?;
//!
//! // Wrap a content key before persisting to SQLite
//! let wrapped: Vec<u8> = vault.wrap(&content_key_bytes)?;
//!
//! // Unwrap when reading it back
//! let original: Vec<u8> = vault.unwrap(&wrapped)?;
//! ```
mod backend;
mod cipher;
mod envelope;
mod error;
mod install_id;
pub use backend::{Backend, BackendKind};
pub use envelope::WrappedSecret;
pub use error::SecretError;
use std::path::Path;
/// Handle to the secret storage. Cheap to clone (reference-counted
/// internally via `Arc` inside the backend).
#[derive(Clone)]
pub struct SecretBox {
backend: std::sync::Arc<Backend>,
}
impl SecretBox {
/// Open the secret store. Tries the OS keyring first; if that fails
/// for any reason (headless, missing libsecret, keyring locked,
/// user denied access), falls back transparently to the HKDF path.
///
/// `service_name` scopes the key inside the OS keyring — use a
/// constant per app. `storage_dir` is where the install UUID (salt
/// for the KDF fallback) lives; it must be writable and persistent
/// across app restarts.
pub fn open(service_name: &str, storage_dir: &Path) -> Result<Self, SecretError> {
let backend = Backend::new(service_name, storage_dir)?;
Ok(Self {
backend: std::sync::Arc::new(backend),
})
}
/// Construct directly from a provided backend, useful for tests.
#[doc(hidden)]
pub fn from_backend(backend: Backend) -> Self {
Self {
backend: std::sync::Arc::new(backend),
}
}
/// Wrap a secret for at-rest storage. The returned bytes are
/// self-describing (include backend marker, nonce, ciphertext+tag)
/// so [`unwrap`](Self::unwrap) on the same machine can round-trip.
pub fn wrap(&self, plaintext: &[u8]) -> Result<Vec<u8>, SecretError> {
self.backend.wrap(plaintext)
}
/// Unwrap previously [`wrap`](Self::wrap)-produced bytes.
pub fn unwrap(&self, wrapped: &[u8]) -> Result<Vec<u8>, SecretError> {
self.backend.unwrap(wrapped)
}
/// Which backend is actually in use. Exposed for diagnostics /
/// settings UI ("Offline cache secured by OS keyring" vs "…by
/// device-derived key").
pub fn backend_kind(&self) -> BackendKind {
self.backend.kind()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_vault() -> (SecretBox, TempDir) {
let dir = TempDir::new().expect("tempdir");
// Use a service name that's extremely unlikely to collide with a
// real keyring entry on the dev machine. The KDF fallback path is
// exercised by design because the sandboxed tempdir + nonexistent
// entry guarantees a fresh state; the keyring may be reachable
// but we accept either backend — the round-trip still holds.
let vault = SecretBox::open("qbz-secrets-test-harness", dir.path())
.expect("open vault");
(vault, dir)
}
#[test]
fn roundtrip_small_secret() {
let (vault, _dir) = test_vault();
let payload = b"hello, secret";
let wrapped = vault.wrap(payload).expect("wrap");
let unwrapped = vault.unwrap(&wrapped).expect("unwrap");
assert_eq!(unwrapped, payload);
}
#[test]
fn roundtrip_16_byte_content_key() {
// The exact shape of a CMAF content key — the primary use case.
let (vault, _dir) = test_vault();
let key = [0x42u8; 16];
let wrapped = vault.wrap(&key).expect("wrap");
let unwrapped = vault.unwrap(&wrapped).expect("unwrap");
assert_eq!(unwrapped, &key[..]);
}
#[test]
fn tampering_is_detected() {
let (vault, _dir) = test_vault();
let mut wrapped = vault.wrap(b"important data").expect("wrap");
// Flip one bit in the ciphertext region (past the 14-byte header)
wrapped[20] ^= 0x01;
let result = vault.unwrap(&wrapped);
assert!(result.is_err(), "GCM tag must detect tampering");
}
#[test]
fn two_wraps_of_same_plaintext_differ() {
// Nonce is random per wrap — two calls must produce distinct ciphertext
let (vault, _dir) = test_vault();
let a = vault.wrap(b"same input").expect("wrap");
let b = vault.wrap(b"same input").expect("wrap");
assert_ne!(a, b);
}
}