fix(core): cover Linux keyring access with a pure-Rust Secret Service client
The keyring crate's Linux backend requires C libdbus, which cross builds (zigbuild) cannot link, so Linux desktop users hit the same desktop-migrated-but-MCP-blocked failure that macOS/Windows users hit in 0.4.94 - with no native build to fix it on. Replace the Linux keyring path with the secret-service crate (pure-Rust zbus, no C dependencies, speaks the session-bus protocol directly), keeping the exact on-disk attribute layout the keyring crate writes (target/service/username, with the legacy no-target fallback) so existing desktop-provisioned keys remain readable both ways. - connect/collection failures (no session bus, headless servers) map to a missing provider so callers keep falling back to the managed .dbx/secret.key file exactly as before - found-but-unreadable keyring items now fail fast with a specific KEYRING_ACCESS_FAILED code on every platform instead of silently provisioning a new key that would strand existing ciphertext - the Linux zigbuild MCP/CLI package legs build with os-keyring
This commit is contained in:
4 files changed
+105
-35
No files matched your search
@@ -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"] }
|
||||
@@ -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<SecretKeyResolution, String>
|
||||
where
|
||||
F: FnOnce(bool) -> Option<SecretCodec>,
|
||||
F: FnOnce(bool) -> Result<Option<SecretCodec>, 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<SecretCodec> {
|
||||
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<Option<SecretCodec>, 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<Option<SecretCodec>, 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<SecretCodec> {
|
||||
fn platform_keyring_codec(_allow_create: bool) -> Result<Option<SecretCodec>, 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<SecretCodec, String> {
|
||||
|
||||
Reference in new issue
Block a user