"""Tests for Phase 6 — DerivedKey zeroing hardening. Phase 6 invariants ------------------ All derived key material must be zeroed, even on exception paths. Z1 DerivedKey.__del__ zeroes private_bytes and chain_code as a safety net. Z2 DerivedKey.zero() is called inside try/finally in resolve_signing_identity._derive — key material is zeroed even when to_ed25519_private_key raises. Z3 SecretByteArray is a bytearray subclass exported from muse.core.slip010. Z4 SecretByteArray.__del__ zeroes its content. Z5 derive_agent_sub_seed returns SecretByteArray, not a plain bytearray. Z6 derive_hd_public_info zeroes dk even when Ed25519PrivateKey.from_private_bytes raises. Z7 run_register's inline derivation zeroes dk even when to_ed25519_private_key raises. """ from __future__ import annotations import gc import pathlib import pytest from muse.core.slip010 import DerivedKey, derive_path # Fixed mnemonic for deterministic derivation in all tests. _MNEMONIC = ( "abandon abandon abandon abandon abandon abandon abandon abandon " "abandon abandon abandon about" ) _PATH = "m/1075233755'/0'/0'/0'/0'/0'" def _seed() -> bytes: from muse.core.bip39 import mnemonic_to_seed return mnemonic_to_seed(_MNEMONIC) # --------------------------------------------------------------------------- # Z1 — DerivedKey.__del__ auto-zeroes # --------------------------------------------------------------------------- class TestDerivedKeyAutoZero: def test_Z1_del_zeroes_private_bytes(self) -> None: """After the DerivedKey is collected, private_bytes must be all-zero.""" private_ba = bytearray(b"\xab" * 32) chain_ba = bytearray(b"\xcd" * 32) dk = DerivedKey(private_bytes=private_ba, chain_code=chain_ba) # Delete the object — in CPython refcount hits 0 immediately. del dk gc.collect() # The bytearray objects are shared: they should now be zeroed in-place. assert private_ba == bytearray(32), "private_bytes not zeroed by __del__" assert chain_ba == bytearray(32), "chain_code not zeroed by __del__" def test_Z1b_explicit_zero_still_works(self) -> None: """Explicit zero() call remains the primary API — __del__ is just a safety net.""" dk = DerivedKey(private_bytes=bytearray(b"\xff" * 32), chain_code=bytearray(32)) dk.zero() assert dk.private_bytes == bytearray(32) # --------------------------------------------------------------------------- # Z2 — try/finally in resolve_signing_identity._derive # --------------------------------------------------------------------------- class TestResolveSigningIdentityZeroing: def _make_identity_file(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: import muse.core.identity as id_module import muse.core.keypair as kp_module fake_home = tmp_path / "home" fake_home.mkdir(parents=True, exist_ok=True) monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home)) monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys") monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse") monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml") from muse.core.identity import save_identity from muse.core.bip39 import mnemonic_to_seed from muse.core.keypair import derive_hd_public_info seed = mnemonic_to_seed(_MNEMONIC) _, fingerprint = derive_hd_public_info(seed) save_identity("https://localhost:1337", { "type": "human", "handle": "gabriel", "hd_path": _PATH, "algorithm": "ed25519", "fingerprint": fingerprint, }) def test_Z2_dk_zeroed_even_when_materialise_raises( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """If to_ed25519_private_key raises, dk must still be zeroed.""" self._make_identity_file(tmp_path, monkeypatch) _kc = {"mnemonic": _MNEMONIC} monkeypatch.setattr("muse.core.keychain.is_available", lambda: True) monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic")) captured_dk: list[DerivedKey] = [] def _capturing_materialise(dk: DerivedKey) -> None: captured_dk.append(dk) raise RuntimeError("simulated key materialisation failure") monkeypatch.setattr("muse.core.slip010.to_ed25519_private_key", _capturing_materialise) from muse.core.identity import resolve_signing_identity result = resolve_signing_identity("https://localhost:1337") # The call should return None (derivation failed), not raise. assert result is None # The DerivedKey captured before the exception must have been zeroed. assert len(captured_dk) == 1, "to_ed25519_private_key was not called" assert captured_dk[0].private_bytes == bytearray(32), ( "dk.private_bytes not zeroed after to_ed25519_private_key raised" ) # --------------------------------------------------------------------------- # Z3 — SecretByteArray exists and is a bytearray subclass # --------------------------------------------------------------------------- class TestSecretByteArray: def test_Z3_is_bytearray_subclass(self) -> None: from muse.core.slip010 import SecretByteArray sba = SecretByteArray(b"\xab" * 16) assert isinstance(sba, bytearray) assert sba == bytearray(b"\xab" * 16) def test_Z4_del_calls_zero(self) -> None: """__del__ must call zero() — use a tracking subclass to observe the call.""" from muse.core.slip010 import SecretByteArray zero_calls: list[int] = [] class TrackingSBA(SecretByteArray): def zero(self) -> None: zero_calls.append(1) super().zero() sba = TrackingSBA(b"\xab" * 32) del sba gc.collect() assert zero_calls, "SecretByteArray.__del__ did not call zero()" def test_Z4b_explicit_zero_method(self) -> None: from muse.core.slip010 import SecretByteArray sba = SecretByteArray(b"\xff" * 16) sba.zero() assert sba == bytearray(16) # --------------------------------------------------------------------------- # Z5 — derive_agent_sub_seed returns SecretByteArray # --------------------------------------------------------------------------- class TestAgentSubSeedType: def test_Z5_derive_agent_sub_seed_returns_secret_bytearray(self) -> None: from muse.core.slip010 import SecretByteArray from muse.core.hdkeys import derive_agent_sub_seed seed = _seed() sub = derive_agent_sub_seed(seed, domain=0, agent_id=0) assert isinstance(sub, SecretByteArray), ( f"Expected SecretByteArray, got {type(sub).__name__}" ) assert len(sub) == 64 def test_Z5b_sub_seed_zeroes_on_del(self) -> None: """__del__ on the returned SecretByteArray must call zero() — tracking subclass.""" from muse.core.slip010 import SecretByteArray from muse.core.hdkeys import derive_agent_sub_seed zero_calls: list[int] = [] class TrackingSBA(SecretByteArray): def zero(self) -> None: zero_calls.append(1) super().zero() # Patch SecretByteArray in hdkeys so derive_agent_sub_seed returns a tracking instance. import muse.core.hdkeys as hdkeys_mod original_sba = hdkeys_mod.SecretByteArray hdkeys_mod.SecretByteArray = TrackingSBA # type: ignore[attr-defined] try: seed = _seed() sub = derive_agent_sub_seed(seed, domain=0, agent_id=0) del sub gc.collect() finally: hdkeys_mod.SecretByteArray = original_sba # type: ignore[attr-defined] assert zero_calls, "SecretByteArray.__del__ not called for agent sub-seed" # --------------------------------------------------------------------------- # Z6 — derive_hd_public_info zeroes dk on exception # --------------------------------------------------------------------------- class TestDeriveHdPublicInfoZeroing: def test_Z6_dk_zeroed_when_from_private_bytes_raises( self, monkeypatch: pytest.MonkeyPatch ) -> None: """derive_hd_public_info must zero dk even if Ed25519PrivateKey.from_private_bytes raises. Patches the class on the cryptography module so the inline ``from cryptography... import Ed25519PrivateKey`` inside the function picks up the failing version. """ import cryptography.hazmat.primitives.asymmetric.ed25519 as _ed25519_mod captured_private_bytes: list[bytearray] = [] class _FailingKey: @classmethod def from_private_bytes(cls, data: bytes) -> "_FailingKey": # Record the key bytes so we can check they're zeroed after the exception. captured_private_bytes.append(bytearray(data)) raise RuntimeError("simulated from_private_bytes failure") monkeypatch.setattr(_ed25519_mod, "Ed25519PrivateKey", _FailingKey) from muse.core.keypair import derive_hd_public_info from muse.core.bip39 import mnemonic_to_seed seed = mnemonic_to_seed(_MNEMONIC) # Import the DerivedKey used inside derive_hd_public_info so we can inspect it. zeroed_dks: list[DerivedKey] = [] import muse.core.hdkeys as hdkeys_mod original_derive = hdkeys_mod.derive_identity_key def tracking_derive(s: bytes, index: int = 0) -> DerivedKey: dk = original_derive(s, index=index) zeroed_dks.append(dk) return dk monkeypatch.setattr(hdkeys_mod, "derive_identity_key", tracking_derive) with pytest.raises(RuntimeError, match="simulated from_private_bytes failure"): derive_hd_public_info(seed) assert zeroed_dks, "derive_identity_key was not called" assert zeroed_dks[0].private_bytes == bytearray(32), ( "dk.private_bytes not zeroed in derive_hd_public_info on exception" ) # --------------------------------------------------------------------------- # Z7 — run_register inline derivation zeroes dk on exception # --------------------------------------------------------------------------- class TestRunRegisterDerivationZeroing: def test_Z7_dk_zeroed_when_materialise_raises_in_register( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """run_register's inline derivation must zero dk even when to_ed25519_private_key raises.""" import muse.core.identity as id_module import muse.core.keypair as kp_module import muse.core.bip39 as bip39_mod from tests.cli_test_helper import CliRunner fake_home = tmp_path / "home" fake_home.mkdir(parents=True, exist_ok=True) monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home)) monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys") monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse") monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml") monkeypatch.setattr("muse.cli.commands.auth._stderr_isatty", lambda: False) _kc: dict[str, str] = {} monkeypatch.setattr("muse.core.keychain.is_available", lambda: True) monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m)) monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic")) monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _MNEMONIC) runner = CliRunner() result = runner.invoke(None, ["auth", "keygen", "--hub", "https://localhost:1337"]) assert result.exit_code == 0 captured_dk: list[DerivedKey] = [] def _failing(dk: DerivedKey) -> None: captured_dk.append(dk) raise RuntimeError("simulated failure during register derivation") monkeypatch.setattr("muse.core.slip010.to_ed25519_private_key", _failing) result = runner.invoke(None, [ "auth", "register", "--hub", "https://localhost:1337", "--handle", "gabriel" ]) # Should fail gracefully (not crash with an unhandled exception) assert result.exit_code != 0 if captured_dk: assert captured_dk[0].private_bytes == bytearray(32), ( "dk.private_bytes not zeroed in run_register on exception" )