Skip to content
Open
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
17 changes: 15 additions & 2 deletions conformance/src/trace_tests/modules/tr_sig.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
_SUPPORTED_KTY = {"OKP", "EC"}
_ED25519_CRV = "Ed25519"
_JCS_SAFE_INTEGER = 9007199254740991
# RFC 7517/7518 private key members. Mirrors _JWK_PRIVATE_PARAMS in tr_env.py
# and the agentrust-trace reference model.
_JWK_PRIVATE_PARAMS = frozenset({"d", "p", "q", "dp", "dq", "qi", "k"})


_B64URL_RE = re.compile(r"[A-Za-z0-9_-]*\Z")
Expand Down Expand Up @@ -188,6 +191,15 @@ def check_cmcp_runtime(record: dict[str, Any]) -> list[Finding]:
return findings

jwk = _jwk_of(record.get("trace"))
private = sorted(_JWK_PRIVATE_PARAMS.intersection(jwk))
if private:
findings.append(Finding(
"TR-SIG-001", Status.FAIL,
"TR-SIG-001: cnf.jwk must not contain private key material "
f"({', '.join(private)} member(s) present in the JWK)",
))
return findings

kty = jwk.get("kty")
crv = jwk.get("crv")
x = jwk.get("x")
Expand Down Expand Up @@ -236,11 +248,12 @@ def check(trace: dict[str, Any], record: dict[str, Any], fmt: str, level: int =
crv = jwk.get("crv")
x = jwk.get("x")

if "d" in jwk:
private = sorted(_JWK_PRIVATE_PARAMS.intersection(jwk))
if private:
findings.append(Finding(
"TR-SIG-004", Status.FAIL,
"TR-SIG-004: cnf.jwk must not contain private key material "
"('d' member present in the JWK)",
f"({', '.join(private)} member(s) present in the JWK)",
))
# The signature is not checked against a key the record should never have
# carried. Say so rather than returning nothing: a consumer reading TR-SIG-005
Expand Down
54 changes: 54 additions & 0 deletions conformance/tests/unit/test_tr_sig.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,3 +286,57 @@ def test_number_spelling_vectors_match_expected_conformance_verdicts():
apr_ts = next(f for f in res["TR-APR"] if f.code == "TR-APR-004").status.value
sig = next(f for f in res["TR-SIG"] if f.code == "TR-SIG-005").status.value
assert (env_iat, apr_ts, sig) == expected_by_name[path.name], path.name


@pytest.mark.parametrize("member", ["d", "p", "q", "dp", "dq", "qi", "k"])
def test_cmcp_runtime_private_key_material_in_cnf_jwk_fails(member: str) -> None:
priv = Ed25519PrivateKey.generate()
pub = priv.public_key()
x = _b64url(pub.public_bytes_raw())
secret = "Zm9yYmlkZGVuLXByaXZhdGUta2V5LW1hdGVyaWFs"

record = _make_signed_record()
record["trace"]["cnf"]["jwk"] = {
"kty": "OKP",
"crv": "Ed25519",
"x": x,
member: secret,
}
body = _canonical_json({k: v for k, v in record.items() if k != "signature"})
record["signature"] = _b64url(priv.sign(body))

findings = check(record["trace"], record, "cmcp-runtime")
assert len(findings) == 1
assert findings[0].code == "TR-SIG-001"
assert findings[0].failed()
assert "private key material" in findings[0].message
assert secret not in findings[0].message


@pytest.mark.parametrize("member", ["d", "p", "q", "dp", "dq", "qi", "k"])
def test_plain_trace_all_private_jwk_members_fail_tr_sig_004(member: str) -> None:
priv = Ed25519PrivateKey.generate()
pub = priv.public_key()
x = _b64url(pub.public_bytes_raw())
secret = "Zm9yYmlkZGVuLXByaXZhdGUta2V5LW1hdGVyaWFs"

trace: dict = {
"eat_profile": "tag:agentrust-io.com,2026:trace-v0.2",
"iat": 1748000000,
"subject": "did:mesh:spiffe://example.org/agent/test",
"runtime": {"platform": "software-only", "measurement": "sha256:" + "a" * 64},
"policy": {"bundle_hash": "sha256:" + "b" * 64, "enforcement_mode": "enforce"},
"data_class": "internal",
"cnf": {"jwk": {"kty": "OKP", "crv": "Ed25519", "x": x, member: secret}},
"signature": "",
}
body = _canonical_json({k: v for k, v in trace.items() if k != "signature"})
trace["signature"] = _b64url(priv.sign(body))

findings = check(trace, trace, "trace", level=1)
by_code = {f.code: f for f in findings}
assert by_code["TR-SIG-004"].status is Status.FAIL
assert "private key material" in by_code["TR-SIG-004"].message
assert secret not in by_code["TR-SIG-004"].message
assert by_code["TR-SIG-005"].status is Status.UNVERIFIED

21 changes: 19 additions & 2 deletions src/agentrust_trace/provenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
import rfc8785
from pydantic import ValidationError

from agentrust_trace.models import RuntimeInfo
from agentrust_trace.models import RuntimeInfo, _JWK_PRIVATE_PARAMS
from agentrust_trace.revocation import NO_CHECK, RevocationCheck
from agentrust_trace.sign import (
RevocationStore,
Expand Down Expand Up @@ -532,6 +532,13 @@ def verify_record(
# Revocation, before the signature rather than after. A signature made by a
# revoked key stays cryptographically valid for ever, so the verifier is the
# only place the fact can be applied.
if isinstance(trusted_jwk, dict):
private = sorted(_JWK_PRIVATE_PARAMS.intersection(trusted_jwk))
if private:
raise ProvenanceError(
f"trusted_jwk must not contain private key material: found {', '.join(private)}. "
"trusted_jwk must be a public confirmation key."
)
if revocation is not None:
try:
_check_not_revoked(trusted_jwk, revocation)
Expand All @@ -546,6 +553,13 @@ def verify_record(
embedded = cnf.get("jwk")
if not embedded:
raise ProvenanceError("record carries no cnf.jwk")
if isinstance(embedded, dict):
private = sorted(_JWK_PRIVATE_PARAMS.intersection(embedded))
if private:
raise ProvenanceError(
f"cnf.jwk must not contain private key material: found {', '.join(private)}. "
"cnf.jwk is a public confirmation key."
)

# Compared by RFC 7638 thumbprint, not by dict equality. A JWK is identified by
# its key material; `kid`, `use` and `alg` are optional members that carry none
Expand All @@ -564,7 +578,10 @@ def verify_record(
"some other key is a record about a server somebody else is describing."
)

pub = _pubkey_from_jwk(trusted_jwk)
try:
pub = _pubkey_from_jwk(trusted_jwk)
except ValueError as exc:
raise ProvenanceError(f"the record's embedded key is unusable: {exc}") from exc
try:
body = _canonical_bytes({k: v for k, v in record.items() if k != "signature"})
except rfc8785.CanonicalizationError as exc:
Expand Down
74 changes: 74 additions & 0 deletions tests/test_provenance_cnf_boundary.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,80 @@ def test_schema_still_accepts_an_ordinary_public_jwk() -> None:
assert validator.is_valid({"kty": "EC", "crv": "P-256", "x": "abc", "y": "def"})


@pytest.mark.parametrize("member", _PRIVATE_JWK_MEMBERS)
def test_provenance_verify_record_refuses_private_key_material_in_cnf_jwk(
member: str,
) -> None:
key = generate_key()
trusted = key_to_jwk(key)
secret = "Zm9yYmlkZGVuLXByaXZhdGUta2V5LW1hdGVyaWFs"
record = build_record(
kind="publisher-asserted",
publisher="did:web:acme.example",
tools=TOOLS,
artifact=ARTIFACT,
)
record["cnf"] = {"jwk": {**trusted, member: secret}}
body = _canonical_bytes(record)
record["signature"] = base64.urlsafe_b64encode(key.sign(body)).rstrip(b"=").decode()

with pytest.raises(ProvenanceError, match="private key material") as exc_info:
verify_record(record, trusted)
assert secret not in str(exc_info.value)


@pytest.mark.parametrize("member", _PRIVATE_JWK_MEMBERS)
def test_provenance_verify_record_refuses_private_key_material_in_trusted_jwk(
member: str,
) -> None:
key = generate_key()
trusted = key_to_jwk(key)
secret = "Zm9yYmlkZGVuLXByaXZhdGUta2V5LW1hdGVyaWFs"
record = build_record(
kind="publisher-asserted",
publisher="did:web:acme.example",
tools=TOOLS,
artifact=ARTIFACT,
)
record["cnf"] = {"jwk": trusted}
body = _canonical_bytes(record)
record["signature"] = base64.urlsafe_b64encode(key.sign(body)).rstrip(b"=").decode()

with pytest.raises(ProvenanceError, match="private key material") as exc_info:
verify_record(record, {**trusted, member: secret})
assert secret not in str(exc_info.value)

called = False

def _revocation_store(_kid: str) -> bool:
nonlocal called
called = True
return False

with pytest.raises(ProvenanceError, match="private key material"):
verify_record(record, {**trusted, member: secret}, revocation=_revocation_store)
assert not called


@pytest.mark.parametrize(
"unusable_jwk",
[
{"kty": "RSA", "n": "abc", "e": "AQAB"},
{"kty": "EC", "crv": "P-256", "x": "abc", "y": "def"},
{"kty": "OKP", "crv": "X25519", "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"},
{"kty": "OKP", "crv": "Ed25519", "x": "AAAA"},
{"kty": "OKP", "crv": "Ed25519", "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="},
],
ids=["rsa", "ec", "x25519", "short-x", "padded-x"],
)
def test_provenance_verify_record_wraps_pubkey_from_jwk_errors_in_provenance_error(
unusable_jwk: dict[str, str],
) -> None:
record, _ = _signed_with_cnf({"jwk": unusable_jwk})
with pytest.raises(ProvenanceError, match="unusable"):
verify_record(record, unusable_jwk)


def test_the_two_schema_copies_stay_in_step() -> None:
"""schema/ is the published artifact, src/ is what the package ships."""
from pathlib import Path
Expand Down
Loading