fix(migration): preserve portable keys across devices

Closes #11957
This commit is contained in:
t8y2
2026-10-13 11:38:15 +08:00
parent a2c8c22a42
commit cd27000fef
14 changed files with 333 additions and 45 deletions
+14 -3
View File
@@ -479,6 +479,16 @@ together with `dbx.db`. Persisting `/app/data` is therefore sufficient for a
normal Docker deployment. This key protects the database contents, but cannot
protect the whole data volume if the volume itself is copied or exposed.
Windows portable builds are the exception to the desktop platform-key rule.
To remain portable across computers, they keep the local storage key at
`data/.dbx/secret.key`; move and back up the complete `data` directory. If an
older portable profile already contains ciphertext protected by Windows
Credential Manager, launch the new build once on the original computer so DBX
can verify and copy that key to the USB drive. If the original computer or key
is gone, the ciphertext cannot be recovered automatically; restore the original
key or a pre-migration backup. Keeping the key beside the database means anyone
who obtains or copies the whole drive can also decrypt its credentials.
For production deployments, replace the managed key with a Docker/Kubernetes
Secret by setting `DBX_SECRET_KEY_FILE`, or provide `DBX_SECRET_KEY` through a
secret manager. Explicit keys take precedence and must never be rotated while
@@ -508,9 +518,10 @@ directory in Desktop or Web and complete its upgrade wizard. For headless
hosts without a platform credential store, configure the persistent key.
Cross-device exports use a separate sync passphrase and never contain the
local storage key. A direct `dbx.db` copy is not a cross-platform sync method:
platform keys do not move with the database. Use encrypted export/import so
the target device stores credentials using its own local key.
local storage key. For installed builds, or when copying only `dbx.db`, platform
keys do not move with the database; use encrypted export/import instead. A
Windows portable build must move the complete `data` directory containing both
`dbx.db` and `.dbx/secret.key`.
For the complete design, migration state machine, implementation map,
troubleshooting, and test plan, see
+3 -1
View File
@@ -473,13 +473,15 @@ volumes:
连接、插件、AI 和 Tunnel 凭据写入 `dbx.db` 前会加密。桌面端使用本机凭据存储(macOS Keychain、Windows Credential Manager 或 Linux Secret Service)。Web/Docker 与直接运行 `dbx-web` 默认使用同一套数据目录托管密钥:`${DBX_DATA_DIR}/.dbx/secret.key`。只有在开始迁移或第一次写入敏感字段时才创建密钥;普通 Docker 部署只需持久化 `/app/data`,并且必须将 `.dbx/secret.key` 与 `dbx.db` 一起备份。该密钥不能防护整个数据卷被复制或泄露。
Windows 便携版例外:为保证 U 盘可跨电脑使用,本地存储密钥保存在便携数据目录的 `data/.dbx/secret.key`,必须与整个 `data` 目录一起移动和备份。已经由旧版使用 Windows Credential Manager 加密过的便携数据,需要先在原电脑启动一次新版;DBX 会验证旧密钥并将其复制到 U 盘。若原电脑或原密钥已经丢失,现有密文无法自动恢复,只能找回原密钥或恢复迁移前备份。密钥与数据库同行意味着复制或丢失整个 U 盘的人也能解密其中凭据,请妥善保护 U 盘。
生产环境可以使用 Docker/Kubernetes Secret 覆盖托管策略:设置 `DBX_SECRET_KEY_FILE`,或由密钥管理系统提供 `DBX_SECRET_KEY`。显式密钥优先,已有密文使用期间不能轮换。密钥不可用时,业务 API 保持阻塞,浏览器显示数据安全升级页面。直接运行二进制时设置 `DBX_DATA_DIR=/var/lib/dbx`,即可使用 `/var/lib/dbx/.dbx/secret.key`。
升级包含历史明文凭据的版本时,桌面端和 Web 会在进入主界面前显示 **数据安全升级向导**。点击 **开始迁移** 后,软件会创建权限受限的备份,迁移旧数据库和 JSON 凭据,并验证密文可读取。失败时保留原始数据和备份,根据向导提示修复后点击 **重试**。成功页面会显示备份路径。确认连接可用后,可点击 **删除迁移备份**,二次确认后删除迁移备份目录和本次迁移生成的旧 JSON `.bak` 文件;其他备份不会删除。没有历史数据的新用户检查后直接进入主界面。
本机 CLI 和独立 MCP 可以复用同一设备已有的平台凭据存储,也可以读取显式配置的 `DBX_SECRET_KEY_FILE` 或 `DBX_SECRET_KEY`。它们不会在启动检查时创建密钥,也不会自动迁移历史数据。遇到 `DATA_MIGRATION_REQUIRED` 时,请先使用桌面端或 Web 打开同一数据目录,完成升级向导。没有平台凭据存储的无界面主机应配置持久化密钥。
跨设备导出使用独立的同步口令,导出包不包含本地存储密钥。直接复制 `dbx.db` 不能作为跨平台同步方式,因为本机平台密钥不会随数据库移动。请使用加密导出/导入,让目标设备使用自己的本地密钥保存凭据。
跨设备导出使用独立的同步口令,导出包不包含本地存储密钥。安装版或只复制 `dbx.db` 时,本机平台密钥不会随数据库移动,不能作为跨平台同步方式;请使用加密导出/导入。Windows 便携版则必须移动包含 `dbx.db` 和 `.dbx/secret.key` 的完整 `data` 目录。
完整的设计、迁移状态、实现模块、排障和测试说明请参阅:[DBX 数据安全升级与迁移](docs/data-security-migration.zh-CN.md)。
+2 -2
View File
@@ -28,7 +28,7 @@ function deferred<T>() {
return { promise, resolve, reject };
}
const readyStatus = { state: "not_required", needsMigration: false };
const readyStatus = { state: "not_required", needsMigration: false, keyRecoveryRequired: false };
let businessReady: ReturnType<typeof deferred<void>>;
let mountedApp: App | undefined;
let root: HTMLDivElement;
@@ -149,7 +149,7 @@ describe("startup boundary", () => {
});
it("does not load the business app until a required migration is completed and entered", async () => {
mocks.migrationStatus.mockResolvedValueOnce({ state: "pending", needsMigration: true }).mockResolvedValue(readyStatus);
mocks.migrationStatus.mockResolvedValueOnce({ state: "pending", needsMigration: true, keyRecoveryRequired: false }).mockResolvedValue(readyStatus);
mocks.migrationStart.mockResolvedValue({ state: "succeeded" });
businessReady.resolve();
await mountGate();
@@ -44,6 +44,7 @@ describe("migration failure actions", () => {
migrationId: "secret-store-v1",
state: "pending" as const,
needsMigration: true,
keyRecoveryRequired: false,
keyProviderAvailable: true,
databasePlaintextCount: 1,
connectionCount: 1,
@@ -71,4 +72,48 @@ describe("migration failure actions", () => {
app.unmount();
}
});
it("treats a locked key after migration as key recovery instead of retrying migration", async () => {
const recoveryStatus = {
migrationId: "secret-store-v1",
state: "succeeded" as const,
needsMigration: false,
keyRecoveryRequired: true,
keyProviderAvailable: false,
keyCreationAllowed: false,
keyStatus: "unavailable" as const,
databasePlaintextCount: 0,
connectionCount: 1,
pluginSecretCount: 0,
aiSecretCount: 0,
tunnelSecretCount: 0,
syncCredentialCount: 0,
legacyJsonFiles: [],
backupRequired: false,
errorCode: "KEYRING_ACCESS_FAILED",
errorMessage: "The platform credential store refused access",
};
const backend = {
migrationStatus: vi.fn().mockResolvedValue(recoveryStatus),
migrationStart: vi.fn(),
migrationRetry: vi.fn(),
migrationCleanupBackups: vi.fn(),
};
const store = useMigrationStore(backend);
await store.initialize();
const root = document.createElement("div");
const app = createApp(Wizard, { store });
app.mount(root);
try {
const labels = Array.from(root.querySelectorAll("button")).map((button) => button.textContent);
expect(root.textContent).toContain("migration.keyUnavailable");
expect(root.textContent).not.toContain("migration.failed");
expect(labels).toContain("migration.retryStatus");
expect(labels).not.toContain("migration.start");
expect(labels).not.toContain("migration.retryMigration");
expect(root.querySelector("textarea")?.value).toContain('"keyRecoveryRequired": true');
} finally {
app.unmount();
}
});
});
@@ -17,6 +17,7 @@ const exportingDiagnostic = ref(false);
const canRevealPaths = computed(() => isTauriRuntime());
const errorCode = computed(() => props.store.state.errorCode);
const errorMessage = computed(() => props.store.state.errorMessage);
const keyRecoveryRequired = computed(() => props.store.state.status?.keyRecoveryRequired === true);
const errorAdviceKey = computed(() => {
if (props.store.state.error === "cleanupFailed") return "migration.errors.cleanupAdvice";
switch (errorCode.value) {
@@ -101,8 +102,8 @@ async function diagnostic() {
<div v-if="props.store.state.loading" class="py-10 text-center text-sm text-muted-foreground">{{ t("migration.checking") }}</div>
<template v-else>
<div v-if="props.store.state.step === 1" class="space-y-5">
<p class="text-sm text-muted-foreground">{{ t("migration.intro") }}</p>
<div class="rounded-lg border border-primary/20 bg-primary/5 p-4 text-sm">
<p class="text-sm text-muted-foreground">{{ keyRecoveryRequired ? t("migration.keyUnavailable") : t("migration.intro") }}</p>
<div v-if="!keyRecoveryRequired" class="rounded-lg border border-primary/20 bg-primary/5 p-4 text-sm">
<h2 class="font-medium">{{ t("migration.whyTitle") }}</h2>
<p class="mt-2 text-muted-foreground">{{ t("migration.whyBody") }}</p>
<ul class="mt-3 list-disc space-y-1 pl-5 text-muted-foreground">
@@ -126,8 +127,7 @@ async function diagnostic() {
</div>
</div>
<p v-if="status?.dataDir" class="break-all text-xs text-muted-foreground">
{{ t("migration.dataDir")
}}<!--
{{ t("migration.dataDir") }}<!--
--><button
type="button"
class="break-all text-left underline decoration-dotted underline-offset-2 hover:text-foreground disabled:cursor-default disabled:no-underline"
@@ -138,17 +138,22 @@ async function diagnostic() {
{{ status.dataDir }}
</button>
</p>
<p v-if="status?.keyProviderAvailable === false && !status.keyCreationAllowed" class="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">{{ t("migration.keyUnavailable") }}</p>
<p v-if="status?.keyProviderAvailable === false && !status.keyCreationAllowed && !keyRecoveryRequired" class="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">{{ t("migration.keyUnavailable") }}</p>
<p v-else-if="status?.keyStatus === 'will_create'" class="rounded-lg bg-primary/5 p-3 text-sm text-muted-foreground">{{ t("migration.keyWillCreateHint") }}</p>
<p v-if="status?.keyFileConfigured === false && status?.persistentKeyConfigured === false && status?.keyCreationAllowed === false" class="text-xs text-muted-foreground">{{ t("migration.keyRecoveryHint") }}</p>
<div v-if="props.store.state.error" class="space-y-1 rounded-lg bg-destructive/10 p-4 text-sm text-destructive">
<p>{{ t(`migration.${props.store.state.error === "statusFailed" ? "statusFailed" : props.store.state.error === "cleanupFailed" ? "cleanupFailed" : "failed"}`) }}</p>
<p>{{ keyRecoveryRequired ? t("migration.keyUnavailable") : t(`migration.${props.store.state.error === "statusFailed" ? "statusFailed" : props.store.state.error === "cleanupFailed" ? "cleanupFailed" : "failed"}`) }}</p>
<p v-if="errorCode">{{ t("migration.errorCode", { code: errorCode }) }}</p>
<p v-if="errorMessage">{{ errorMessage }}</p>
<p>{{ t(errorAdviceKey) }}</p>
</div>
<div class="flex flex-wrap gap-3">
<button v-if="props.store.state.error === 'statusFailed' || (status?.keyProviderAvailable === false && !status?.keyCreationAllowed)" class="rounded-md bg-primary px-4 py-2 text-primary-foreground disabled:opacity-50" :disabled="props.store.state.busy" @click="props.store.retryStatus">
<button
v-if="props.store.state.error === 'statusFailed' || keyRecoveryRequired || (status?.keyProviderAvailable === false && !status?.keyCreationAllowed)"
class="rounded-md bg-primary px-4 py-2 text-primary-foreground disabled:opacity-50"
:disabled="props.store.state.busy"
@click="props.store.retryStatus"
>
{{ t("migration.retryStatus") }}</button
><button
v-else
@@ -184,8 +189,7 @@ async function diagnostic() {
<h2 class="font-medium">{{ t("migration.successTitle") }}</h2>
<p class="mt-2 text-sm text-muted-foreground">{{ t("migration.successIntro") }}</p>
<p v-if="status?.backupPath" class="mt-3 break-all text-xs text-muted-foreground">
{{ t("migration.backupPath")
}}<!--
{{ t("migration.backupPath") }}<!--
--><button
type="button"
class="break-all text-left underline decoration-dotted underline-offset-2 hover:text-foreground disabled:cursor-default disabled:no-underline"
@@ -4,6 +4,7 @@ export interface MigrationPreflight {
migrationId: string;
state: MigrationState;
needsMigration: boolean;
keyRecoveryRequired: boolean;
keyProviderAvailable: boolean;
keyStatus?: MigrationKeyStatus;
keyCreationAllowed?: boolean;
+53
View File
@@ -6,6 +6,7 @@ const pending = (): MigrationPreflight => ({
migrationId: "secret-store-v1",
state: "pending",
needsMigration: true,
keyRecoveryRequired: false,
keyProviderAvailable: true,
keyCreationAllowed: false,
databasePlaintextCount: 2,
@@ -64,6 +65,58 @@ describe("migration store", () => {
expect(store.state.entered).toBe(false);
});
it("keeps completed migration state on the status retry path when the device key needs recovery", async () => {
const recovery = {
...done(),
keyRecoveryRequired: true,
keyProviderAvailable: false,
keyStatus: "unavailable" as const,
errorCode: "KEYRING_ACCESS_FAILED",
errorMessage: "The platform credential store refused access",
};
const status = vi.fn().mockResolvedValueOnce(recovery).mockResolvedValue(done());
const retry = vi.fn();
const store = useMigrationStore({ migrationStatus: status, migrationStart: vi.fn(), migrationRetry: retry, migrationCleanupBackups: vi.fn() });
await store.initialize();
expect(store.state.status?.state).toBe("succeeded");
expect(store.state.error).toBe("keyRecoveryRequired");
expect(store.blocking.value).toBe(true);
expect(store.diagnostic().keyRecoveryRequired).toBe(true);
await store.retryStatus();
expect(status).toHaveBeenNthCalledWith(2, true);
expect(retry).not.toHaveBeenCalled();
expect(store.completed.value).toBe(true);
expect(store.blocking.value).toBe(false);
});
it("does not retry migration when key recovery becomes necessary after migration completes", async () => {
const recovery = {
...done(),
keyRecoveryRequired: true,
keyProviderAvailable: false,
keyStatus: "unavailable" as const,
errorCode: "KEYRING_ACCESS_FAILED",
errorMessage: "The platform credential store refused access",
};
const retry = vi.fn();
const store = useMigrationStore({
migrationStatus: vi.fn().mockResolvedValueOnce(pending()).mockResolvedValue(recovery),
migrationStart: vi.fn().mockResolvedValue({ state: "succeeded" }),
migrationRetry: retry,
migrationCleanupBackups: vi.fn(),
});
await store.initialize();
await store.start();
expect(store.state.step).toBe(1);
expect(store.state.error).toBe("keyRecoveryRequired");
expect(store.state.status?.state).toBe("succeeded");
expect(retry).not.toHaveBeenCalled();
});
it("keeps status failures on the status retry path", async () => {
const status = vi
.fn()
+11 -5
View File
@@ -11,14 +11,14 @@ export function useMigrationStore(backend: MigrationApi = api) {
report: MigrationReport | null;
loading: boolean;
busy: boolean;
error: "statusFailed" | "migrationFailed" | "cleanupFailed" | null;
error: "statusFailed" | "migrationFailed" | "keyRecoveryRequired" | "cleanupFailed" | null;
errorCode: string | null;
errorMessage: string | null;
entered: boolean;
step: 1 | 2 | 3;
}>({ status: null, report: null, loading: true, busy: false, error: null, errorCode: null, errorMessage: null, entered: false, step: 1 });
const migrationAttempted = ref(false);
const completed = computed(() => state.status !== null && !state.status.needsMigration && ["succeeded", "not_required"].includes(state.status.state));
const completed = computed(() => state.status !== null && !state.status.needsMigration && !state.status.keyRecoveryRequired && ["succeeded", "not_required"].includes(state.status.state));
// A managed data-directory key is intentionally created when migration
// starts. Its absence during the read-only preflight is actionable, not a
// migration failure, as long as the backend explicitly allows creation.
@@ -50,7 +50,8 @@ export function useMigrationStore(backend: MigrationApi = api) {
// not a reason to block the normal application startup. `entered` is
// therefore only changed by the explicit success-page action below.
state.entered = false;
if (state.status?.state === "failed" || hasBlockingError()) state.error = "migrationFailed";
if (state.status?.keyRecoveryRequired) state.error = "keyRecoveryRequired";
else if (state.status?.state === "failed" || hasBlockingError()) state.error = "migrationFailed";
} catch {
state.error = "statusFailed";
} finally {
@@ -72,11 +73,15 @@ export function useMigrationStore(backend: MigrationApi = api) {
}
try {
await refreshStatus();
if (completed.value) {
if (state.status?.keyRecoveryRequired) {
state.step = 1;
state.error = "keyRecoveryRequired";
} else if (completed.value) {
state.step = 3;
state.error = null;
} else if (hasBlockingError()) {
state.error = "migrationFailed";
}
if (!completed.value || hasBlockingError()) state.error = "migrationFailed";
} catch {
state.error = "statusFailed";
} finally {
@@ -104,6 +109,7 @@ export function useMigrationStore(backend: MigrationApi = api) {
actionError: state.error,
step: state.step,
needsMigration: s?.needsMigration,
keyRecoveryRequired: s?.keyRecoveryRequired,
legacyJsonFiles: s?.legacyJsonFiles,
state: s?.state,
counts: s ? { connections: s.connectionCount, databasePlaintext: s.databasePlaintextCount, plugin: s.pluginSecretCount, ai: s.aiSecretCount, tunnel: s.tunnelSecretCount, sync: s.syncCredentialCount } : {},
@@ -232,6 +232,27 @@ impl SecretCodec {
Self::resolve(SecretKeyPolicy::PlatformDefault, std::path::Path::new("."), true).map(|resolved| resolved.codec)
}
pub(crate) fn from_platform_store() -> Result<Option<Self>, String> {
platform_keyring_codec(false)
}
pub(crate) fn persist_managed_data_dir_key(&self, data_dir: &std::path::Path) -> Result<(), String> {
let path = managed_key_path(data_dir);
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())?;
}
let persisted = write_key_file(&path, true, *self)?;
if persisted.key != self.key {
return Err("SECRET_KEY_MISMATCH".to_string());
}
secure_managed_key_permissions(&path)
}
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())?;
@@ -506,12 +527,16 @@ fn secure_managed_key_permissions(path: &std::path::Path) -> Result<(), String>
}
fn create_key_file(path: &std::path::Path, managed: bool) -> Result<SecretCodec, String> {
let mut key = [0u8; 32];
OsRng.fill_bytes(&mut key);
write_key_file(path, managed, SecretCodec::new(key))
}
fn write_key_file(path: &std::path::Path, managed: bool, codec: SecretCodec) -> 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 encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(codec.key);
let write_result = {
#[cfg(unix)]
{
+110 -14
View File
@@ -300,6 +300,7 @@ pub struct MigrationPreflight {
pub migration_id: String,
pub state: MigrationState,
pub needs_migration: bool,
pub key_recovery_required: bool,
pub key_provider_available: bool,
pub key_status: MigrationKeyStatus,
/// Desktop may provision a new local key only for plaintext-only data.
@@ -326,10 +327,9 @@ pub struct MigrationPreflight {
impl MigrationPreflight {
pub fn is_ready(&self) -> bool {
// A brand-new desktop profile has no secrets and may provision its
// first key lazily. Strict headless profiles report missing keys as
// `needs_migration`, so no additional provider check is needed here.
!self.needs_migration && matches!(self.state, MigrationState::Succeeded | MigrationState::NotRequired)
!self.needs_migration
&& !self.key_recovery_required
&& matches!(self.state, MigrationState::Succeeded | MigrationState::NotRequired)
}
}
@@ -1441,6 +1441,44 @@ impl Storage {
self
}
pub async fn prepare_portable_secret_key(&self) -> Result<bool, String> {
self.prepare_portable_secret_key_with(SecretCodec::from_platform_store).await
}
async fn prepare_portable_secret_key_with<F>(&self, platform_key: F) -> Result<bool, String>
where
F: FnOnce() -> Result<Option<SecretCodec>, String>,
{
if !matches!(self.secret_key_policy, SecretKeyPolicy::ManagedDataDir) {
return Err("Portable key preparation requires a managed data-directory key".to_string());
}
match SecretCodec::resolve(SecretKeyPolicy::ManagedDataDir, self.data_dir(), false) {
Ok(_) => return Ok(false),
Err(error) if error == "MISSING_MANAGED_KEY" => {}
Err(error) => return Err(error),
}
let has_ciphertext = self
.with_conn(|conn| {
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM connection_secrets WHERE secret_enc IS NOT NULL AND secret_enc <> '')",
[],
|row| row.get::<_, bool>(0),
)
.map_err(|error| error.to_string())
})
.await?;
if !has_ciphertext {
return Ok(false);
}
let Some(codec) = platform_key()? else {
return Ok(false);
};
self.validate_existing_encrypted_data(codec).await.map_err(|_| "SECRET_KEY_MISMATCH".to_string())?;
codec.persist_managed_data_dir_key(self.data_dir())?;
self.invalidate_secret_codec();
Ok(true)
}
/// Compatibility builder for callers that require an externally managed key.
pub fn require_persistent_key(self) -> Self {
self.with_secret_key_policy(SecretKeyPolicy::ExternalOnly)
@@ -1852,11 +1890,12 @@ impl Storage {
let source_changed = stored.source_fingerprint.as_deref().is_some_and(|value| value != current_fingerprint);
let state = if matches!(stored.state, MigrationState::Failed | MigrationState::Running) {
stored.state.clone()
} else if fatal_key_error
|| missing_required_key
|| missing_existing_key
|| (has_legacy_data
&& (source_changed || matches!(stored.state, MigrationState::Succeeded | MigrationState::NotRequired)))
} else if has_legacy_data
&& (fatal_key_error
|| missing_required_key
|| missing_existing_key
|| source_changed
|| matches!(stored.state, MigrationState::Succeeded | MigrationState::NotRequired))
{
MigrationState::Pending
} else if !has_legacy_data && matches!(stored.state, MigrationState::Pending) {
@@ -1864,11 +1903,10 @@ impl Storage {
} else {
stored.state.clone()
};
let needs_migration = fatal_key_error
|| missing_required_key
|| missing_existing_key
|| matches!(state, MigrationState::Failed | MigrationState::Running)
let needs_migration = matches!(state, MigrationState::Failed | MigrationState::Running)
|| (has_legacy_data && !matches!(state, MigrationState::Succeeded | MigrationState::NotRequired));
let key_recovery_required =
!needs_migration && (fatal_key_error || missing_required_key || missing_existing_key);
let (error_code, error_message) = if let Some(code) = key_error_code.clone() {
let message = match code.as_str() {
"MISSING_MANAGED_KEY" => {
@@ -1915,6 +1953,7 @@ impl Storage {
migration_id: SECRET_STORE_MIGRATION_ID.to_string(),
state,
needs_migration,
key_recovery_required,
key_provider_available,
key_status,
key_creation_allowed,
@@ -10283,11 +10322,15 @@ mod tests {
.unwrap()
.with_secret_key_policy(SecretKeyPolicy::ManagedDataDir);
storage.set_secret("connection", "password", "secret").await.unwrap();
storage.set_migration_state(super::MigrationState::Succeeded, None, None, None, None).await.unwrap();
let key_path = managed_key_path(dir.path());
std::fs::remove_file(&key_path).unwrap();
let status = storage.inspect_data_migration().await.unwrap();
assert!(status.needs_migration);
assert_eq!(status.state, super::MigrationState::Succeeded);
assert!(!status.needs_migration);
assert!(status.key_recovery_required);
assert!(!status.is_ready());
assert!(!status.key_creation_allowed);
assert_eq!(status.error_code.as_deref(), Some("ENCRYPTED_DATA_KEY_MISSING"));
assert!(!key_path.exists());
@@ -10295,6 +10338,59 @@ mod tests {
assert!(!key_path.exists());
}
#[tokio::test]
async fn portable_storage_copies_only_the_matching_platform_key_into_its_data_dir() {
let directory = tempfile::tempdir().unwrap();
let storage = crate::persistence::test_storage::open_unmigrated(&directory.path().join("dbx.db"))
.await
.unwrap()
.with_secret_key_policy(SecretKeyPolicy::ManagedDataDir);
let platform_codec = super::SecretCodec::new([7; 32]);
let encrypted = platform_codec.encrypt("connection", "password", "portable-secret").unwrap();
storage
.with_conn(move |conn| {
conn.execute(
"INSERT INTO connection_secrets (connection_id, key, secret, secret_enc) VALUES ('connection', 'password', '', ?1)",
[encrypted],
)
.map_err(|error| error.to_string())?;
Ok(())
})
.await
.unwrap();
assert!(storage.prepare_portable_secret_key_with(|| Ok(Some(platform_codec))).await.unwrap());
assert!(managed_key_path(directory.path()).is_file());
assert_eq!(storage.get_secret("connection", "password").await.unwrap().as_deref(), Some("portable-secret"));
}
#[tokio::test]
async fn portable_storage_never_persists_a_platform_key_that_cannot_decrypt_its_data() {
let directory = tempfile::tempdir().unwrap();
let storage = crate::persistence::test_storage::open_unmigrated(&directory.path().join("dbx.db"))
.await
.unwrap()
.with_secret_key_policy(SecretKeyPolicy::ManagedDataDir);
let encrypted = super::SecretCodec::new([7; 32]).encrypt("connection", "password", "portable-secret").unwrap();
storage
.with_conn(move |conn| {
conn.execute(
"INSERT INTO connection_secrets (connection_id, key, secret, secret_enc) VALUES ('connection', 'password', '', ?1)",
[encrypted],
)
.map_err(|error| error.to_string())?;
Ok(())
})
.await
.unwrap();
assert_eq!(
storage.prepare_portable_secret_key_with(|| Ok(Some(super::SecretCodec::new([8; 32])))).await.unwrap_err(),
"SECRET_KEY_MISMATCH"
);
assert!(!managed_key_path(directory.path()).exists());
}
#[tokio::test]
async fn secret_write_after_managed_key_loss_never_creates_a_replacement() {
let directory = tempfile::tempdir().unwrap();
+26 -2
View File
@@ -832,7 +832,12 @@ impl LocalBackend {
// 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 data_dir = path.parent().unwrap_or_else(|| Path::new("."));
let mut storage = Storage::open_unmigrated(path).await?.with_secret_key_creation(false);
if dbx_core::persistence::secret_codec::managed_key_path(data_dir).is_file() {
storage =
storage.with_secret_key_policy(dbx_core::persistence::secret_codec::SecretKeyPolicy::ManagedDataDir);
}
let migration = storage.inspect_data_migration().await?;
if !migration.is_ready() {
// The migration wizard may have already completed on Desktop with
@@ -856,7 +861,7 @@ impl LocalBackend {
}
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();
let data_dir = data_dir.to_path_buf();
let plugin_dir = local_plugin_dir(&desktop_settings, &data_dir);
let agent_dir = local_agent_dir(&desktop_settings, &data_dir);
let state = Arc::new(AppState::new_with_plugin_and_agent_dir_and_app_version(
@@ -4584,6 +4589,25 @@ mod tests {
assert!(storage.load_connections().await.unwrap().is_empty());
}
#[tokio::test]
async fn local_backend_uses_portable_data_directory_key() {
let data_dir = tempfile::tempdir().unwrap();
let database_path = data_dir.path().join("dbx.db");
let storage = Storage::open_unmigrated(&database_path)
.await
.unwrap()
.with_secret_key_policy(dbx_core::persistence::secret_codec::SecretKeyPolicy::ManagedDataDir);
storage.set_secret("portable", "password", "portable-secret").await.unwrap();
drop(storage);
let backend = LocalBackend::open(&database_path).await.unwrap();
assert_eq!(
backend.state.storage.get_secret("portable", "password").await.unwrap().as_deref(),
Some("portable-secret")
);
}
#[tokio::test]
async fn local_backend_uses_desktop_plugin_directory() {
let data_dir = tempfile::tempdir().unwrap();
+6 -2
View File
@@ -17,12 +17,14 @@ DBX uses two independent key systems:
| Local storage key | Desktop platform store, Web managed data-dir key, or `DBX_SECRET_KEY(_FILE)` | Protects Secret Store values in the local `dbx.db` | Never copied by sync |
| Sync passphrase | Entered by the user during export/import | Protects sensitive fields in an encrypted sync package | Used with that package |
Local keys are never written to a sync file. Copying `dbx.db` to another computer is not a supported cross-device migration method; use encrypted export/import instead. Local values use the `dbxenc1` envelope, AES-256-GCM, and authenticated data bound to the namespace and field name. Sync packages use a separate crypto format and passphrase.
Local keys are never written to a sync file. For installed builds, copying `dbx.db` to another computer is not a supported cross-device migration method; use encrypted export/import instead. Windows portable builds move their managed key with the complete portable data directory. Local values use the `dbxenc1` envelope, AES-256-GCM, and authenticated data bound to the namespace and field name. Sync packages use a separate crypto format and passphrase.
## Supported environments
Desktop builds use the platform credential store on macOS, Windows, and Linux.
Windows portable builds instead use `data/.dbx/secret.key` so the complete data directory can move between computers. Existing portable ciphertext protected by Windows Credential Manager must be opened once by the new build on the original computer; DBX copies the old key only after verifying that it decrypts the data. If the original computer or key is unavailable, restore that key or a pre-migration backup. A replacement key cannot decrypt the ciphertext. Keeping the key beside the database trades protection against whole-drive copying or loss for portability.
`dbx-web` uses `${DBX_DATA_DIR}/.dbx/secret.key` by default, whether it runs in Docker, under systemd, or directly from the binary. The key is created with restricted permissions only when migration starts or the first sensitive value is written. It is part of the same recovery unit as `dbx.db`; back up and restore both files together. A key stored in the data directory does not protect against disclosure of the entire data volume.
Production deployments can require an externally managed key instead:
@@ -100,7 +102,7 @@ Import can restore metadata only or restore secrets as well. Wrong passphrases,
- Older sync package versions remain readable according to their compatibility rules.
- Failed migrations keep the original data and backup and can be retried.
- Successful migration backups remain until the user explicitly cleans them up.
- Direct `dbx.db` copying is not a supported cross-platform migration path.
- Installed builds cannot migrate by copying only `dbx.db`; Windows portable builds must copy the complete `data` directory.
## Troubleshooting
@@ -112,6 +114,8 @@ Confirm that the installed build includes the migration-cache fix. Export the re
Allow DBX Desktop to access the platform credential store. For Web/Docker, confirm that `${DBX_DATA_DIR}/.dbx/secret.key` is present with the data volume, or that an explicitly configured `DBX_SECRET_KEY_FILE` is readable. Do not delete or replace the key used to encrypt an existing database.
For Windows portable builds, keep `data/.dbx/secret.key` beside `dbx.db`. When upgrading existing portable data, launch the new build once on the original computer before moving the USB drive. DBX cannot generate a replacement key for ciphertext whose original key has been lost.
### Migration failed
The source data is retained. Fix the issue shown by the wizard and choose **Retry migration**. Export only the redacted diagnostic; do not upload `dbx.db`, WAL files, or migration backup directories.
+7 -3
View File
@@ -14,10 +14,10 @@ DBX 使用两套互相独立的密钥体系:
| 用途 | 密钥来源 | 作用 | 是否跨设备 |
| --- | --- | --- | --- |
| 本地存储密钥 | 桌面系统凭据库、Web 数据目录托管密钥,或 `DBX_SECRET_KEY(_FILE)` | 保护当前设备 `dbx.db` 中的 Secret Store 密文 | 不通过同步传输 |
| 本地存储密钥 | 桌面系统凭据库、Windows 便携数据目录托管密钥、Web 数据目录托管密钥,或 `DBX_SECRET_KEY(_FILE)` | 保护当前数据目录 `dbx.db` 中的 Secret Store 密文 | 不通过同步传输;便携版随完整数据目录移动 |
| 同步口令 | 用户在导出/导入时主动输入 | 保护跨平台同步包中的敏感 payload | 随加密同步包使用 |
本地密钥不会写入同步文件,也不能通过复制 `dbx.db` 迁移到另一台设备。跨平台迁移必须使用加密导出/导入。
本地密钥不会写入同步文件。安装版或只复制 `dbx.db` 时,不能迁移到另一台设备,必须使用加密导出/导入;Windows 便携版则移动包含密钥的完整数据目录。
本地 Secret Store 使用 `dbxenc1` 密文封装、AES-256-GCM 和绑定命名空间/字段名的 AAD。同步包使用独立的同步加密格式和同步口令,不能直接复用本地密文。
@@ -27,9 +27,12 @@ DBX 使用两套互相独立的密钥体系:
- macOS:使用 Keychain;必要时使用受限权限的兼容密钥文件。
- Windows:使用 Credential Manager。
- Windows 便携版:使用 `data/.dbx/secret.key`,使完整便携数据目录可以跨电脑读取。
- Linux:使用 Secret Service;无可用平台存储时,迁移向导会提示配置持久化密钥。
- 向导阻止进入主界面,直到迁移完成或确认当前数据不需要迁移。
旧版 Windows 便携数据如果已经由原电脑的 Credential Manager 密钥加密,必须先在原电脑启动一次新版。DBX 只有在确认该密钥能解密现有密文后,才会原样写入便携数据目录;不匹配的密钥绝不会覆盖或替代原密钥。若原电脑或原密钥已丢失,只能恢复原密钥或迁移前备份。便携密钥与数据库同行会降低整盘被复制或丢失时的保护能力,这是跨电脑免配置使用的安全取舍。
### Web、Docker、CLI 和 MCP
`dbx-web` 无论通过 Docker、systemd 还是直接运行二进制,默认都使用 `${DBX_DATA_DIR}/.dbx/secret.key`。只有在开始迁移或第一次写入敏感字段时才创建该密钥,并使用受限权限。它与 `dbx.db` 属于同一恢复单元,必须一起备份和恢复;数据目录内密钥不能防护整个数据卷被复制或泄露。
@@ -154,7 +157,7 @@ Windows 到 Mac 的流程是:
- 旧同步包继续按版本兼容规则读取;新同步包使用独立的 crypto version。
- 迁移失败保留原数据库和迁移备份,可以反复重试。
- 迁移成功后备份默认保留,用户确认连接正常后才清理。
- 直接复制 `dbx.db` 不属于受支持的跨平台迁移方式。
- 安装版或只复制 `dbx.db` 不属于受支持的跨平台迁移方式;Windows 便携版必须复制完整 `data` 目录。
## 9. 用户排障
@@ -173,6 +176,7 @@ Windows 到 Mac 的流程是:
### 密钥不可用
- 桌面端:允许 DBX 访问系统钥匙串/凭据存储。
- Windows 便携版:确认 `data/.dbx/secret.key` 与 `dbx.db` 一起存在。旧数据首次升级时先回到原电脑启动一次新版;原密钥丢失后不能生成替代密钥解密旧数据。
- Web/Docker:确认 `${DBX_DATA_DIR}/.dbx/secret.key` 与数据卷一起保留,或显式配置的 `DBX_SECRET_KEY_FILE` 可读。
- 不要删除或更换已经用于加密数据库的密钥文件。
+14 -1
View File
@@ -20,6 +20,7 @@ mod webview2_recovery;
mod window_state_guard;
use commands::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::{maybe_import_user_data_db, DesktopIconTheme, DesktopSettings, Storage};
@@ -1700,6 +1701,7 @@ pub fn run() {
app.path().app_data_dir().map_err(|e| e.to_string()).expect("Failed to resolve app data dir");
let data_dir_resolution = data_dir::resolve_data_dir_with_mode(default_data_dir);
let data_dir = data_dir_resolution.data_dir.clone();
let portable_mode = data_dir_resolution.is_portable_mode();
std::fs::create_dir_all(&data_dir).expect("Failed to create data dir");
let data_dir_mode = startup_data_dir_mode(&data_dir_resolution.mode);
append_startup_probe(format!("data dir ready mode={data_dir_mode}"));
@@ -1718,7 +1720,18 @@ pub fn run() {
let t = Instant::now();
append_startup_probe(format!("opening storage file=dbx.db data_dir_mode={data_dir_mode}"));
let storage = tauri::async_runtime::block_on(async {
let s = Storage::open_unmigrated(&db_path).await.expect("Failed to open storage");
let mut s = Storage::open_unmigrated(&db_path).await.expect("Failed to open storage");
if portable_mode {
s = s.with_secret_key_policy(SecretKeyPolicy::ManagedDataDir);
match s.prepare_portable_secret_key().await {
Ok(true) => append_startup_probe("portable key copied from the platform credential store"),
Ok(false) => {}
Err(error) => {
log::warn!("[STARTUP] portable key preparation failed: {error}");
append_startup_probe(format!("portable key preparation failed: {error}"));
}
}
}
eprintln!("[STARTUP] Storage::open in {:?}", t.elapsed());
append_startup_probe(format!("storage opened in {:?}", t.elapsed()));
let t2 = Instant::now();