diff --git a/homeassistant/components/backup/const.py b/homeassistant/components/backup/const.py index 131acf99a80..b985283040e 100644 --- a/homeassistant/components/backup/const.py +++ b/homeassistant/components/backup/const.py @@ -34,4 +34,4 @@ EXCLUDE_DATABASE_FROM_BACKUP = [ "home-assistant_v2.db-wal", ] -SECURETAR_CREATE_VERSION = 2 +SECURETAR_CREATE_VERSION = 3 diff --git a/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.encrypted_skip_core2 b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.encrypted_v2_skip_core2 similarity index 100% rename from tests/components/backup/fixtures/test_backups/c0cb53bd.tar.encrypted_skip_core2 rename to tests/components/backup/fixtures/test_backups/c0cb53bd.tar.encrypted_v2_skip_core2 diff --git a/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.encrypted_v3 b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.encrypted_v3 new file mode 100644 index 00000000000..2b90d0dfdbb Binary files /dev/null and b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.encrypted_v3 differ diff --git a/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.encrypted_v3_skip_core2 b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.encrypted_v3_skip_core2 new file mode 100644 index 00000000000..f3649b2b02b Binary files /dev/null and b/tests/components/backup/fixtures/test_backups/c0cb53bd.tar.encrypted_v3_skip_core2 differ diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index d4b6e16b2ef..2a64bcd6843 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -3542,7 +3542,7 @@ async def test_initiate_backup_per_agent_encryption( await hass.async_block_till_done() assert mock_secure_tar_archive.mock_calls[0] == call( - ANY, ANY, "w", bufsize=4194304, create_version=2, password=inner_tar_password + ANY, ANY, "w", bufsize=4194304, create_version=3, password=inner_tar_password ) result = await ws_client.receive_json() diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py index 47bb1160812..83b7a6a7944 100644 --- a/tests/components/backup/test_util.py +++ b/tests/components/backup/test_util.py @@ -5,10 +5,13 @@ from __future__ import annotations import asyncio from collections.abc import AsyncIterator import dataclasses +import hashlib +import os from pathlib import Path import tarfile from unittest.mock import Mock, patch +import nacl.bindings.crypto_secretstream as nss import pytest import securetar @@ -25,6 +28,32 @@ from homeassistant.core import HomeAssistant from tests.common import get_fixture_path +def _deterministic_init_push( + state: nss.crypto_secretstream_xchacha20poly1305_state, key: bytes +) -> bytes: + """Replace init_push with init_pull + deterministic header from os.urandom. + + libsodium's init_push generates random bytes internally, bypassing os.urandom. + This replacement generates the header via os.urandom (which can be patched for + deterministic tests) and uses init_pull to correctly initialize the state. + """ + header = os.urandom(nss.crypto_secretstream_xchacha20poly1305_HEADERBYTES) + nss.crypto_secretstream_xchacha20poly1305_init_pull(state, header, key) + return header + + +def _make_deterministic_urandom() -> callable: + """Create a deterministic os.urandom replacement.""" + call_idx = 0 + + def deterministic_urandom(n: int) -> bytes: + nonlocal call_idx + call_idx += 1 + return hashlib.sha256(f"deterministic-{call_idx}".encode()).digest()[:n] + + return deterministic_urandom + + @pytest.mark.parametrize( ("backup_json_content", "expected_backup"), [ @@ -432,14 +461,14 @@ async def test_decrypted_backup_streamer_wrong_password(hass: HomeAssistant) -> AddonInfo(name="Core 2", slug="core2", version="1.0.0"), ], 40960, # 4 x 10240 byte of padding - "test_backups/c0cb53bd.tar", + "test_backups/c0cb53bd.tar.encrypted_v3", ), ( [ AddonInfo(name="Core 1", slug="core1", version="1.0.0"), ], 30720, # 3 x 10240 byte of padding - "test_backups/c0cb53bd.tar.encrypted_skip_core2", + "test_backups/c0cb53bd.tar.encrypted_v3_skip_core2", ), ], ) @@ -477,16 +506,17 @@ async def test_encrypted_backup_streamer( async def open_backup() -> AsyncIterator[bytes]: return send_backup() - # Patch os.urandom to return values matching the nonce used in the encrypted - # test backup. The backup has three inner tar files, but we need an extra nonce - # for a future planned supervisor.tar. - with patch("os.urandom") as mock_randbytes: - mock_randbytes.side_effect = ( - bytes.fromhex("bd34ea6fc93b0614ce7af2b44b4f3957"), - bytes.fromhex("1296d6f7554e2cb629a3dc4082bae36c"), - bytes.fromhex("8b7a58e48faf2efb23845eb3164382e0"), - bytes.fromhex("00000000000000000000000000000000"), - ) + # Patch os.urandom for deterministic key derivation salts, and patch + # crypto_secretstream init_push to use os.urandom for the stream header + # instead of libsodium's internal CSPRNG. + with ( + patch("os.urandom", side_effect=_make_deterministic_urandom()), + patch( + "nacl.bindings.crypto_secretstream" + ".crypto_secretstream_xchacha20poly1305_init_push", + side_effect=_deterministic_init_push, + ), + ): encryptor = EncryptedBackupStreamer(hass, backup, open_backup, "hunter2") assert encryptor.backup() == dataclasses.replace( @@ -587,7 +617,9 @@ async def test_encrypted_backup_streamer_random_nonce(hass: HomeAssistant) -> No decrypted_backup_path = get_fixture_path( "test_backups/c0cb53bd.tar.decrypted", DOMAIN ) - encrypted_backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN) + encrypted_backup_path = get_fixture_path( + "test_backups/c0cb53bd.tar.encrypted_v3", DOMAIN + ) backup = AgentBackup( addons=[ AddonInfo(name="Core 1", slug="core1", version="1.0.0"),