diff --git a/.github/workflows/mcp-release.yml b/.github/workflows/mcp-release.yml index 6fe8247b20..52624fe2f5 100644 --- a/.github/workflows/mcp-release.yml +++ b/.github/workflows/mcp-release.yml @@ -424,7 +424,7 @@ jobs: shell: bash run: | if [[ "${{ runner.os }}" == "Linux" ]]; then - cargo zigbuild --release -p dbx-cli --no-default-features --target "${{ matrix.target }}.2.31" + cargo zigbuild --release -p dbx-cli --no-default-features --features dbx-mcp/os-keyring --target "${{ matrix.target }}.2.31" else # Same keychain rationale as the MCP native legs; the feature is # forwarded through the dbx-mcp dependency. @@ -662,9 +662,9 @@ jobs: shell: bash run: | if [[ "${{ runner.os }}" == "Linux" ]]; then - # Linux zigbuild cannot link the dbus Secret Service backend; the - # managed .dbx/secret.key file remains the key material source. - cargo zigbuild --release -p dbx-mcp --target "${{ matrix.target }}.2.31" + # Pure-Rust (zbus) Secret Service backend: no C libdbus, so the + # zigbuild legs can read desktop-provisioned keys too. + cargo zigbuild --release -p dbx-mcp --features os-keyring --target "${{ matrix.target }}.2.31" else # Native macOS/Windows builds read the OS keychain key that DBX # Desktop provisions during the data security upgrade, so the MCP diff --git a/Cargo.lock b/Cargo.lock index 63dfafb9d2..d03cf44cdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2190,6 +2190,7 @@ dependencies = [ "rusqlite", "rustls 0.23.43", "rustls-pemfile 2.2.0", + "secret-service", "serde", "serde_json", "serde_yaml_ng", @@ -4862,7 +4863,6 @@ checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" dependencies = [ "byteorder", "dbus-secret-service", - "linux-keyutils", "log", "secret-service", "security-framework 2.11.1", @@ -5020,16 +5020,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linux-keyutils" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" -dependencies = [ - "bitflags 2.13.1", - "libc", -] - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -9711,6 +9701,7 @@ dependencies = [ "signal-hook-registry", "socket2 0.6.5", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -11677,6 +11668,7 @@ dependencies = [ "serde_repr", "sha1 0.10.7", "static_assertions", + "tokio", "tracing", "uds_windows", "windows-sys 0.52.0", diff --git a/crates/dbx-core/Cargo.toml b/crates/dbx-core/Cargo.toml index eea1376940..4245d739ae 100644 --- a/crates/dbx-core/Cargo.toml +++ b/crates/dbx-core/Cargo.toml @@ -15,9 +15,11 @@ sqlite-multiple-ciphers = ["dbx-drivers/sqlite-multiple-ciphers"] # (dbx-cli, dbx-mcp); do not combine with the cipher features. sqlite-bundled = ["rusqlite/bundled", "dbx-drivers/sqlite-bundled"] openapi = ["dbx-types/openapi", "dbx-sql/openapi"] -# OS keyring storage for the secret-store key material (macOS/Windows keychain, -# Linux dbus Secret Service with persistent fallback). Desktop only. -os-keyring = ["dep:keyring", "keyring/linux-native-sync-persistent"] +# OS keyring storage for the secret-store key material: macOS/Windows +# keychain via the keyring crate, Linux Secret Service via the pure-Rust +# secret-service/zbus client so cross builds (zigbuild) link no C libdbus. +# Desktop and native package builds enable this. +os-keyring = ["dep:keyring", "dep:secret-service"] system-fonts = ["font-kit"] [dependencies] @@ -67,10 +69,9 @@ aes-gcm = "0.10" aes = "0.8" cbc = "0.1" argon2 = "0.5" -# The Linux dbus Secret Service backend (linux-native-sync-persistent) pulls -# libdbus-sys, whose pkg-config build script cannot cross-compile (musl static -# browser, docker zigbuild). It stays opt-in so server/cross builds skip it and -# fall back to the managed .dbx/secret.key file. +# macOS/Windows keychain backend. The crate's own Linux backend requires +# C libdbus (cannot cross-compile); Linux uses the secret-service crate below +# with the same on-disk attribute layout instead. keyring = { version = "3.6.3", default-features = false, optional = true, features = ["apple-native", "windows-native", "crypto-rust"] } sha2 = "0.10" async-trait = "0.1" @@ -99,3 +100,9 @@ tokio = { version = "1", features = ["test-util"] } [target.'cfg(all(unix, not(target_os = "redox")))'.dependencies] nix = { version = "0.31.3", features = ["user"] } + +[target.'cfg(target_os = "linux")'.dependencies] +# Pure-Rust (zbus) Secret Service client; speaks the session-bus protocol +# directly so zigbuild targets need no system libdbus. Attribute names match +# what the keyring crate writes (target/service/username) for interop. +secret-service = { version = "4.0", optional = true, default-features = false, features = ["rt-tokio-crypto-rust"] } diff --git a/crates/dbx-core/src/persistence/secret_codec.rs b/crates/dbx-core/src/persistence/secret_codec.rs index daad28b1ca..f65d61b3ff 100644 --- a/crates/dbx-core/src/persistence/secret_codec.rs +++ b/crates/dbx-core/src/persistence/secret_codec.rs @@ -20,6 +20,12 @@ const PREFIX: &str = "dbxenc1"; const KEYRING_SERVICE: &str = "com.dbx.app.secret-store.v1"; #[cfg(feature = "os-keyring")] const KEYRING_USER: &str = "local-data-encryption-key"; +// Secret Service attribute layout written by the keyring crate (the desktop +// writer): target is always present and defaults to "default"; entries from +// older keyring versions may lack it, so lookups fall back to the two-attribute +// search. +#[cfg(all(feature = "os-keyring", target_os = "linux"))] +const KEYRING_TARGET: &str = "default"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SecretKeyPolicy { @@ -131,7 +137,7 @@ impl SecretCodec { platform_provider: F, ) -> Result where - F: FnOnce(bool) -> Option, + F: FnOnce(bool) -> Result, String>, { let compatibility_error = if let Some(path) = compatibility_path.as_ref().filter(|path| path.exists()) { match Self::read_key_file(path, false) { @@ -141,8 +147,13 @@ impl SecretCodec { } else { None }; - if let Some(codec) = platform_provider(allow_create) { - return Ok(SecretKeyResolution { codec, source: SecretKeySource::PlatformStore }); + // A keychain item that exists but cannot be read must not silently + // fall through to provisioning a brand-new key, which would strand the + // existing ciphertext behind the wrong key. + match platform_provider(allow_create) { + Ok(Some(codec)) => return Ok(SecretKeyResolution { codec, source: SecretKeySource::PlatformStore }), + Ok(None) => {} + Err(detail) => return Err(detail), } if allow_create { if let Some(path) = compatibility_path { @@ -248,29 +259,89 @@ impl SecretCodec { } } -#[cfg(feature = "os-keyring")] -fn platform_keyring_codec(allow_create: bool) -> Option { - let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER).ok()?; +#[cfg(all(feature = "os-keyring", any(target_os = "macos", target_os = "windows")))] +fn platform_keyring_codec(allow_create: bool) -> Result, String> { + let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER) + .map_err(|error| format!("keyring entry unavailable: {error}"))?; match entry.get_password() { - Ok(value) => return SecretCodec::from_key_material(value.trim()).ok(), + Ok(value) => return Ok(SecretCodec::from_key_material(value.trim()).ok()), Err(keyring::Error::NoEntry) => {} - Err(_) => return None, + // Distinguish a present-but-unreadable keychain item (ACL denial, + // locked keychain) from a missing one: callers surface this class in + // diagnostics instead of the generic provider-unavailable code. + Err(error) => return Err(format!("KEYRING_ACCESS_FAILED: {error}")), } if !allow_create { - return None; + return Ok(None); } let mut key = [0u8; 32]; OsRng.fill_bytes(&mut key); let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(key); - entry.set_password(&encoded).ok()?; - Some(SecretCodec::new(key)) + entry.set_password(&encoded).map_err(|error| format!("KEYRING_WRITE_FAILED: {error}"))?; + Ok(Some(SecretCodec::new(key))) +} + +#[cfg(all(feature = "os-keyring", target_os = "linux"))] +fn platform_keyring_codec(allow_create: bool) -> Result, String> { + use secret_service::{blocking::SecretService, EncryptionType}; + use std::collections::HashMap; + + // A missing session bus (headless server/container) means the provider is + // absent rather than broken; return None so callers fall back to the + // managed .dbx/secret.key file exactly as before. + let service = match SecretService::connect(EncryptionType::Dh) { + Ok(service) => service, + Err(_) => return Ok(None), + }; + let collection = match service.get_default_collection() { + Ok(collection) => collection, + Err(_) => return Ok(None), + }; + let mut attributes = HashMap::new(); + attributes.insert("target", KEYRING_TARGET); + attributes.insert("service", KEYRING_SERVICE); + attributes.insert("username", KEYRING_USER); + let mut found = collection + .search_items(attributes) + .map_err(|error| format!("KEYRING_ACCESS_FAILED: secret search failed: {error}"))?; + if found.is_empty() { + let mut legacy = HashMap::new(); + legacy.insert("service", KEYRING_SERVICE); + legacy.insert("username", KEYRING_USER); + found = collection + .search_items(legacy) + .map_err(|error| format!("KEYRING_ACCESS_FAILED: secret search failed: {error}"))?; + } + let item = found.into_iter().next(); + match item { + Some(item) => { + let secret = + item.get_secret().map_err(|error| format!("KEYRING_ACCESS_FAILED: secret read failed: {error}"))?; + let material = String::from_utf8(secret).map_err(|_| "SECRET_KEY_INVALID".to_string())?; + Ok(SecretCodec::from_key_material(material.trim()).ok()) + } + None if allow_create => { + let mut key = [0u8; 32]; + OsRng.fill_bytes(&mut key); + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(key); + let mut created = HashMap::new(); + created.insert("target", KEYRING_TARGET); + created.insert("service", KEYRING_SERVICE); + created.insert("username", KEYRING_USER); + collection + .create_item("DBX secret-store key", created, encoded.as_bytes(), true, "text/plain") + .map_err(|error| format!("KEYRING_WRITE_FAILED: {error}"))?; + Ok(Some(SecretCodec::new(key))) + } + None => Ok(None), + } } #[cfg(not(feature = "os-keyring"))] -fn platform_keyring_codec(_allow_create: bool) -> Option { +fn platform_keyring_codec(_allow_create: bool) -> Result, String> { // Cross/server builds compile without the OS keyring backend; the managed // .dbx/secret.key file remains the key material source. - None + Ok(None) } fn create_managed_key(path: &std::path::Path) -> Result {