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