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
120 lines
3.9 KiB
Rust
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
|
|
}
|
|
}
|