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
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
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
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.
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.
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
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