feat(security): encrypt local credentials and guide data migration
This commit is contained in:
58 files changed
+8831
-435
No files matched your search
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user