diff --git a/apps/desktop/src/components/migration/SecurityMigrationWizard.vue b/apps/desktop/src/components/migration/SecurityMigrationWizard.vue index fcd9882811..831ebfe804 100644 --- a/apps/desktop/src/components/migration/SecurityMigrationWizard.vue +++ b/apps/desktop/src/components/migration/SecurityMigrationWizard.vue @@ -119,7 +119,8 @@ async function diagnostic() { {{ t("migration.backupRequiredLabel") }} {{ status.backupRequired ? t("migration.yes") : t("migration.no") }}
- {{ t("migration.keyProviderLabel") }} {{ status.persistentKeyConfigured ? t("migration.ready") : t("migration.pending") }} + {{ t("migration.keyProviderLabel") }} + {{ status.keyStatus === "will_create" ? t("migration.keyWillCreate") : status.persistentKeyConfigured ? t("migration.ready") : t("migration.pending") }}

@@ -136,6 +137,7 @@ async function diagnostic() {

{{ t("migration.keyUnavailable") }}

+

{{ t("migration.keyWillCreateHint") }}

{{ t("migration.keyRecoveryHint") }}

{{ t(`migration.${props.store.state.error === "statusFailed" ? "statusFailed" : props.store.state.error === "cleanupFailed" ? "cleanupFailed" : "failed"}`) }}

diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index a9872d7b11..49fdfd8dcb 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -10978,6 +10978,8 @@ export default { yes: "yes", no: "no", keyUnavailable: "The device key is unavailable. Allow keychain access or configure a persistent key.", + keyWillCreate: "created during first migration", + keyWillCreateHint: "DBX has not created a local encryption key on this device yet. Start the migration to create a persistent key, and allow credential-store access if prompted.", keyRecoveryHint: "Restore the original key used for this data directory, or configure the same external key that encrypted the existing data. Do not replace it with a new key.", progress: "Creating backup, migrating, and verifying. Do not close DBX…", failed: "Migration did not complete. Original data has been retained.", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 02e7092dfd..b4960ff007 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -10489,6 +10489,8 @@ export default withEnglishFallback({ }, launching: "Iniciando DBX…", openBackupDir: "Abrir ubicación de copia de seguridad", + keyWillCreate: "Se crea en la primera migración", + keyWillCreateHint: "Este dispositivo aún no tiene una clave de cifrado local de DBX. Después de hacer clic en «Iniciar migración», DBX creará una clave persistente; si el sistema muestra una ventana de autorización de acceso a credenciales, permita el acceso.", }, solrAdmin: { title: "Administración de Solr", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index baa2351027..6fee077670 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -10489,6 +10489,8 @@ export default withEnglishFallback({ }, launching: "Avvio di DBX in corso…", openBackupDir: "Apri posizione backup", + keyWillCreate: "Creato durante la prima migrazione", + keyWillCreateHint: "Questo dispositivo non dispone ancora di una chiave di crittografia locale DBX. Dopo aver fatto clic su \"Avvia migrazione\", DBX creerà una chiave persistente; se il sistema visualizza una finestra di autorizzazione all'accesso alle credenziali, consenti l'accesso.", }, solrAdmin: { title: "Amministrazione Solr", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index e5c526a336..b65db01c55 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -10539,6 +10539,8 @@ export default withEnglishFallback({ retry: "再試行", launching: "DBX を起動しています…", openBackupDir: "バックアップの場所を開く", + keyWillCreate: "初回移行時に作成", + keyWillCreateHint: "現在のデバイスにはまだ DBX ローカル暗号化キーがありません。「移行を開始」をクリックすると、DBX が永続化キーを作成します。システムが資格情報アクセスの承認ウィンドウを表示した場合は、アクセスを許可してください。", }, solrAdmin: { title: "Solr 管理", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 812cb53c49..2b40af1034 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -9946,6 +9946,8 @@ export default withEnglishFallback({ }, launching: "DBX를 시작하는 중…", openBackupDir: "백업 위치 열기", + keyWillCreate: "최초 마이그레이션 시 생성", + keyWillCreateHint: "현재 장치에는 아직 DBX 로컬 암호화 키가 없습니다. “마이그레이션 시작”을 클릭하면 DBX가 영구 키를 생성합니다. 시스템에서 자격 증명 접근 권한 창이 표시되면 접근을 허용해 주세요.", }, solrAdmin: { title: "Solr 관리", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 6376881232..0be0b9f173 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -10490,6 +10490,8 @@ export default withEnglishFallback({ }, launching: "Iniciando o DBX…", openBackupDir: "Abrir local do backup", + keyWillCreate: "Criado na primeira migração", + keyWillCreateHint: "O dispositivo atual ainda não tem uma chave de criptografia local do DBX. Depois de clicar em “Iniciar migração”, o DBX criará uma chave persistente; se o sistema exibir uma janela de autorização de acesso a credenciais, permita o acesso.", }, solrAdmin: { title: "Administração do Solr", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 448f9d6946..5652198fec 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -10945,6 +10945,8 @@ export default withEnglishFallback({ yes: "是", no: "否", keyUnavailable: "当前设备密钥不可用,请允许钥匙串访问或配置持久化密钥。", + keyWillCreate: "首次迁移时创建", + keyWillCreateHint: "当前设备还没有 DBX 本地加密密钥。点击“开始迁移”后,DBX 会创建持久化密钥;如果系统弹出凭据访问授权窗口,请允许访问。", keyRecoveryHint: "请恢复该数据目录原有的密钥,或配置用于加密现有数据的同一外部密钥。不要使用新密钥替换原密钥。", progress: "正在创建备份、迁移并验证,请勿关闭 DBX…", failed: "迁移没有完成,原始数据已保留。", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 9ff46ca8e7..08b491da92 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -10485,6 +10485,8 @@ export default withEnglishFallback({ verifyAdvice: "驗證成功前請保留備份。", }, launching: "正在啟動 DBX…", + keyWillCreate: "首次遷移時建立", + keyWillCreateHint: "目前裝置還沒有 DBX 本機加密金鑰。點選「開始遷移」後,DBX 會建立持久化金鑰;如果系統彈出認證存取授權視窗,請允許存取。", }, solrAdmin: { title: "Solr 管理", diff --git a/apps/desktop/src/lib/backend/migration.ts b/apps/desktop/src/lib/backend/migration.ts index aa13185944..8ef2a4857b 100644 --- a/apps/desktop/src/lib/backend/migration.ts +++ b/apps/desktop/src/lib/backend/migration.ts @@ -1,9 +1,11 @@ export type MigrationState = "pending" | "running" | "succeeded" | "failed" | "not_required"; +export type MigrationKeyStatus = "ready" | "will_create" | "unavailable" | "invalid" | "mismatch" | "missing_for_ciphertext"; export interface MigrationPreflight { migrationId: string; state: MigrationState; needsMigration: boolean; keyProviderAvailable: boolean; + keyStatus?: MigrationKeyStatus; keyCreationAllowed?: boolean; databasePlaintextCount: number; connectionCount: number; diff --git a/apps/desktop/src/stores/migrationStore.spec.ts b/apps/desktop/src/stores/migrationStore.spec.ts index 992d639687..cf63811cd3 100644 --- a/apps/desktop/src/stores/migrationStore.spec.ts +++ b/apps/desktop/src/stores/migrationStore.spec.ts @@ -118,6 +118,28 @@ describe("migration store", () => { expect(store.state.errorCode).toBe("MISSING_MANAGED_KEY"); }); + it("treats an absent platform key as actionable setup before migration", async () => { + const store = useMigrationStore({ + migrationStatus: vi.fn().mockResolvedValue({ + ...pending(), + keyProviderAvailable: false, + keyCreationAllowed: true, + keyStatus: "will_create", + errorCode: "KEY_PROVIDER_UNAVAILABLE", + errorMessage: "The local secret provider is unavailable", + }), + migrationStart: vi.fn(), + migrationRetry: vi.fn(), + migrationCleanupBackups: vi.fn(), + }); + + await store.initialize(); + + expect(store.state.error).toBeNull(); + expect(store.state.errorCode).toBe("KEY_PROVIDER_UNAVAILABLE"); + expect(store.diagnostic().keyStatus).toBe("will_create"); + }); + it("keeps completion through a failed cleanup and successful cleanup retry", async () => { let finishCleanup!: () => void; const cleanup = vi diff --git a/apps/desktop/src/stores/migrationStore.ts b/apps/desktop/src/stores/migrationStore.ts index 819ba8ef06..29cfb4b473 100644 --- a/apps/desktop/src/stores/migrationStore.ts +++ b/apps/desktop/src/stores/migrationStore.ts @@ -22,7 +22,7 @@ export function useMigrationStore(backend: MigrationApi = api) { // 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. - const hasBlockingError = () => Boolean(state.errorCode && !(state.errorCode === "MISSING_MANAGED_KEY" && state.status?.keyCreationAllowed === true)); + const hasBlockingError = () => Boolean(state.errorCode && state.status?.keyStatus !== "will_create" && !(state.errorCode === "MISSING_MANAGED_KEY" && state.status?.keyCreationAllowed === true)); // A retained backup is a recovery asset and must not make the success page // reappear on later launches. An attempt in this session keeps the wizard // visible even when its response is lost, until the user enters the app. @@ -106,6 +106,7 @@ export function useMigrationStore(backend: MigrationApi = api) { state: s?.state, counts: s ? { connections: s.connectionCount, databasePlaintext: s.databasePlaintextCount, plugin: s.pluginSecretCount, ai: s.aiSecretCount, tunnel: s.tunnelSecretCount, sync: s.syncCredentialCount } : {}, keyProviderAvailable: s?.keyProviderAvailable, + keyStatus: s?.keyStatus, backupPath: s?.backupPath, errorCode: state.errorCode, }; diff --git a/crates/dbx-core/src/persistence/storage.rs b/crates/dbx-core/src/persistence/storage.rs index b98629c2cb..7b310aabd4 100644 --- a/crates/dbx-core/src/persistence/storage.rs +++ b/crates/dbx-core/src/persistence/storage.rs @@ -197,6 +197,17 @@ pub enum MigrationState { NotRequired, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MigrationKeyStatus { + Ready, + WillCreate, + Unavailable, + Invalid, + Mismatch, + MissingForCiphertext, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct LegacyJsonFileStatus { @@ -212,6 +223,7 @@ pub struct MigrationPreflight { pub state: MigrationState, pub needs_migration: bool, pub key_provider_available: bool, + pub key_status: MigrationKeyStatus, /// Desktop may provision a new local key only for plaintext-only data. /// Headless callers always set this false. pub key_creation_allowed: bool, @@ -1427,6 +1439,17 @@ impl Storage { | "ENCRYPTED_DATA_KEY_MISSING" ) }); + let key_status = match key_error_code.as_deref() { + Some("SECRET_KEY_INVALID") => MigrationKeyStatus::Invalid, + Some("SECRET_KEY_MISMATCH") => MigrationKeyStatus::Mismatch, + Some("ENCRYPTED_DATA_KEY_MISSING") => MigrationKeyStatus::MissingForCiphertext, + Some("KEY_PROVIDER_UNAVAILABLE") | Some("MISSING_MANAGED_KEY") if key_creation_allowed => { + MigrationKeyStatus::WillCreate + } + Some(_) => MigrationKeyStatus::Unavailable, + None if key_provider_available => MigrationKeyStatus::Ready, + None => MigrationKeyStatus::Unavailable, + }; let source_changed = stored.source_fingerprint.as_deref().is_some_and(|value| value != current_fingerprint); let state = if fatal_key_error || missing_required_key @@ -1478,6 +1501,7 @@ impl Storage { state, needs_migration, key_provider_available, + key_status, key_creation_allowed, database_plaintext_count, connection_count: connections.max(0) as usize,