|
| 1 | +"""Level 1 conformance tests: signed EAT envelope with Ed25519 verification. |
| 2 | +
|
| 3 | +A Level 1 record must be a cmcp-runtime envelope carrying a valid Ed25519 |
| 4 | +signature by the key in trace.cnf.jwk over the canonical JSON body. The runner |
| 5 | +module (TR-SIG) is the authoritative implementation; these tests drive it through |
| 6 | +representative conformant and non-conformant fixtures to verify it behaves correctly. |
| 7 | +""" |
| 8 | + |
| 9 | +import base64 |
| 10 | +import json |
| 11 | + |
1 | 12 | import pytest |
2 | 13 |
|
| 14 | +from trace_tests.modules.tr_sig import check as tr_sig_check |
| 15 | +from trace_tests.result import Status |
| 16 | + |
| 17 | + |
| 18 | +def _b64url(b: bytes) -> str: |
| 19 | + return base64.urlsafe_b64encode(b).rstrip(b"=").decode() |
| 20 | + |
| 21 | + |
| 22 | +def _canonical_json(d: dict) -> bytes: |
| 23 | + return json.dumps(d, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode() |
| 24 | + |
3 | 25 |
|
4 | 26 | @pytest.mark.level1 |
5 | | -@pytest.mark.skip(reason="Level 1 requires a signed EAT implementation") |
6 | 27 | class TestLevel1Conformance: |
7 | | - def test_eat_is_cose_sign1(self, signed_eat_bytes): |
8 | | - raise NotImplementedError |
| 28 | + def test_eat_is_cose_sign1(self, signed_eat_fixture): |
| 29 | + """The envelope must have cmcp_version and a non-empty signature field. |
| 30 | +
|
| 31 | + In the TRACE cMCP profile the cmcp-runtime envelope is the signed EAT |
| 32 | + carrier. A present, non-empty 'signature' field is the indicator that |
| 33 | + the record was signed rather than merely assembled. |
| 34 | + """ |
| 35 | + assert "cmcp_version" in signed_eat_fixture, ( |
| 36 | + "Level 1 record must be a cmcp-runtime envelope (cmcp_version key required)" |
| 37 | + ) |
| 38 | + sig = signed_eat_fixture.get("signature", "") |
| 39 | + assert isinstance(sig, str) and len(sig) > 0, ( |
| 40 | + "Level 1 record must carry a non-empty signature field" |
| 41 | + ) |
| 42 | + assert "trace" in signed_eat_fixture and isinstance(signed_eat_fixture["trace"], dict), ( |
| 43 | + "Level 1 record must embed a trace object" |
| 44 | + ) |
| 45 | + |
| 46 | + def test_eat_protected_header_content_type(self, signed_eat_fixture): |
| 47 | + """The trace envelope must declare the expected EAT profile sentinel. |
| 48 | +
|
| 49 | + In the cMCP profile the eat_profile field inside trace serves the role |
| 50 | + of the COSE protected header content-type: it binds the record to the |
| 51 | + TRACE v0.1 specification and prevents cross-profile replay. |
| 52 | + """ |
| 53 | + trace = signed_eat_fixture["trace"] |
| 54 | + assert trace.get("eat_profile") == "tag:agentrust.io,2026:trace-v0.1", ( |
| 55 | + "trace.eat_profile must be 'tag:agentrust.io,2026:trace-v0.1'" |
| 56 | + ) |
| 57 | + |
| 58 | + def test_signature_verifies_against_cnf_key(self, signed_eat_fixture): |
| 59 | + """TR-SIG must pass for a validly-signed cmcp-runtime record.""" |
| 60 | + trace = signed_eat_fixture["trace"] |
| 61 | + findings = tr_sig_check(trace, signed_eat_fixture, "cmcp-runtime") |
| 62 | + failures = [f for f in findings if f.failed()] |
| 63 | + assert not failures, ( |
| 64 | + f"Valid signed record must pass TR-SIG at Level 1; failures: {failures}" |
| 65 | + ) |
| 66 | + passed = [f for f in findings if f.passed()] |
| 67 | + assert passed, "TR-SIG must emit at least one PASS finding for a valid signature" |
| 68 | + |
| 69 | + def test_signature_byte_flipped_fails(self, signed_eat_fixture): |
| 70 | + """A record with a tampered signature must fail TR-SIG. |
| 71 | +
|
| 72 | + Flipping a byte in the base64url signature produces an invalid |
| 73 | + Ed25519 signature that cannot verify against the embedded cnf.jwk |
| 74 | + public key. |
| 75 | + """ |
| 76 | + import base64 |
| 77 | + |
| 78 | + original = signed_eat_fixture["signature"] |
| 79 | + # Decode, flip the first byte, re-encode without padding. |
| 80 | + raw = base64.urlsafe_b64decode(original + "=" * (4 - len(original) % 4)) |
| 81 | + tampered = bytes([raw[0] ^ 0xFF]) + raw[1:] |
| 82 | + signed_eat_fixture["signature"] = base64.urlsafe_b64encode(tampered).rstrip(b"=").decode() |
| 83 | + |
| 84 | + trace = signed_eat_fixture["trace"] |
| 85 | + findings = tr_sig_check(trace, signed_eat_fixture, "cmcp-runtime") |
| 86 | + assert any(f.failed() and "TR-SIG-001" in f.code for f in findings), ( |
| 87 | + "Byte-flipped signature must produce TR-SIG-001 FAIL" |
| 88 | + ) |
| 89 | + |
| 90 | + def test_cnf_jwk_swapped_key_fails(self, signed_eat_fixture): |
| 91 | + """A record whose cnf.jwk has been replaced with a different key must fail TR-SIG. |
| 92 | +
|
| 93 | + The signature was produced by the original private key; verifying it |
| 94 | + against a freshly-generated unrelated public key must fail. |
| 95 | + """ |
| 96 | + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey |
| 97 | + |
| 98 | + different_priv = Ed25519PrivateKey.generate() |
| 99 | + different_pub_raw = different_priv.public_key().public_bytes_raw() |
| 100 | + different_x = _b64url(different_pub_raw) |
| 101 | + |
| 102 | + # Swap x in cnf.jwk while leaving the signature unchanged. |
| 103 | + signed_eat_fixture["trace"]["cnf"]["jwk"]["x"] = different_x |
9 | 104 |
|
10 | | - def test_eat_protected_header_content_type(self, signed_eat_bytes): |
11 | | - raise NotImplementedError |
| 105 | + trace = signed_eat_fixture["trace"] |
| 106 | + findings = tr_sig_check(trace, signed_eat_fixture, "cmcp-runtime") |
| 107 | + assert any(f.failed() for f in findings), ( |
| 108 | + "Swapped cnf.jwk public key must cause TR-SIG to fail" |
| 109 | + ) |
12 | 110 |
|
13 | | - def test_signature_verifies_against_cnf_key(self, signed_eat_bytes): |
14 | | - raise NotImplementedError |
| 111 | + def test_eat_nonce_matches_challenge(self, signed_eat_fixture, challenge_nonce): |
| 112 | + """The runtime.nonce embedded in the EAT must match the challenge nonce. |
15 | 113 |
|
16 | | - def test_eat_nonce_matches_challenge(self, signed_eat_bytes, challenge_nonce): |
17 | | - raise NotImplementedError |
| 114 | + Nonce binding prevents replay: a verifier issues a freshness challenge |
| 115 | + before the agent signs; the resulting EAT must echo that exact nonce. |
| 116 | + """ |
| 117 | + trace = signed_eat_fixture["trace"] |
| 118 | + assert trace["runtime"].get("nonce") == challenge_nonce, ( |
| 119 | + "trace.runtime.nonce must match the challenge nonce issued by the verifier" |
| 120 | + ) |
0 commit comments