From 4d7bd49d2c6824a4b446c8405cd7d5cafe46abb7 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 25 Mar 2026 19:10:58 +0100 Subject: [PATCH] Make SecureTar v3 the default for backup creation (#166272) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/backup/const.py | 2 +- ...2 => c0cb53bd.tar.encrypted_v2_skip_core2} | Bin .../test_backups/c0cb53bd.tar.encrypted_v3 | Bin 0 -> 10240 bytes .../c0cb53bd.tar.encrypted_v3_skip_core2 | Bin 0 -> 10240 bytes tests/components/backup/test_manager.py | 2 +- tests/components/backup/test_util.py | 58 ++++++++++++++---- 6 files changed, 47 insertions(+), 15 deletions(-) rename tests/components/backup/fixtures/test_backups/{c0cb53bd.tar.encrypted_skip_core2 => c0cb53bd.tar.encrypted_v2_skip_core2} (100%) create mode 100644 tests/components/backup/fixtures/test_backups/c0cb53bd.tar.encrypted_v3 create mode 100644 tests/components/backup/fixtures/test_backups/c0cb53bd.tar.encrypted_v3_skip_core2 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 0000000000000000000000000000000000000000..2b90d0dfdbb1de71260d361f13ba9bc0ebb0c5ce GIT binary patch literal 10240 zcmeHLd010d7DrYAt)PN}=@5b-VF@oU2_ftPZa~>L0Sk}h0bxmE^4J7fsw|?zCaARz zA`Fl!(&B;)Rapc9mk|_EK{i=M1VlsukxA&Nb@Xdzz80r`=KgcPd)~R{ocp`y-1~bM zr{e=r14CH201lHOv@i|eHC-Z6h~M+tfA#>G^yb^o-|+xQ0))`O!i4j#)esH>v3ViA zEB$Onkr*m7n8k)U9GHgDMnMea63Ir1gpsz3J>jOcM zE|EVujE?wwK_Lh;7((b&FAj_#bcP>?H{BNs;=nt3D*nu17~*i~eA~#pA2^&E6hec& zcu_(=5QiV>zvB*t*&I5P!S~J&9S7n791RWyFnmJ{D0qHR=DaOu2BUw@d-S_{JT(@Z ziNI9kmvC@`Lj3qPsQ}f7MDU^UBS#`w^VV1p8=*r%e9>F)Aa4ZGXiNrwB_5HOHwKt& z7|&OI6AMp(o|n9xi~(@-z;ft2_#Sv8Ui9zU0N=AQFZop)+jf2^sc||u9aB3fd@~Hu zVD^VJe!pzK>ff*X*XIBL5Z z`T>G7X+N1969im85$>R=s%OvDs8>dJyAOn(Qoh%-69pNly()3O4>%^YXb=@!pS5D| zVis*ww_1wI58l)1cDRmfZ6#fn(1tZ@(HNX5xT+-s-W&sdsFae)@$v@!8=g`a55%O6 z=jguDF6k2qCbe6bEDEL|^6!%6jrL~3*M+6F#v4);Sk_nN{00m&Q*=`aFT32gcXl3d zCr4>+7!D_I8UIp$@QbO55>`63H=#OfmGo|5&wW~B7i9FO22veSK}ff2q(5%$7gjwA z$W;28p-z#eV!~G|D%Wh!=^0X(*!|UdQ@zl&s^%RX+==mC)(zXvPPH>i75gu{HhU|Bf zU@y@^l>|;NKbFUoq6n$?x0H{csh-U-F*BSQ7A@SI(tUV5Z0e==lK$_V!1y?9|H;@C zYc=9`EH8yt6OXMmEaJdmM~Fn>BaGit2`D>by36JErJfgY*l#yHu3%VBKSag0+W5>~ za-K_1vne-|J{0D-sr9O8_2eBK;9EJk0hLsgWxa1%bF=6odbOCQ+5KykW7n~-Q$)~z z!UzcX-{<}R_d)(g(4***^#uIy6Rzw3Hgqcgk92`}-|HCucm4<9b@2lJ_Zq*2>=&B< zF+#Z!4Tc%#TjDA@+ebZ=9DKxL9m=;G>avTlZU>%@r_wu(lbeu%Kw0-j8-s*fO|?~i z@C9yba7OO!N0qlPF1Eg3BmWbvXLh{PSt3AL%E@H;H+b8$ZI{Jx9o7^_C$j00+BvfJ z`5&LJy!L`=9aC)X^|d&X7v4{Qv0v+iXvN=HYuv7wUlMn{Uzq#du$_I%NY@)u8ur;?ABYao(oB0LE7H?LRE(Xc14RDxMCbU zNcDFZOA=zl1Bnug;J1_mIEJKWN)=4%?J4 z)2gz)j&;i{yzW#VH9P9L&&@BPW$maO*95=E$iCUFO$?byS%p5iTNWIuvNljXt7;|D z7hxo>q@xsy26IB?6fN#(K#msrhcfqZ?s3)OfPrk&@G=o0_X}Ez-v%*F3!az^Hw3zz zT32W(eGAqmF2Z`+7h53((<1%*H zk3~w)8me#2*<}Qet_O~2yZ^9`SlcTlGS_awL6xP29X;KQ--}mK?)yG7Pb>e~mcLq5 zo_40RjR91PBNa K5Fqf!Bk(UpdqY70 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f3649b2b02b0089a5519bce2ec6d7d16a74122e3 GIT binary patch literal 10240 zcmeH~dss|q9LGmWDpqb|XSLfHw2f+<%Uml(J+4KmbVbh0oNCfs%$%u+luNEpHSUYu zr?S$n*v(oz<5CeytXo2ArVJI5q>Uo&IoZ(iu=~(HkH>lcd7t-pe&_rC-p~8~&igy8 z(LzKtSFK=0s^l_C_cQ=+TsE6R^mzMc5AZlIpS^ev0uTqF(1Gp=r>m-3g&|5jq^{P_ zW)#N|$)ySw^t!k{oAf*@Qr@p6m= ziwH&3m|Tir5>coM#W0C1T!p_LhD52*MYv0ZT#6zpm4wj7I_5!RMNw)o8j44W2oV(# z>ECfjqe_)TE+b|qLT5oNfF(ww0R|x?L!=#xa>ut^E~URX_w=rQxSK*L$50XWIu2Eo zI-JlH0U{v>7K(|;vH6M)H3g!?BuErtdNn(Qj}Wm~E+bYF632KcAXlOw;rcQb+(7TJ zyn2}dusXo1BnyZc@DUz;R}JV5jSkB@HD)b(+o5KSW{n=}jl@nu5izQKo5zRSI^!U^X zbosin@T-s&zTji8iOBzV-2WiJgDD`J$A&;QhfCA~AOyg8{{Js`@@Jm^zNkp8MEwwD z&)0XoM@Hv}_dESzRYZJ!c3Z(WC51OQA+y|c=30ej;pQB}FpHZQ%e7+9>cvsOx!tio zb|d-IvqlvTqgMsjMDH7R-LS|Cak6=yGou{vO)4G5{;KTHGUhc#j|sLOeXVv%SLp+{ zp3d_0usD=-gXvZ}s`hDSvArd9sR8&U&)hOCbPg0z^hhAPX=35hE(x>n*EA)$o}4UK z2pE}hKbmPTOm{OnXJj6<+*v?Vcoq*1uW{ax%uRuxRR+(~>o*1S7TS%ki{(vj{Mxbh ziG;MW7R!uguC;GCYU$Wglj3U?g;fQ_MX*ME;bEX*Et;Ws^*t}{g{Kb8 z8#*t|aF^D!Z0fkN{OA!Q-F3RGrp8-}^Im$r&3^0L^``<#LTJC$Cure~>)O(X`%r=a zhx#$uZIJ^l4i@d*WVE~h)A)?D?wfA5-ed8}`&#+oQ3nRrCpOPKs!paZyW`ZBBdFM3 z?R+Oavw_;HAI50CSvO9nkvyo|-g4eeA7r=C%$<-yt^_ zP;9G9wT=6ax23tdIX|s4-Zd$?YHMRm%dmM1c+_ar2H+OW4NLTP2LAwdNS zc1^8LZMy3oBfPIE^255;O8+Y-3{Mi7-;KYYE1S}K$10(0ny~G}jCM`xG_9M(mKfiO zWyQwFo3FBfu)NywRx7hNdamtPQexaga@@qut^90W!#QT>5h43em;hP-ecu0nZ>|4e zzJSZ)ll9*xoY((0bP@4K+D&y|XAJ+n{sTZRNY;Oy{JOL6?)py_oi)G6dEJrH#9V#F zgAi*Up-F;|cAhg=navE`^r$gKqIX$+5v!ToKls8lr=-glPv(cCN3+VL>oTs~%e!){ zw`X|)^|08`)~KIh8ad3|&vn2O&?|M;DHE2?Q{d~z8@v5vJI~?Bua5_veIoZ2A*+0(JP>QSH`wF@98U>rYC8b>fO}D+k;k4q?lcrz_znl zmGbZpmq8P*oj!VBy>CY07DOlxO&#JCMIF>|rYSY18yjCjpK@42ZfoKh;esj1PrNkwLk{H)Ba4pg{!?TS2| zrM9A_?93r>r{;;Pdwmskv}vlh%Y{Sz!yMe|9 z{l{(C30%&YQo6tAm`dFaDl_Qv^T!l3CL~@{PRZXAxir3)Qvky!gj0A`)+gzy`Qor* z-_wx|(zAVQqkhh|F=}oWY?Swo*|Bo4Uu8mNCRM0XT^ur{Y%{I!ddL!g%L><+r3;^QahpT_uNx+cxAIqRt!H_OEh^Ph#F2Dp){mt)yA*X#kr??xGu%6Ba(o uj>xg@&SObxlLSZtBmt5DNq{6k5+DhX1V{oT0g?bofFwW?APIaV0)GLnjuo^3 literal 0 HcmV?d00001 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"),