gabriel / muse public

test_security_zeroing.py file-level

at sha256:8 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:b adding issues docs to bust staging mpack prebuild cache. · gabriel · Jun 20, 2026
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 )