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
191 lines
7.1 KiB
Rust
191 lines
7.1 KiB
Rust
//! 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);
|
|
}
|
|
}
|