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 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 RXb6mkz-S1JhQQDa0RTMeqo4o) 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 zcmdPXPfASAE-lc@D$dVipbaoEFfcGPF<}7F1_lP`w1KHHTnway!NAbK$i#p_!GJc- zK{d6sxFoTN!GJDyq1rqsc-2bD`MCu}sl~;qDN0rfB}JvFItofDi6yB@Rtic+21cg3 z28OzZCLxBVRtCmaMizRe#-^5LhPno(Rt5%2Itogu6(vQ9N>&QhN||}ZC5d^-sqvX0 zqm;~&OiYc4D?b`%M6r22IVE@f|M#bmll`g=PE$by#mOK2z5FNN(DvvC8^0J z$iY#ZQ<@GkGd0D~%)lbi66Erd$^x)y1&Ku^nTa_dA%yKlwSyvZiExCTzMj5AKw^bQ zYGO)i5fK{5Py))-Sn@psRNTPO$Q;fFt1~n(GBjmSs35~GlC_NR{BLZm5MP{{Tw0V` zl31iykdv61SCU#$5?`EIm1=8ZXu)M<1VSI7cf!&RJSE%00 z*m%?M-I53Eyp8y`Ioih_U+-4dzd&V&;Gfp?fYvb9CpTvDNT(kz7Jk0IeqYL;wI^N` zt~`2CLMTZ#W_jSDmkW>V{l!yueR52zfR(fN+FyI$)Y)f$XF3wOb(8CZkPoO?==_B?}C zJ>ie8@m$)fUm?)jwI@%U|GG}nib4kVH(x*VcnNNO{<%0=cG17dn?>8@dao7B*|qF@ zVlKe_>lyoX=VK~aQcMbut=dnuZ@R(A$H70pS~%cBxAK{Qxa$#S*Zjo8*Yvl$I4i{o zO$!#PaR~d`kiZaP@mVumYT-ErftNE~80Km=|7esCU+`0ND@THQpeaR4-d>VG2|Hvq_} z{|!xzN9%ujB!{KV%tq^a^M5br&HUKGu)1_<^0CI+-!-bNvX6?yH>NIAN_etT;E%=} z^V46K3H{$FKkP_Gr`8oW^J)DJIaX9R ze{meg9qUGYxziUT#g+D#7HBSbw`-@a5s!A`MH|o03lC(;KC@ZJRcynhS5c_FV5-+g z=8NwS_F6SR%6GVXX`S$x9)T4t)S;JokL zkGveb4y1qB)nn@L;l$mn)f1QOUh~_o^YYQ@yZN5ISLL-$b6lC|vuTxE%g%R#^CFxM z&%71A=15S>oU8v=JI}4pPVLwgd$@pOrr)0*N*4ok=e4wj8j2i!SCMA3{OZP8FIBbF zC8XAFkJoY(tg(0;SiL8-dS&cK{o|&xOG7{2(``Sv#eeZ#(KYRQ);p8V&9kZ6G&jPX zeJfkQrXL-0*2(v`u4p;!?e4{4r5UNR$UJEO&*S?w7RXGz-TAOE#OA?OwuxRnx^J}g zq<4Q4XZ$1oBdp#~_;vP~-2sdAzbG8jzRwZ!v44G%*4vtyE3f|3d0jTy@@l(~hv(gO z8cE8N^s9dyJD=TtT+o8jG%{NMkJkUx?Ef1YnVFiJjn@B^=1Y3%2i5<^gR%ZM8Lj{6 zkp>dx#~fI5!z5thV|JPMYx1spUS51mApe`k39roK(&1&jsoMh?`xI_;a|M;G^AGuV zry;KJo9dkH3m^aacVeeZ)e0V0_S^!t6^#YUq$_tNANA2)`TXbSH_>`+m)jmsQZC=i z;c+ixWmHk(w_Vrymx$FmY!_h_`OvyCXi;+3+zC&m%$EwDJS$pK^}KY0N~Qfm*5I11 zrD6A`FI@6-Gv{t@j|^?gZ{JqP8_wCH7{uN(f#KuE>NjntPVxRa^V`p8$%=c84o4(j zZfc3ycx-cO?5uU?tlDp;%}xnuTK}1c+hF(KB(`lzDQPRu&G&j{bbY6@RMGWE*Y~?6 zU$~JZaP4F6(Iv%G&(7vc=*fQa_uAVHJiioAU*x|OZu~}Iv4i{Pz5L5Nl@yzor=B}* z9=87f%d)9{Q}-TE@2OwbmN8?~Re{)36E8A9dF)pf`BirHzeBqe9zNGsuohn}b+T+; z`ew20_9@Fn)<)XjX+D}ZTg^5(qj1@lJi%%vu44sH1!rA uZ7WS*I^NxtL60;tYR6~@jE2By2#kinXb6mkz-S1JhQMeDjE2DQ2mt`WZOASF 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