Improve encrypted backup dialog (#23991)

* Improve encrypted backup dialog

* Remove unused code
This commit is contained in:
Paul Bottein 2025-01-31 17:10:43 +01:00 committed by Bram Kragten
parent 15f33e1f19
commit f0a56e75f5
6 changed files with 326 additions and 130 deletions

View File

@ -0,0 +1,225 @@
import { mdiClose } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-password-field";
import "../../../../components/ha-alert";
import {
canDecryptBackupOnDownload,
getPreferredAgentForDownload,
} from "../../../../data/backup";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { downloadBackupFile } from "../helper/download_backup";
import type { DownloadDecryptedBackupDialogParams } from "./show-dialog-download-decrypted-backup";
@customElement("ha-dialog-download-decrypted-backup")
class DialogDownloadDecryptedBackup extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _opened = false;
@state() private _params?: DownloadDecryptedBackupDialogParams;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
@state() private _encryptionKey = "";
@state() private _error = "";
public showDialog(params: DownloadDecryptedBackupDialogParams): void {
this._opened = true;
this._params = params;
}
public closeDialog() {
this._dialog?.close();
return true;
}
private _dialogClosed() {
if (this._opened) {
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
this._opened = false;
this._params = undefined;
this._encryptionKey = "";
this._error = "";
}
protected render() {
if (!this._opened || !this._params) {
return nothing;
}
return html`
<ha-md-dialog open @closed=${this._dialogClosed} disable-cancel-action>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
@click=${this.closeDialog}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<span slot="title">
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.title"
)}
</span>
</ha-dialog-header>
<div slot="content">
<p>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.description"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.download_backup_encrypted",
{
download_it_encrypted: html`<button
class="link"
@click=${this._downloadEncrypted}
>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.download_it_encrypted"
)}
</button>`,
}
)}
</p>
<ha-password-field
.label=${this.hass.localize(
"ui.panel.config.backup.dialogs.download.encryption_key"
)}
@input=${this._keyChanged}
></ha-password-field>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
</div>
<div slot="actions">
<ha-button @click=${this._cancel}>
${this.hass.localize("ui.dialogs.generic.cancel")}
</ha-button>
<ha-button @click=${this._submit}>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.download"
)}
</ha-button>
</div>
</ha-md-dialog>
`;
}
private _cancel() {
this.closeDialog();
}
private async _submit() {
if (this._encryptionKey === "") {
return;
}
try {
await canDecryptBackupOnDownload(
this.hass,
this._params!.backup.backup_id,
this._agentId,
this._encryptionKey
);
downloadBackupFile(
this.hass,
this._params!.backup.backup_id,
this._agentId,
this._encryptionKey
);
this.closeDialog();
} catch (err: any) {
if (err?.code === "password_incorrect") {
this._error = this.hass.localize(
"ui.panel.config.backup.dialogs.download.incorrect_encryption_key"
);
} else if (err?.code === "decrypt_not_supported") {
this._error = this.hass.localize(
"ui.panel.config.backup.dialogs.download.decryption_not_supported"
);
} else {
alert(err.message);
}
}
}
private _keyChanged(ev) {
this._encryptionKey = ev.currentTarget.value;
this._error = "";
}
private get _agentId() {
if (this._params?.agentId) {
return this._params.agentId;
}
return getPreferredAgentForDownload(
Object.keys(this._params!.backup.agents)
);
}
private async _downloadEncrypted() {
downloadBackupFile(
this.hass,
this._params!.backup.backup_id,
this._agentId
);
this.closeDialog();
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
css`
ha-md-dialog {
--dialog-content-padding: 8px 24px;
max-width: 500px;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-md-dialog {
max-width: none;
}
div[slot="content"] {
margin-top: 0;
}
}
button.link {
background: none;
border: none;
padding: 0;
font-size: 14px;
color: var(--primary-color);
text-decoration: underline;
cursor: pointer;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-dialog-download-decrypted-backup": DialogDownloadDecryptedBackup;
}
}

View File

@ -0,0 +1,21 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { BackupContent } from "../../../../data/backup";
export interface DownloadDecryptedBackupDialogParams {
backup: BackupContent;
agentId?: string;
}
export const loadDownloadDecryptedBackupDialog = () =>
import("./dialog-download-decrypted-backup");
export const showDownloadDecryptedBackupDialog = (
element: HTMLElement,
params: DownloadDecryptedBackupDialogParams
) => {
fireEvent(element, "show-dialog", {
dialogTag: "ha-dialog-download-decrypted-backup",
dialogImport: loadDownloadDecryptedBackupDialog,
dialogParams: params,
});
};

View File

@ -531,12 +531,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
}
private async _downloadBackup(backup: BackupContent): Promise<void> {
downloadBackup(
this.hass,
this,
backup,
this.config?.create_backup.password
);
downloadBackup(this.hass, this, backup, this.config);
}
private async _deleteBackup(backup: BackupContent): Promise<void> {

View File

@ -401,13 +401,7 @@ class HaConfigBackupDetails extends LitElement {
}
private async _downloadBackup(agentId?: string): Promise<void> {
await downloadBackup(
this.hass,
this,
this._backup!,
this.config?.create_backup.password,
agentId
);
await downloadBackup(this.hass, this, this._backup!, this.config, agentId);
}
private async _deleteBackup(): Promise<void> {

View File

@ -1,20 +1,17 @@
import type { LitElement } from "lit";
import { getSignedPath } from "../../../../data/auth";
import type { BackupConfig, BackupContent } from "../../../../data/backup";
import {
canDecryptBackupOnDownload,
getBackupDownloadUrl,
getPreferredAgentForDownload,
type BackupContent,
} from "../../../../data/backup";
import type { HomeAssistant } from "../../../../types";
import {
showAlertDialog,
showConfirmationDialog,
showPromptDialog,
} from "../../../lovelace/custom-card-helpers";
import { getSignedPath } from "../../../../data/auth";
import { fileDownload } from "../../../../util/file_download";
import { showAlertDialog } from "../../../lovelace/custom-card-helpers";
import { showDownloadDecryptedBackupDialog } from "../dialogs/show-dialog-download-decrypted-backup";
const triggerDownload = async (
export const downloadBackupFile = async (
hass: HomeAssistant,
backupId: string,
preferedAgent: string,
@ -27,120 +24,79 @@ const triggerDownload = async (
fileDownload(signedUrl.path);
};
const downloadEncryptedBackup = async (
hass: HomeAssistant,
element: LitElement,
backup: BackupContent,
agentId?: string
) => {
if (
await showConfirmationDialog(element, {
title: "Encryption key incorrect",
text: hass.localize(
"ui.panel.config.backup.dialogs.download.incorrect_entered_encryption_key"
),
confirmText: "Download encrypted",
})
) {
const agentIds = Object.keys(backup.agents);
const preferedAgent = agentId ?? getPreferredAgentForDownload(agentIds);
triggerDownload(hass, backup.backup_id, preferedAgent);
}
};
const requestEncryptionKey = async (
hass: HomeAssistant,
element: LitElement,
backup: BackupContent,
agentId?: string
): Promise<void> => {
const encryptionKey = await showPromptDialog(element, {
title: hass.localize(
"ui.panel.config.backup.dialogs.show_encryption_key.title"
),
text: hass.localize(
"ui.panel.config.backup.dialogs.download.incorrect_current_encryption_key"
),
inputLabel: hass.localize(
"ui.panel.config.backup.dialogs.show_encryption_key.title"
),
inputType: "password",
confirmText: hass.localize("ui.common.download"),
});
if (encryptionKey === null) {
return;
}
downloadBackup(hass, element, backup, encryptionKey, agentId, true);
};
export const downloadBackup = async (
hass: HomeAssistant,
element: LitElement,
backup: BackupContent,
encryptionKey?: string | null,
agentId?: string,
userProvided = false
backupConfig?: BackupConfig,
agentId?: string
): Promise<void> => {
const agentIds = Object.keys(backup.agents);
const preferedAgent = agentId ?? getPreferredAgentForDownload(agentIds);
const isProtected = backup.agents[preferedAgent]?.protected;
if (isProtected) {
if (encryptionKey) {
try {
await canDecryptBackupOnDownload(
hass,
backup.backup_id,
preferedAgent,
encryptionKey
);
} catch (err: any) {
if (err?.code === "password_incorrect") {
if (userProvided) {
downloadEncryptedBackup(hass, element, backup, agentId);
} else {
requestEncryptionKey(hass, element, backup, agentId);
}
return;
}
if (err?.code === "decrypt_not_supported") {
showAlertDialog(element, {
title: hass.localize(
"ui.panel.config.backup.dialogs.download.decryption_unsupported_title"
),
text: hass.localize(
"ui.panel.config.backup.dialogs.download.decryption_unsupported"
),
confirm() {
triggerDownload(hass, backup.backup_id, preferedAgent);
},
});
encryptionKey = undefined;
return;
}
showAlertDialog(element, {
title: hass.localize(
"ui.panel.config.backup.dialogs.download.error_check_title",
{
error: err.message,
}
),
text: hass.localize(
"ui.panel.config.backup.dialogs.download.error_check_description",
{
error: err.message,
}
),
});
return;
}
} else {
requestEncryptionKey(hass, element, backup, agentId);
return;
}
if (!isProtected) {
downloadBackupFile(hass, backup.backup_id, preferedAgent);
}
await triggerDownload(hass, backup.backup_id, preferedAgent, encryptionKey);
const encryptionKey = backupConfig?.create_backup?.password;
if (!encryptionKey) {
showDownloadDecryptedBackupDialog(element, {
backup,
agentId: preferedAgent,
});
return;
}
try {
// Check if we can decrypt it
await canDecryptBackupOnDownload(
hass,
backup.backup_id,
preferedAgent,
encryptionKey
);
downloadBackupFile(hass, backup.backup_id, preferedAgent, encryptionKey);
} catch (err: any) {
// If encryption key is incorrect, ask for encryption key
if (err?.code === "password_incorrect") {
showDownloadDecryptedBackupDialog(element, {
backup,
agentId: preferedAgent,
});
return;
}
// If decryption is not supported, ask for confirmation and download it encrypted
if (err?.code === "decrypt_not_supported") {
showAlertDialog(element, {
title: hass.localize(
"ui.panel.config.backup.dialogs.download.decryption_unsupported_title"
),
text: hass.localize(
"ui.panel.config.backup.dialogs.download.decryption_unsupported"
),
confirm() {
downloadBackupFile(hass, backup.backup_id, preferedAgent);
},
});
return;
}
// Else, show generic error
showAlertDialog(element, {
title: hass.localize(
"ui.panel.config.backup.dialogs.download.error_check_title",
{
error: err.message,
}
),
text: hass.localize(
"ui.panel.config.backup.dialogs.download.error_check_description",
{
error: err.message,
}
),
});
}
};

View File

@ -2392,11 +2392,16 @@
"download": {
"decryption_unsupported_title": "Decryption unsupported",
"decryption_unsupported": "Decryption is not supported for this backup. The downloaded backup will remain encrypted and can't be opened. To restore it, you will need the encryption key.",
"incorrect_entered_encryption_key": "The entered encryption key was incorrect, try again or download the encrypted backup. The encrypted backup can't be opened. To restore it, you will need the encryption key.",
"download_encrypted": "Download encrypted",
"incorrect_current_encryption_key": "This backup is encrypted with a different encryption key than the current one, please enter the encryption key of this backup.",
"error_check_title": "Error checking backup",
"error_check_description": "An error occurred while checking the backup, please try again. Error message: {error}"
"error_check_description": "An error occurred while checking the backup, please try again. Error message: {error}",
"title": "Download backup",
"description": "This backup is encrypted with a different encryption key than the current one, please enter the encryption key of this backup.",
"download_backup_encrypted": "You can still {download_it_encrypted}. To restore it, you will need the encryption key.",
"download_it_encrypted": "download the backup encrypted",
"encryption_key": "Encryption key",
"incorrect_encryption_key": "Incorrect encryption key",
"decryption_not_supported": "Decryption not supported",
"download": "Download"
}
},
"agents": {