gabriel / muse public
test_cmd_sign_hardening.py python
463 lines 17.7 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
1 """Hardening tests for ``muse sign``.
2
3 Gaps closed
4 -----------
5 1. ``duration_ms`` + ``exit_code`` absent from ALL JSON output paths
6 (``header``, ``verify``, ``payment``).
7 2. ``_emit()`` is a dead stub — text-mode branch does nothing; stub removed.
8 3. ``run_verify`` valid/invalid JSON must carry the envelope.
9 4. ``run_payment`` text mode prints 6 lines to stderr then sig to stdout —
10 inconsistent; text mode should use stderr only.
11 5. Module docstring JSON schemas missing ``duration_ms`` / ``exit_code``.
12 """
13
14 from __future__ import annotations
15
16 import argparse
17 import base64
18 import io
19 import json
20 import time
21 import unittest
22 from unittest.mock import patch
23
24
25 # ---------------------------------------------------------------------------
26 # Shared helpers (mirrors test_cmd_sign.py pattern)
27 # ---------------------------------------------------------------------------
28
29
30 def _make_signing(handle: str = "gabriel") -> object:
31 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
32 from muse.core.transport import SigningIdentity
33
34 return SigningIdentity(handle=handle, private_key=Ed25519PrivateKey.generate())
35
36
37 def _public_key_b64(signing: object) -> str:
38 pub_raw = signing.private_key.public_key().public_bytes_raw() # type: ignore[attr-defined]
39 return base64.urlsafe_b64encode(pub_raw).rstrip(b"=").decode("ascii")
40
41
42 def _make_header(signing: object, method: str, url: str,
43 body: bytes = b"", ts: int | None = None) -> str:
44 from muse.core.msign import build_msign_header
45 return build_msign_header(signing, method, url, body, ts=ts or int(time.time()))
46
47
48 # ---------------------------------------------------------------------------
49 # TestElapsedAndExitCode — every JSON output path must carry the envelope
50 # ---------------------------------------------------------------------------
51
52
53 class TestElapsedAndExitCode(unittest.TestCase):
54 def setUp(self) -> None:
55 self.signing = _make_signing("gabriel")
56 self.pub_b64 = _public_key_b64(self.signing)
57 self.url = "https://hub.example.com/gabriel/muse/push"
58 self.ts = int(time.time())
59 self.header_value = _make_header(self.signing, "POST", self.url, ts=self.ts)
60
61 # ── header ────────────────────────────────────────────────────────────────
62
63 def _header_args(self, **extra) -> argparse.Namespace:
64 base = dict(
65 method="POST",
66 path=None,
67 url=self.url,
68 hub=None,
69 body=None,
70 body_file=None,
71 timestamp=self.ts,
72 key_path=None,
73 agent_id=None,
74 json=True,
75 format="json",
76 )
77 base.update(extra)
78 return argparse.Namespace(**base)
79
80 def test_header_json_has_duration_ms(self) -> None:
81 from muse.cli.commands.sign import run_header
82
83 with patch("muse.cli.commands.sign._load_signing", return_value=self.signing), \
84 patch("sys.stdout", new_callable=io.StringIO) as mock_out:
85 run_header(self._header_args())
86 data = json.loads(mock_out.getvalue())
87 assert "duration_ms" in data, f"'duration_ms' missing: {list(data)}"
88
89 def test_header_json_duration_ms_is_float(self) -> None:
90 from muse.cli.commands.sign import run_header
91
92 with patch("muse.cli.commands.sign._load_signing", return_value=self.signing), \
93 patch("sys.stdout", new_callable=io.StringIO) as mock_out:
94 run_header(self._header_args())
95 data = json.loads(mock_out.getvalue())
96 assert isinstance(data["duration_ms"], float)
97 assert data["duration_ms"] >= 0.0
98
99 def test_header_json_has_exit_code_zero(self) -> None:
100 from muse.cli.commands.sign import run_header
101
102 with patch("muse.cli.commands.sign._load_signing", return_value=self.signing), \
103 patch("sys.stdout", new_callable=io.StringIO) as mock_out:
104 run_header(self._header_args())
105 data = json.loads(mock_out.getvalue())
106 assert "exit_code" in data, f"'exit_code' missing: {list(data)}"
107 assert data["exit_code"] == 0
108
109 # ── verify (valid) ────────────────────────────────────────────────────────
110
111 def _verify_args(self, **extra) -> argparse.Namespace:
112 base = dict(
113 header=self.header_value,
114 method="POST",
115 url=self.url,
116 public_key_b64=self.pub_b64,
117 body=None,
118 body_file=None,
119 max_age=300, # generous window to avoid flaky tests
120 json=True,
121 format="json",
122 )
123 base.update(extra)
124 return argparse.Namespace(**base)
125
126 def test_verify_valid_json_has_duration_ms(self) -> None:
127 from muse.cli.commands.sign import run_verify
128
129 with patch("sys.stdout", new_callable=io.StringIO) as mock_out:
130 run_verify(self._verify_args())
131 data = json.loads(mock_out.getvalue())
132 assert "duration_ms" in data, f"'duration_ms' missing: {list(data)}"
133
134 def test_verify_valid_json_has_exit_code_zero(self) -> None:
135 from muse.cli.commands.sign import run_verify
136
137 with patch("sys.stdout", new_callable=io.StringIO) as mock_out:
138 run_verify(self._verify_args())
139 data = json.loads(mock_out.getvalue())
140 assert "exit_code" in data, f"'exit_code' missing: {list(data)}"
141 assert data["exit_code"] == 0
142
143 def test_verify_valid_json_duration_ms_is_float(self) -> None:
144 from muse.cli.commands.sign import run_verify
145
146 with patch("sys.stdout", new_callable=io.StringIO) as mock_out:
147 run_verify(self._verify_args())
148 data = json.loads(mock_out.getvalue())
149 assert isinstance(data["duration_ms"], float)
150 assert data["duration_ms"] >= 0.0
151
152 def test_verify_invalid_json_has_duration_ms(self) -> None:
153 """Even failed verification carries the envelope."""
154 from muse.cli.commands.sign import run_verify
155
156 other_signing = _make_signing("impostor")
157 bad_pub_b64 = _public_key_b64(other_signing)
158 args = self._verify_args(public_key_b64=bad_pub_b64)
159 with patch("sys.stdout", new_callable=io.StringIO) as mock_out:
160 try:
161 run_verify(args)
162 except SystemExit:
163 pass
164 data = json.loads(mock_out.getvalue())
165 assert "duration_ms" in data, f"'duration_ms' missing from invalid verify JSON: {list(data)}"
166
167 def test_verify_invalid_json_has_exit_code_nonzero(self) -> None:
168 from muse.cli.commands.sign import run_verify
169
170 other_signing = _make_signing("impostor")
171 bad_pub_b64 = _public_key_b64(other_signing)
172 args = self._verify_args(public_key_b64=bad_pub_b64)
173 with patch("sys.stdout", new_callable=io.StringIO) as mock_out:
174 try:
175 run_verify(args)
176 except SystemExit:
177 pass
178 data = json.loads(mock_out.getvalue())
179 assert "exit_code" in data
180 assert data["exit_code"] != 0
181
182 # ── payment ───────────────────────────────────────────────────────────────
183
184 def _payment_args(self, **extra) -> argparse.Namespace:
185 base = dict(
186 from_handle="gabriel",
187 to_handle="alice",
188 amount=1_000_000,
189 nonce="a" * 64,
190 currency="nanoMUSE",
191 memo="stem:sha256:deadbeef",
192 hub=None,
193 key_path=None,
194 agent_id=None,
195 timestamp=self.ts,
196 json=True,
197 format="json",
198 )
199 base.update(extra)
200 return argparse.Namespace(**base)
201
202 def test_payment_json_has_duration_ms(self) -> None:
203 from muse.cli.commands.sign import run_payment
204
205 with patch("muse.cli.commands.sign._load_signing", return_value=self.signing), \
206 patch("sys.stdout", new_callable=io.StringIO) as mock_out:
207 run_payment(self._payment_args())
208 data = json.loads(mock_out.getvalue())
209 assert "duration_ms" in data, f"'duration_ms' missing: {list(data)}"
210
211 def test_payment_json_duration_ms_is_float(self) -> None:
212 from muse.cli.commands.sign import run_payment
213
214 with patch("muse.cli.commands.sign._load_signing", return_value=self.signing), \
215 patch("sys.stdout", new_callable=io.StringIO) as mock_out:
216 run_payment(self._payment_args())
217 data = json.loads(mock_out.getvalue())
218 assert isinstance(data["duration_ms"], float)
219 assert data["duration_ms"] >= 0.0
220
221 def test_payment_json_has_exit_code_zero(self) -> None:
222 from muse.cli.commands.sign import run_payment
223
224 with patch("muse.cli.commands.sign._load_signing", return_value=self.signing), \
225 patch("sys.stdout", new_callable=io.StringIO) as mock_out:
226 run_payment(self._payment_args())
227 data = json.loads(mock_out.getvalue())
228 assert "exit_code" in data, f"'exit_code' missing: {list(data)}"
229 assert data["exit_code"] == 0
230
231
232 # ---------------------------------------------------------------------------
233 # TestHeaderJsonSchema — all documented fields present + envelope
234 # ---------------------------------------------------------------------------
235
236
237 class TestHeaderJsonSchema(unittest.TestCase):
238 REQUIRED_KEYS = {
239 "handle", "hub", "method", "path", "timestamp",
240 "body_sha256", "signature_b64", "header_value",
241 "algorithm", "fingerprint",
242 "duration_ms", "exit_code",
243 }
244
245 def test_all_required_keys_present(self) -> None:
246 from muse.cli.commands.sign import run_header
247
248 signing = _make_signing("gabriel")
249 ts = int(time.time())
250 args = argparse.Namespace(
251 method="POST",
252 path=None,
253 url="https://hub.example.com/gabriel/muse/push",
254 hub="https://hub.example.com",
255 body=None,
256 body_file=None,
257 timestamp=ts,
258 key_path=None,
259 agent_id=None,
260 json=True,
261 format="json",
262 )
263 with patch("muse.cli.commands.sign._load_signing", return_value=signing), \
264 patch("sys.stdout", new_callable=io.StringIO) as mock_out:
265 run_header(args)
266 data = json.loads(mock_out.getvalue())
267 missing = self.REQUIRED_KEYS - set(data)
268 assert not missing, f"Missing JSON keys: {missing}"
269
270
271 # ---------------------------------------------------------------------------
272 # TestVerifyJsonSchema — valid and invalid paths both have full schema
273 # ---------------------------------------------------------------------------
274
275
276 class TestVerifyJsonSchema(unittest.TestCase):
277 REQUIRED_KEYS = {"valid", "reason", "duration_ms", "exit_code"}
278
279 def setUp(self) -> None:
280 self.signing = _make_signing("gabriel")
281 self.pub_b64 = _public_key_b64(self.signing)
282 self.url = "https://hub.example.com/gabriel/muse/push"
283 self.ts = int(time.time())
284 self.header_value = _make_header(self.signing, "POST", self.url, ts=self.ts)
285
286 def test_valid_has_all_required_keys(self) -> None:
287 from muse.cli.commands.sign import run_verify
288
289 args = argparse.Namespace(
290 header=self.header_value,
291 method="POST",
292 url=self.url,
293 public_key_b64=self.pub_b64,
294 body=None,
295 body_file=None,
296 max_age=300,
297 json=True,
298 format="json",
299 )
300 with patch("sys.stdout", new_callable=io.StringIO) as mock_out:
301 run_verify(args)
302 data = json.loads(mock_out.getvalue())
303 missing = self.REQUIRED_KEYS - set(data)
304 assert not missing, f"Missing JSON keys: {missing}"
305
306 def test_invalid_has_all_required_keys(self) -> None:
307 from muse.cli.commands.sign import run_verify
308
309 other = _make_signing("impostor")
310 args = argparse.Namespace(
311 header=self.header_value,
312 method="POST",
313 url=self.url,
314 public_key_b64=_public_key_b64(other),
315 body=None,
316 body_file=None,
317 max_age=300,
318 json=True,
319 format="json",
320 )
321 with patch("sys.stdout", new_callable=io.StringIO) as mock_out:
322 try:
323 run_verify(args)
324 except SystemExit:
325 pass
326 data = json.loads(mock_out.getvalue())
327 missing = self.REQUIRED_KEYS - set(data)
328 assert not missing, f"Missing JSON keys in invalid-verify JSON: {missing}"
329
330
331 # ---------------------------------------------------------------------------
332 # TestPaymentJsonSchema — all documented fields present + envelope
333 # ---------------------------------------------------------------------------
334
335
336 class TestPaymentJsonSchema(unittest.TestCase):
337 REQUIRED_KEYS = {
338 "from_handle", "to_handle", "amount_nano", "currency",
339 "nonce_hex", "memo", "ts", "signature_b64", "canonical_message",
340 "duration_ms", "exit_code",
341 }
342
343 def test_all_required_keys_present(self) -> None:
344 from muse.cli.commands.sign import run_payment
345
346 signing = _make_signing("gabriel")
347 ts = int(time.time())
348 args = argparse.Namespace(
349 from_handle="gabriel",
350 to_handle="alice",
351 amount=1_000_000,
352 nonce="b" * 64,
353 currency="nanoMUSE",
354 memo="",
355 hub=None,
356 key_path=None,
357 agent_id=None,
358 timestamp=ts,
359 json=True,
360 format="json",
361 )
362 with patch("muse.cli.commands.sign._load_signing", return_value=signing), \
363 patch("sys.stdout", new_callable=io.StringIO) as mock_out:
364 run_payment(args)
365 data = json.loads(mock_out.getvalue())
366 missing = self.REQUIRED_KEYS - set(data)
367 assert not missing, f"Missing JSON keys: {missing}"
368
369
370 # ---------------------------------------------------------------------------
371 # TestPaymentTextMode — text mode must not bleed to stdout
372 # ---------------------------------------------------------------------------
373
374
375 class TestPaymentTextMode(unittest.TestCase):
376 def setUp(self) -> None:
377 self.signing = _make_signing("gabriel")
378
379 def _args(self) -> argparse.Namespace:
380 return argparse.Namespace(
381 from_handle="gabriel",
382 to_handle="alice",
383 amount=1_000_000,
384 nonce="c" * 64,
385 currency="nanoMUSE",
386 memo="",
387 hub=None,
388 key_path=None,
389 agent_id=None,
390 timestamp=int(time.time()),
391 json=False,
392 format="text",
393 )
394
395 def test_text_mode_stdout_is_empty(self) -> None:
396 """Text mode must not write anything to stdout."""
397 from muse.cli.commands.sign import run_payment
398
399 with patch("muse.cli.commands.sign._load_signing", return_value=self.signing), \
400 patch("sys.stdout", new_callable=io.StringIO) as mock_out:
401 run_payment(self._args())
402 assert mock_out.getvalue() == "", (
403 f"Text mode should not write to stdout, got: {mock_out.getvalue()!r}"
404 )
405
406 def test_text_mode_stderr_has_content(self) -> None:
407 """Text mode must write payment info to stderr."""
408 from muse.cli.commands.sign import run_payment
409
410 with patch("muse.cli.commands.sign._load_signing", return_value=self.signing), \
411 patch("sys.stderr", new_callable=io.StringIO) as mock_err:
412 run_payment(self._args())
413 assert mock_err.getvalue().strip(), "Text mode must write to stderr"
414
415
416 # ---------------------------------------------------------------------------
417 # TestAlgorithmDowngradeProtection — canonical_message binds the algorithm
418 # ---------------------------------------------------------------------------
419
420
421 class TestAlgorithmDowngradeProtection(unittest.TestCase):
422 """``canonical_message()`` must include the algorithm as the first line.
423
424 This guards against downgrade attacks: a server using a weaker algorithm
425 cannot accept a signature computed under a stronger one.
426 """
427
428 def test_algorithm_is_first_line(self) -> None:
429 from muse.core.msign import canonical_message
430
431 msg = canonical_message("POST", "/path", 1744000000, b"", host="example.com")
432 first_line = msg.decode().split("\n")[0]
433 assert first_line == "ed25519", (
434 f"First line of canonical_message must be the algorithm, got: {first_line!r}"
435 )
436
437 def test_custom_algorithm_is_bound(self) -> None:
438 from muse.core.msign import canonical_message
439
440 msg = canonical_message(
441 "POST", "/path", 1744000000, b"", host="example.com", algorithm="ed448"
442 )
443 first_line = msg.decode().split("\n")[0]
444 assert first_line == "ed448"
445
446 def test_different_algorithms_produce_different_messages(self) -> None:
447 from muse.core.msign import canonical_message
448
449 msg_25519 = canonical_message("GET", "/x", 1, b"", host="h.io", algorithm="ed25519")
450 msg_448 = canonical_message("GET", "/x", 1, b"", host="h.io", algorithm="ed448")
451 assert msg_25519 != msg_448
452
453 def test_host_is_in_canonical_message(self) -> None:
454 """Host must appear in signed bytes so signature is host-bound."""
455 from muse.core.msign import canonical_message
456
457 msg_prod = canonical_message("GET", "/", 1, b"", host="musehub.ai")
458 msg_staging = canonical_message("GET", "/", 1, b"", host="staging.musehub.ai")
459 assert msg_prod != msg_staging
460
461
462 if __name__ == "__main__":
463 unittest.main()
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago