feat(security): encrypt local credentials and guide data migration

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-23 11:02:52 +08:00
1 parent 19f36557b9
commit 250e0c0a10
58 files changed
+8831 -435

No files matched your search

+1
View File
@@ -62,6 +62,7 @@ aes-gcm = "0.10"
aes = "0.8"
cbc = "0.1"
argon2 = "0.5"
keyring = { version = "3.6.3", default-features = false, features = ["apple-native", "windows-native", "linux-native-sync-persistent", "crypto-rust"] }
sha2 = "0.10"
async-trait = "0.1"
bytes = "1"
File diff suppressed because it is too large. Load diff
+1
View File
@@ -2,5 +2,6 @@ pub mod cloud_sync;
pub mod config;
pub mod history;
pub mod saved_sql;
pub mod secret_codec;
pub mod state_persistence;
pub mod storage;
@@ -0,0 +1,609 @@
//! Encryption primitives for values persisted in `connection_secrets`.
//!
//! The database deliberately stores only an envelope (`dbxenc1...`) for new
//! values. Desktop builds keep the local key in the operating system
//! credential store (macOS Keychain, Windows Credential Manager, or Linux
//! Secret Service). `DBX_SECRET_KEY` and `DBX_SECRET_KEY_FILE` are supported
//! for headless deployments. A per-user key file remains as a compatibility
//! fallback for installations created before native credential storage was
//! enabled; the key is always outside the database and never enters a sync file.
use aes_gcm::{
aead::{rand_core::RngCore, Aead, KeyInit, OsRng},
Aes256Gcm, Nonce,
};
use argon2::{Algorithm, Argon2, Params, Version};
use base64::Engine as _;
const PREFIX: &str = "dbxenc1";
const KEYRING_SERVICE: &str = "com.dbx.app.secret-store.v1";
const KEYRING_USER: &str = "local-data-encryption-key";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SecretKeyPolicy {
PlatformDefault,
ManagedDataDir,
ExternalOnly,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SecretKeySource {
ExplicitFile,
ExplicitEnv,
ManagedDataDir,
PlatformStore,
Unavailable,
}
impl SecretKeySource {
pub const fn as_str(self) -> &'static str {
match self {
Self::ExplicitFile => "explicit_file",
Self::ExplicitEnv => "explicit_env",
Self::ManagedDataDir => "managed_data_dir",
Self::PlatformStore => "platform_store",
Self::Unavailable => "unavailable",
}
}
}
#[derive(Clone, Copy)]
pub struct SecretKeyResolution {
pub codec: SecretCodec,
pub source: SecretKeySource,
}
#[derive(Clone, Copy)]
pub struct SecretCodec {
key: [u8; 32],
}
impl SecretCodec {
pub const fn new(key: [u8; 32]) -> Self {
Self { key }
}
/// Build a codec from a high-entropy passphrase using Argon2id.
pub fn from_passphrase(passphrase: &str) -> Result<Self, String> {
if passphrase.is_empty() {
return Err("secret key cannot be empty".to_string());
}
let mut key = [0u8; 32];
let params = Params::new(19 * 1024, 2, 1, Some(32)).map_err(|e| e.to_string())?;
Argon2::new(Algorithm::Argon2id, Version::V0x13, params)
.hash_password_into(passphrase.as_bytes(), b"dbx-local-secret-store-v1", &mut key)
.map_err(|e| e.to_string())?;
Ok(Self::new(key))
}
/// Resolve the configured key without exposing key material in errors.
/// `allow_create` is intentionally explicit so status checks remain read-only.
pub fn resolve(
policy: SecretKeyPolicy,
data_dir: &std::path::Path,
allow_create: bool,
) -> Result<SecretKeyResolution, String> {
if let Some(path) = std::env::var_os("DBX_SECRET_KEY_FILE") {
let path = std::path::PathBuf::from(path);
let codec = Self::read_key_file(&path, false)?;
return Ok(SecretKeyResolution { codec, source: SecretKeySource::ExplicitFile });
}
if let Some(value) = std::env::var_os("DBX_SECRET_KEY") {
let value = value.to_str().ok_or_else(|| "SECRET_KEY_INVALID".to_string())?;
let codec = Self::from_key_material(value.trim()).map_err(|_| "SECRET_KEY_INVALID".to_string())?;
return Ok(SecretKeyResolution { codec, source: SecretKeySource::ExplicitEnv });
}
match policy {
SecretKeyPolicy::ManagedDataDir => {
let path = managed_key_path(data_dir);
match path.symlink_metadata() {
Ok(metadata) if metadata.file_type().is_symlink() => Err("KEY_FILE_UNAVAILABLE".to_string()),
Ok(_) => {
if allow_create {
secure_managed_key_permissions(&path)?;
}
let codec = read_key_file_with_retry(&path, true)?;
Ok(SecretKeyResolution { codec, source: SecretKeySource::ManagedDataDir })
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound && allow_create => {
let codec = create_managed_key(&path)?;
Ok(SecretKeyResolution { codec, source: SecretKeySource::ManagedDataDir })
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
Err("MISSING_MANAGED_KEY".to_string())
}
Err(_) => Err("KEY_FILE_UNAVAILABLE".to_string()),
}
}
SecretKeyPolicy::ExternalOnly => Err("MISSING_EXTERNAL_KEY".to_string()),
SecretKeyPolicy::PlatformDefault => {
Self::resolve_platform_default(default_key_path(), allow_create, platform_keyring_codec)
}
}
}
fn resolve_platform_default<F>(
compatibility_path: Option<std::path::PathBuf>,
allow_create: bool,
platform_provider: F,
) -> Result<SecretKeyResolution, String>
where
F: FnOnce(bool) -> Option<SecretCodec>,
{
let compatibility_error = if let Some(path) = compatibility_path.as_ref().filter(|path| path.exists()) {
match Self::read_key_file(path, false) {
Ok(codec) => return Ok(SecretKeyResolution { codec, source: SecretKeySource::ManagedDataDir }),
Err(error) => Some(error),
}
} else {
None
};
if let Some(codec) = platform_provider(allow_create) {
return Ok(SecretKeyResolution { codec, source: SecretKeySource::PlatformStore });
}
if allow_create {
if let Some(path) = compatibility_path {
let codec = create_key_file(&path, false)?;
return Ok(SecretKeyResolution { codec, source: SecretKeySource::ManagedDataDir });
}
}
compatibility_error.map_or_else(|| Err("KEY_PROVIDER_UNAVAILABLE".to_string()), Err)
}
/// Read an explicitly configured key, then an existing compatibility key
/// file, then the platform credential store. The file is checked before
/// creating a new keyring entry so upgrades cannot strand an existing
/// database behind a newly generated device key.
pub fn from_env_or_default() -> Result<Self, String> {
Self::resolve(SecretKeyPolicy::PlatformDefault, std::path::Path::new("."), true).map(|resolved| resolved.codec)
}
/// Inspect existing key material without creating files or keyring entries.
/// Headless migration must use an explicitly configured persistent key.
pub fn from_existing_provider(require_external: bool) -> Result<Self, String> {
let policy = if require_external { SecretKeyPolicy::ExternalOnly } else { SecretKeyPolicy::PlatformDefault };
Self::resolve(policy, std::path::Path::new("."), false).map(|resolved| resolved.codec)
}
/// Provision only after a read-only probe found no usable provider. This
/// is called by the desktop migration wizard only for plaintext-only
/// legacy data; headless callers never use it. If the platform keyring is
/// unavailable, the documented per-user key-file fallback keeps Linux
/// desktop upgrades recoverable without putting a key in SQLite.
pub fn provision_for_migration() -> Result<Self, String> {
Self::resolve(SecretKeyPolicy::PlatformDefault, std::path::Path::new("."), true).map(|resolved| resolved.codec)
}
fn read_key_file(path: &std::path::Path, reject_symlink: bool) -> Result<Self, String> {
let metadata = if reject_symlink { path.symlink_metadata() } else { path.metadata() }
.map_err(|_| "KEY_FILE_UNAVAILABLE".to_string())?;
if (reject_symlink && metadata.file_type().is_symlink()) || !metadata.is_file() {
return Err("KEY_FILE_UNAVAILABLE".to_string());
}
let value = std::fs::read_to_string(path).map_err(|error| {
if error.kind() == std::io::ErrorKind::InvalidData {
"SECRET_KEY_INVALID"
} else {
"KEY_FILE_UNAVAILABLE"
}
})?;
Self::from_key_material(value.trim()).map_err(|_| "SECRET_KEY_INVALID".to_string())
}
fn from_key_material(value: &str) -> Result<Self, String> {
if let Ok(bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(value) {
if bytes.len() == 32 {
return bytes.try_into().map(Self::new).map_err(|_| "invalid key".into());
}
}
if value.is_ascii() && value.len() == 64 {
let mut out = [0u8; 32];
for (i, slot) in out.iter_mut().enumerate() {
*slot = u8::from_str_radix(&value[i * 2..i * 2 + 2], 16).map_err(|_| "invalid hex key")?;
}
return Ok(Self::new(out));
}
Self::from_passphrase(value)
}
pub fn encrypt(&self, namespace: &str, key: &str, plaintext: &str) -> Result<String, String> {
let mut nonce = [0u8; 12];
OsRng.fill_bytes(&mut nonce);
let cipher = Aes256Gcm::new_from_slice(&self.key).map_err(|e| e.to_string())?;
let ciphertext = cipher
.encrypt(
Nonce::from_slice(&nonce),
aes_gcm::aead::Payload { msg: plaintext.as_bytes(), aad: aad(namespace, key).as_bytes() },
)
.map_err(|_| "secret encryption failed".to_string())?;
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD;
Ok(format!("{PREFIX}.{}.{}", b64.encode(nonce), b64.encode(ciphertext)))
}
pub fn decrypt(&self, namespace: &str, key: &str, envelope: &str) -> Result<String, String> {
let mut parts = envelope.split('.');
if parts.next() != Some(PREFIX) {
return Err("unsupported secret envelope".to_string());
}
let nonce = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(parts.next().ok_or("invalid secret envelope")?)
.map_err(|_| "invalid secret nonce".to_string())?;
let ciphertext = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(parts.next().ok_or("invalid secret envelope")?)
.map_err(|_| "invalid secret ciphertext".to_string())?;
if parts.next().is_some() || nonce.len() != 12 {
return Err("invalid secret envelope".to_string());
}
let cipher = Aes256Gcm::new_from_slice(&self.key).map_err(|e| e.to_string())?;
let plaintext = cipher
.decrypt(
Nonce::from_slice(&nonce),
aes_gcm::aead::Payload { msg: &ciphertext, aad: aad(namespace, key).as_bytes() },
)
.map_err(|_| "secret decryption failed".to_string())?;
String::from_utf8(plaintext).map_err(|_| "secret is not valid UTF-8".to_string())
}
}
fn platform_keyring_codec(allow_create: bool) -> Option<SecretCodec> {
let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER).ok()?;
match entry.get_password() {
Ok(value) => return SecretCodec::from_key_material(value.trim()).ok(),
Err(keyring::Error::NoEntry) => {}
Err(_) => return None,
}
if !allow_create {
return 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))
}
fn create_managed_key(path: &std::path::Path) -> Result<SecretCodec, String> {
let parent = path.parent().ok_or_else(|| "KEY_FILE_UNAVAILABLE".to_string())?;
std::fs::create_dir_all(parent).map_err(|_| "KEY_FILE_UNAVAILABLE".to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
.map_err(|_| "KEY_FILE_UNAVAILABLE".to_string())?;
}
create_key_file(path, true)
}
fn secure_managed_key_permissions(path: &std::path::Path) -> Result<(), String> {
if path.symlink_metadata().map_or(true, |metadata| metadata.file_type().is_symlink() || !metadata.is_file()) {
return Err("KEY_FILE_UNAVAILABLE".to_string());
}
let parent = path.parent().ok_or_else(|| "KEY_FILE_UNAVAILABLE".to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
.map_err(|_| "KEY_FILE_UNAVAILABLE".to_string())?;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.map_err(|_| "KEY_FILE_UNAVAILABLE".to_string())?;
}
Ok(())
}
fn create_key_file(path: &std::path::Path, managed: bool) -> Result<SecretCodec, String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|_| "KEY_FILE_UNAVAILABLE".to_string())?;
}
let mut key = [0u8; 32];
OsRng.fill_bytes(&mut key);
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(key);
let write_result = {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(path).and_then(|mut file| {
std::io::Write::write_all(&mut file, encoded.as_bytes())?;
file.sync_all()
})
}
#[cfg(not(unix))]
{
std::fs::OpenOptions::new().write(true).create_new(true).open(path).and_then(|mut file| {
std::io::Write::write_all(&mut file, encoded.as_bytes())?;
file.sync_all()
})
}
};
if write_result.is_ok() {
return SecretCodec::read_key_file(path, managed);
}
match path.symlink_metadata() {
Ok(metadata) if managed && metadata.file_type().is_symlink() => Err("KEY_FILE_UNAVAILABLE".to_string()),
Ok(_) => {
if managed {
read_key_file_with_retry(path, managed)
} else {
SecretCodec::read_key_file(path, managed)
}
}
Err(_) => Err("KEY_FILE_UNAVAILABLE".to_string()),
}
}
pub fn managed_key_path(data_dir: &std::path::Path) -> std::path::PathBuf {
data_dir.join(".dbx").join("secret.key")
}
fn read_key_file_with_retry(path: &std::path::Path, reject_symlink: bool) -> Result<SecretCodec, String> {
for attempt in 0..20 {
match SecretCodec::read_key_file(path, reject_symlink) {
Ok(codec) => return Ok(codec),
Err(error) if attempt < 19 && error == "SECRET_KEY_INVALID" && path.metadata().is_ok() => {
std::thread::sleep(std::time::Duration::from_millis(5));
}
Err(error) => return Err(error),
}
}
Err("KEY_FILE_UNAVAILABLE".to_string())
}
fn default_key_path() -> Option<std::path::PathBuf> {
#[cfg(target_os = "macos")]
if let Ok(home) = std::env::var("HOME") {
return Some(std::path::PathBuf::from(home).join("Library/Application Support/dbx/secret.key"));
}
if let Ok(config) = std::env::var("XDG_CONFIG_HOME") {
return Some(std::path::PathBuf::from(config).join("dbx/secret.key"));
}
std::env::var_os("APPDATA").map(std::path::PathBuf::from).map(|path| path.join("dbx/secret.key")).or_else(|| {
std::env::var_os("HOME").map(std::path::PathBuf::from).map(|path| path.join(".config/dbx/secret.key"))
})
}
fn aad(namespace: &str, key: &str) -> String {
format!("dbx-secret-v1\0{namespace}\0{key}")
}
#[cfg(test)]
mod tests {
use super::{managed_key_path, SecretCodec, SecretKeyPolicy, SecretKeySource};
use base64::Engine as _;
use std::sync::{Mutex, OnceLock};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
#[test]
fn roundtrip_binds_namespace_and_key() {
let codec = SecretCodec::new([7u8; 32]);
let envelope = codec.encrypt("connection-1", "password", "s3cret").unwrap();
assert!(envelope.starts_with("dbxenc1."));
assert_eq!(codec.decrypt("connection-1", "password", &envelope).unwrap(), "s3cret");
assert!(codec.decrypt("connection-2", "password", &envelope).is_err());
assert!(codec.decrypt("connection-1", "token", &envelope).is_err());
}
#[test]
fn unicode_key_material_never_slices_inside_a_character() {
// 64 bytes, with the second byte inside a three-byte character.
let material = format!("{}a", "密".repeat(21));
assert_eq!(material.len(), 64);
let codec = SecretCodec::from_key_material(&material).unwrap();
let encrypted = codec.encrypt("n", "k", "value").unwrap();
assert_eq!(codec.decrypt("n", "k", &encrypted).unwrap(), "value");
}
#[test]
fn tampering_is_rejected() {
let codec = SecretCodec::new([3u8; 32]);
let mut envelope = codec.encrypt("n", "k", "value").unwrap();
envelope.push('x');
assert!(codec.decrypt("n", "k", &envelope).is_err());
}
#[test]
fn managed_key_is_created_idempotently_with_restricted_permissions() {
let _guard = env_lock().lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let first = SecretCodec::resolve(SecretKeyPolicy::ManagedDataDir, dir.path(), true).unwrap();
let second = SecretCodec::resolve(SecretKeyPolicy::ManagedDataDir, dir.path(), false).unwrap();
assert_eq!(first.source, SecretKeySource::ManagedDataDir);
assert_eq!(second.source, SecretKeySource::ManagedDataDir);
let envelope = first.codec.encrypt("n", "k", "value").unwrap();
assert_eq!(second.codec.decrypt("n", "k", &envelope).unwrap(), "value");
assert!(managed_key_path(dir.path()).is_file());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let key_dir = dir.path().join(".dbx");
let key_path = managed_key_path(dir.path());
assert_eq!(std::fs::metadata(&key_dir).unwrap().permissions().mode() & 0o777, 0o700);
assert_eq!(std::fs::metadata(&key_path).unwrap().permissions().mode() & 0o777, 0o600);
std::fs::set_permissions(&key_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o644)).unwrap();
SecretCodec::resolve(SecretKeyPolicy::ManagedDataDir, dir.path(), false).unwrap();
assert_eq!(std::fs::metadata(&key_dir).unwrap().permissions().mode() & 0o777, 0o755);
assert_eq!(std::fs::metadata(&key_path).unwrap().permissions().mode() & 0o777, 0o644);
SecretCodec::resolve(SecretKeyPolicy::ManagedDataDir, dir.path(), true).unwrap();
assert_eq!(std::fs::metadata(&key_dir).unwrap().permissions().mode() & 0o777, 0o700);
assert_eq!(std::fs::metadata(&key_path).unwrap().permissions().mode() & 0o777, 0o600);
}
}
#[test]
fn managed_key_creation_is_safe_for_concurrent_starters() {
let _guard = env_lock().lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let paths = (0..8).map(|_| dir.path().to_path_buf()).collect::<Vec<_>>();
let threads = paths
.into_iter()
.map(|path| {
std::thread::spawn(move || SecretCodec::resolve(SecretKeyPolicy::ManagedDataDir, &path, true).unwrap())
})
.collect::<Vec<_>>();
let resolutions = threads.into_iter().map(|thread| thread.join().unwrap()).collect::<Vec<_>>();
let envelope = resolutions[0].codec.encrypt("n", "k", "value").unwrap();
for resolution in resolutions {
assert_eq!(resolution.codec.decrypt("n", "k", &envelope).unwrap(), "value");
}
}
#[cfg(unix)]
#[test]
fn managed_key_symlink_is_rejected() {
use std::os::unix::fs::symlink;
let _guard = env_lock().lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real.key");
std::fs::write(&real, base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([4u8; 32])).unwrap();
let key_path = managed_key_path(dir.path());
std::fs::create_dir_all(key_path.parent().unwrap()).unwrap();
symlink(&real, &key_path).unwrap();
assert!(matches!(
SecretCodec::resolve(SecretKeyPolicy::ManagedDataDir, dir.path(), false),
Err(error) if error == "KEY_FILE_UNAVAILABLE"
));
}
#[cfg(unix)]
#[test]
fn explicit_key_file_symlink_is_allowed() {
use std::os::unix::fs::symlink;
let _guard = env_lock().lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real.key");
let link = dir.path().join("external.key");
std::fs::write(&real, base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([5u8; 32])).unwrap();
symlink(&real, &link).unwrap();
let previous_file = std::env::var_os("DBX_SECRET_KEY_FILE");
let previous_env = std::env::var_os("DBX_SECRET_KEY");
std::env::set_var("DBX_SECRET_KEY_FILE", &link);
std::env::remove_var("DBX_SECRET_KEY");
let resolved = SecretCodec::resolve(SecretKeyPolicy::ManagedDataDir, dir.path(), false).unwrap();
assert_eq!(resolved.source, SecretKeySource::ExplicitFile);
restore_env("DBX_SECRET_KEY_FILE", previous_file);
restore_env("DBX_SECRET_KEY", previous_env);
}
#[cfg(unix)]
#[test]
fn invalid_non_utf8_explicit_environment_key_does_not_fallback() {
use std::os::unix::ffi::OsStringExt;
let _guard = env_lock().lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let previous_file = std::env::var_os("DBX_SECRET_KEY_FILE");
let previous_env = std::env::var_os("DBX_SECRET_KEY");
std::env::remove_var("DBX_SECRET_KEY_FILE");
std::env::set_var("DBX_SECRET_KEY", std::ffi::OsString::from_vec(vec![0xff, 0xfe]));
assert!(matches!(
SecretCodec::resolve(SecretKeyPolicy::ManagedDataDir, dir.path(), true),
Err(error) if error == "SECRET_KEY_INVALID"
));
restore_env("DBX_SECRET_KEY_FILE", previous_file);
restore_env("DBX_SECRET_KEY", previous_env);
}
#[test]
fn unavailable_platform_probe_does_not_create_a_fallback_key() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("secret.key");
let result = SecretCodec::resolve_platform_default(Some(path.clone()), false, |allow_create| {
assert!(!allow_create);
None
});
assert!(matches!(result, Err(error) if error == "KEY_PROVIDER_UNAVAILABLE"));
assert!(!path.exists());
}
#[test]
fn unavailable_platform_creation_uses_a_stable_fallback_key() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("secret.key");
let created = SecretCodec::resolve_platform_default(Some(path.clone()), true, |allow_create| {
assert!(allow_create);
None
})
.unwrap();
let envelope = created.codec.encrypt("connection", "password", "secret").unwrap();
let original = std::fs::read(&path).unwrap();
let reopened = SecretCodec::resolve_platform_default(Some(path.clone()), true, |_| {
panic!("an existing compatibility key must take precedence over the platform provider")
})
.unwrap();
assert_eq!(reopened.source, SecretKeySource::ManagedDataDir);
assert_eq!(reopened.codec.decrypt("connection", "password", &envelope).unwrap(), "secret");
assert_eq!(std::fs::read(path).unwrap(), original);
}
#[test]
fn unavailable_platform_provider_does_not_replace_a_corrupt_fallback_key() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("secret.key");
std::fs::write(&path, "\n").unwrap();
for allow_create in [false, true] {
let result = SecretCodec::resolve_platform_default(Some(path.clone()), allow_create, |_| None);
assert!(matches!(result, Err(error) if error == "SECRET_KEY_INVALID"));
assert_eq!(std::fs::read_to_string(&path).unwrap(), "\n");
}
}
#[test]
fn platform_default_falls_back_after_corrupt_compatibility_file() {
let dir = tempfile::tempdir().unwrap();
let compatibility_path = dir.path().join("secret.key");
std::fs::write(&compatibility_path, "\n").unwrap();
let resolution = SecretCodec::resolve_platform_default(Some(compatibility_path), false, |_| {
Some(SecretCodec::new([9u8; 32]))
})
.unwrap();
assert_eq!(resolution.source, SecretKeySource::PlatformStore);
let envelope = resolution.codec.encrypt("n", "k", "value").unwrap();
assert_eq!(resolution.codec.decrypt("n", "k", &envelope).unwrap(), "value");
}
#[test]
fn explicit_sources_follow_precedence_without_fallback() {
let _guard = env_lock().lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("external.key");
let previous_file = std::env::var_os("DBX_SECRET_KEY_FILE");
let previous_env = std::env::var("DBX_SECRET_KEY").ok();
std::env::remove_var("DBX_SECRET_KEY_FILE");
std::env::remove_var("DBX_SECRET_KEY");
SecretCodec::resolve(SecretKeyPolicy::ManagedDataDir, dir.path(), true).unwrap();
std::fs::write(&file, "\n").unwrap();
std::env::set_var("DBX_SECRET_KEY_FILE", &file);
std::env::set_var("DBX_SECRET_KEY", base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([2u8; 32]));
assert!(matches!(
SecretCodec::resolve(SecretKeyPolicy::ManagedDataDir, dir.path(), false),
Err(error) if error == "SECRET_KEY_INVALID"
));
std::fs::write(&file, base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([1u8; 32])).unwrap();
let resolved = SecretCodec::resolve(SecretKeyPolicy::ManagedDataDir, dir.path(), false).unwrap();
assert_eq!(resolved.source, SecretKeySource::ExplicitFile);
std::env::remove_var("DBX_SECRET_KEY_FILE");
let resolved = SecretCodec::resolve(SecretKeyPolicy::ManagedDataDir, dir.path(), false).unwrap();
assert_eq!(resolved.source, SecretKeySource::ExplicitEnv);
restore_env("DBX_SECRET_KEY_FILE", previous_file);
restore_env("DBX_SECRET_KEY", previous_env.map(std::ffi::OsString::from));
}
fn restore_env(name: &str, value: Option<std::ffi::OsString>) {
if let Some(value) = value {
std::env::set_var(name, value);
} else {
std::env::remove_var(name);
}
}
}
File diff suppressed because it is too large. Load diff
+31 -1
View File
@@ -691,7 +691,14 @@ impl LocalBackend {
/// Same as [`open`], but lets tests and embedded callers pin the app version
/// used for plugin compatibility checks instead of the compile-time version.
pub async fn open_with_app_version(path: &Path, app_version: &str) -> Result<Self, String> {
let storage = Storage::open(path).await?;
// A local CLI/MCP process may share an already provisioned desktop
// Keychain/credential-store key. Preflight only reads that provider;
// it never provisions a key or migrates legacy credentials.
let storage = Storage::open_unmigrated(path).await?.with_secret_key_creation(false);
let migration = storage.inspect_data_migration().await?;
if !migration.is_ready() {
return Err("DATA_MIGRATION_REQUIRED: open DBX Desktop or Web to complete the data security upgrade".into());
}
let configs = storage.load_connections().await?;
let desktop_settings = storage.load_desktop_settings().await.unwrap_or_default();
let data_dir = path.parent().unwrap_or_else(|| Path::new(".")).to_path_buf();
@@ -4012,6 +4019,29 @@ mod tests {
assert!(connections.is_empty());
}
#[tokio::test]
async fn local_backend_rejects_legacy_data_without_migrating_it() {
let data_dir = tempfile::tempdir().unwrap();
let database_path = data_dir.path().join("dbx.db");
let legacy_path = data_dir.path().join("secrets.json");
let legacy_contents = br#"{"legacy-connection":{"password":"legacy-test-password"}}"#;
std::fs::write(&legacy_path, legacy_contents).unwrap();
let error = match LocalBackend::open(&database_path).await {
Ok(_) => panic!("legacy data must not be opened by CLI/MCP"),
Err(error) => error,
};
assert!(error.starts_with("DATA_MIGRATION_REQUIRED"));
assert_eq!(std::fs::read(&legacy_path).unwrap(), legacy_contents);
assert!(!data_dir.path().join("secrets.json.bak").exists());
assert!(std::fs::read_dir(data_dir.path())
.unwrap()
.all(|entry| { !entry.unwrap().file_name().to_string_lossy().starts_with("dbx-secret-migration-") }));
let storage = Storage::open_unmigrated(&database_path).await.unwrap();
assert!(storage.inspect_data_migration().await.unwrap().needs_migration);
assert!(storage.load_connections().await.unwrap().is_empty());
}
#[tokio::test]
async fn local_backend_uses_desktop_plugin_directory() {
let data_dir = tempfile::tempdir().unwrap();
+2 -2
View File
@@ -48,7 +48,7 @@ fn api_path_suffix<'a>(path: &'a str, public_base_path: &str) -> Option<&'a str>
path.strip_prefix(base)?.strip_prefix("/api/")
}
fn middleware_api_path_suffix<'a>(path: &'a str, public_base_path: &str) -> Option<&'a str> {
pub(crate) fn middleware_api_path_suffix<'a>(path: &'a str, public_base_path: &str) -> Option<&'a str> {
if let Some(suffix) = api_path_suffix(path, public_base_path) {
return Some(suffix);
}
@@ -281,7 +281,7 @@ mod tests {
}
#[test]
fn middleware_api_path_suffix_handles_nested_router_paths() {
pub(crate) fn middleware_api_path_suffix_handles_nested_router_paths() {
assert_eq!(middleware_api_path_suffix("/auth/check", "/"), Some("auth/check"));
assert_eq!(middleware_api_path_suffix("/connection/list", "/"), Some("connection/list"));
assert_eq!(middleware_api_path_suffix("/api/connection/list", "/"), Some("connection/list"));
+84 -5
View File
@@ -13,17 +13,19 @@ use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::SaltString;
use argon2::{Argon2, PasswordHasher};
use axum::extract::DefaultBodyLimit;
use axum::http::Uri;
use axum::http::{Request, StatusCode, Uri};
use axum::middleware;
use axum::response::Redirect;
use axum::response::{IntoResponse, Redirect, Response};
use axum::routing::{delete, get, post};
use axum::Router;
use dbx_core::connection::AppState;
use dbx_core::persistence::secret_codec::SecretKeyPolicy;
use dbx_core::sql_dialect::dialect_loader::{register_core_dialects, DialectPluginLoader, DialectRegistry};
use dbx_core::sql_dialect::hot_reload::DialectHotReload;
use dbx_core::storage::Storage;
use dbx_mcp::{streamable_http_router, DbxBackend, HttpAuth, LocalBackend};
use state::WebState;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::RwLock;
use tower_http::compression::predicate::{DefaultPredicate, NotForContentType, Predicate};
use tower_http::compression::CompressionLayer;
@@ -78,6 +80,35 @@ fn web_body_limit_bytes_from_value(value: Option<&str>) -> usize {
mb.saturating_mul(1024 * 1024)
}
async fn migration_gate(
state: axum::extract::State<Arc<WebState>>,
request: Request<axum::body::Body>,
next: axum::middleware::Next,
) -> Response {
let suffix = auth::middleware_api_path_suffix(request.uri().path(), &state.public_base_path);
let allowed = suffix.is_some_and(|path| {
matches!(
path,
"migration/status" | "migration/start" | "migration/retry" | "migration/cleanup-backups" | "ping"
) || path.starts_with("auth/")
});
if !allowed && !state.migration_ready.load(Ordering::Acquire) {
return (
StatusCode::LOCKED,
axum::Json(serde_json::json!({
"code": "DATA_MIGRATION_REQUIRED",
"message": "Complete the data security migration before using DBX"
})),
)
.into_response();
}
next.run(request).await
}
async fn storage_migration_ready(app: &Arc<AppState>) -> bool {
app.storage.inspect_data_migration().await.map(|status| status.is_ready()).unwrap_or(false)
}
fn web_agent_dir(data_dir: &std::path::Path) -> std::path::PathBuf {
web_agent_dir_from_env(data_dir, std::env::var("DBX_AGENT_DIR").ok())
}
@@ -301,8 +332,10 @@ async fn main() {
let app_state = {
let db_path = data_dir.join("dbx.db");
let storage = Storage::open(&db_path).await.expect("Failed to open storage");
storage.migrate_from_json(&data_dir).await.expect("Failed to migrate JSON data");
let storage = Storage::open_unmigrated(&db_path)
.await
.expect("Failed to open storage")
.with_secret_key_policy(SecretKeyPolicy::ManagedDataDir);
// Initialize core dialect registry and load external plugin dialects
register_core_dialects();
@@ -349,6 +382,7 @@ async fn main() {
let public_base_path = normalize_public_base_path(std::env::var("DBX_PUBLIC_BASE_PATH").ok());
let migration_ready = storage_migration_ready(&app_state).await;
let web_state = Arc::new(WebState {
app: app_state,
data_dir,
@@ -364,12 +398,17 @@ async fn main() {
login_rate_limit: tokio::sync::Mutex::new(state::LoginRateLimit { fail_count: 0, locked_until: None }),
export_files: RwLock::new(HashMap::new()),
ssh_prompts: Arc::new(ssh_prompt::SshPromptHub::new()),
migration_ready: Arc::new(AtomicBool::new(migration_ready)),
});
ssh_prompt::install_web_ssh_prompt_bridge(web_state.ssh_prompts.clone());
// API routes
let api = Router::new()
.route("/migration/status", get(routes::migration::status))
.route("/migration/start", post(routes::migration::start))
.route("/migration/retry", post(routes::migration::retry))
.route("/migration/cleanup-backups", post(routes::migration::cleanup_backups))
// Auth
.route("/auth/login", post(auth::login))
.route("/auth/check", get(auth::check))
@@ -1188,6 +1227,7 @@ async fn main() {
api.route("/query/build-duckdb-attach-database-sql", post(routes::query::build_duckdb_attach_database_sql));
let api = add_mq_routes(api)
.layer(middleware::from_fn_with_state(web_state.clone(), migration_gate))
.layer(middleware::from_fn_with_state(web_state.clone(), auth::auth_middleware))
.with_state(web_state.clone());
@@ -1199,7 +1239,7 @@ async fn main() {
.layer(tower_http::trace::TraceLayer::new_for_http());
if let Some(mcp_router) = web_mcp_router(&web_state).expect("Invalid DBX Web MCP configuration") {
app = app.merge(mcp_router);
app = app.merge(mcp_router.layer(middleware::from_fn_with_state(web_state.clone(), migration_gate)));
tracing::info!("DBX Web MCP is enabled at /mcp");
}
@@ -1248,6 +1288,45 @@ mod tests {
use axum::Router;
use tower_http::compression::predicate::Predicate;
#[tokio::test]
async fn migration_http_gate_blocks_business_until_ready_but_allows_cleanup_handler() {
use std::sync::{atomic::Ordering, Arc};
let directory = tempfile::tempdir().unwrap();
let storage = dbx_core::storage::Storage::open_unmigrated(&directory.path().join("dbx.db")).await.unwrap();
let app = Arc::new(dbx_core::connection::AppState::new(storage));
let state = Arc::new(crate::state::WebState::for_tests(app, directory.path().to_path_buf()));
state.migration_ready.store(false, Ordering::Release);
let router = Router::new()
.route("/api/migration/status", get(|| async { "status" }))
.route("/api/migration/cleanup-backups", post(|| async { "cleanup" }))
.route("/api/connection/list", get(|| async { "connections" }))
.route("/mcp", post(|| async { "mcp" }))
.layer(axum::middleware::from_fn_with_state(state.clone(), super::migration_gate));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
let client = reqwest::Client::new();
assert_eq!(
client.get(format!("http://{address}/api/migration/status")).send().await.unwrap().status(),
StatusCode::OK
);
for (method, path) in [(reqwest::Method::GET, "/api/connection/list"), (reqwest::Method::POST, "/mcp")] {
let response = client.request(method, format!("http://{address}{path}")).send().await.unwrap();
assert_eq!(response.status(), StatusCode::LOCKED);
assert_eq!(response.json::<serde_json::Value>().await.unwrap()["code"], "DATA_MIGRATION_REQUIRED");
}
assert_eq!(
client.post(format!("http://{address}/api/migration/cleanup-backups")).send().await.unwrap().status(),
StatusCode::OK
);
state.migration_ready.store(true, Ordering::Release);
assert_eq!(
client.get(format!("http://{address}/api/connection/list")).send().await.unwrap().status(),
StatusCode::OK
);
server.abort();
}
fn compression_response(content_type: &str) -> Response<Body> {
Response::builder().header(CONTENT_TYPE, content_type).body(Body::from(vec![b'x'; 64])).unwrap()
}
+56 -17
View File
@@ -3,16 +3,16 @@ use std::sync::Arc;
use axum::extract::State;
use axum::Json;
use dbx_core::cloud_sync::{
apply_sync_snapshot, build_sync_snapshot, build_sync_snapshot_with_saved_secrets, finalize_snippet_migration,
forget_snippet_token, forget_webdav_password,
forget_webdav_sync_secrets_passphrase as core_forget_webdav_sync_secrets_passphrase, resolve_snippet_token,
resolve_webdav_password, resolve_webdav_sync_secrets_passphrase, retry_pending_snippet_cleanup,
save_snippet_sync_id_for_instance as core_save_snippet_sync_id, save_snippet_token, save_webdav_password,
save_webdav_sync_secrets_preference as core_save_webdav_sync_secrets_preference, snippet_saved_token_status,
snippet_sync_settings_for_instance as core_snippet_sync_settings, webdav_saved_password_status,
webdav_sync_secrets_status as core_webdav_sync_secrets_status, ApplySnapshotOptions, ApplySnapshotSummary,
SnippetProvider, SnippetSyncClient, SnippetSyncConfig, SnippetSyncSettings, SnippetSyncSummary, SnippetTokenStatus,
WebDavClient, WebDavConfig, WebDavPasswordStatus, WebDavSyncSecretsStatus, WebDavSyncSummary,
apply_sync_snapshot, build_sync_snapshot_with_options, finalize_snippet_migration, forget_snippet_token,
forget_webdav_password, forget_webdav_sync_secrets_passphrase as core_forget_webdav_sync_secrets_passphrase,
resolve_snippet_token, resolve_webdav_password, resolve_webdav_sync_secrets_passphrase,
retry_pending_snippet_cleanup, save_snippet_sync_id_for_instance as core_save_snippet_sync_id, save_snippet_token,
save_webdav_password, save_webdav_sync_secrets_preference as core_save_webdav_sync_secrets_preference,
snippet_saved_token_status, snippet_sync_settings_for_instance as core_snippet_sync_settings,
webdav_saved_password_status, webdav_sync_secrets_status as core_webdav_sync_secrets_status, ApplySnapshotOptions,
ApplySnapshotSummary, SnippetProvider, SnippetSyncClient, SnippetSyncConfig, SnippetSyncSettings,
SnippetSyncSummary, SnippetTokenStatus, WebDavClient, WebDavConfig, WebDavPasswordStatus, WebDavSyncSecretsStatus,
WebDavSyncSummary,
};
use dbx_core::storage::DesktopSettings;
use serde::{Deserialize, Serialize};
@@ -57,6 +57,9 @@ pub struct WebDavUploadRequest {
pub config: WebDavConfig,
pub editor_settings: Option<serde_json::Value>,
pub secrets_passphrase: Option<String>,
/// Explicit opt-in for exporting credentials.
#[serde(default)]
pub include_secrets: bool,
}
#[derive(Debug, Clone, Deserialize)]
@@ -64,6 +67,15 @@ pub struct WebDavUploadRequest {
pub struct WebDavDownloadRequest {
pub config: WebDavConfig,
pub secrets_passphrase: Option<String>,
/// Explicitly controls whether encrypted or legacy plaintext secrets may
/// be restored. Older clients omitted this field and retain the historical
/// restore behavior.
#[serde(default = "default_restore_secrets")]
pub restore_secrets: bool,
}
fn default_restore_secrets() -> bool {
true
}
#[derive(Debug, Clone, Deserialize)]
@@ -180,11 +192,27 @@ pub async fn webdav_sync_upload(
Json(mut req): Json<WebDavUploadRequest>,
) -> Result<Json<WebDavSyncSummary>, AppError> {
resolve_webdav_password(&state.app.storage, &mut req.config).await.map_err(AppError::from)?;
let snapshot = build_sync_snapshot_with_saved_secrets(
let explicit_passphrase = req.secrets_passphrase.as_deref().and_then(|value| {
let value = value.trim();
(!value.is_empty()).then_some(value)
});
let saved_passphrase = if explicit_passphrase.is_none() {
resolve_webdav_sync_secrets_passphrase(&state.app.storage).await.map_err(AppError::from)?
} else {
None
};
let passphrase = explicit_passphrase.or(saved_passphrase.as_deref());
let snapshot = build_sync_snapshot_with_options(
&state.app.storage,
env!("CARGO_PKG_VERSION"),
req.editor_settings,
req.secrets_passphrase.as_deref(),
dbx_core::cloud_sync::SyncExportOptions {
include_secrets: req.include_secrets,
sync_passphrase: passphrase,
include_ai_secrets: req.include_secrets,
include_tunnel_secrets: req.include_secrets,
include_plugin_secrets: req.include_secrets,
},
)
.await
.map_err(AppError::from)?;
@@ -208,7 +236,7 @@ pub async fn webdav_sync_download(
&snapshot,
ApplySnapshotOptions {
secrets_passphrase: explicit_passphrase.or(saved_passphrase.as_deref()),
restore_secrets: true,
restore_secrets: req.restore_secrets,
},
)
.await
@@ -299,10 +327,21 @@ pub async fn snippet_sync_upload(
} else {
None
};
let snapshot =
build_sync_snapshot(&state.app.storage, env!("CARGO_PKG_VERSION"), req.editor_settings, secrets_passphrase)
.await
.map_err(AppError::from)?;
let include_secrets = req.include_secrets;
let snapshot = build_sync_snapshot_with_options(
&state.app.storage,
env!("CARGO_PKG_VERSION"),
req.editor_settings,
dbx_core::cloud_sync::SyncExportOptions {
include_secrets,
sync_passphrase: secrets_passphrase,
include_ai_secrets: include_secrets,
include_tunnel_secrets: include_secrets,
include_plugin_secrets: include_secrets,
},
)
.await
.map_err(AppError::from)?;
let client = SnippetSyncClient::new(req.config).map_err(AppError::from)?;
let mut summary = client
.put_snapshot(&snapshot, req.snippet_passphrase.as_deref(), secrets_passphrase)
+47
View File
@@ -0,0 +1,47 @@
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use dbx_core::storage::{MigrationPreflight, MigrationReport};
use serde_json::json;
use std::sync::Arc;
use crate::state::WebState;
pub async fn status(
State(state): State<Arc<WebState>>,
) -> Result<Json<MigrationPreflight>, (StatusCode, Json<serde_json::Value>)> {
state.app.storage.inspect_data_migration().await.map(Json).map_err(error_response)
}
pub async fn start(
State(state): State<Arc<WebState>>,
) -> Result<Json<MigrationReport>, (StatusCode, Json<serde_json::Value>)> {
state.migration_ready.store(false, std::sync::atomic::Ordering::Release);
match state.app.storage.start_data_migration().await {
Ok(report) => {
let ready =
state.app.storage.inspect_data_migration().await.map(|status| status.is_ready()).unwrap_or(false);
state.migration_ready.store(ready, std::sync::atomic::Ordering::Release);
Ok(Json(report))
}
Err(error) => {
state.migration_ready.store(false, std::sync::atomic::Ordering::Release);
Err(error_response(error))
}
}
}
pub async fn retry(
State(state): State<Arc<WebState>>,
) -> Result<Json<MigrationReport>, (StatusCode, Json<serde_json::Value>)> {
start(State(state)).await
}
pub async fn cleanup_backups(
State(state): State<Arc<WebState>>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
state.app.storage.cleanup_migration_backups().await.map_err(error_response)?;
Ok(StatusCode::NO_CONTENT)
}
fn error_response(error: String) -> (StatusCode, Json<serde_json::Value>) {
(StatusCode::BAD_REQUEST, Json(json!({"error": error})))
}
+1
View File
@@ -16,6 +16,7 @@ pub mod history;
pub mod jdbc;
pub mod layout;
pub mod mcp_policy;
pub mod migration;
pub mod mongo;
pub mod mongodb_dump;
pub mod mongodb_import_export;
+1
View File
@@ -1571,6 +1571,7 @@ mod tests {
login_rate_limit: Mutex::new(LoginRateLimit { fail_count: 0, locked_until: None }),
export_files: RwLock::new(HashMap::new()),
ssh_prompts: Arc::new(crate::ssh_prompt::SshPromptHub::new()),
migration_ready: Arc::new(std::sync::atomic::AtomicBool::new(true)),
});
(state, dir)
}
+3
View File
@@ -1,6 +1,7 @@
use dbx_core::connection::AppState;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use tokio::sync::{broadcast, watch, Mutex, RwLock};
use tokio_util::sync::CancellationToken;
@@ -43,6 +44,7 @@ pub struct WebState {
/// Completed Web export temp files waiting for the browser download.
pub export_files: RwLock<HashMap<String, WebExportFile>>,
pub ssh_prompts: Arc<crate::ssh_prompt::SshPromptHub>,
pub migration_ready: Arc<AtomicBool>,
}
impl WebState {
@@ -68,6 +70,7 @@ impl WebState {
login_rate_limit: Mutex::new(LoginRateLimit { fail_count: 0, locked_until: None }),
export_files: RwLock::new(HashMap::new()),
ssh_prompts: Arc::new(crate::ssh_prompt::SshPromptHub::new()),
migration_ready: Arc::new(AtomicBool::new(true)),
}
}
}