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: 2 additions & 2 deletions examples/amd-sev-snp.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Expand Down
4 changes: 2 additions & 2 deletions examples/intel-tdx.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Expand Down
4 changes: 2 additions & 2 deletions examples/nvidia-h100.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
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 @@ -17,6 +17,7 @@
load_key,
load_signing_key,
sign_record,
verify_record,
)
from agentrust_trace.validate import (
iter_errors,
Expand Down Expand Up @@ -45,4 +46,5 @@
"load_key",
"load_signing_key",
"sign_record",
"verify_record",
]
2 changes: 1 addition & 1 deletion src/agentrust_trace/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions src/agentrust_trace/sign.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
24 changes: 22 additions & 2 deletions tests/test_sign.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(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)
Loading