fix(security): require passphrases for local backups
Derive every MDD2 key from a non-empty user passphrase and the per-backup scrypt salt while keeping the embedded application material isolated to read-only MDD1 migration imports. Normalize missing, wrong, and authentication-failure results to the same controlled decryption error. Add the modal-based export confirmation and format-aware import flow across renderer, preload, IPC, and controller boundaries. Clear transient passphrase state on completion or cancellation, retain pending import data only until consumption, and keep passphrases out of results, snapshots, payloads, and logs. Cover mismatch and cancellation paths, MDD1 passphrase-free migration, preload forwarding, successful UI/crypto round-trips, one-byte-short ciphertext, authenticated empty ciphertext, and truncated legacy envelopes.
This commit is contained in:
+36
-6
@@ -45,6 +45,7 @@ import type { AccountEditState, AccountEditTarget, AccountKind, AccountService,
|
||||
import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons";
|
||||
import { DOWNLOAD_SPEED_MAX_SAMPLES, updateDownloadSpeedHistory } from "./download-speed-state";
|
||||
import { createUiLocalizer, normalizeLanguage } from "./i18n";
|
||||
import { runLocalBackupExport, runLocalBackupImport, type BackupPassphraseMode } from "./backup-flow";
|
||||
import type { DownloadSpeedHistoryState } from "./download-speed-state";
|
||||
import { extractHoster, formatDateTime, formatSpeedMbps, humanSize, providerLabels } from "./download-format";
|
||||
import { AppShell } from "./shell/AppShell";
|
||||
@@ -53,6 +54,7 @@ import { OverlayHost } from "./shell/OverlayHost";
|
||||
import { UpdateExperience } from "./shell/UpdateExperience";
|
||||
import type { MainView } from "./shell/shell-model";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { BackupPassphraseDialog } from "./ui/BackupPassphraseDialog";
|
||||
import { Dialog } from "./ui/Dialog";
|
||||
import { Icon } from "./ui/Icon";
|
||||
import { Toast } from "./ui/Toast";
|
||||
@@ -1865,6 +1867,7 @@ export function App(): ReactElement {
|
||||
const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null);
|
||||
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
|
||||
const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null);
|
||||
const [backupPassphraseMode, setBackupPassphraseMode] = useState<BackupPassphraseMode | null>(null);
|
||||
const [onlineBackupDialog, setOnlineBackupDialog] = useState<OnlineBackupDialogState | null>(null);
|
||||
const [remoteDiag, setRemoteDiag] = useState<RemoteDiagnosticsInfo | null>(null);
|
||||
const [remoteDiagOpen, setRemoteDiagOpen] = useState(false);
|
||||
@@ -1874,7 +1877,8 @@ export function App(): ReactElement {
|
||||
const [rdPort, setRdPort] = useState("9868");
|
||||
const [rdAllowlist, setRdAllowlist] = useState("");
|
||||
const [rdName, setRdName] = useState("");
|
||||
const confirmResolverRef = useRef<((confirmed: boolean) => void) | null>(null);
|
||||
const confirmResolverRef = useRef<((confirmed: boolean) => void) | null>(null);
|
||||
const backupPassphraseResolverRef = useRef<((passphrase: string | null) => void) | null>(null);
|
||||
const confirmQueueRef = useRef<Array<{ prompt: ConfirmPromptState; resolve: (confirmed: boolean) => void }>>([]);
|
||||
const importQueueFocusHandlerRef = useRef<(() => void) | null>(null);
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
@@ -2262,11 +2266,16 @@ export function App(): ReactElement {
|
||||
startConflictResolverRef.current = null;
|
||||
resolver(null);
|
||||
}
|
||||
if (confirmResolverRef.current) {
|
||||
if (confirmResolverRef.current) {
|
||||
const resolver = confirmResolverRef.current;
|
||||
confirmResolverRef.current = null;
|
||||
resolver(false);
|
||||
}
|
||||
resolver(false);
|
||||
}
|
||||
if (backupPassphraseResolverRef.current) {
|
||||
const resolver = backupPassphraseResolverRef.current;
|
||||
backupPassphraseResolverRef.current = null;
|
||||
resolver(null);
|
||||
}
|
||||
while (confirmQueueRef.current.length > 0) {
|
||||
const request = confirmQueueRef.current.shift();
|
||||
request?.resolve(false);
|
||||
@@ -3422,6 +3431,20 @@ export function App(): ReactElement {
|
||||
});
|
||||
}, [pumpConfirmQueue]);
|
||||
|
||||
const closeBackupPassphraseDialog = useCallback((passphrase: string | null): void => {
|
||||
const resolver = backupPassphraseResolverRef.current;
|
||||
backupPassphraseResolverRef.current = null;
|
||||
setBackupPassphraseMode(null);
|
||||
resolver?.(passphrase);
|
||||
}, []);
|
||||
|
||||
const askBackupPassphrase = useCallback((mode: BackupPassphraseMode): Promise<string | null> => {
|
||||
return new Promise((resolve) => {
|
||||
backupPassphraseResolverRef.current = resolve;
|
||||
setBackupPassphraseMode(mode);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const restoreHistoryEntries = useCallback(async (entryIds: string[]): Promise<void> => {
|
||||
const requested = new Set(entryIds);
|
||||
const entries = historyEntriesRef.current.filter((entry) => requested.has(entry.id));
|
||||
@@ -4519,7 +4542,7 @@ export function App(): ReactElement {
|
||||
const onExportBackup = async (): Promise<void> => {
|
||||
closeMenus();
|
||||
await performQuickAction(async () => {
|
||||
const result = await window.rd.exportBackup();
|
||||
const result = await runLocalBackupExport(window.rd, askBackupPassphrase);
|
||||
if (result.saved) {
|
||||
showToast("Sicherung exportiert");
|
||||
}
|
||||
@@ -4531,7 +4554,7 @@ export function App(): ReactElement {
|
||||
const onImportBackup = async (): Promise<void> => {
|
||||
closeMenus();
|
||||
await performQuickAction(async () => {
|
||||
const result = await window.rd.importBackup();
|
||||
const result = await runLocalBackupImport(window.rd, askBackupPassphrase);
|
||||
if (result.restored) {
|
||||
showToast(result.message, 4000);
|
||||
// A settings-only import applies live without a relaunch, so the editable
|
||||
@@ -5987,6 +6010,13 @@ export function App(): ReactElement {
|
||||
</AppShell>
|
||||
|
||||
<OverlayHost
|
||||
backupPassphrase={backupPassphraseMode ? (
|
||||
<BackupPassphraseDialog
|
||||
mode={backupPassphraseMode}
|
||||
onCancel={() => closeBackupPassphraseDialog(null)}
|
||||
onSubmit={(passphrase) => closeBackupPassphraseDialog(passphrase)}
|
||||
/>
|
||||
) : null}
|
||||
confirm={confirmPrompt ? (
|
||||
<Dialog actions={null} danger={confirmPrompt.danger} onClose={() => closeConfirmPrompt(false)} open title={confirmPrompt.title}>
|
||||
<p style={{ whiteSpace: "pre-line" }}>{confirmPrompt.message}</p>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
export interface BackupSelectionResult {
|
||||
selected: boolean;
|
||||
requiresPassphrase: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface BackupImportResult {
|
||||
restored: boolean;
|
||||
relaunch: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface LocalBackupApi {
|
||||
exportBackup: (passphrase: string) => Promise<{ saved: boolean }>;
|
||||
selectBackupImport: () => Promise<BackupSelectionResult>;
|
||||
importBackup: (passphrase?: string) => Promise<BackupImportResult>;
|
||||
cancelBackupImport: () => Promise<void>;
|
||||
}
|
||||
|
||||
export type BackupPassphraseMode = "export" | "import";
|
||||
export type BackupPassphraseRequest = (mode: BackupPassphraseMode) => Promise<string | null>;
|
||||
|
||||
export function validateBackupPassphrase(
|
||||
mode: BackupPassphraseMode,
|
||||
passphrase: string,
|
||||
confirmation: string
|
||||
): string | null {
|
||||
if (passphrase.trim().length === 0) {
|
||||
return "Bitte eine Passphrase eingeben";
|
||||
}
|
||||
if (mode === "export" && passphrase !== confirmation) {
|
||||
return "Die Passphrasen stimmen nicht überein";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function runLocalBackupExport(
|
||||
api: LocalBackupApi,
|
||||
requestPassphrase: BackupPassphraseRequest
|
||||
): Promise<{ saved: boolean }> {
|
||||
const passphrase = await requestPassphrase("export");
|
||||
if (passphrase === null) {
|
||||
return { saved: false };
|
||||
}
|
||||
return api.exportBackup(passphrase);
|
||||
}
|
||||
|
||||
export async function runLocalBackupImport(
|
||||
api: LocalBackupApi,
|
||||
requestPassphrase: BackupPassphraseRequest
|
||||
): Promise<BackupImportResult> {
|
||||
const selection = await api.selectBackupImport();
|
||||
if (!selection.selected) {
|
||||
return { restored: false, relaunch: false, message: selection.message || "Abgebrochen" };
|
||||
}
|
||||
let passphrase: string | undefined;
|
||||
if (selection.requiresPassphrase) {
|
||||
const requested = await requestPassphrase("import");
|
||||
if (requested === null) {
|
||||
await api.cancelBackupImport();
|
||||
return { restored: false, relaunch: false, message: "Abgebrochen" };
|
||||
}
|
||||
passphrase = requested;
|
||||
}
|
||||
return api.importBackup(passphrase);
|
||||
}
|
||||
@@ -133,6 +133,9 @@ const pairs = [
|
||||
["Start abgebrochen", "Start cancelled"], ["Keine gültigen Links gefunden", "No valid links found"], ["Keine gültigen Links in den DLC-Dateien gefunden", "No valid links found in the DLC files"],
|
||||
["Keine gültigen Links in den Import-Dateien gefunden", "No valid links found in the import files"], ["Links per Drag-and-Drop eingefügt", "Links added by drag and drop"], ["Queue exportiert", "Queue exported"],
|
||||
["Keine gültigen Links in der Datei gefunden", "No valid links found in the file"], ["Sicherung exportiert", "Backup exported"], ["Online-Schlüssel erstellt", "Online key created"],
|
||||
["Sicherung schützen", "Protect backup"], ["Sicherung entsperren", "Unlock backup"], ["Lege eine Passphrase für diese Sicherung fest. Sie wird nicht gespeichert und wird beim Import erneut benötigt.", "Set a passphrase for this backup. It is not stored and will be required again during import."],
|
||||
["Diese Sicherung ist mit einer Passphrase geschützt.", "This backup is protected with a passphrase."], ["Passphrase", "Passphrase"], ["Passphrase bestätigen", "Confirm passphrase"],
|
||||
["Bitte eine Passphrase eingeben", "Enter a passphrase"], ["Die Passphrasen stimmen nicht überein", "The passphrases do not match"], ["Sicherung exportieren", "Export backup"], ["Sicherung importieren", "Import backup"],
|
||||
["Online-Sicherung konnte nicht erstellt werden.", "Online backup could not be created."], ["Online-Sicherung konnte nicht geladen werden. Schlüssel prüfen und erneut versuchen.", "Online backup could not be loaded. Check the key and try again."],
|
||||
["Online-Schlüssel kopiert", "Online key copied"], ["Schlüssel konnte nicht kopiert werden", "Key could not be copied"], ["Support-Bundle exportiert", "Support bundle exported"],
|
||||
["Support-Trace für 2 Stunden aktiviert", "Support trace enabled for 2 hours"], ["Support-Trace deaktiviert", "Support trace disabled"], ["Keine akuten Warnungen", "No current warnings"],
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ReactElement, ReactNode } from "react";
|
||||
|
||||
export interface OverlayHostProps {
|
||||
confirm?: ReactNode;
|
||||
backupPassphrase?: ReactNode;
|
||||
onlineBackup?: ReactNode;
|
||||
diagnostics?: ReactNode;
|
||||
deleteConfirmation?: ReactNode;
|
||||
@@ -21,6 +22,7 @@ export interface OverlayHostProps {
|
||||
|
||||
export function OverlayHost({
|
||||
confirm,
|
||||
backupPassphrase,
|
||||
onlineBackup,
|
||||
diagnostics,
|
||||
deleteConfirmation,
|
||||
@@ -40,6 +42,7 @@ export function OverlayHost({
|
||||
return (
|
||||
<div className="md-overlay-host" id="md-overlay-host">
|
||||
{confirm}
|
||||
{backupPassphrase}
|
||||
{onlineBackup}
|
||||
{diagnostics}
|
||||
{deleteConfirmation}
|
||||
|
||||
@@ -3269,6 +3269,46 @@ td {
|
||||
width: min(760px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
.backup-passphrase-modal {
|
||||
width: min(520px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
.backup-passphrase-form,
|
||||
.backup-passphrase-fields {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.backup-passphrase-fields label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.backup-passphrase-fields input {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
box-sizing: border-box;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.backup-passphrase-fields input:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.backup-passphrase-error {
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.online-backup-key {
|
||||
width: 100%;
|
||||
min-height: 86px;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { FormEvent, ReactElement, useState } from "react";
|
||||
import { BackupPassphraseMode, validateBackupPassphrase } from "../backup-flow";
|
||||
import { Dialog } from "./Dialog";
|
||||
|
||||
export interface BackupPassphraseDialogProps {
|
||||
mode: BackupPassphraseMode;
|
||||
onCancel: () => void;
|
||||
onSubmit: (passphrase: string) => void;
|
||||
}
|
||||
|
||||
export function BackupPassphraseDialog({ mode, onCancel, onSubmit }: BackupPassphraseDialogProps): ReactElement {
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [confirmation, setConfirmation] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const clear = (): void => {
|
||||
setPassphrase("");
|
||||
setConfirmation("");
|
||||
setError("");
|
||||
};
|
||||
|
||||
const cancel = (): void => {
|
||||
clear();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const submit = (event: FormEvent<HTMLFormElement>): void => {
|
||||
event.preventDefault();
|
||||
const validationError = validateBackupPassphrase(mode, passphrase, confirmation);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
const submittedPassphrase = passphrase;
|
||||
clear();
|
||||
onSubmit(submittedPassphrase);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog actions={null} className="backup-passphrase-modal" onClose={cancel} open title={mode === "export" ? "Sicherung schützen" : "Sicherung entsperren"}>
|
||||
<p>{mode === "export" ? "Lege eine Passphrase für diese Sicherung fest. Sie wird nicht gespeichert und wird beim Import erneut benötigt." : "Diese Sicherung ist mit einer Passphrase geschützt."}</p>
|
||||
<form className="backup-passphrase-form" onSubmit={submit}>
|
||||
<div className="backup-passphrase-fields">
|
||||
<label>
|
||||
Passphrase
|
||||
<input
|
||||
aria-label="Backup-Passphrase"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
onChange={(event) => { setPassphrase(event.target.value); setError(""); }}
|
||||
spellCheck={false}
|
||||
type="password"
|
||||
value={passphrase}
|
||||
/>
|
||||
</label>
|
||||
{mode === "export" && (
|
||||
<label>
|
||||
Passphrase bestätigen
|
||||
<input
|
||||
aria-label="Backup-Passphrase bestätigen"
|
||||
autoComplete="off"
|
||||
onChange={(event) => { setConfirmation(event.target.value); setError(""); }}
|
||||
spellCheck={false}
|
||||
type="password"
|
||||
value={confirmation}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className="backup-passphrase-error" role="alert">{error}</div>}
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={cancel} type="button">Abbrechen</button>
|
||||
<button className="btn primary" type="submit">{mode === "export" ? "Sicherung exportieren" : "Sicherung importieren"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user