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
40 changes: 40 additions & 0 deletions schema/trace-claim.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
40 changes: 40 additions & 0 deletions src/agentrust_trace/schema/trace-v0.2.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
56 changes: 56 additions & 0 deletions tests/test_provenance_cnf_boundary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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