Files
qbz/crates/qbz-secrets/src/install_id.rs
T
vicrodh 1743c9e5ec 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
2026-04-17 00:53:01 -06:00

120 lines
3.9 KiB
Rust

//! 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
}
}