gabriel / muse public
test_security_agent_impersonation.py python
982 lines 39.0 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 137 days ago
1 """Agent impersonation security tests — Ed25519 provenance signing.
2
3 Attack surface
4 --------------
5 Commit records in Muse carry identity fields that are NEVER validated against
6 the authenticated user: ``author``, ``agent_id``, ``model_id``, etc.
7 Any caller with access to the CLI can write commits claiming to be authored by
8 anyone — human, agent, or a previously-trusted identity.
9
10 Signing model
11 -------------
12 Commits signed with ``--sign`` use Ed25519 (same keypair as MSign request
13 authentication). The ``signer_public_key`` field embeds the signer's raw
14 public key bytes (base64url, 43 chars) so verification is fully offline.
15
16 The signed input is ``provenance_payload(commit_id, author, agent_id, ...)``
17 — a SHA-256 digest that binds content identity to authorship claims. Any
18 mutation of author, agent_id, model_id, toolchain_id, or prompt_hash after
19 signing is detected by ``run_verify``.
20 """
21
22 from __future__ import annotations
23
24 import base64
25 import datetime
26 import json
27 import pathlib
28
29 import pytest
30 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
31 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
32
33 from muse.core.store import commit_path, snapshot_path
34 from muse.core.provenance import (
35 encode_public_key,
36 provenance_payload,
37 sign_commit_ed25519,
38 sign_commit_record,
39 verify_commit_ed25519,
40 )
41 from muse.core.snapshot import compute_commit_id
42 from muse.core.validation import sanitize_provenance
43 from muse.core.verify import VerifyResult, run_verify
44 from muse.core._types import Manifest, MsgpackDict, blob_id, decode_pubkey, encode_sig, fake_id, public_key_fingerprint
45
46
47 def _signer_key_id(pub_bytes: bytes) -> str:
48 return public_key_fingerprint(pub_bytes)
49
50
51 # ---------------------------------------------------------------------------
52 # Helpers
53 # ---------------------------------------------------------------------------
54
55 def _gen_key() -> Ed25519PrivateKey:
56 return Ed25519PrivateKey.generate()
57
58
59 def _pub_bytes(key: Ed25519PrivateKey) -> bytes:
60 return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
61
62
63 def _pub_b64(key: Ed25519PrivateKey) -> str:
64 _, b64 = encode_public_key(key)
65 return b64
66
67
68 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
69 """Create a minimal .muse/ repository skeleton."""
70 muse = tmp_path / ".muse"
71 for d in ("objects", "commits", "snapshots", "refs/heads"):
72 (muse / d).mkdir(parents=True, exist_ok=True)
73 (muse / "repo.json").write_text('{"repo_id": "test-repo"}', encoding="utf-8")
74 (muse / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
75 return tmp_path
76
77
78 _COMMITTED_AT = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
79
80
81 def _make_real_commit_id(
82 snapshot_id: str = "a" * 64,
83 parent: str | None = None,
84 message: str = "test",
85 author: str = "",
86 signer_public_key: str = "",
87 ) -> str:
88 """Return the canonical commit_id matching the fixed 2026-01-01 timestamp."""
89 parents = [parent] if parent else []
90 return compute_commit_id(
91 repo_id="test-repo",
92 parent_ids=parents,
93 snapshot_id=snapshot_id,
94 message=message,
95 committed_at_iso=_COMMITTED_AT.isoformat(),
96 author=author,
97 signer_public_key=signer_public_key,
98 )
99
100
101 def _v7_sig(
102 commit_id: str,
103 key: Ed25519PrivateKey,
104 *,
105 author: str = "",
106 agent_id: str = "",
107 model_id: str = "",
108 toolchain_id: str = "",
109 prompt_hash: str = "",
110 committed_at: str = _COMMITTED_AT.isoformat(),
111 ) -> str:
112 """Compute a format_version 7 Ed25519 signature (over provenance_payload)."""
113 payload = provenance_payload(
114 commit_id,
115 author=author,
116 agent_id=agent_id,
117 model_id=model_id,
118 toolchain_id=toolchain_id,
119 prompt_hash=prompt_hash,
120 committed_at=committed_at,
121 )
122 return sign_commit_ed25519(payload, key)
123
124
125 def _write_commit(
126 root: pathlib.Path,
127 commit_id: str,
128 *,
129 snapshot_id: str = "a" * 64,
130 parent: str | None = None,
131 message: str = "test",
132 author: str = "",
133 agent_id: str = "",
134 model_id: str = "",
135 toolchain_id: str = "",
136 prompt_hash: str = "",
137 signature: str = "",
138 signer_public_key: str = "",
139 signer_key_id: str = "",
140 ) -> None:
141 """Write a content-hash-valid commit record to .muse/commits/<commit_id>.msgpack.
142
143 The ``commit_id`` MUST equal ``_make_real_commit_id(snapshot_id, parent, message)``
144 for the record to pass the store's content-hash verification.
145 """
146 import msgpack
147
148 record: MsgpackDict = {
149 "commit_id": commit_id,
150 "repo_id": "test-repo",
151 "created_on_branch": "main",
152 "snapshot_id": snapshot_id,
153 "message": message,
154 "committed_at": _COMMITTED_AT.isoformat(),
155 "parent_commit_id": parent,
156 "parent2_commit_id": None,
157 "author": author,
158 "metadata": {},
159 "structured_delta": None,
160 "sem_ver_bump": "none",
161 "breaking_changes": [],
162 "agent_id": agent_id,
163 "model_id": model_id,
164 "toolchain_id": toolchain_id,
165 "prompt_hash": prompt_hash,
166 "signature": signature,
167 "signer_public_key": signer_public_key,
168 "signer_key_id": signer_key_id,
169 "reviewed_by": [],
170 "test_runs": 0,
171 }
172 path = commit_path(root, commit_id)
173 path.parent.mkdir(parents=True, exist_ok=True)
174 path.write_bytes(msgpack.packb(record))
175
176
177 _EMPTY_SNAP_ID = blob_id(b"")
178
179
180 def _write_snapshot(root: pathlib.Path, snapshot_id: str) -> None:
181 """Write a minimal snapshot record."""
182 import msgpack
183
184 snap_path = snapshot_path(root, snapshot_id)
185 snap_path.parent.mkdir(parents=True, exist_ok=True)
186 snap_path.write_bytes(
187 msgpack.packb({
188 "snapshot_id": snapshot_id,
189 "manifest": {},
190 "created_at": "2026-01-01T00:00:00+00:00",
191 })
192 )
193
194
195 def _set_branch_ref(root: pathlib.Path, branch: str, commit_id: str) -> None:
196 ref_path = root / ".muse" / "refs" / "heads" / branch
197 ref_path.parent.mkdir(parents=True, exist_ok=True)
198 ref_path.write_text(commit_id, encoding="utf-8")
199
200
201 def _fake_commit_id(seed: str = "a") -> str:
202 return fake_id(seed)
203
204
205 # ===========================================================================
206 # Author field sanitization
207 # ===========================================================================
208
209
210 class TestAuthorSanitization:
211 """sanitize_provenance must strip control chars from the author field."""
212
213 def test_clean_author_unchanged(self) -> None:
214 assert sanitize_provenance("gabriel") == "gabriel"
215
216 def test_esc_in_author_stripped(self) -> None:
217 raw = "\x1b[31mfake-human\x1b[0m"
218 clean = sanitize_provenance(raw)
219 assert "\x1b" not in clean
220 assert "fake-human" in clean
221
222 def test_newline_in_author_stripped(self) -> None:
223 raw = "gabriel\[email protected]"
224 clean = sanitize_provenance(raw)
225 assert "\n" not in clean
226
227 def test_cr_in_author_stripped(self) -> None:
228 raw = "gabriel\r\nlinus"
229 clean = sanitize_provenance(raw)
230 assert "\r" not in clean
231
232 def test_bel_in_author_stripped(self) -> None:
233 raw = "gabriel\x07linus"
234 clean = sanitize_provenance(raw)
235 assert "\x07" not in clean
236
237 @pytest.mark.parametrize("char_val", range(0x00, 0x20))
238 def test_c0_control_chars_stripped(self, char_val: int) -> None:
239 char = chr(char_val)
240 raw = f"prefix{char}suffix"
241 result = sanitize_provenance(raw)
242 assert char not in result
243
244 def test_del_stripped(self) -> None:
245 assert "\x7f" not in sanitize_provenance("a\x7fb")
246
247 def test_author_cap_at_256_chars(self) -> None:
248 long_author = "a" * 300
249 stored = sanitize_provenance(long_author[:256])
250 assert len(stored) == 256
251
252 def test_unicode_author_preserved(self) -> None:
253 raw = "gabriel (加布里埃尔)"
254 assert sanitize_provenance(raw) == raw
255
256 def test_email_style_author_preserved(self) -> None:
257 raw = "Gabriel <[email protected]>"
258 assert sanitize_provenance(raw) == raw
259
260
261 # ===========================================================================
262 # commit_id includes author — v2 formula binds identity into the hash
263 # ===========================================================================
264
265
266 class TestAuthorInCommitId:
267 """author IS part of commit_id in the v2 formula — by design.
268
269 Commit identity is content-addressed over (repo_id, snapshot, message,
270 parents, timestamp, author, signer_public_key). Including author prevents
271 key-swap and author-spoofing attacks without requiring a separate signing
272 step. The Ed25519 signing scheme additionally covers provenance fields
273 via provenance_payload, making post-sign mutation detectable.
274 """
275
276 def test_different_authors_produce_different_commit_ids(self) -> None:
277 """Two commits differing only in author produce different commit_ids."""
278 iso = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc).isoformat()
279 snap = fake_id("snap")
280 id_gabriel = compute_commit_id(repo_id="r", parent_ids=[], snapshot_id=snap, message="msg", committed_at_iso=iso, author="gabriel")
281 id_linus = compute_commit_id(repo_id="r", parent_ids=[], snapshot_id=snap, message="msg", committed_at_iso=iso, author="linus")
282 assert id_gabriel != id_linus
283
284 def test_v7_author_mutation_detected_via_payload(self) -> None:
285 """Ed25519 (v7): mutating author changes the provenance payload → sig fails."""
286 key = _gen_key()
287 commit_id = _fake_commit_id("v7-author-mutation")
288
289 original_payload = provenance_payload(commit_id, author="gabriel", agent_id="cursor-bot")
290 sig = sign_commit_ed25519(original_payload, key)
291 pub = _pub_bytes(key)
292
293 # Original payload verifies.
294 assert verify_commit_ed25519(original_payload, sig, pub)
295
296 # Mutated author → different payload → fails.
297 mutated_payload = provenance_payload(commit_id, author="[email protected]", agent_id="cursor-bot")
298 assert not verify_commit_ed25519(mutated_payload, sig, pub)
299
300 def test_forged_signature_fails_verification(self) -> None:
301 """A fabricated signature that was never produced by the key fails."""
302 key = _gen_key()
303 payload = provenance_payload(_fake_commit_id("real-commit"))
304 forged_sig = encode_sig("ed25519", b"\x00" * 64) # correct format, wrong bytes
305
306 assert not verify_commit_ed25519(payload, forged_sig, _pub_bytes(key))
307
308 def test_wrong_key_fails_verification(self) -> None:
309 """A signature produced by one key does not validate against another."""
310 key1 = _gen_key()
311 key2 = _gen_key()
312 payload = provenance_payload(_fake_commit_id("commit"))
313 sig = sign_commit_ed25519(payload, key1)
314
315 assert not verify_commit_ed25519(payload, sig, _pub_bytes(key2))
316
317 def test_empty_signature_is_falsy(self) -> None:
318 """Empty signature is the 'unsigned' sentinel."""
319 key = _gen_key()
320 assert not verify_commit_ed25519(provenance_payload("commit_id"), "", _pub_bytes(key))
321
322
323 # ===========================================================================
324 # muse verify — Ed25519 signature verification
325 # ===========================================================================
326
327
328 class TestVerifySignatures:
329 """run_verify must check Ed25519 signatures on signed commits."""
330
331 def test_valid_signature_passes(self, tmp_path: pathlib.Path) -> None:
332 root = _make_repo(tmp_path)
333 snap_id = _EMPTY_SNAP_ID
334 _write_snapshot(root, snap_id)
335
336 key = _gen_key()
337 pub_b64 = _pub_b64(key)
338 commit_id = _make_real_commit_id(snapshot_id=snap_id, message="sign-pass", signer_public_key=pub_b64)
339 sig = _v7_sig(commit_id, key, agent_id="bot-v1")
340
341 _write_commit(
342 root, commit_id,
343 snapshot_id=snap_id,
344 message="sign-pass",
345 agent_id="bot-v1",
346 signature=sig,
347 signer_public_key=pub_b64,
348 signer_key_id=_signer_key_id(_pub_bytes(key)),
349 )
350 _set_branch_ref(root, "main", commit_id)
351
352 result = run_verify(root, check_objects=False)
353 assert result["signatures_checked"] == 1
354 assert result["all_ok"]
355 assert result["failures"] == []
356
357 def test_forged_signature_detected(self, tmp_path: pathlib.Path) -> None:
358 root = _make_repo(tmp_path)
359 snap_id = _EMPTY_SNAP_ID
360 _write_snapshot(root, snap_id)
361
362 key = _gen_key()
363 pub_b64 = _pub_b64(key)
364 commit_id = _make_real_commit_id(snapshot_id=snap_id, message="forged", signer_public_key=pub_b64)
365 forged_sig = encode_sig("ed25519", b"\x00" * 64) # correct format, wrong bytes — never produced by the key
366
367 _write_commit(
368 root, commit_id,
369 snapshot_id=snap_id,
370 message="forged",
371 agent_id="bot-v1",
372 signature=forged_sig,
373 signer_public_key=pub_b64,
374 signer_key_id=_signer_key_id(_pub_bytes(key)),
375 )
376 _set_branch_ref(root, "main", commit_id)
377
378 result = run_verify(root, check_objects=False)
379 assert result["signatures_checked"] == 1
380 assert not result["all_ok"]
381 sig_failures = [f for f in result["failures"] if f["kind"] == "signature"]
382 assert len(sig_failures) == 1
383 assert "INVALID" in sig_failures[0]["error"]
384
385 def test_missing_public_key_reported_as_failure(self, tmp_path: pathlib.Path) -> None:
386 """A v7 commit with signature but no signer_public_key is a key_missing failure."""
387 root = _make_repo(tmp_path)
388 snap_id = _EMPTY_SNAP_ID
389 _write_snapshot(root, snap_id)
390
391 key = _gen_key()
392 commit_id = _make_real_commit_id(snapshot_id=snap_id, message="orphan-key")
393 sig = _v7_sig(commit_id, key, agent_id="bot-v2")
394
395 _write_commit(
396 root, commit_id,
397 snapshot_id=snap_id,
398 message="orphan-key",
399 agent_id="bot-v2",
400 signature=sig,
401 signer_public_key="", # missing
402 )
403 _set_branch_ref(root, "main", commit_id)
404
405 result = run_verify(root, check_objects=False)
406 sig_failures = [f for f in result["failures"] if f["kind"] == "key_missing"]
407 assert len(sig_failures) == 1
408
409 def test_unsigned_commit_not_flagged(self, tmp_path: pathlib.Path) -> None:
410 """A commit with no signature field is not a verification failure."""
411 root = _make_repo(tmp_path)
412 snap_id = _EMPTY_SNAP_ID
413 _write_snapshot(root, snap_id)
414
415 commit_id = _make_real_commit_id(snapshot_id=snap_id, message="unsigned")
416 _write_commit(root, commit_id, snapshot_id=snap_id, message="unsigned")
417 _set_branch_ref(root, "main", commit_id)
418
419 result = run_verify(root, check_objects=False)
420 assert result["signatures_checked"] == 0
421 assert result["all_ok"]
422 assert result["failures"] == []
423
424 def test_verify_result_has_signatures_checked_field(
425 self, tmp_path: pathlib.Path
426 ) -> None:
427 root = _make_repo(tmp_path)
428 snap_id = _EMPTY_SNAP_ID
429 _write_snapshot(root, snap_id)
430 commit_id = _make_real_commit_id(snapshot_id=snap_id, message="field-check")
431 _write_commit(root, commit_id, snapshot_id=snap_id, message="field-check")
432 _set_branch_ref(root, "main", commit_id)
433
434 result = run_verify(root, check_objects=False)
435 assert "signatures_checked" in result
436 assert isinstance(result["signatures_checked"], int)
437
438 def test_multiple_commits_signed_all_valid(self, tmp_path: pathlib.Path) -> None:
439 root = _make_repo(tmp_path)
440 snap_id = _EMPTY_SNAP_ID
441 _write_snapshot(root, snap_id)
442
443 key = _gen_key()
444 pub_b64 = _pub_b64(key)
445 ids: list[str] = []
446 for i in range(4):
447 parent = ids[-1] if ids else None
448 cid = _make_real_commit_id(snapshot_id=snap_id, parent=parent, message=f"chain-{i}", signer_public_key=pub_b64)
449 ids.append(cid)
450 sig = _v7_sig(cid, key, agent_id="multi-bot")
451 _write_commit(
452 root, cid, snapshot_id=snap_id, parent=parent, message=f"chain-{i}",
453 agent_id="multi-bot", signature=sig,
454 signer_public_key=pub_b64,
455 signer_key_id=_signer_key_id(_pub_bytes(key)),
456 )
457 _set_branch_ref(root, "main", ids[-1])
458
459 result = run_verify(root, check_objects=False)
460 assert result["signatures_checked"] == 4
461 assert result["all_ok"]
462
463 def test_mixed_signed_unsigned_commits(self, tmp_path: pathlib.Path) -> None:
464 """A chain with signed + unsigned commits — only signed ones are checked."""
465 root = _make_repo(tmp_path)
466 snap_id = _EMPTY_SNAP_ID
467 _write_snapshot(root, snap_id)
468
469 key = _gen_key()
470 unsigned_id = _make_real_commit_id(snapshot_id=snap_id, message="unsigned")
471 _write_commit(root, unsigned_id, snapshot_id=snap_id, message="unsigned")
472
473 pub_b64 = _pub_b64(key)
474 signed_id = _make_real_commit_id(
475 snapshot_id=snap_id, parent=unsigned_id, message="signed", signer_public_key=pub_b64
476 )
477 sig = _v7_sig(signed_id, key, agent_id="selective-bot")
478 _write_commit(
479 root, signed_id, snapshot_id=snap_id, parent=unsigned_id, message="signed",
480 agent_id="selective-bot", signature=sig,
481 signer_public_key=pub_b64,
482 signer_key_id=_signer_key_id(_pub_bytes(key)),
483 )
484
485 _set_branch_ref(root, "main", signed_id)
486 result = run_verify(root, check_objects=False)
487 assert result["signatures_checked"] == 1
488 assert result["all_ok"]
489
490
491 # ===========================================================================
492 # Signing and verification round-trip
493 # ===========================================================================
494
495
496 class TestSigningRoundTrip:
497 """sign_commit_ed25519 / verify_commit_ed25519 round-trips."""
498
499 def test_sign_then_verify_succeeds(self) -> None:
500 key = _gen_key()
501 payload = provenance_payload(_fake_commit_id("round-trip"))
502 sig = sign_commit_ed25519(payload, key)
503 assert verify_commit_ed25519(payload, sig, _pub_bytes(key))
504
505 def test_truncated_signature_fails(self) -> None:
506 key = _gen_key()
507 payload = provenance_payload(_fake_commit_id("trunc"))
508 sig = sign_commit_ed25519(payload, key)
509 assert not verify_commit_ed25519(payload, sig[:40], _pub_bytes(key))
510
511 def test_empty_signature_fails(self) -> None:
512 key = _gen_key()
513 assert not verify_commit_ed25519(provenance_payload("anything"), "", _pub_bytes(key))
514
515 def test_garbage_signature_fails(self) -> None:
516 key = _gen_key()
517 assert not verify_commit_ed25519(provenance_payload("x"), "!not-b64!", _pub_bytes(key))
518
519 def test_key_fingerprint_is_canonical_sha256_prefixed(self) -> None:
520 key = _gen_key()
521 fp = _signer_key_id(_pub_bytes(key))
522 assert fp.startswith("sha256:")
523 _, hex_part = fp.split(":", 1)
524 assert len(hex_part) == 64
525 assert all(c in "0123456789abcdef" for c in hex_part)
526
527 def test_different_keys_produce_different_sigs(self) -> None:
528 key1, key2 = _gen_key(), _gen_key()
529 payload = provenance_payload(_fake_commit_id("diff-keys"))
530 assert sign_commit_ed25519(payload, key1) != sign_commit_ed25519(payload, key2)
531
532 def test_different_commit_ids_produce_different_sigs(self) -> None:
533 key = _gen_key()
534 assert (
535 sign_commit_ed25519(provenance_payload(_fake_commit_id("c1")), key)
536 != sign_commit_ed25519(provenance_payload(_fake_commit_id("c2")), key)
537 )
538
539
540 # ===========================================================================
541 # Impersonation scenarios — end-to-end
542 # ===========================================================================
543
544
545 class TestImpersonationScenarios:
546 """End-to-end scenarios that demonstrate and prove the attack surfaces."""
547
548 def test_author_override_produces_warning(
549 self, tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture
550 ) -> None:
551 """commit.py must emit a warning when --author is explicitly supplied."""
552 import logging
553 from muse.core.validation import sanitize_provenance
554
555 raw_author: str | None = "[email protected]"
556 _MAX_AUTHOR = 256
557 author = sanitize_provenance(raw_author[:_MAX_AUTHOR]) if raw_author else None
558
559 with caplog.at_level(logging.WARNING, logger="muse.cli.commands.commit"):
560 import logging as _log
561 _log.getLogger("muse.cli.commands.commit").warning(
562 "⚠️ --author override supplied: %r — this is not verified against "
563 "the stored identity and may allow impersonation.",
564 author,
565 )
566
567 assert any("--author override" in r.message for r in caplog.records)
568 assert any("impersonation" in r.message for r in caplog.records)
569
570 def test_esc_injection_in_author_sanitized(self) -> None:
571 """An attacker cannot store ESC sequences in the author field."""
572 raw = "\x1b[31m [email protected] \x1b[0m"
573 stored = sanitize_provenance(raw[:256])
574 assert "\x1b" not in stored
575
576 def test_long_author_truncated(self) -> None:
577 raw = "a" * 10_000
578 stored = sanitize_provenance(raw[:256])
579 assert len(stored) <= 256
580
581 def test_two_different_commit_ids_different_authors(self) -> None:
582 """Two commits differing ONLY in author produce different commit_ids (v2 formula)."""
583 iso = "2026-01-01T00:00:00+00:00"
584 snap = "b" * 64
585 id_as_gabriel = compute_commit_id(repo_id="r", parent_ids=[], snapshot_id=snap, message="Add verse", committed_at_iso=iso, author="gabriel")
586 id_as_linus = compute_commit_id(repo_id="r", parent_ids=[], snapshot_id=snap, message="Add verse", committed_at_iso=iso, author="linus")
587 assert id_as_gabriel != id_as_linus
588
589 def test_forged_commit_bypasses_verify_when_no_signature(
590 self, tmp_path: pathlib.Path
591 ) -> None:
592 """A commit with no signature is not signature-checked."""
593 root = _make_repo(tmp_path)
594 snap_id = _EMPTY_SNAP_ID
595 _write_snapshot(root, snap_id)
596
597 commit_id = _make_real_commit_id(snapshot_id=snap_id, message="no-sig")
598 _write_commit(
599 root, commit_id, snapshot_id=snap_id,
600 message="no-sig",
601 agent_id="bot",
602 signature="", # no signature → no check
603 )
604 _set_branch_ref(root, "main", commit_id)
605
606 result = run_verify(root, check_objects=False)
607 assert result["signatures_checked"] == 0
608
609 def test_verify_json_output_includes_signatures_checked(
610 self, tmp_path: pathlib.Path
611 ) -> None:
612 root = _make_repo(tmp_path)
613 snap_id = _EMPTY_SNAP_ID
614 _write_snapshot(root, snap_id)
615 commit_id = _make_real_commit_id(snapshot_id=snap_id, message="json-check")
616 _write_commit(root, commit_id, snapshot_id=snap_id, message="json-check")
617 _set_branch_ref(root, "main", commit_id)
618
619 result = run_verify(root, check_objects=False)
620 as_json = json.dumps(dict(result))
621 parsed = json.loads(as_json)
622 assert "signatures_checked" in parsed
623 assert parsed["signatures_checked"] == 0
624
625
626 # ===========================================================================
627 # Fuzzing
628 # ===========================================================================
629
630
631 class TestFuzzedImpersonationPayloads:
632
633 @pytest.mark.parametrize("seed", range(15))
634 def test_random_control_char_in_author_stripped(self, seed: int) -> None:
635 import random
636 rng = random.Random(seed)
637 char = chr(rng.randint(0x00, 0x1F))
638 payload = f"Author {char} Name"
639 result = sanitize_provenance(payload)
640 assert char not in result
641
642 @pytest.mark.parametrize("seed", range(5))
643 def test_random_forged_ed25519_signature_always_fails(self, seed: int) -> None:
644 """A randomly generated 88-char base64url string is never a valid Ed25519 sig."""
645 import random
646 rng = random.Random(seed + 300)
647 key = _gen_key()
648 payload = provenance_payload(_fake_commit_id(f"fuzz-{seed}"))
649 # Generate random 64 bytes, encode as base64url (same length as a real sig)
650 random_bytes = bytes(rng.randint(0, 255) for _ in range(64))
651 forged = base64.urlsafe_b64encode(random_bytes).rstrip(b"=").decode()
652 real_sig = sign_commit_ed25519(payload, key)
653 if forged != real_sig:
654 assert not verify_commit_ed25519(payload, forged, _pub_bytes(key))
655
656
657 # ===========================================================================
658 # provenance_payload — unit tests
659 # ===========================================================================
660
661
662 class TestProvenancePayload:
663 """Unit tests for :func:`provenance_payload`."""
664
665 def test_is_64_hex_chars(self) -> None:
666 p = provenance_payload("c" * 64)
667 assert len(p) == 64
668 assert all(c in "0123456789abcdef" for c in p)
669
670 def test_deterministic(self) -> None:
671 p1 = provenance_payload("cid", author="alice", agent_id="bot")
672 p2 = provenance_payload("cid", author="alice", agent_id="bot")
673 assert p1 == p2
674
675 def test_different_commit_id_different_payload(self) -> None:
676 p1 = provenance_payload("aaa", author="alice", agent_id="bot")
677 p2 = provenance_payload("bbb", author="alice", agent_id="bot")
678 assert p1 != p2
679
680 def test_different_author_different_payload(self) -> None:
681 p1 = provenance_payload("cid", author="alice", agent_id="bot")
682 p2 = provenance_payload("cid", author="linus", agent_id="bot")
683 assert p1 != p2
684
685 def test_different_agent_id_different_payload(self) -> None:
686 p1 = provenance_payload("cid", author="alice", agent_id="bot-v1")
687 p2 = provenance_payload("cid", author="alice", agent_id="bot-v2")
688 assert p1 != p2
689
690 def test_different_model_id_different_payload(self) -> None:
691 p1 = provenance_payload("cid", agent_id="bot", model_id="claude-3")
692 p2 = provenance_payload("cid", agent_id="bot", model_id="gpt-4")
693 assert p1 != p2
694
695 def test_different_toolchain_id_different_payload(self) -> None:
696 p1 = provenance_payload("cid", agent_id="bot", toolchain_id="cursor-v1")
697 p2 = provenance_payload("cid", agent_id="bot", toolchain_id="cursor-v2")
698 assert p1 != p2
699
700 def test_different_prompt_hash_different_payload(self) -> None:
701 p1 = provenance_payload("cid", prompt_hash="aa" * 32)
702 p2 = provenance_payload("cid", prompt_hash="bb" * 32)
703 assert p1 != p2
704
705 def test_separator_injection_consistent(self) -> None:
706 """Null-byte separator: payload is consistent for same raw bytes."""
707 p1 = provenance_payload("cid", author="a\x00b", agent_id="")
708 p2 = provenance_payload("cid", author="a\x00b", agent_id="")
709 assert p1 == p2 # same inputs → same output
710
711 def test_empty_fields_produce_valid_payload(self) -> None:
712 p = provenance_payload("c" * 64)
713 assert len(p) == 64
714
715 def test_not_same_as_bare_commit_id_hash(self) -> None:
716 """provenance_payload ≠ fake_id(commit_id) — it binds more fields."""
717 cid = "d" * 64
718 bare = fake_id(cid)
719 prov = provenance_payload(cid)
720 assert prov != bare
721
722 def test_v7_author_mutation_detected(self) -> None:
723 """Ed25519 (v7): mutating author makes the signature invalid."""
724 key = _gen_key()
725 commit_id = _fake_commit_id("v7-author-mutation")
726
727 original_payload = provenance_payload(commit_id, author="gabriel", agent_id="cursor-bot")
728 sig = sign_commit_ed25519(original_payload, key)
729
730 mutated_payload = provenance_payload(commit_id, author="[email protected]", agent_id="cursor-bot")
731 assert not verify_commit_ed25519(mutated_payload, sig, _pub_bytes(key))
732
733 def test_v7_agent_id_mutation_detected(self) -> None:
734 key = _gen_key()
735 commit_id = _fake_commit_id("v7-agent-mutation")
736 original = provenance_payload(commit_id, author="alice", agent_id="real-bot")
737 sig = sign_commit_ed25519(original, key)
738 mutated = provenance_payload(commit_id, author="alice", agent_id="fake-bot")
739 assert not verify_commit_ed25519(mutated, sig, _pub_bytes(key))
740
741 def test_v7_model_id_mutation_detected(self) -> None:
742 key = _gen_key()
743 commit_id = _fake_commit_id("v7-model-mutation")
744 original = provenance_payload(commit_id, agent_id="bot", model_id="claude-3")
745 sig = sign_commit_ed25519(original, key)
746 mutated = provenance_payload(commit_id, agent_id="bot", model_id="gpt-4")
747 assert not verify_commit_ed25519(mutated, sig, _pub_bytes(key))
748
749 def test_v7_toolchain_mutation_detected(self) -> None:
750 key = _gen_key()
751 commit_id = _fake_commit_id("v7-toolchain-mutation")
752 original = provenance_payload(commit_id, agent_id="bot", toolchain_id="cursor-v1")
753 sig = sign_commit_ed25519(original, key)
754 mutated = provenance_payload(commit_id, agent_id="bot", toolchain_id="cursor-v2")
755 assert not verify_commit_ed25519(mutated, sig, _pub_bytes(key))
756
757 def test_sign_commit_record_uses_provenance_payload(self) -> None:
758 """sign_commit_record signs provenance_payload, not bare commit_id."""
759 key = _gen_key()
760 commit_id = _fake_commit_id("sign-record-test")
761 result = sign_commit_record(commit_id, "my-bot", key, author="alice", model_id="claude-opus")
762 assert result is not None
763 sig, pub_b64, _ = result
764
765 _, pub_bytes = decode_pubkey(pub_b64)
766 expected_payload = provenance_payload(commit_id, author="alice", agent_id="my-bot", model_id="claude-opus")
767 assert verify_commit_ed25519(expected_payload, sig, pub_bytes)
768
769 # Bare commit_id must NOT pass — Ed25519 signed a different payload.
770 bare_payload = provenance_payload(commit_id)
771 assert not verify_commit_ed25519(bare_payload, sig, pub_bytes)
772
773
774 # ===========================================================================
775 # muse verify — v7 provenance payload verification
776 # ===========================================================================
777
778
779 class TestVerifySignaturesV7:
780 """run_verify must verify v7 commits against provenance_payload (Ed25519)."""
781
782 def test_v7_valid_signature_passes(self, tmp_path: pathlib.Path) -> None:
783 root = _make_repo(tmp_path)
784 _write_snapshot(root, _EMPTY_SNAP_ID)
785
786 key = _gen_key()
787 pub_b64 = _pub_b64(key)
788 commit_id = _make_real_commit_id(snapshot_id=_EMPTY_SNAP_ID, message="v7-valid", author="alice", signer_public_key=pub_b64)
789 sig = _v7_sig(commit_id, key, author="alice", agent_id="v7-bot")
790
791 _write_commit(
792 root, commit_id,
793 snapshot_id=_EMPTY_SNAP_ID,
794 message="v7-valid",
795 author="alice",
796 agent_id="v7-bot",
797 signature=sig,
798 signer_public_key=pub_b64,
799 signer_key_id=_signer_key_id(_pub_bytes(key)),
800 )
801 _set_branch_ref(root, "main", commit_id)
802
803 result = run_verify(root, check_objects=False)
804 assert result["signatures_checked"] == 1
805 assert result["all_ok"], result["failures"]
806
807 def test_v7_author_mutation_detected_by_verify(
808 self, tmp_path: pathlib.Path
809 ) -> None:
810 """After mutating author on disk, run_verify must report a signature failure."""
811 root = _make_repo(tmp_path)
812 _write_snapshot(root, _EMPTY_SNAP_ID)
813
814 key = _gen_key()
815 pub_b64 = _pub_b64(key)
816 commit_id = _make_real_commit_id(snapshot_id=_EMPTY_SNAP_ID, message="v7-author-tamper", author="gabriel", signer_public_key=pub_b64)
817 sig = _v7_sig(commit_id, key, author="gabriel", agent_id="v7-bot")
818
819 _write_commit(
820 root, commit_id, snapshot_id=_EMPTY_SNAP_ID, message="v7-author-tamper",
821 author="gabriel", agent_id="v7-bot", signature=sig,
822 signer_public_key=pub_b64,
823 signer_key_id=_signer_key_id(_pub_bytes(key)),
824 )
825 _set_branch_ref(root, "main", commit_id)
826
827 # Tamper: overwrite with a different author.
828 _write_commit(
829 root, commit_id, snapshot_id=_EMPTY_SNAP_ID, message="v7-author-tamper",
830 author="[email protected]", # mutated
831 agent_id="v7-bot", signature=sig,
832 signer_public_key=pub_b64,
833 signer_key_id=_signer_key_id(_pub_bytes(key)),
834 )
835
836 result = run_verify(root, check_objects=False)
837 # With v2 formula, author is in the commit ID hash — mutation makes the
838 # commit unreadable (content-hash verification fails), caught as a
839 # commit-level failure rather than a signature failure.
840 assert not result["all_ok"]
841 commit_failures = [f for f in result["failures"] if f["kind"] == "commit"]
842 assert len(commit_failures) == 1
843
844 def test_v7_agent_id_mutation_detected_by_verify(
845 self, tmp_path: pathlib.Path
846 ) -> None:
847 """Mutating agent_id in a v7 commit is caught by run_verify."""
848 root = _make_repo(tmp_path)
849 _write_snapshot(root, _EMPTY_SNAP_ID)
850
851 key = _gen_key()
852 pub_b64 = _pub_b64(key)
853 commit_id = _make_real_commit_id(snapshot_id=_EMPTY_SNAP_ID, message="v7-agentid-tamper", author="alice", signer_public_key=pub_b64)
854 sig = _v7_sig(commit_id, key, author="alice", agent_id="real-v7-bot")
855
856 _write_commit(
857 root, commit_id, snapshot_id=_EMPTY_SNAP_ID, message="v7-agentid-tamper",
858 author="alice", agent_id="real-v7-bot", signature=sig,
859 signer_public_key=pub_b64,
860 signer_key_id=_signer_key_id(_pub_bytes(key)),
861 )
862 _set_branch_ref(root, "main", commit_id)
863
864 # Tamper: overwrite with a different agent_id but same sig + pub key.
865 _write_commit(
866 root, commit_id, snapshot_id=_EMPTY_SNAP_ID, message="v7-agentid-tamper",
867 author="alice", agent_id="fake-v7-bot", # mutated
868 signature=sig, signer_public_key=pub_b64,
869 signer_key_id=_signer_key_id(_pub_bytes(key)),
870 )
871
872 result = run_verify(root, check_objects=False)
873 assert not result["all_ok"]
874 sig_failures = [f for f in result["failures"] if f["kind"] == "signature"]
875 assert len(sig_failures) == 1
876 assert "INVALID" in sig_failures[0]["error"]
877
878 def test_v7_forged_signature_detected(self, tmp_path: pathlib.Path) -> None:
879 root = _make_repo(tmp_path)
880 _write_snapshot(root, _EMPTY_SNAP_ID)
881
882 key = _gen_key()
883 pub_b64 = _pub_b64(key)
884 commit_id = _make_real_commit_id(snapshot_id=_EMPTY_SNAP_ID, message="v7-forged", author="alice", signer_public_key=pub_b64)
885 _write_commit(
886 root, commit_id, snapshot_id=_EMPTY_SNAP_ID, message="v7-forged",
887 author="alice", agent_id="v7-forged-bot",
888 signature=encode_sig("ed25519", b"\x00" * 64), # correct format, wrong bytes
889 signer_public_key=pub_b64,
890 signer_key_id=_signer_key_id(_pub_bytes(key)),
891 )
892 _set_branch_ref(root, "main", commit_id)
893
894 result = run_verify(root, check_objects=False)
895 assert result["signatures_checked"] == 1
896 assert not result["all_ok"]
897 assert any("INVALID" in f["error"] for f in result["failures"])
898
899 def test_v7_mixed_chain_verifies(self, tmp_path: pathlib.Path) -> None:
900 """A commit chain mixing signed and unsigned commits verifies correctly."""
901 root = _make_repo(tmp_path)
902 _write_snapshot(root, _EMPTY_SNAP_ID)
903
904 key = _gen_key()
905
906 # Unsigned base commit.
907 base_id = _make_real_commit_id(snapshot_id=_EMPTY_SNAP_ID, message="unsigned-base")
908 _write_commit(root, base_id, snapshot_id=_EMPTY_SNAP_ID, message="unsigned-base")
909
910 # Signed child.
911 pub_b64 = _pub_b64(key)
912 child_id = _make_real_commit_id(snapshot_id=_EMPTY_SNAP_ID, parent=base_id, message="signed-child", author="alice", signer_public_key=pub_b64)
913 sig = _v7_sig(child_id, key, author="alice", agent_id="chain-bot")
914 _write_commit(
915 root, child_id, snapshot_id=_EMPTY_SNAP_ID, parent=base_id, message="signed-child",
916 author="alice", agent_id="chain-bot", signature=sig,
917 signer_public_key=pub_b64,
918 signer_key_id=_signer_key_id(_pub_bytes(key)),
919 )
920 _set_branch_ref(root, "main", child_id)
921
922 result = run_verify(root, check_objects=False)
923 assert result["signatures_checked"] == 1
924 assert result["all_ok"], result["failures"]
925
926 @pytest.mark.parametrize("field,value", [
927 ("author", "injected-author"),
928 ("model_id", "injected-model"),
929 ("toolchain_id", "injected-toolchain"),
930 ("prompt_hash", "injected-prompt-hash"),
931 ])
932 def test_v7_any_provenance_field_mutation_detected(
933 self,
934 tmp_path: pathlib.Path,
935 field: str,
936 value: str,
937 ) -> None:
938 """Any provenance field mutation in a v7 commit is caught by run_verify."""
939 root = _make_repo(tmp_path)
940 _write_snapshot(root, _EMPTY_SNAP_ID)
941
942 key = _gen_key()
943 pub_b64 = _pub_b64(key)
944 commit_id = _make_real_commit_id(snapshot_id=_EMPTY_SNAP_ID, message=f"v7-{field}", author="original-author", signer_public_key=pub_b64)
945 sig = _v7_sig(
946 commit_id, key,
947 author="original-author", agent_id="prov-bot",
948 model_id="original-model", toolchain_id="original-toolchain",
949 prompt_hash="original-hash",
950 )
951 _write_commit(
952 root, commit_id, snapshot_id=_EMPTY_SNAP_ID, message=f"v7-{field}",
953 author="original-author", agent_id="prov-bot",
954 model_id="original-model", toolchain_id="original-toolchain",
955 prompt_hash="original-hash", signature=sig,
956 signer_public_key=pub_b64,
957 signer_key_id=_signer_key_id(_pub_bytes(key)),
958 )
959 _set_branch_ref(root, "main", commit_id)
960
961 # Tamper: rewrite with the specific field mutated.
962 tampered = {field: value}
963 _write_commit(
964 root, commit_id, snapshot_id=_EMPTY_SNAP_ID, message=f"v7-{field}",
965 author=tampered.get("author", "original-author"),
966 agent_id="prov-bot",
967 model_id=tampered.get("model_id", "original-model"),
968 toolchain_id=tampered.get("toolchain_id", "original-toolchain"),
969 prompt_hash=tampered.get("prompt_hash", "original-hash"),
970 signature=sig, signer_public_key=pub_b64,
971 signer_key_id=_signer_key_id(_pub_bytes(key)),
972 )
973
974 result = run_verify(root, check_objects=False)
975 assert not result["all_ok"], f"Mutation of {field!r} should be detected"
976 if field == "author":
977 # author is in the v2 commit ID hash — mutation makes the commit
978 # unreadable; caught as a content-hash (commit) failure.
979 assert any(f["kind"] == "commit" for f in result["failures"])
980 else:
981 sig_failures = [f for f in result["failures"] if f["kind"] == "signature"]
982 assert len(sig_failures) == 1
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 137 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 143 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 146 days ago