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
Generated
+81
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ members = [
|
||||
"qbz-library",
|
||||
"qbz-cast",
|
||||
"qbz-cmaf",
|
||||
"qbz-secrets",
|
||||
"qconnect-core",
|
||||
"qconnect-protocol",
|
||||
"qconnect-transport-ws",
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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