gabriel / muse public
test_cmd_sign_hardening.py python
477 lines 18.2 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 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"):
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) -> 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, 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_out=True,
75 )
76 base.update(extra)
77 return argparse.Namespace(**base)
78
79 def test_header_json_has_duration_ms(self) -> None:
80 from muse.cli.commands.sign import run_header
81
82 with patch("muse.cli.commands.sign._load_signing", return_value=self.signing), \
83 patch("sys.stdout", new_callable=io.StringIO) as mock_out:
84 run_header(self._header_args())
85 data = json.loads(mock_out.getvalue())
86 assert "duration_ms" in data, f"'duration_ms' missing: {list(data)}"
87
88 def test_header_json_duration_ms_is_float(self) -> None:
89 from muse.cli.commands.sign import run_header
90
91 with patch("muse.cli.commands.sign._load_signing", return_value=self.signing), \
92 patch("sys.stdout", new_callable=io.StringIO) as mock_out:
93 run_header(self._header_args())
94 data = json.loads(mock_out.getvalue())
95 assert isinstance(data["duration_ms"], float)
96 assert data["duration_ms"] >= 0.0
97
98 def test_header_json_has_exit_code_zero(self) -> None:
99 from muse.cli.commands.sign import run_header
100
101 with patch("muse.cli.commands.sign._load_signing", return_value=self.signing), \
102 patch("sys.stdout", new_callable=io.StringIO) as mock_out:
103 run_header(self._header_args())
104 data = json.loads(mock_out.getvalue())
105 assert "exit_code" in data, f"'exit_code' missing: {list(data)}"
106 assert data["exit_code"] == 0
107
108 # ── verify (valid) ────────────────────────────────────────────────────────
109
110 def _verify_args(self, **extra) -> argparse.Namespace:
111 base = dict(
112 header=self.header_value,
113 method="POST",
114 url=self.url,
115 public_key_b64=self.pub_b64,
116 body=None,
117 body_file=None,
118 max_age=300, # generous window to avoid flaky tests
119 json_out=True,
120 )
121 base.update(extra)
122 return argparse.Namespace(**base)
123
124 def test_verify_valid_json_has_duration_ms(self) -> None:
125 from muse.cli.commands.sign import run_verify
126
127 with patch("sys.stdout", new_callable=io.StringIO) as mock_out:
128 run_verify(self._verify_args())
129 data = json.loads(mock_out.getvalue())
130 assert "duration_ms" in data, f"'duration_ms' missing: {list(data)}"
131
132 def test_verify_valid_json_has_exit_code_zero(self) -> None:
133 from muse.cli.commands.sign import run_verify
134
135 with patch("sys.stdout", new_callable=io.StringIO) as mock_out:
136 run_verify(self._verify_args())
137 data = json.loads(mock_out.getvalue())
138 assert "exit_code" in data, f"'exit_code' missing: {list(data)}"
139 assert data["exit_code"] == 0
140
141 def test_verify_valid_json_duration_ms_is_float(self) -> None:
142 from muse.cli.commands.sign import run_verify
143
144 with patch("sys.stdout", new_callable=io.StringIO) as mock_out:
145 run_verify(self._verify_args())
146 data = json.loads(mock_out.getvalue())
147 assert isinstance(data["duration_ms"], float)
148 assert data["duration_ms"] >= 0.0
149
150 def test_verify_invalid_json_has_duration_ms(self) -> None:
151 """Even failed verification carries the envelope."""
152 from muse.cli.commands.sign import run_verify
153
154 other_signing = _make_signing("impostor")
155 bad_pub_b64 = _public_key_b64(other_signing)
156 args = self._verify_args(public_key_b64=bad_pub_b64)
157 with patch("sys.stdout", new_callable=io.StringIO) as mock_out:
158 try:
159 run_verify(args)
160 except SystemExit:
161 pass
162 data = json.loads(mock_out.getvalue())
163 assert "duration_ms" in data, f"'duration_ms' missing from invalid verify JSON: {list(data)}"
164
165 def test_verify_invalid_json_has_exit_code_nonzero(self) -> None:
166 from muse.cli.commands.sign import run_verify
167
168 other_signing = _make_signing("impostor")
169 bad_pub_b64 = _public_key_b64(other_signing)
170 args = self._verify_args(public_key_b64=bad_pub_b64)
171 with patch("sys.stdout", new_callable=io.StringIO) as mock_out:
172 try:
173 run_verify(args)
174 except SystemExit:
175 pass
176 data = json.loads(mock_out.getvalue())
177 assert "exit_code" in data
178 assert data["exit_code"] != 0
179
180 # ── payment ───────────────────────────────────────────────────────────────
181
182 def _payment_args(self, **extra) -> argparse.Namespace:
183 base = dict(
184 from_handle="gabriel",
185 to_handle="alice",
186 amount=1_000_000,
187 nonce="a" * 64,
188 currency="nanoMUSE",
189 memo="stem:sha256:deadbeef",
190 hub=None,
191 key_path=None,
192 agent_id=None,
193 timestamp=self.ts,
194 json_out=True,
195 )
196 base.update(extra)
197 return argparse.Namespace(**base)
198
199 def test_payment_json_has_duration_ms(self) -> None:
200 from muse.cli.commands.sign import run_payment
201
202 with patch("muse.cli.commands.sign._load_signing", return_value=self.signing), \
203 patch("sys.stdout", new_callable=io.StringIO) as mock_out:
204 run_payment(self._payment_args())
205 data = json.loads(mock_out.getvalue())
206 assert "duration_ms" in data, f"'duration_ms' missing: {list(data)}"
207
208 def test_payment_json_duration_ms_is_float(self) -> None:
209 from muse.cli.commands.sign import run_payment
210
211 with patch("muse.cli.commands.sign._load_signing", return_value=self.signing), \
212 patch("sys.stdout", new_callable=io.StringIO) as mock_out:
213 run_payment(self._payment_args())
214 data = json.loads(mock_out.getvalue())
215 assert isinstance(data["duration_ms"], float)
216 assert data["duration_ms"] >= 0.0
217
218 def test_payment_json_has_exit_code_zero(self) -> None:
219 from muse.cli.commands.sign import run_payment
220
221 with patch("muse.cli.commands.sign._load_signing", return_value=self.signing), \
222 patch("sys.stdout", new_callable=io.StringIO) as mock_out:
223 run_payment(self._payment_args())
224 data = json.loads(mock_out.getvalue())
225 assert "exit_code" in data, f"'exit_code' missing: {list(data)}"
226 assert data["exit_code"] == 0
227
228
229 # ---------------------------------------------------------------------------
230 # TestHeaderJsonSchema — all documented fields present + envelope
231 # ---------------------------------------------------------------------------
232
233
234 class TestHeaderJsonSchema(unittest.TestCase):
235 REQUIRED_KEYS = {
236 "handle", "hub", "method", "path", "signing_ts",
237 "body_sha256", "signature_b64", "header_value",
238 "algorithm", "fingerprint",
239 "duration_ms", "exit_code",
240 }
241
242 def test_all_required_keys_present(self) -> None:
243 from muse.cli.commands.sign import run_header
244
245 signing = _make_signing("gabriel")
246 ts = int(time.time())
247 args = argparse.Namespace(
248 method="POST",
249 path=None,
250 url="https://hub.example.com/gabriel/muse/push",
251 hub="https://hub.example.com",
252 body=None,
253 body_file=None,
254 timestamp=ts,
255 key_path=None,
256 agent_id=None,
257 json_out=True,
258 )
259 with patch("muse.cli.commands.sign._load_signing", return_value=signing), \
260 patch("sys.stdout", new_callable=io.StringIO) as mock_out:
261 run_header(args)
262 data = json.loads(mock_out.getvalue())
263 missing = self.REQUIRED_KEYS - set(data)
264 assert not missing, f"Missing JSON keys: {missing}"
265
266
267 # ---------------------------------------------------------------------------
268 # TestVerifyJsonSchema — valid and invalid paths both have full schema
269 # ---------------------------------------------------------------------------
270
271
272 class TestVerifyJsonSchema(unittest.TestCase):
273 REQUIRED_KEYS = {"valid", "reason", "duration_ms", "exit_code"}
274
275 def setUp(self) -> None:
276 self.signing = _make_signing("gabriel")
277 self.pub_b64 = _public_key_b64(self.signing)
278 self.url = "https://hub.example.com/gabriel/muse/push"
279 self.ts = int(time.time())
280 self.header_value = _make_header(self.signing, "POST", self.url, ts=self.ts)
281
282 def test_valid_has_all_required_keys(self) -> None:
283 from muse.cli.commands.sign import run_verify
284
285 args = argparse.Namespace(
286 header=self.header_value,
287 method="POST",
288 url=self.url,
289 public_key_b64=self.pub_b64,
290 body=None,
291 body_file=None,
292 max_age=300,
293 json_out=True,
294 )
295 with patch("sys.stdout", new_callable=io.StringIO) as mock_out:
296 run_verify(args)
297 data = json.loads(mock_out.getvalue())
298 missing = self.REQUIRED_KEYS - set(data)
299 assert not missing, f"Missing JSON keys: {missing}"
300
301 def test_invalid_has_all_required_keys(self) -> None:
302 from muse.cli.commands.sign import run_verify
303
304 other = _make_signing("impostor")
305 args = argparse.Namespace(
306 header=self.header_value,
307 method="POST",
308 url=self.url,
309 public_key_b64=_public_key_b64(other),
310 body=None,
311 body_file=None,
312 max_age=300,
313 json_out=True,
314 )
315 with patch("sys.stdout", new_callable=io.StringIO) as mock_out:
316 try:
317 run_verify(args)
318 except SystemExit:
319 pass
320 data = json.loads(mock_out.getvalue())
321 missing = self.REQUIRED_KEYS - set(data)
322 assert not missing, f"Missing JSON keys in invalid-verify JSON: {missing}"
323
324
325 # ---------------------------------------------------------------------------
326 # TestPaymentJsonSchema — all documented fields present + envelope
327 # ---------------------------------------------------------------------------
328
329
330 class TestPaymentJsonSchema(unittest.TestCase):
331 REQUIRED_KEYS = {
332 "from_handle", "to_handle", "amount_nano", "currency",
333 "nonce_hex", "memo", "ts", "signature_b64", "canonical_message",
334 "duration_ms", "exit_code",
335 }
336
337 def test_all_required_keys_present(self) -> None:
338 from muse.cli.commands.sign import run_payment
339
340 signing = _make_signing("gabriel")
341 ts = int(time.time())
342 args = argparse.Namespace(
343 from_handle="gabriel",
344 to_handle="alice",
345 amount=1_000_000,
346 nonce="b" * 64,
347 currency="nanoMUSE",
348 memo="",
349 hub=None,
350 key_path=None,
351 agent_id=None,
352 timestamp=ts,
353 json_out=True,
354 )
355 with patch("muse.cli.commands.sign._load_signing", return_value=signing), \
356 patch("sys.stdout", new_callable=io.StringIO) as mock_out:
357 run_payment(args)
358 data = json.loads(mock_out.getvalue())
359 missing = self.REQUIRED_KEYS - set(data)
360 assert not missing, f"Missing JSON keys: {missing}"
361
362
363 # ---------------------------------------------------------------------------
364 # TestPaymentTextMode — text mode must not bleed to stdout
365 # ---------------------------------------------------------------------------
366
367
368 class TestPaymentTextMode(unittest.TestCase):
369 def setUp(self) -> None:
370 self.signing = _make_signing("gabriel")
371
372 def _args(self) -> argparse.Namespace:
373 return argparse.Namespace(
374 from_handle="gabriel",
375 to_handle="alice",
376 amount=1_000_000,
377 nonce="c" * 64,
378 currency="nanoMUSE",
379 memo="",
380 hub=None,
381 key_path=None,
382 agent_id=None,
383 timestamp=int(time.time()),
384 json_out=False,
385 )
386
387 def test_text_mode_stdout_is_empty(self) -> None:
388 """Text mode must not write anything to stdout."""
389 from muse.cli.commands.sign import run_payment
390
391 with patch("muse.cli.commands.sign._load_signing", return_value=self.signing), \
392 patch("sys.stdout", new_callable=io.StringIO) as mock_out:
393 run_payment(self._args())
394 assert mock_out.getvalue() == "", (
395 f"Text mode should not write to stdout, got: {mock_out.getvalue()!r}"
396 )
397
398 def test_text_mode_stderr_has_content(self) -> None:
399 """Text mode must write payment info to stderr."""
400 from muse.cli.commands.sign import run_payment
401
402 with patch("muse.cli.commands.sign._load_signing", return_value=self.signing), \
403 patch("sys.stderr", new_callable=io.StringIO) as mock_err:
404 run_payment(self._args())
405 assert mock_err.getvalue().strip(), "Text mode must write to stderr"
406
407
408 # ---------------------------------------------------------------------------
409 # TestAlgorithmDowngradeProtection — canonical_message binds the algorithm
410 # ---------------------------------------------------------------------------
411
412
413 class TestAlgorithmDowngradeProtection(unittest.TestCase):
414 """``canonical_message()`` must include the algorithm as the first line.
415
416 This guards against downgrade attacks: a server using a weaker algorithm
417 cannot accept a signature computed under a stronger one.
418 """
419
420 def test_algorithm_is_first_line(self) -> None:
421 from muse.core.msign import canonical_message
422
423 msg = canonical_message("POST", "/path", 1744000000, b"", host="example.com")
424 first_line = msg.decode().split("\n")[0]
425 assert first_line == "ed25519", (
426 f"First line of canonical_message must be the algorithm, got: {first_line!r}"
427 )
428
429 def test_custom_algorithm_is_bound(self) -> None:
430 from muse.core.msign import canonical_message
431
432 msg = canonical_message(
433 "POST", "/path", 1744000000, b"", host="example.com", algorithm="ed448"
434 )
435 first_line = msg.decode().split("\n")[0]
436 assert first_line == "ed448"
437
438 def test_different_algorithms_produce_different_messages(self) -> None:
439 from muse.core.msign import canonical_message
440
441 msg_25519 = canonical_message("GET", "/x", 1, b"", host="h.io", algorithm="ed25519")
442 msg_448 = canonical_message("GET", "/x", 1, b"", host="h.io", algorithm="ed448")
443 assert msg_25519 != msg_448
444
445 def test_host_is_in_canonical_message(self) -> None:
446 """Host must appear in signed bytes so signature is host-bound."""
447 from muse.core.msign import canonical_message
448
449 msg_prod = canonical_message("GET", "/", 1, b"", host="musehub.ai")
450 msg_staging = canonical_message("GET", "/", 1, b"", host="staging.musehub.ai")
451 assert msg_prod != msg_staging
452
453
454 class TestRegisterFlags(unittest.TestCase):
455 def _parser(self):
456 import argparse
457 from muse.cli.commands.sign import register
458 p = argparse.ArgumentParser()
459 subs = p.add_subparsers()
460 register(subs)
461 return p
462
463 def test_default_json_out_is_false(self):
464 args = self._parser().parse_args(["sign", "header", "--path", "/test"])
465 assert args.json_out is False
466
467 def test_json_flag_sets_json_out(self):
468 args = self._parser().parse_args(["sign", "header", "--path", "/test", "--json"])
469 assert args.json_out is True
470
471 def test_j_shorthand_sets_json_out(self):
472 args = self._parser().parse_args(["sign", "header", "--path", "/test", "-j"])
473 assert args.json_out is True
474
475
476 if __name__ == "__main__":
477 unittest.main()
File History 2 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 137 days ago