From 7ebc3778c5394371fc64fa95ebbb22e2a865bab3 Mon Sep 17 00:00:00 2001 From: lywinged <48041247+lywinged@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:44:29 +1200 Subject: [PATCH] fix(verify): enforce the profile cutover at verification time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v0.2 changelog declares that a v0.2 verifier "requires the new URI and rejects the old one; it does not accept both", and spec/trace-v0.2.md section 2 makes the same requirement normative. verify_record() never read eat_profile: a record carrying the superseded v0.1 identifier, a future version, a foreign tag, or no profile at all verified exactly as a v0.2 record, provided its signature checked out. A valid signature over semantics this build does not implement is not evidence. The profile is now checked first, before any cryptographic work — safe, because the only action taken on the unauthenticated value is refusal, and a record that verifies has had its profile covered by the signature, which spans the whole record. Anything other than the v0.2 identifier raises ValueError; the superseded v0.1 identifier gets a message that says why. TRACE_PROFILE_V0_2 is exported so callers can pin the same constant the verifier accepts; the v0.1 tag is deliberately not exported. Same shape as the revocation fix (#76): an already-merged requirement the reference implementation did not carry out. Five tests, including one that proves the refusal does not depend on cryptographic work; neutralizing the check fails four of them. docs/verification.md step 4 notes the check is now built in. No normative text, schema, or record field changed. Signed-off-by: lywinged <48041247+lywinged@users.noreply.github.com> --- CHANGELOG.md | 4 ++ docs/verification.md | 8 ++++ src/agentrust_trace/__init__.py | 2 + src/agentrust_trace/sign.py | 53 ++++++++++++++++++++++++-- tests/test_sign.py | 66 +++++++++++++++++++++++++++++++++ 5 files changed, 130 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c64689d3..15bd4f03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,10 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR. - Other `agentrust.io` URLs moved to `agentrust-io.com`: the registry and verifier hosts in the AGT adapter and the schema `$id`. +### Fixed + +- **`verify_record()` now enforces the profile cutover this changelog already declares.** The entry above states that a v0.2 verifier "requires the new URI and rejects the old one; it does not accept both" — but `verify_record()` never read `eat_profile`, so a record carrying the v0.1 identifier, a future version, a foreign tag, or no profile at all verified exactly as a v0.2 record, provided its signature checked out. A valid signature over semantics this build does not implement is not evidence, so the profile is now checked first, before any cryptographic work: anything other than `TRACE_PROFILE_V0_2` (newly exported) raises `ValueError`, with a message that says why when the profile is the superseded v0.1 identifier. Same shape as the revocation fix above: an already-merged spec requirement (`spec/trace-v0.2.md` section 2) that the reference implementation did not carry out. `docs/verification.md` step 4 notes the check is now built in. No normative text, schema, or record field changed. + ## [0.4.0] ### Added diff --git a/docs/verification.md b/docs/verification.md index 1504f981..adaa9486 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -53,6 +53,14 @@ assert record["eat_profile"] == "tag:agentrust-io.com,2026:trace-v0.2", "Unknown print("✓ eat_profile correct") ``` +If you verify with `agentrust_trace.verify_record`, this step is enforced for you, +before any cryptographic work: a record whose `eat_profile` is missing, superseded +(the v0.1 identifier), or anything other than `TRACE_PROFILE_V0_2` raises +`ValueError`. The manual assert above is what a from-scratch verifier must do +itself — spec section 2 requires a v0.2 verifier to reject everything but the v0.2 +identifier, and a valid signature over semantics your build does not implement is +not evidence. + ### Step 5 — Appraise the claims Interpret `appraisal.status` against your policy: diff --git a/src/agentrust_trace/__init__.py b/src/agentrust_trace/__init__.py index ff1a32bf..84d30246 100644 --- a/src/agentrust_trace/__init__.py +++ b/src/agentrust_trace/__init__.py @@ -14,6 +14,7 @@ TrustRecord, ) from agentrust_trace.sign import ( + TRACE_PROFILE_V0_2, RevocationStore, generate_key, jwk_thumbprint, @@ -46,6 +47,7 @@ "ToolTranscript", "TrustRecord", "RevocationStore", + "TRACE_PROFILE_V0_2", "SCHEMA", "iter_errors", "validate_json", diff --git a/src/agentrust_trace/sign.py b/src/agentrust_trace/sign.py index 28a4932d..04a79bfa 100644 --- a/src/agentrust_trace/sign.py +++ b/src/agentrust_trace/sign.py @@ -20,6 +20,16 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +TRACE_PROFILE_V0_2 = "tag:agentrust-io.com,2026:trace-v0.2" +"""The profile URI this build implements — the only one ``verify_record`` accepts.""" + +_TRACE_PROFILE_V0_1 = "tag:agentrust.io,2026:trace-v0.1" +"""The superseded identifier, minted under a domain the project does not own. + +Named only so its rejection can say why. Deliberately not exported: no caller +should be able to spell it without reading this file. +""" + RevocationStore: TypeAlias = Container[str] | Callable[[str], bool] """Caller-supplied source of key revocation status, consulted by ``verify_record``. @@ -265,9 +275,24 @@ def verify_record( *public_key_or_jwk* to verify against a key the caller already trusts. Raises ``InvalidSignature`` if the signature does not verify, and ``ValueError`` - for every other rejection (no signature, no trusted key, malformed input, - unsupported JWK type, stale record, nonce mismatch, or revoked key). Returns - ``None`` on success. All checks fail closed. + for every other rejection (wrong or missing profile, no signature, no trusted + key, malformed input, unsupported JWK type, stale record, nonce mismatch, or + revoked key). Returns ``None`` on success. All checks fail closed. + + Profile (fail closed): + The record's ``eat_profile`` must be exactly ``TRACE_PROFILE_V0_2``. + ``spec/trace-v0.2.md`` section 2 requires this of a v0.2 verifier: require + the v0.2 identifier, reject the superseded v0.1 identifier, and never accept + both. Any other profile is refused rather than verified on a best-effort + basis, because "the signature checks out" says nothing about whether this + code implements the semantics the record was written under. A missing + profile is refused for the same reason: a verifier cannot supply it by + assumption. + + The profile is read before any cryptographic work, which is safe because + the only action taken on the unauthenticated value is refusal; a record + that verifies has had its profile covered by the signature, since the + signature spans the whole record. Trust anchoring (fail closed): Without a trusted key, the record cannot vouch for itself, so verification @@ -300,6 +325,28 @@ def verify_record( from cryptography.exceptions import InvalidSignature as _InvalidSignature # noqa: F401 + # Profile first: refuse semantics this build does not implement before spending + # any work on the record. + profile = record.get("eat_profile") + if not isinstance(profile, str) or not profile: + raise ValueError( + "record has no 'eat_profile': the profile URI states which semantics the " + "record was written under, and a verifier cannot supply it by assumption" + ) + if profile != TRACE_PROFILE_V0_2: + if profile == _TRACE_PROFILE_V0_1: + raise ValueError( + f"record carries the superseded v0.1 profile {profile!r}. " + "spec/trace-v0.2.md section 2: the cutover is cutover, not " + "coexistence — a v0.2 verifier rejects the v0.1 identifier, which " + "was minted under a domain the project does not own." + ) + raise ValueError( + f"record profile {profile!r} is not {TRACE_PROFILE_V0_2!r}. Verification " + "is refused rather than attempted: a valid signature over semantics this " + "build does not implement is not evidence." + ) + sig_b64 = record.get("signature") if not sig_b64: raise ValueError("record has no 'signature' field") diff --git a/tests/test_sign.py b/tests/test_sign.py index f35d4a2b..bb8bf71e 100644 --- a/tests/test_sign.py +++ b/tests/test_sign.py @@ -10,6 +10,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from agentrust_trace import ( + TRACE_PROFILE_V0_2, TrustRecord, generate_key, jwk_thumbprint, @@ -148,6 +149,71 @@ def test_verify_record_passes_for_valid_signature(): verify_record(record, key_to_jwk(key)) # must not raise +def _fresh_record_with_profile(profile) -> dict: + record = _fresh_record() + if profile is None: + del record["eat_profile"] + else: + record["eat_profile"] = profile + return record + + +def test_verify_record_rejects_superseded_v0_1_profile(): + """spec/trace-v0.2.md section 2: a v0.2 verifier MUST reject the v0.1 identifier. + + The signature is genuine; the refusal must come from the profile, not from + tampering, or this would test the wrong check. + """ + key = generate_key() + record = sign_record( + _fresh_record_with_profile("tag:agentrust.io,2026:trace-v0.1"), key + ) + + with pytest.raises(ValueError, match="superseded v0.1 profile"): + verify_record(record, key_to_jwk(key)) + + +def test_verify_record_rejects_unknown_profile(): + """A future or foreign profile is refused, not best-effort verified.""" + key = generate_key() + record = sign_record( + _fresh_record_with_profile("tag:example.com,2031:trace-v9.9"), key + ) + + with pytest.raises(ValueError, match="is not"): + verify_record(record, key_to_jwk(key)) + + +def test_verify_record_rejects_missing_profile(): + """A missing profile cannot be supplied by assumption.""" + key = generate_key() + record = sign_record(_fresh_record_with_profile(None), key) + + with pytest.raises(ValueError, match="no 'eat_profile'"): + verify_record(record, key_to_jwk(key)) + + +def test_verify_record_profile_check_runs_before_signature_work(): + """A wrong-profile record is refused even when its signature is garbage. + + The refusal must not depend on cryptographic work: the profile error, not a + signature error, is what surfaces. + """ + record = _fresh_record_with_profile("tag:agentrust.io,2026:trace-v0.1") + record["signature"] = "not-even-base64url!!" + + with pytest.raises(ValueError, match="superseded v0.1 profile"): + verify_record(record, key_to_jwk(generate_key())) + + +def test_verified_records_carry_the_exported_profile_constant(): + """The constant callers can pin is the one the verifier accepts.""" + assert TRACE_PROFILE_V0_2 == "tag:agentrust-io.com,2026:trace-v0.2" + key = generate_key() + record = sign_record(_fresh_record_with_profile(TRACE_PROFILE_V0_2), key) + verify_record(record, key_to_jwk(key)) # must not raise + + def test_verify_record_raises_for_tampered_record(): key = generate_key() record = sign_record(_fresh_record(), key)