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