Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions src/agentrust_trace/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
TrustRecord,
)
from agentrust_trace.sign import (
TRACE_PROFILE_V0_2,
RevocationStore,
generate_key,
jwk_thumbprint,
Expand Down Expand Up @@ -46,6 +47,7 @@
"ToolTranscript",
"TrustRecord",
"RevocationStore",
"TRACE_PROFILE_V0_2",
"SCHEMA",
"iter_errors",
"validate_json",
Expand Down
53 changes: 50 additions & 3 deletions src/agentrust_trace/sign.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
66 changes: 66 additions & 0 deletions tests/test_sign.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
Loading