From 68aab7a457149d864e7f024481ba63b7e073a37e Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Thu, 18 Jun 2026 21:46:02 -0700 Subject: [PATCH 1/2] security: add verify_record, fix example JWK coords, constrain transparency - add verify_record() to sign.py: verifies Ed25519 signature against cnf.jwk or an explicit public key; raises InvalidSignature on tamper, ValueError on missing signature or key - export verify_record from package root alongside sign_record - add three tests: valid sig passes, tampered record raises, missing sig raises - replace fake ASCII JWK coordinates in examples/ with real P-256 throwaway keys - update transparency test fixture from empty string to example Rekor URI - add min_length=1 to transparency field (empty string is now rejected) Signed-off-by: Imran Siddique Co-Authored-By: Claude Sonnet 4.6 --- examples/amd-sev-snp.json | 4 +-- examples/intel-tdx.json | 4 +-- examples/nvidia-h100.json | 4 +-- src/agentrust_trace/__init__.py | 2 ++ src/agentrust_trace/models.py | 2 +- src/agentrust_trace/sign.py | 46 +++++++++++++++++++++++++++++++++ tests/test_sign.py | 24 +++++++++++++++-- 7 files changed, 77 insertions(+), 9 deletions(-) diff --git a/examples/amd-sev-snp.json b/examples/amd-sev-snp.json index 65bcbd6..35a627d 100644 --- a/examples/amd-sev-snp.json +++ b/examples/amd-sev-snp.json @@ -44,8 +44,8 @@ "jwk": { "kty": "EC", "crv": "P-256", - "x": "MEkwEwYHKoZIzj0CAQY", - "y": "GHkVPyQs9bXm2A", + "x": "x9ZBJpokJFQ_oRZzbtzo1Pqqkexd7MqEqP8wsZWdr_c", + "y": "1Z0fdYWZI_aooZGNiqXl24SAoGmPK9e1F5-114jl8I0", "kid": "sev-snp-workload-key-2026-06-23" } } diff --git a/examples/intel-tdx.json b/examples/intel-tdx.json index eb3e1f6..debfa4d 100644 --- a/examples/intel-tdx.json +++ b/examples/intel-tdx.json @@ -45,8 +45,8 @@ "jwk": { "kty": "EC", "crv": "P-384", - "x": "TDXd2VyaWZpY2F0aW9uS2V5", - "y": "UmVmZXJlbmNlTWVhc3VyZW1l", + "x": "TDT3KJx-pab2b1lRzN8lMilbwQC4zn1RMrxakKVZn3eYLMw9ViDEHU98IanGQIRW", + "y": "q95ZCKsGi9ZtLnBZQHutas2UvfeCWi4vlie9PDfP3w7TXXl657d9FZ5at5c4j1Ry", "kid": "tdx-workload-key-2026-06-23" } } diff --git a/examples/nvidia-h100.json b/examples/nvidia-h100.json index 6c1e7b2..3a28a4d 100644 --- a/examples/nvidia-h100.json +++ b/examples/nvidia-h100.json @@ -45,8 +45,8 @@ "jwk": { "kty": "EC", "crv": "P-256", - "x": "SDEwMENvbmZpZGVudGlhbA", - "y": "Q29tcHV0aW5nS2V5Rm9yVA", + "x": "xlzGde-bJZPFE_HUCbtBQrl__mYdc0pDZfrVsxCQm7k", + "y": "3OrVWg4XZwTgi5oHutl2kHzTW8rhh6Q9NxdSydZoBqA", "kid": "h100-cc-workload-key-2026-06-23" } } diff --git a/src/agentrust_trace/__init__.py b/src/agentrust_trace/__init__.py index 11cb6ee..bdfc336 100644 --- a/src/agentrust_trace/__init__.py +++ b/src/agentrust_trace/__init__.py @@ -17,6 +17,7 @@ load_key, load_signing_key, sign_record, + verify_record, ) from agentrust_trace.validate import ( iter_errors, @@ -45,4 +46,5 @@ "load_key", "load_signing_key", "sign_record", + "verify_record", ] diff --git a/src/agentrust_trace/models.py b/src/agentrust_trace/models.py index e565e37..fe324ed 100644 --- a/src/agentrust_trace/models.py +++ b/src/agentrust_trace/models.py @@ -121,7 +121,7 @@ class TrustRecord(BaseModel): tool_transcript: ToolTranscript | None = None build_provenance: BuildProvenance appraisal: Appraisal - transparency: str + transparency: Annotated[str, Field(min_length=1)] cnf: ConfirmationKey signature: Annotated[str, Field(pattern=r"^[A-Za-z0-9_-]+$")] | None = None """Optional embedded signature (base64url, no padding) by the cnf key over the diff --git a/src/agentrust_trace/sign.py b/src/agentrust_trace/sign.py index 7dfffbb..1bd37a3 100644 --- a/src/agentrust_trace/sign.py +++ b/src/agentrust_trace/sign.py @@ -78,3 +78,49 @@ def sign_record(record: dict[str, Any], key: Ed25519PrivateKey) -> dict[str, Any sig_bytes = key.sign(body) sig_b64 = base64.urlsafe_b64encode(sig_bytes).rstrip(b"=").decode() return {**payload, "signature": sig_b64} + + +def verify_record(record: dict[str, Any], public_key_or_jwk: Any = None) -> None: + """Verify an Ed25519 signature on a signed TRACE Trust Record. + + Raises InvalidSignature if the signature does not verify. + Raises ValueError if the record has no signature field or the key cannot + be decoded. + + If public_key_or_jwk is None, the public key is taken from record["cnf"]["jwk"]. + Pass an Ed25519PublicKey or a JWK dict to verify against an explicit key. + """ + from cryptography.exceptions import InvalidSignature as _InvalidSignature # noqa: F401 + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + + sig_b64 = record.get("signature") + if not sig_b64: + raise ValueError("record has no 'signature' field") + + # Decode signature + pad = 4 - len(sig_b64) % 4 + sig_bytes = base64.urlsafe_b64decode(sig_b64 + "=" * (pad % 4)) + + # Resolve public key + if public_key_or_jwk is None: + jwk = record.get("cnf", {}).get("jwk", {}) + if not jwk: + raise ValueError("record has no cnf.jwk and no public key was supplied") + public_key_or_jwk = jwk + + if isinstance(public_key_or_jwk, dict): + jwk = public_key_or_jwk + x_b64 = jwk.get("x") + if not x_b64: + raise ValueError("JWK missing 'x' field") + pad = 4 - len(x_b64) % 4 + x_bytes = base64.urlsafe_b64decode(x_b64 + "=" * (pad % 4)) + pub = Ed25519PublicKey.from_public_bytes(x_bytes) + else: + pub = public_key_or_jwk + + # Canonical bytes: record without "signature" key + record_no_sig = {k: v for k, v in record.items() if k != "signature"} + msg = _canonical_bytes(record_no_sig) + + pub.verify(sig_bytes, msg) # raises InvalidSignature on failure diff --git a/tests/test_sign.py b/tests/test_sign.py index 102051c..7be2a28 100644 --- a/tests/test_sign.py +++ b/tests/test_sign.py @@ -6,7 +6,7 @@ from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey -from agentrust_trace import TrustRecord, generate_key, key_to_jwk, sign_record +from agentrust_trace import TrustRecord, generate_key, key_to_jwk, sign_record, verify_record from agentrust_trace.sign import _canonical_bytes @@ -40,7 +40,7 @@ def _minimal_record() -> dict: "status": "affirming", "verifier": "https://agt.example.org/verifier", }, - "transparency": "", + "transparency": "https://rekor.sigstore.dev/api/v1/log/entries/example", "tool_transcript": { "hash": "sha256:" + "c" * 64, "call_count": 3, @@ -118,3 +118,23 @@ def test_sign_record_spiffe_subject(): signed = sign_record(record, key) validated = TrustRecord.model_validate(signed) assert validated.subject.startswith("spiffe://") + + +def test_verify_record_passes_for_valid_signature(): + key = generate_key() + record = sign_record(_minimal_record(), key) + verify_record(record) # must not raise + + +def test_verify_record_raises_for_tampered_record(): + key = generate_key() + record = sign_record(_minimal_record(), key) + record["iat"] = record["iat"] + 1 # tamper + with pytest.raises(Exception): # InvalidSignature + verify_record(record) + + +def test_verify_record_raises_for_missing_signature(): + record = dict(_minimal_record()) + with pytest.raises(ValueError, match="no 'signature' field"): + verify_record(record) From 17d0881f8637b11f83bcf529fb0f626970d81592 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Thu, 18 Jun 2026 22:01:12 -0700 Subject: [PATCH 2/2] fix: use InvalidSignature instead of bare Exception in pytest.raises Ruff B017 forbids asserting on bare Exception. Use the specific cryptography.exceptions.InvalidSignature type that verify_record raises. Signed-off-by: Imran Siddique --- tests/test_sign.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_sign.py b/tests/test_sign.py index 7be2a28..6a57748 100644 --- a/tests/test_sign.py +++ b/tests/test_sign.py @@ -130,7 +130,7 @@ def test_verify_record_raises_for_tampered_record(): key = generate_key() record = sign_record(_minimal_record(), key) record["iat"] = record["iat"] + 1 # tamper - with pytest.raises(Exception): # InvalidSignature + with pytest.raises(InvalidSignature): verify_record(record)