diff --git a/conformance/src/trace_tests/modules/tr_sig.py b/conformance/src/trace_tests/modules/tr_sig.py index 3ecef5ed..9801e243 100644 --- a/conformance/src/trace_tests/modules/tr_sig.py +++ b/conformance/src/trace_tests/modules/tr_sig.py @@ -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") @@ -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") @@ -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 diff --git a/conformance/tests/unit/test_tr_sig.py b/conformance/tests/unit/test_tr_sig.py index e8b4d30f..fcb7265b 100644 --- a/conformance/tests/unit/test_tr_sig.py +++ b/conformance/tests/unit/test_tr_sig.py @@ -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 + diff --git a/src/agentrust_trace/provenance.py b/src/agentrust_trace/provenance.py index e666802f..85db249f 100644 --- a/src/agentrust_trace/provenance.py +++ b/src/agentrust_trace/provenance.py @@ -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, @@ -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) @@ -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 @@ -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: diff --git a/tests/test_provenance_cnf_boundary.py b/tests/test_provenance_cnf_boundary.py index 90d18e77..bf7a9d95 100644 --- a/tests/test_provenance_cnf_boundary.py +++ b/tests/test_provenance_cnf_boundary.py @@ -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