test_security_zeroing.py
file-level
1
files
1
commits
0
hotspots
0
π§ dead
0
π₯ blast risk
| 1 | """Tests for Phase 6 β DerivedKey zeroing hardening. |
| 2 | |
| 3 | Phase 6 invariants |
| 4 | ------------------ |
| 5 | All derived key material must be zeroed, even on exception paths. |
| 6 | |
| 7 | Z1 DerivedKey.__del__ zeroes private_bytes and chain_code as a safety net. |
| 8 | Z2 DerivedKey.zero() is called inside try/finally in resolve_signing_identity._derive |
| 9 | β key material is zeroed even when to_ed25519_private_key raises. |
| 10 | Z3 SecretByteArray is a bytearray subclass exported from muse.core.slip010. |
| 11 | Z4 SecretByteArray.__del__ zeroes its content. |
| 12 | Z5 derive_agent_sub_seed returns SecretByteArray, not a plain bytearray. |
| 13 | Z6 derive_hd_public_info zeroes dk even when Ed25519PrivateKey.from_private_bytes raises. |
| 14 | Z7 run_register's inline derivation zeroes dk even when to_ed25519_private_key raises. |
| 15 | """ |
| 16 | |
| 17 | from __future__ import annotations |
| 18 | |
| 19 | import gc |
| 20 | import pathlib |
| 21 | |
| 22 | import pytest |
| 23 | |
| 24 | from muse.core.slip010 import DerivedKey, derive_path |
| 25 | |
| 26 | |
| 27 | # Fixed mnemonic for deterministic derivation in all tests. |
| 28 | _MNEMONIC = ( |
| 29 | "abandon abandon abandon abandon abandon abandon abandon abandon " |
| 30 | "abandon abandon abandon about" |
| 31 | ) |
| 32 | _PATH = "m/1075233755'/0'/0'/0'/0'/0'" |
| 33 | |
| 34 | |
| 35 | def _seed() -> bytes: |
| 36 | from muse.core.bip39 import mnemonic_to_seed |
| 37 | return mnemonic_to_seed(_MNEMONIC) |
| 38 | |
| 39 | |
| 40 | # --------------------------------------------------------------------------- |
| 41 | # Z1 β DerivedKey.__del__ auto-zeroes |
| 42 | # --------------------------------------------------------------------------- |
| 43 | |
| 44 | |
| 45 | class TestDerivedKeyAutoZero: |
| 46 | def test_Z1_del_zeroes_private_bytes(self) -> None: |
| 47 | """After the DerivedKey is collected, private_bytes must be all-zero.""" |
| 48 | private_ba = bytearray(b"\xab" * 32) |
| 49 | chain_ba = bytearray(b"\xcd" * 32) |
| 50 | |
| 51 | dk = DerivedKey(private_bytes=private_ba, chain_code=chain_ba) |
| 52 | |
| 53 | # Delete the object β in CPython refcount hits 0 immediately. |
| 54 | del dk |
| 55 | gc.collect() |
| 56 | |
| 57 | # The bytearray objects are shared: they should now be zeroed in-place. |
| 58 | assert private_ba == bytearray(32), "private_bytes not zeroed by __del__" |
| 59 | assert chain_ba == bytearray(32), "chain_code not zeroed by __del__" |
| 60 | |
| 61 | def test_Z1b_explicit_zero_still_works(self) -> None: |
| 62 | """Explicit zero() call remains the primary API β __del__ is just a safety net.""" |
| 63 | dk = DerivedKey(private_bytes=bytearray(b"\xff" * 32), chain_code=bytearray(32)) |
| 64 | dk.zero() |
| 65 | assert dk.private_bytes == bytearray(32) |
| 66 | |
| 67 | |
| 68 | # --------------------------------------------------------------------------- |
| 69 | # Z2 β try/finally in resolve_signing_identity._derive |
| 70 | # --------------------------------------------------------------------------- |
| 71 | |
| 72 | |
| 73 | class TestResolveSigningIdentityZeroing: |
| 74 | def _make_identity_file(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: |
| 75 | import muse.core.identity as id_module |
| 76 | import muse.core.keypair as kp_module |
| 77 | |
| 78 | fake_home = tmp_path / "home" |
| 79 | fake_home.mkdir(parents=True, exist_ok=True) |
| 80 | monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home)) |
| 81 | monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys") |
| 82 | monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse") |
| 83 | monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml") |
| 84 | |
| 85 | from muse.core.identity import save_identity |
| 86 | from muse.core.bip39 import mnemonic_to_seed |
| 87 | from muse.core.keypair import derive_hd_public_info |
| 88 | |
| 89 | seed = mnemonic_to_seed(_MNEMONIC) |
| 90 | _, fingerprint = derive_hd_public_info(seed) |
| 91 | save_identity("https://localhost:1337", { |
| 92 | "type": "human", |
| 93 | "handle": "gabriel", |
| 94 | "hd_path": _PATH, |
| 95 | "algorithm": "ed25519", |
| 96 | "fingerprint": fingerprint, |
| 97 | }) |
| 98 | |
| 99 | def test_Z2_dk_zeroed_even_when_materialise_raises( |
| 100 | self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path |
| 101 | ) -> None: |
| 102 | """If to_ed25519_private_key raises, dk must still be zeroed.""" |
| 103 | self._make_identity_file(tmp_path, monkeypatch) |
| 104 | |
| 105 | _kc = {"mnemonic": _MNEMONIC} |
| 106 | monkeypatch.setattr("muse.core.keychain.is_available", lambda: True) |
| 107 | monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic")) |
| 108 | |
| 109 | captured_dk: list[DerivedKey] = [] |
| 110 | |
| 111 | def _capturing_materialise(dk: DerivedKey) -> None: |
| 112 | captured_dk.append(dk) |
| 113 | raise RuntimeError("simulated key materialisation failure") |
| 114 | |
| 115 | monkeypatch.setattr("muse.core.slip010.to_ed25519_private_key", _capturing_materialise) |
| 116 | |
| 117 | from muse.core.identity import resolve_signing_identity |
| 118 | result = resolve_signing_identity("https://localhost:1337") |
| 119 | |
| 120 | # The call should return None (derivation failed), not raise. |
| 121 | assert result is None |
| 122 | # The DerivedKey captured before the exception must have been zeroed. |
| 123 | assert len(captured_dk) == 1, "to_ed25519_private_key was not called" |
| 124 | assert captured_dk[0].private_bytes == bytearray(32), ( |
| 125 | "dk.private_bytes not zeroed after to_ed25519_private_key raised" |
| 126 | ) |
| 127 | |
| 128 | |
| 129 | # --------------------------------------------------------------------------- |
| 130 | # Z3 β SecretByteArray exists and is a bytearray subclass |
| 131 | # --------------------------------------------------------------------------- |
| 132 | |
| 133 | |
| 134 | class TestSecretByteArray: |
| 135 | def test_Z3_is_bytearray_subclass(self) -> None: |
| 136 | from muse.core.slip010 import SecretByteArray |
| 137 | sba = SecretByteArray(b"\xab" * 16) |
| 138 | assert isinstance(sba, bytearray) |
| 139 | assert sba == bytearray(b"\xab" * 16) |
| 140 | |
| 141 | def test_Z4_del_calls_zero(self) -> None: |
| 142 | """__del__ must call zero() β use a tracking subclass to observe the call.""" |
| 143 | from muse.core.slip010 import SecretByteArray |
| 144 | |
| 145 | zero_calls: list[int] = [] |
| 146 | |
| 147 | class TrackingSBA(SecretByteArray): |
| 148 | def zero(self) -> None: |
| 149 | zero_calls.append(1) |
| 150 | super().zero() |
| 151 | |
| 152 | sba = TrackingSBA(b"\xab" * 32) |
| 153 | del sba |
| 154 | gc.collect() |
| 155 | |
| 156 | assert zero_calls, "SecretByteArray.__del__ did not call zero()" |
| 157 | |
| 158 | def test_Z4b_explicit_zero_method(self) -> None: |
| 159 | from muse.core.slip010 import SecretByteArray |
| 160 | sba = SecretByteArray(b"\xff" * 16) |
| 161 | sba.zero() |
| 162 | assert sba == bytearray(16) |
| 163 | |
| 164 | |
| 165 | # --------------------------------------------------------------------------- |
| 166 | # Z5 β derive_agent_sub_seed returns SecretByteArray |
| 167 | # --------------------------------------------------------------------------- |
| 168 | |
| 169 | |
| 170 | class TestAgentSubSeedType: |
| 171 | def test_Z5_derive_agent_sub_seed_returns_secret_bytearray(self) -> None: |
| 172 | from muse.core.slip010 import SecretByteArray |
| 173 | from muse.core.hdkeys import derive_agent_sub_seed |
| 174 | |
| 175 | seed = _seed() |
| 176 | sub = derive_agent_sub_seed(seed, domain=0, agent_id=0) |
| 177 | |
| 178 | assert isinstance(sub, SecretByteArray), ( |
| 179 | f"Expected SecretByteArray, got {type(sub).__name__}" |
| 180 | ) |
| 181 | assert len(sub) == 64 |
| 182 | |
| 183 | def test_Z5b_sub_seed_zeroes_on_del(self) -> None: |
| 184 | """__del__ on the returned SecretByteArray must call zero() β tracking subclass.""" |
| 185 | from muse.core.slip010 import SecretByteArray |
| 186 | from muse.core.hdkeys import derive_agent_sub_seed |
| 187 | |
| 188 | zero_calls: list[int] = [] |
| 189 | |
| 190 | class TrackingSBA(SecretByteArray): |
| 191 | def zero(self) -> None: |
| 192 | zero_calls.append(1) |
| 193 | super().zero() |
| 194 | |
| 195 | # Patch SecretByteArray in hdkeys so derive_agent_sub_seed returns a tracking instance. |
| 196 | import muse.core.hdkeys as hdkeys_mod |
| 197 | original_sba = hdkeys_mod.SecretByteArray |
| 198 | hdkeys_mod.SecretByteArray = TrackingSBA # type: ignore[attr-defined] |
| 199 | try: |
| 200 | seed = _seed() |
| 201 | sub = derive_agent_sub_seed(seed, domain=0, agent_id=0) |
| 202 | del sub |
| 203 | gc.collect() |
| 204 | finally: |
| 205 | hdkeys_mod.SecretByteArray = original_sba # type: ignore[attr-defined] |
| 206 | |
| 207 | assert zero_calls, "SecretByteArray.__del__ not called for agent sub-seed" |
| 208 | |
| 209 | |
| 210 | # --------------------------------------------------------------------------- |
| 211 | # Z6 β derive_hd_public_info zeroes dk on exception |
| 212 | # --------------------------------------------------------------------------- |
| 213 | |
| 214 | |
| 215 | class TestDeriveHdPublicInfoZeroing: |
| 216 | def test_Z6_dk_zeroed_when_from_private_bytes_raises( |
| 217 | self, monkeypatch: pytest.MonkeyPatch |
| 218 | ) -> None: |
| 219 | """derive_hd_public_info must zero dk even if Ed25519PrivateKey.from_private_bytes raises. |
| 220 | |
| 221 | Patches the class on the cryptography module so the inline |
| 222 | ``from cryptography... import Ed25519PrivateKey`` inside the function |
| 223 | picks up the failing version. |
| 224 | """ |
| 225 | import cryptography.hazmat.primitives.asymmetric.ed25519 as _ed25519_mod |
| 226 | |
| 227 | captured_private_bytes: list[bytearray] = [] |
| 228 | |
| 229 | class _FailingKey: |
| 230 | @classmethod |
| 231 | def from_private_bytes(cls, data: bytes) -> "_FailingKey": |
| 232 | # Record the key bytes so we can check they're zeroed after the exception. |
| 233 | captured_private_bytes.append(bytearray(data)) |
| 234 | raise RuntimeError("simulated from_private_bytes failure") |
| 235 | |
| 236 | monkeypatch.setattr(_ed25519_mod, "Ed25519PrivateKey", _FailingKey) |
| 237 | |
| 238 | from muse.core.keypair import derive_hd_public_info |
| 239 | from muse.core.bip39 import mnemonic_to_seed |
| 240 | |
| 241 | seed = mnemonic_to_seed(_MNEMONIC) |
| 242 | |
| 243 | # Import the DerivedKey used inside derive_hd_public_info so we can inspect it. |
| 244 | zeroed_dks: list[DerivedKey] = [] |
| 245 | import muse.core.hdkeys as hdkeys_mod |
| 246 | original_derive = hdkeys_mod.derive_identity_key |
| 247 | |
| 248 | def tracking_derive(s: bytes, index: int = 0) -> DerivedKey: |
| 249 | dk = original_derive(s, index=index) |
| 250 | zeroed_dks.append(dk) |
| 251 | return dk |
| 252 | |
| 253 | monkeypatch.setattr(hdkeys_mod, "derive_identity_key", tracking_derive) |
| 254 | |
| 255 | with pytest.raises(RuntimeError, match="simulated from_private_bytes failure"): |
| 256 | derive_hd_public_info(seed) |
| 257 | |
| 258 | assert zeroed_dks, "derive_identity_key was not called" |
| 259 | assert zeroed_dks[0].private_bytes == bytearray(32), ( |
| 260 | "dk.private_bytes not zeroed in derive_hd_public_info on exception" |
| 261 | ) |
| 262 | |
| 263 | |
| 264 | # --------------------------------------------------------------------------- |
| 265 | # Z7 β run_register inline derivation zeroes dk on exception |
| 266 | # --------------------------------------------------------------------------- |
| 267 | |
| 268 | |
| 269 | class TestRunRegisterDerivationZeroing: |
| 270 | def test_Z7_dk_zeroed_when_materialise_raises_in_register( |
| 271 | self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path |
| 272 | ) -> None: |
| 273 | """run_register's inline derivation must zero dk even when to_ed25519_private_key raises.""" |
| 274 | import muse.core.identity as id_module |
| 275 | import muse.core.keypair as kp_module |
| 276 | import muse.core.bip39 as bip39_mod |
| 277 | from tests.cli_test_helper import CliRunner |
| 278 | |
| 279 | fake_home = tmp_path / "home" |
| 280 | fake_home.mkdir(parents=True, exist_ok=True) |
| 281 | monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home)) |
| 282 | monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys") |
| 283 | monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse") |
| 284 | monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml") |
| 285 | monkeypatch.setattr("muse.cli.commands.auth._stderr_isatty", lambda: False) |
| 286 | |
| 287 | _kc: dict[str, str] = {} |
| 288 | monkeypatch.setattr("muse.core.keychain.is_available", lambda: True) |
| 289 | monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m)) |
| 290 | monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic")) |
| 291 | |
| 292 | monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _MNEMONIC) |
| 293 | runner = CliRunner() |
| 294 | result = runner.invoke(None, ["auth", "keygen", "--hub", "https://localhost:1337"]) |
| 295 | assert result.exit_code == 0 |
| 296 | |
| 297 | captured_dk: list[DerivedKey] = [] |
| 298 | |
| 299 | def _failing(dk: DerivedKey) -> None: |
| 300 | captured_dk.append(dk) |
| 301 | raise RuntimeError("simulated failure during register derivation") |
| 302 | |
| 303 | monkeypatch.setattr("muse.core.slip010.to_ed25519_private_key", _failing) |
| 304 | |
| 305 | result = runner.invoke(None, [ |
| 306 | "auth", "register", "--hub", "https://localhost:1337", "--handle", "gabriel" |
| 307 | ]) |
| 308 | # Should fail gracefully (not crash with an unhandled exception) |
| 309 | assert result.exit_code != 0 |
| 310 | |
| 311 | if captured_dk: |
| 312 | assert captured_dk[0].private_bytes == bytearray(32), ( |
| 313 | "dk.private_bytes not zeroed in run_register on exception" |
| 314 | ) |