From a0c3c9863baa05909c8905e39ce1a697bd7b0129 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Sat, 5 Sep 2026 16:59:52 -0700 Subject: [PATCH] fix(schema): refuse private key material in cnf.jwk GHSA-vc4p-h84j-7qxj. RFC 8747 defines cnf as a confirmation key: the public half, carried so a verifier can bind the record to the key that signed it. models.TrustRecord already refuses d, p, q, dp, dq, qi and k, with the comment "cnf.jwk is a public proof-of-possession key". The verification path validates against the schema rather than the model, and the schema's jwk block constrained only kty, crv, x and y shapes. Everything else fell through additionalProperties, so a record carrying its own private key validated and sign.verify_record accepted it. No attacker is involved. This is a producer mistake the format did not defend against, and its consequence is durable: the record is signed, self-authenticating and typically anchored, so the only remedy afterwards is to revoke the identity. A format that can be handed the signing key by accident should say no in the half that verification actually calls. Both copies of the schema get the constraint and stay byte-identical, with a test that keeps them that way. sign.sign_record was never able to produce this, because it builds cnf from key_to_jwk, which returns the public half only. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XbDBXDWWvMFa7c2jGgyq9t --- schema/trace-claim.json | 40 ++++++++++++++++ src/agentrust_trace/schema/trace-v0.2.json | 40 ++++++++++++++++ tests/test_provenance_cnf_boundary.py | 56 ++++++++++++++++++++++ 3 files changed, 136 insertions(+) diff --git a/schema/trace-claim.json b/schema/trace-claim.json index 985ef6e..cbf796d 100644 --- a/schema/trace-claim.json +++ b/schema/trace-claim.json @@ -472,6 +472,46 @@ } } ], + "not": { + "anyOf": [ + { + "required": [ + "d" + ] + }, + { + "required": [ + "p" + ] + }, + { + "required": [ + "q" + ] + }, + { + "required": [ + "dp" + ] + }, + { + "required": [ + "dq" + ] + }, + { + "required": [ + "qi" + ] + }, + { + "required": [ + "k" + ] + } + ] + }, + "$comment": "RFC 8747 defines cnf as a confirmation key: the public half, present so a verifier can bind the record to the key that signed it. A private member here publishes the signing key inside the signed, self-authenticating, typically anchored record, and the only remedy afterwards is to revoke the identity. Mirrors _JWK_PRIVATE_PARAMS in the reference model, which already refuses these.", "additionalProperties": { "$ref": "#/$defs/canonicalizableValue" } diff --git a/src/agentrust_trace/schema/trace-v0.2.json b/src/agentrust_trace/schema/trace-v0.2.json index 985ef6e..cbf796d 100644 --- a/src/agentrust_trace/schema/trace-v0.2.json +++ b/src/agentrust_trace/schema/trace-v0.2.json @@ -472,6 +472,46 @@ } } ], + "not": { + "anyOf": [ + { + "required": [ + "d" + ] + }, + { + "required": [ + "p" + ] + }, + { + "required": [ + "q" + ] + }, + { + "required": [ + "dp" + ] + }, + { + "required": [ + "dq" + ] + }, + { + "required": [ + "qi" + ] + }, + { + "required": [ + "k" + ] + } + ] + }, + "$comment": "RFC 8747 defines cnf as a confirmation key: the public half, present so a verifier can bind the record to the key that signed it. A private member here publishes the signing key inside the signed, self-authenticating, typically anchored record, and the only remedy afterwards is to revoke the identity. Mirrors _JWK_PRIVATE_PARAMS in the reference model, which already refuses these.", "additionalProperties": { "$ref": "#/$defs/canonicalizableValue" } diff --git a/tests/test_provenance_cnf_boundary.py b/tests/test_provenance_cnf_boundary.py index 86a441e..90d18e7 100644 --- a/tests/test_provenance_cnf_boundary.py +++ b/tests/test_provenance_cnf_boundary.py @@ -76,3 +76,59 @@ def test_valid_embedded_cnf_jwk_still_verifies() -> None: record["signature"] = base64.urlsafe_b64encode(key.sign(body)).rstrip(b"=").decode() verify_record(record, trusted) + + +# --------------------------------------------------------------------------- +# GHSA-vc4p-h84j-7qxj: cnf.jwk accepted private key material, so a record could +# publish the key that signed it. +# +# RFC 8747 defines cnf as a confirmation key: the public half, present so a +# verifier can bind the record to the key that signed it. models.TrustRecord +# already refused d/p/q/dp/dq/qi/k, but the verification path validates against +# the schema rather than the model, and the schema's jwk block carried no +# constraint on private members. No attacker is involved; this is a producer +# mistake the format did not defend against, and the consequence is durable: +# the record is signed, self-authenticating and typically anchored, so the only +# remedy after the fact is to revoke the identity. +# --------------------------------------------------------------------------- + +_PRIVATE_JWK_MEMBERS = ["d", "p", "q", "dp", "dq", "qi", "k"] + + +def _jwk_schema(): + import json + from pathlib import Path + + root = Path(__file__).resolve().parents[1] + schema = json.loads((root / "schema" / "trace-claim.json").read_text(encoding="utf-8")) + return schema["properties"]["cnf"]["properties"]["jwk"] + + +@pytest.mark.parametrize("member", _PRIVATE_JWK_MEMBERS) +def test_schema_refuses_private_key_material_in_cnf_jwk(member: str) -> None: + import jsonschema + + validator = jsonschema.Draft202012Validator(_jwk_schema()) + jwk = {"kty": "OKP", "crv": "Ed25519", "x": "abc", member: "SECRET"} + + assert not validator.is_valid(jwk) + + +def test_schema_still_accepts_an_ordinary_public_jwk() -> None: + import jsonschema + + validator = jsonschema.Draft202012Validator(_jwk_schema()) + + assert validator.is_valid({"kty": "OKP", "crv": "Ed25519", "x": "abc"}) + assert validator.is_valid({"kty": "EC", "crv": "P-256", "x": "abc", "y": "def"}) + + +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 + + root = Path(__file__).resolve().parents[1] + published = (root / "schema" / "trace-claim.json").read_bytes() + packaged = (root / "src" / "agentrust_trace" / "schema" / "trace-v0.2.json").read_bytes() + + assert published == packaged