Compare commits

...

5 Commits

Author SHA1 Message Date
Stefan Agner
cb0176b745 Fix pytest for SecureTar v3
Patch crypto_secretstream_xchacha20poly1305_init_push() to generate
a deterministic header for testing. This allows us to use a fixed
fixture for SecureTar v3 encrypted backups.
2026-03-24 21:20:44 +01:00
Stefan Agner
0ea9a07120 Rename encrypted backup fixtures to indicate v2 format
Rename encrypted backup fixtures to include "v2" in their names, to
clearly distinguish them from upcoming v3 fixtures. Update all test
references accordingly.
2026-03-24 20:13:09 +01:00
Stefan Agner
483497c640 Revert "Update encrypted backup streamer test for SecureTar v3"
This reverts commit 8474aa0d51.
2026-03-24 19:31:46 +01:00
Stefan Agner
8474aa0d51 Update encrypted backup streamer test for SecureTar v3
SecureTar v3 uses libsodium's crypto_secretstream which generates
random headers internally (not through os.urandom), making encrypted
output non-deterministic. Replace the fixture byte-comparison approach
with an encrypt-then-decrypt round-trip test that verifies output size,
padding, and tar content integrity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:06:13 +01:00
Stefan Agner
d46723c443 Make SecureTar v3 the default for backup creation
Update SECURETAR_CREATE_VERSION from 2 to 3 to use the latest
SecureTar format when creating backups.
2026-03-23 15:41:48 +01:00
6 changed files with 47 additions and 15 deletions

View File

@@ -34,4 +34,4 @@ EXCLUDE_DATABASE_FROM_BACKUP = [
"home-assistant_v2.db-wal",
]
SECURETAR_CREATE_VERSION = 2
SECURETAR_CREATE_VERSION = 3

View File

@@ -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()

View File

@@ -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"),