Commit Graph
9 Commits
Author SHA1 Message Date
vicrodh 0cded1e395 chore(deps): rand 0.10 everywhere (qbz-player, qbz-secrets were on 0.9)
RngCore/fill_bytes -> RngExt/fill, the trait names rand 0.10 exports.

cc
2026-08-29 08:24:28 -06:00
vicrodh cb2a61cbd9 fix(secrets): stop the machine-id probe flashing a console
install_id spawns reg query for MachineGuid. The shipped image is a
GUI-subsystem binary and owns no console, so Windows allocated one and a black
box flashed at every startup. CREATE_NO_WINDOW suppresses it, through
std::os::windows::process::CommandExt, with no new dependency.

The command, its arguments, its stdout and its parse are untouched. That
matters: this value is a KDF input, and .output() captures the same bytes --
only the console attachment changes.

cc
2026-08-27 12:44:20 -06:00
vicrodh b94e0e6cdb fix(secrets): select the keyring backend per target and refuse the mock
qbz-credentials still asked for the Linux features on every target with
default_features on. keyring 3 has no default backend: without the
per-platform feature it exports the in-memory mock, whose writes succeed
and whose contents die with the process, so the offline-cache master key
was regenerated every launch and every wrapped blob failed AES-GCM on the
next start, with the logs reporting a working keyring. It survived only
because Cargo unified features with qbz-secrets, which is the fragility
the per-target block exists to remove.

Also refuse a non-disk-backed backend at runtime and take the KDF path.
Measured in keyring-3.6.3, not assumed: windows.rs, macos.rs and
secret_service.rs all inherit the trait default UntilDelete
(credential.rs:171); mock.rs:228 overrides it to EntryOnly. Testing for
ProcessOnly, as first drafted, would never have fired.

cc
2026-08-26 23:06:41 -06:00
vicrodh b60ed2c268 fix(secrets): integrate real desktop keyring backends (#697)
Preserve Afonso’s restart regression test while selecting native stores only on Linux, macOS and Windows. Unsupported targets take the persisted KDF path instead of keyring’s successful in-memory mock; the stale workflow change is deliberately excluded.

cx
2026-08-26 20:59:27 -06:00
Afonso Jorge Ramos 7814d4d2d5 fix(secrets): keep the unit tests off the OS keyring
Asking for real keyring backends made the four round-trip tests write a genuine
login-Keychain item, and macOS binds an item's ACL to the exact binary that
created it. So every run after the first, with any source edit in between, was
a new binary against an existing item, and the OS blocked all four tests on an
authorization dialog that a headless run can never answer. Reproduced: four
tests hanging past 60s, where a fresh keychain passes in 0.39s.

The comment claiming the sandboxed tempdir guaranteed a fresh state was the
thing that hid it. try_open_keyring ignores storage_dir entirely and looks the
entry up by service name alone, so the tempdir bought nothing.

Backend::kdf_only pins them to the path they were always documented as taking.
What they assert, wrap/unwrap round-trips and tamper detection, is identical on
either backend, so nothing is lost. survives_a_restart.rs still exercises the
real backend, which is where it belongs: persistence across processes is what
that test is about.

Also names Linux's backend here instead of inheriting it. qbz-credentials
enables async-secret-service and Cargo unifies features, which holds for every
shipping build but makes this crate's correctness depend on a sibling: built on
its own, cargo tree showed keyring with no secret-service, i.e. the same silent
mock this PR exists to remove. Verified: secret-service v4.0.0 on Linux,
security-framework on macOS, and still zero keyring on iOS and Android.
2026-08-12 03:21:26 +02:00
Afonso Jorge Ramos 99ba4aa48e test(secrets): prove a wrapped secret survives a process restart
The property this crate exists for is the one no in-process test could
show. A single process wraps and unwraps happily against a master key
that never left memory, which is exactly what keyring's mock hands back —
so the whole suite stayed green while every offline download lost its
content key on the next launch.

The test re-executes the test binary: wrap in one child, unwrap in
another. It asserts persistence and not which backend provided it, so the
KDF fallback passes it too and a runner without secret-service is a pass
rather than a flake, while the mock fails everywhere.

The existing job is Linux-only, which is where these crates are least
likely to break — both bugs it now guards shipped on macOS while Linux
was fine. Adds a narrow macOS job for the two platform-sensitive crates:
no ALSA/JACK/D-Bus, no UI graph, a couple of minutes on a 10x runner.
2026-08-11 17:08:53 +02:00
Afonso Jorge Ramos 934a83fdac fix(secrets): enable the real keyring backends on macOS and Windows
keyring 3.x ships no `default` feature contents, and every platform arm
reads `not(feature = "...-native")` -> `pub use mock as default`. Naming
no features therefore selected the in-memory mock on macOS and Windows,
whose writes succeed and whose master key dies with the process, while
`Backend::new` still reported `BackendKind::Keyring`.

The consequence is that every offline track's content key becomes
undecryptable on the next launch, so playback treats it as a cache miss
and silently re-streams what the user had already downloaded.

Linux is left off the list because qbz-credentials already enables
`async-secret-service` and Cargo unifies features; naming
`sync-secret-service` here would trip keyring's own compile_error for
enabling both halves of a store.

Proven with a probe wrapping in one process and unwrapping in another,
on the same machine, changing only this line:
  before: backend = Keyring, UNWRAP FAILED: cipher error: aead::Error
  after:  backend = Keyring, UNWRAPPED OK
2026-08-11 17:00:26 +02:00
Afonso Jorge Ramos af7fd2a545 fix(secrets): take the KDF path on mobile instead of keyring's mock backend 2026-08-11 01:36:01 +02:00
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