diff --git a/README.md b/README.md index bc98f71241..bba8690bfd 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 65aaab0360..07f1e79842 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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)。 diff --git a/apps/desktop/src/__tests__/StartupGate.spec.ts b/apps/desktop/src/__tests__/StartupGate.spec.ts index 3ce5b62f53..1c650d9069 100644 --- a/apps/desktop/src/__tests__/StartupGate.spec.ts +++ b/apps/desktop/src/__tests__/StartupGate.spec.ts @@ -28,7 +28,7 @@ function deferred() { return { promise, resolve, reject }; } -const readyStatus = { state: "not_required", needsMigration: false }; +const readyStatus = { state: "not_required", needsMigration: false, keyRecoveryRequired: false }; let businessReady: ReturnType>; 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(); diff --git a/apps/desktop/src/components/migration/SecurityMigrationWizard.spec.ts b/apps/desktop/src/components/migration/SecurityMigrationWizard.spec.ts index edcfb0935d..324c34af8a 100644 --- a/apps/desktop/src/components/migration/SecurityMigrationWizard.spec.ts +++ b/apps/desktop/src/components/migration/SecurityMigrationWizard.spec.ts @@ -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(); + } + }); }); diff --git a/apps/desktop/src/components/migration/SecurityMigrationWizard.vue b/apps/desktop/src/components/migration/SecurityMigrationWizard.vue index adeffe2ff5..cef27b52b6 100644 --- a/apps/desktop/src/components/migration/SecurityMigrationWizard.vue +++ b/apps/desktop/src/components/migration/SecurityMigrationWizard.vue @@ -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() {
{{ t("migration.checking") }}