From f36a10126c0cc9c35b3b4a42b9684aa4b76adec1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 15 Jan 2025 19:40:29 +0100 Subject: [PATCH] Add WS command backup/can_decrypt_on_download (#135662) * Add WS command backup/can_decrypt_on_download * Wrap errors * Add default messages to exceptions * Improve test coverage --- homeassistant/components/backup/manager.py | 54 ++++++++++- homeassistant/components/backup/util.py | 85 +++++++++++++++++- homeassistant/components/backup/websocket.py | 39 +++++++- .../backup/fixtures/test_backups/2bcb3113.tar | Bin 0 -> 10240 bytes .../backup/fixtures/test_backups/ed1608a9.tar | Bin 0 -> 10240 bytes .../backup/snapshots/test_websocket.ambr | 52 +++++++++++ tests/components/backup/test_websocket.py | 55 +++++++++++- 7 files changed, 278 insertions(+), 7 deletions(-) create mode 100644 tests/components/backup/fixtures/test_backups/2bcb3113.tar create mode 100644 tests/components/backup/fixtures/test_backups/ed1608a9.tar diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 76e1c261e31..73bbfafdcf8 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -14,7 +14,7 @@ from pathlib import Path, PurePath import shutil import tarfile import time -from typing import TYPE_CHECKING, Any, Protocol, TypedDict +from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast import aiohttp from securetar import SecureTarFile, atomic_contents_add @@ -31,6 +31,7 @@ from homeassistant.helpers import ( from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util +from . import util as backup_util from .agent import ( BackupAgent, BackupAgentError, @@ -48,7 +49,13 @@ from .const import ( ) from .models import AgentBackup, BackupManagerError, Folder from .store import BackupStore -from .util import make_backup_dir, read_backup, validate_password +from .util import ( + AsyncIteratorReader, + make_backup_dir, + read_backup, + validate_password, + validate_password_stream, +) @dataclass(frozen=True, kw_only=True, slots=True) @@ -248,6 +255,14 @@ class BackupReaderWriterError(HomeAssistantError): class IncorrectPasswordError(BackupReaderWriterError): """Raised when the password is incorrect.""" + _message = "The password provided is incorrect." + + +class DecryptOnDowloadNotSupported(BackupManagerError): + """Raised when on-the-fly decryption is not supported.""" + + _message = "On-the-fly decryption is not supported for this backup." + class BackupManager: """Define the format that backup managers can have.""" @@ -990,6 +1005,39 @@ class BackupManager: translation_placeholders={"failed_agents": ", ".join(agent_errors)}, ) + async def async_can_decrypt_on_download( + self, + backup_id: str, + *, + agent_id: str, + password: str | None, + ) -> None: + """Check if we are able to decrypt the backup on download.""" + try: + agent = self.backup_agents[agent_id] + except KeyError as err: + raise BackupManagerError(f"Invalid agent selected: {agent_id}") from err + if not await agent.async_get_backup(backup_id): + raise BackupManagerError( + f"Backup {backup_id} not found in agent {agent_id}" + ) + reader: IO[bytes] + if agent_id in self.local_backup_agents: + local_agent = self.local_backup_agents[agent_id] + path = local_agent.get_backup_path(backup_id) + reader = await self.hass.async_add_executor_job(open, path.as_posix(), "rb") + else: + backup_stream = await agent.async_download_backup(backup_id) + reader = cast(IO[bytes], AsyncIteratorReader(self.hass, backup_stream)) + try: + validate_password_stream(reader, password) + except backup_util.IncorrectPassword as err: + raise IncorrectPasswordError from err + except backup_util.UnsuppertedSecureTarVersion as err: + raise DecryptOnDowloadNotSupported from err + except backup_util.DecryptError as err: + raise BackupManagerError(str(err)) from err + class KnownBackups: """Track known backups.""" @@ -1372,7 +1420,7 @@ class CoreBackupReaderWriter(BackupReaderWriter): validate_password, path, password ) if not password_valid: - raise IncorrectPasswordError("The password provided is incorrect.") + raise IncorrectPasswordError def _write_restore_file() -> None: """Write the restore file.""" diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index 930625c52ca..ae0244591d8 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -3,13 +3,14 @@ from __future__ import annotations import asyncio +from collections.abc import AsyncIterator from pathlib import Path from queue import SimpleQueue import tarfile -from typing import cast +from typing import IO, cast import aiohttp -from securetar import SecureTarFile +from securetar import VERSION_HEADER, SecureTarFile, SecureTarReadError from homeassistant.backup_restore import password_to_key from homeassistant.core import HomeAssistant @@ -19,6 +20,22 @@ from .const import BUF_SIZE, LOGGER from .models import AddonInfo, AgentBackup, Folder +class DecryptError(Exception): + """Error during decryption.""" + + +class UnsuppertedSecureTarVersion(DecryptError): + """Unsupported securetar version.""" + + +class IncorrectPassword(DecryptError): + """Invalid password or corrupted backup.""" + + +class BackupEmpty(DecryptError): + """No tar files found in the backup.""" + + def make_backup_dir(path: Path) -> None: """Create a backup directory if it does not exist.""" path.mkdir(exist_ok=True) @@ -106,6 +123,70 @@ def validate_password(path: Path, password: str | None) -> bool: return False +class AsyncIteratorReader: + """Wrap an AsyncIterator.""" + + def __init__(self, hass: HomeAssistant, stream: AsyncIterator[bytes]) -> None: + """Initialize the wrapper.""" + self._hass = hass + self._stream = stream + self._buffer: bytes | None = None + self._pos: int = 0 + + async def _next(self) -> bytes | None: + """Get the next chunk from the iterator.""" + return await anext(self._stream, None) + + def read(self, n: int = -1, /) -> bytes: + """Read data from the iterator.""" + result = bytearray() + while n < 0 or len(result) < n: + if not self._buffer: + self._buffer = asyncio.run_coroutine_threadsafe( + self._next(), self._hass.loop + ).result() + self._pos = 0 + if not self._buffer: + # The stream is exhausted + break + chunk = self._buffer[self._pos : self._pos + n] + result.extend(chunk) + n -= len(chunk) + self._pos += len(chunk) + if self._pos == len(self._buffer): + self._buffer = None + return bytes(result) + + +def validate_password_stream( + input_stream: IO[bytes], + password: str | None, +) -> None: + """Decrypt a backup.""" + with ( + tarfile.open(fileobj=input_stream, mode="r|", bufsize=BUF_SIZE) as input_tar, + ): + for obj in input_tar: + if not obj.name.endswith((".tar", ".tgz", ".tar.gz")): + continue + if obj.pax_headers.get(VERSION_HEADER) != "2.0": + raise UnsuppertedSecureTarVersion + istf = SecureTarFile( + None, # Not used + gzip=False, + key=password_to_key(password) if password is not None else None, + mode="r", + fileobj=input_tar.extractfile(obj), + ) + with istf.decrypt(obj) as decrypted: + try: + decrypted.read(1) # Read a single byte to trigger the decryption + except SecureTarReadError as err: + raise IncorrectPassword from err + return + raise BackupEmpty + + async def receive_file( hass: HomeAssistant, contents: aiohttp.BodyPartReader, path: Path ) -> None: diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 0139b7fdb77..1b8433e2f24 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -9,7 +9,11 @@ from homeassistant.core import HomeAssistant, callback from .config import ScheduleState from .const import DATA_MANAGER, LOGGER -from .manager import IncorrectPasswordError, ManagerStateEvent +from .manager import ( + DecryptOnDowloadNotSupported, + IncorrectPasswordError, + ManagerStateEvent, +) from .models import Folder @@ -24,6 +28,7 @@ def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) -> websocket_api.async_register_command(hass, handle_details) websocket_api.async_register_command(hass, handle_info) + websocket_api.async_register_command(hass, handle_can_decrypt_on_download) websocket_api.async_register_command(hass, handle_create) websocket_api.async_register_command(hass, handle_create_with_automatic_settings) websocket_api.async_register_command(hass, handle_delete) @@ -147,6 +152,38 @@ async def handle_restore( connection.send_result(msg["id"]) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "backup/can_decrypt_on_download", + vol.Required("backup_id"): str, + vol.Required("agent_id"): str, + vol.Required("password"): str, + } +) +@websocket_api.async_response +async def handle_can_decrypt_on_download( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Check if the supplied password is correct.""" + try: + await hass.data[DATA_MANAGER].async_can_decrypt_on_download( + msg["backup_id"], + agent_id=msg["agent_id"], + password=msg.get("password"), + ) + except IncorrectPasswordError: + connection.send_error(msg["id"], "password_incorrect", "Incorrect password") + except DecryptOnDowloadNotSupported: + connection.send_error( + msg["id"], "decrypt_not_supported", "Decrypt on download not supported" + ) + else: + connection.send_result(msg["id"]) + + @websocket_api.require_admin @websocket_api.websocket_command( { diff --git a/tests/components/backup/fixtures/test_backups/2bcb3113.tar b/tests/components/backup/fixtures/test_backups/2bcb3113.tar new file mode 100644 index 0000000000000000000000000000000000000000..8a6556634f3e3cb52618ec5b9b5d8eb6675dfa4f GIT binary patch literal 10240 zcmdPXPfASAE-lc@D$dVipbanp0y7g61`rJd=(K^UFbrYKn{loXYw>L@6sB$lKCc}hkGMy9$3hPs9(A%><_ z2F6xK7J8<}rj}-gx&}bLff7({YDGy=B2aO)Qf3}7Ec236<1;}QcK8>bkh zn46>+C0Zt?niw0Ur5U9qB_<}CnSeBxXO?8dCzh7v=O&h9CdU`2mXu`Xr56LWrzPeT zr`7_MWaQ_jCKeZGg3K-f+ml+6oKu>T8V_`3Vp3u;$fZzsm!%dJXXfXDYzH}A&q&We zFD13iKnY|}USclTQ_jFB&(BqWqImy`2&=})tgM|?lT`Cd=hRi+Qi)f0y5knO#Zu(0Nqx*xzO#vUp3U&gKdosL zs-(x1cH;K3;u^6C>k7Au4Ml5~uI8B>rs^6T@ued2X=m~`%eewKlU2hL-#pl~mY{gpa-GiJ@F;vDrnZ~j9u4!adHegCs685}+Iny)ra-|s(pBQB=f989!_jb74yT9K~gsr)JtyA+ttL}o_ng$jl zmF+g~TBPg~I-g6g-dDThpzBTh{M*^bk1|&DzFOXsWiw6I+wiMru+Z-vlQlwH{;hoy zu;Ig*eYN^QdVR8!W+6o_QOwc$e`L4+vDN=3z~a~3(s;D~Cn^UGZAGB^--yNy05a-- zL(|dzKV1@5rttR@nHzS*ZONM1?qkWpe8N3!YFSmL%@hWCIit{Bu1e>h`FYRSRL%9> za>GRD!$IPl0VdDS&-$xT6CrbC_Mb@CMHW{w@1EMTpfe(tn>#J*)85TnZ{?k4_dY45 za#Nj8aU#o}!d`F7GVkmEM1vv>7_6+R8YXSe(s#)H4`}*xS7qTp3sW$E3vD4 z+~97`@$zxzzl*w>Vp7*l6rZ|X*d{FUW{2K3nYz`EIURd4Sr-HaZj#t_Er74{X3&?f zi8fnI8ts%V8B_EXJ*rQ?w@;Qn{AovDy0m%x!tU#P_pGd6bMTl1gGcV;+zUB3*!g;5 zc?BDix-7WU4j*ju(!V!#p=VcDw4VDtky};2rWKu>H1UPPzWh^rCfV02ufMBmwa9sO z{e%gRg$y?JwU_;I%@5MMrE**=XxF0)%IyuWB^N1qWi2cb4}E&mH8Wf-I7okPkX`Mn zy~Tx@RsxakT>i^aOWHQJU$zg``Mp5z^q({f=GDDV=G?oG@;yMOt77(-3{TsYc}pKn zID6o8L0#}$=E$?AH=@7S2u{g+E?o9X{7d2WcWu6>!{*rZY)n`iZ4+9wx-#eY)d#IE zlMmaxbnQM6Q4$|H?~@Gw$yNWXE*VVT$izr$q8S|ZzcFGg05nbj9RD*k9PR&;8kgj# zH!{cU|C<;XSsGi8R{!LL=TOrFs{f4#WBm`z|8yCXAK~MFC;r5r%XoZ~W0G|!d&`c} zhtn6#o*q)4J4ZO>z3%^ADTzUfU%GRD{L7r9xLiMdLKoM&7mEUg%3mmEEY6kMxzTr( z|H>ErW;W%nf(}ZniX&sY?N_eb6Z-zlMyXUCj}^!CPThHSv2(`OO*bc&8mRSeo&P<4 zp7XT7SJrOv`Pw>7$4Yd;JCX5e%9^R>)V#(O}km3XK-nOz@1gM<{xkjIT>;1hOqjt!hZ@|RCadCXmD*?{VH{d zg;4C}`$~JtS08<}@?GJXH2I%9C#EeAe^j?pTS&NmpSxLD@om<_inj~8qx=^C7Z>E5 zzwWry86Nqm3te9`z2q-H=<(lvyW-pc*Ed(*{qvjtd9U~vJLi((*cBC1cU|7i5G|DV zJKNBm>wNK)l3SrSHs*0l2dI5|~C)wtQmzs^u={T4T@~ zzC7sW`m-Gyr|Ol?^RO?kjJqjk^6T-1qc{Bb{qmnw=U6Sz<#YR!<1UHdEnA;V`Sk4S~@R7!85Z5Eu=C(GVC7fzc2c4FURv002Ddqo4o) literal 0 HcmV?d00001 diff --git a/tests/components/backup/fixtures/test_backups/ed1608a9.tar b/tests/components/backup/fixtures/test_backups/ed1608a9.tar new file mode 100644 index 0000000000000000000000000000000000000000..fc928b16d1b1c3c480ea3600ef8f69d7f87dbc4c GIT binary patch literal 10240 zcmeHKX;@QN8jkv~$Ql-bfQyNK`V_ zmIhh@SRWu_u}F+i0As;%L=G3@lFLK_IkHql8iPV5)5&BO#G+GJ5Nr!06fzVX3Y2bx(LP4h!Q8 zctV6Je4Y+8Jc=#LiLgK#U<2FAkP8p-m$LC;fsg}-%ZC2BIu!rEQa~hYlC_g39OH_> zEJUz8sFzg#@4El>8~{Lw{@eS~`GDykq~ZuM%LC`dtgpHL$z%eiI%bpr!Hz)^KOE+8 zg_xoPQBEwfl>*wRK(yxrekt*-n6OkJ8$yJT6M<|6>13KMnGVvZBnp6i(#a|>axCxt zX&GSh1PDpC_3YSX!zk1LfAW7ib~b<%It8QybTTG<00aO!DEI$m+mPoH7y4F@LO2Jh z^7XFV{s-YkrE#fAnSXJ$7$s7}TfcX9n7*hLaQ0X>j7M}TRhx5)(QVGbwz{d?rR=J` z8`f&DPo=T;E8!Zl3*85eyF`I4JEaM?4=If9JD*U#tq|&vDtEBSUfoBu??E@U>visO z3srh+irYraiWJ>q`$huM&`V9C<}&l)km_&1`$cyucS4$0h_Gsl*-};)j?=UMeM@%YfgOY2&?HQE=_{%Y-4m)=3AO6XE*WERiBXvSEX{*P! zb6N)IO^2gxw;Tt3R6k}|<^pF^eMbA$dh4xY@M-UT&ehi(*vu%4#3@bAOslzT)k~*Q zrg7n7M(rB0Nm@T@;*fN4oQ_w0a!+Z%rQIyla3x{PPTVQ38C;>EteF?5#utT z4D6@%d+7UAoDjd|LJZJ8xmWwJllRQgAe@)Yw1tmhK{o+Ek@XfX$0G5u$kZo)&Z1s9 zXoK&m8`H-AMO7jK*sKKa{qrR zG5!}O$^0K$<_o|}_&-P`%l-dVltXdiDyTAzH(#pGnL36mk1A%ii{j@Gn=2TP3iPXy z5@OJ}1i!E`o8C23qW!GeH2o@(r7qg+BK|J3sn*b3(kyk#>BT+xR{0I&B;z1*1WnY~ z^wg_ea3t?cfby*^B5RYb9$$T8OBCNC|9)eG6{Kb<>alm5F1QqGe9yj81+`Zp#Y9@> zXSz?V>UnTE%`S12=QPw?sZ-NlYIf?*c&F{0-mJBOMo@7f2knnvjpB%b9Q@D!+ovX{o7iz zzCBoRbx%@u-?MU;oP;psSfhU{UpdQT;W4qt(<(P9Wgn>9c0VTAzO=9U(+TrU8w?E3 zT?pLdtaaFCY3dq{I$Ad7uTB5%_(-Ie{hdC=boW%NNlTJZ)2#lA z1=Gjg382moVb_~H3$1@5v|HX*_M19U#n|-Y;Vent;^rTsGi>|B+OBRxl^YrBKeCQ{ z-2PRVxI@e4_tuEq|K^dAGIUs;%8wWIt z^x~Z^=ql(wOs?Kj$PCT-@U9`fSnG=~*9c>!QRmGLITk4FJ^Ws=_wXkLMRT>Qn^avx zENy3J%S^%Sx^;V$l0L*uRmV-Hbatvgxjyd!6_pK(oUR&7)Fk;;x7Q;6pH_C;iN6Ux z&GP(f)wG%_(Dam{_!*HEEa}d3zXuI8xEKlsMh9B9Grt~W;QOZ{+KSN3FHdU(rG||^ z?f>z-+LLu%J(|5fk>sw$ECXGd>wBkwgsPlb1O3>vZRiy z6}M8QoqyNFOGuI&xVGWp0ba}4g@s4)_AEUW*4!a41n2Jk2B$>2F{EIIHgkT)7=*O> zAJo--r8jb=xBShUPcvMy8ZWwa^;5!AB-9D#p~jF`Q6us-K+b@i0XYM5 N2ILIL8TkKT;2*(l$Swc? literal 0 HcmV?d00001 diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 98b2f764d43..ac4e77fca41 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -175,6 +175,58 @@ 'type': 'result', }) # --- +# name: test_can_decrypt_on_download[backup.local-2bcb3113-hunter2] + dict({ + 'error': dict({ + 'code': 'decrypt_not_supported', + 'message': 'Decrypt on download not supported', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_can_decrypt_on_download[backup.local-ed1608a9-hunter2] + dict({ + 'id': 1, + 'result': None, + 'success': True, + 'type': 'result', + }) +# --- +# name: test_can_decrypt_on_download[backup.local-ed1608a9-wrong_password] + dict({ + 'error': dict({ + 'code': 'password_incorrect', + 'message': 'Incorrect password', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_can_decrypt_on_download[backup.local-no_such_backup-hunter2] + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Backup no_such_backup not found in agent backup.local', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- +# name: test_can_decrypt_on_download[no_such_agent-ed1608a9-hunter2] + dict({ + 'error': dict({ + 'code': 'home_assistant_error', + 'message': 'Invalid agent selected: no_such_agent', + }), + 'id': 1, + 'success': False, + 'type': 'result', + }) +# --- # name: test_config_info[None] dict({ 'id': 1, diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index e95481373d6..7820408f265 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -36,7 +36,7 @@ from .common import ( setup_backup_platform, ) -from tests.common import async_fire_time_changed, async_mock_service +from tests.common import async_fire_time_changed, async_mock_service, get_fixture_path from tests.typing import WebSocketGenerator BACKUP_CALL = call( @@ -2554,3 +2554,56 @@ async def test_subscribe_event( CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS) ) assert await client.receive_json() == snapshot + + +@pytest.fixture +def mock_backups() -> Generator[None]: + """Fixture to setup test backups.""" + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.backup import backup as core_backup + + class CoreLocalBackupAgent(core_backup.CoreLocalBackupAgent): + def __init__(self, hass: HomeAssistant) -> None: + super().__init__(hass) + self._backup_dir = get_fixture_path("test_backups", DOMAIN) + + with patch.object(core_backup, "CoreLocalBackupAgent", CoreLocalBackupAgent): + yield + + +@pytest.mark.parametrize( + ("agent_id", "backup_id", "password"), + [ + # Invalid agent or backup + ("no_such_agent", "ed1608a9", "hunter2"), + ("backup.local", "no_such_backup", "hunter2"), + # Legacy backup, which can't be streamed + ("backup.local", "2bcb3113", "hunter2"), + # New backup, which can be streamed, try with correct and wrong password + ("backup.local", "ed1608a9", "hunter2"), + ("backup.local", "ed1608a9", "wrong_password"), + ], +) +@pytest.mark.usefixtures("mock_backups") +async def test_can_decrypt_on_download( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + agent_id: str, + backup_id: str, + password: str, +) -> None: + """Test can decrypt on download.""" + await setup_backup_integration(hass, with_hassio=False) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/can_decrypt_on_download", + "backup_id": backup_id, + "agent_id": agent_id, + "password": password, + } + ) + assert await client.receive_json() == snapshot