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:
9 files changed
+729
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user