From af7fd2a5450e466c22012ec99603d7f5daeba3f8 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Tue, 11 Aug 2026 01:35:39 +0200 Subject: [PATCH] fix(secrets): take the KDF path on mobile instead of keyring's mock backend --- crates/qbz-secrets/Cargo.toml | 16 +++++++++++----- crates/qbz-secrets/src/backend.rs | 22 ++++++++++++++++++---- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/crates/qbz-secrets/Cargo.toml b/crates/qbz-secrets/Cargo.toml index 4a93850d1..377ef764c 100644 --- a/crates/qbz-secrets/Cargo.toml +++ b/crates/qbz-secrets/Cargo.toml @@ -16,11 +16,6 @@ sha2 = "0.10" # RNG for nonces + install UUID generation rand = "0.9" -# OS keyring backends: libsecret/secret-service (Linux), Keychain (macOS), -# DPAPI (Windows). Default features on v3 already pick the right backend -# per platform; if it fails at runtime we fall through to the KDF path. -keyring = "3" - # UUID for the persistent install identifier (salt component for fallback) uuid = { version = "1", features = ["v4", "serde"] } @@ -36,3 +31,14 @@ log = { workspace = true } [dev-dependencies] tempfile = "3" + +# OS keyring backends: libsecret/secret-service (Linux), Keychain (macOS), +# DPAPI (Windows). **Desktop only, deliberately.** keyring 3.x compiles no real +# backend for Android, and none for iOS without its `apple-native` feature — it +# routes both to an in-memory `mock` whose writes *succeed*. Depending on it +# there would not fail loudly; it would hand back a master key that never +# leaves the process, so every blob wrapped in one run is undecryptable in the +# next. Mobile takes the KDF path, which is derived from a persisted install id +# and is stable across launches. See `backend.rs`. +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] +keyring = "3" diff --git a/crates/qbz-secrets/src/backend.rs b/crates/qbz-secrets/src/backend.rs index b1e0e139d..9a66198aa 100644 --- a/crates/qbz-secrets/src/backend.rs +++ b/crates/qbz-secrets/src/backend.rs @@ -5,6 +5,16 @@ //! AES-256-GCM wraps. Failure (for any reason) = we fall back to HKDF //! over device identifiers. //! +//! **Mobile skips the keyring entirely and goes straight to the KDF path.** +//! Not a preference — a correctness requirement. `keyring` 3.x compiles no real +//! backend for Android, and none for iOS without its `apple-native` feature; +//! both resolve to an in-memory `mock`. The mock's writes *succeed*, so the +//! attempt below would not fail into the fallback: it would report success and +//! hand back a key that dies with the process, silently making every blob +//! wrapped in one run undecryptable in the next. The KDF path derives from a +//! persisted install id and is stable across launches, which is what a phone +//! needs. `keyring` is a desktop-only dependency for the same reason. +//! //! The backend discriminator is baked into every wrapped blob so a blob //! produced by one backend can't be silently decrypted by another (and //! so that if a user later gains keyring access, we don't accidentally @@ -13,6 +23,8 @@ use std::path::Path; use hkdf::Hkdf; +// Only the keyring path mints a random master key; the KDF path derives one. +#[cfg(not(any(target_os = "android", target_os = "ios")))] use rand::RngCore; use sha2::Sha256; @@ -21,6 +33,7 @@ use crate::error::SecretError; use crate::install_id; const MASTER_KEY_LEN: usize = 32; +#[cfg(not(any(target_os = "android", target_os = "ios")))] const KEYRING_ENTRY_NAME: &str = "master-key-v1"; const HKDF_INFO: &[u8] = b"qbz-secrets master-key derivation v1"; @@ -47,7 +60,9 @@ pub struct Backend { impl Backend { pub fn new(service_name: &str, storage_dir: &Path) -> Result { - // Try keyring first. + // Try keyring first — desktop only; see the module comment for why + // mobile must not, and why a mock's *success* is the dangerous case. + #[cfg(not(any(target_os = "android", target_os = "ios")))] match try_open_keyring(service_name) { Ok(master_key) => { log::info!("[qbz-secrets] Using OS keyring backend"); @@ -94,6 +109,7 @@ impl Backend { } /// Read (or create and store) the master key from the OS keyring. +#[cfg(not(any(target_os = "android", target_os = "ios")))] fn try_open_keyring(service_name: &str) -> Result<[u8; MASTER_KEY_LEN], SecretError> { use base64::engine::general_purpose::STANDARD as B64; use base64::Engine; @@ -125,9 +141,7 @@ fn try_open_keyring(service_name: &str) -> Result<[u8; MASTER_KEY_LEN], SecretEr entry .set_password(&encoded) .map_err(|e| SecretError::Keyring(format!("set_password: {}", e)))?; - log::info!( - "[qbz-secrets] Generated fresh 256-bit master key and stored in OS keyring" - ); + log::info!("[qbz-secrets] Generated fresh 256-bit master key and stored in OS keyring"); Ok(key) } Err(e) => Err(SecretError::Keyring(format!("get_password: {}", e))),