diff --git a/docs/error-codes.md b/docs/error-codes.md index 1c948ae..99ff45b 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -10,6 +10,7 @@ All TRACE test failures emit a structured error code of the form `TR--/` or a `did:` URI | | TR-ENV-004 | `cnf` is absent or not an object, `cnf.jwk` is absent or not an object, or `cnf.jwk.kty` is absent | Populate `cnf.jwk` with at least `kty`. This checks that one field, not the schema's full required set, which structural validation covers | +| TR-ENV-005 | `cnf.jwk` carries private key material (`d`, `p`, `q`, `dp`, `dq`, `qi`, `k`) | Publish the public half only. RFC 8747 makes `cnf` a confirmation key, and a record is signed and usually anchored, so a key exposed this way must be treated as compromised and the identity revoked | ## TR-SIG — Signature diff --git a/docs/modules/tr-env.md b/docs/modules/tr-env.md index f94e8d7..f13db6f 100644 --- a/docs/modules/tr-env.md +++ b/docs/modules/tr-env.md @@ -10,3 +10,4 @@ Tests the top-level EAT envelope structure of a TRACE Trust Record. | TR-ENV-002 | `iat` is a valid Unix timestamp | integer, reasonable range | string, future date | | TR-ENV-003 | `subject` matches SPIFFE URI or DID | `spiffe://trust.example/agent/x` or `did:key:z6Mk...` | bare string | | TR-ENV-004 | `cnf.jwk.kty` is present. This is not a gate over the schema's required set | `cnf.jwk.kty` set to any value | `cnf` absent, `cnf.jwk` absent or not an object, `kty` absent | +| TR-ENV-005 | `cnf.jwk` carries no private key material | `cnf.jwk` with `kty`/`crv`/`x` only | `cnf.jwk` containing `d`, `p`, `q`, `dp`, `dq`, `qi` or `k` | diff --git a/schemas/trace-claim.json b/schemas/trace-claim.json index 97954c2..2b07971 100644 --- a/schemas/trace-claim.json +++ b/schemas/trace-claim.json @@ -395,7 +395,47 @@ ] } } - ] + ], + "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": false diff --git a/src/trace_tests/modules/tr_env.py b/src/trace_tests/modules/tr_env.py index 4e1e405..81e6d8f 100644 --- a/src/trace_tests/modules/tr_env.py +++ b/src/trace_tests/modules/tr_env.py @@ -9,6 +9,10 @@ from trace_tests.result import Finding, Status _PROFILE = "tag:agentrust-io.com,2026:trace-v0.2" +# RFC 7517/7518 private key members. Mirrors _JWK_PRIVATE_PARAMS in the +# agentrust-trace reference model, which already refuses these. +_JWK_PRIVATE_PARAMS = frozenset({"d", "p", "q", "dp", "dq", "qi", "k"}) + _SUBJECT_RE = re.compile(r'^(spiffe://[^/]+/.+|did:[a-z0-9]+:.+)$') _IAT_MIN = 1_700_000_000 @@ -58,4 +62,24 @@ def check(trace: dict[str, Any], max_age_seconds: int = DEFAULT_MAX_AGE_SECONDS) else: findings.append(Finding("TR-ENV-004", Status.FAIL, "cnf must contain jwk with kty")) + # TR-ENV-005 (GHSA-vc4p-h84j-7qxj). RFC 8747 makes cnf a confirmation key: + # the public half, so a verifier can bind the record to the key that signed + # it. A private member here publishes the signing key inside a record that + # is signed, self-authenticating and usually anchored, and the only remedy + # afterwards is to revoke the identity. TR-ENV-004 checks kty is present, + # so a record carrying `d` passed the whole suite. + if isinstance(cnf, dict) and isinstance(cnf.get("jwk"), dict): + private = sorted(_JWK_PRIVATE_PARAMS.intersection(cnf["jwk"])) + if private: + findings.append(Finding( + "TR-ENV-005", Status.FAIL, + "cnf.jwk carries private key material: " + + ", ".join(private) + + ". Publish the public half only; this key must be treated as compromised", + )) + else: + findings.append(Finding( + "TR-ENV-005", Status.PASS, "cnf.jwk carries no private key material" + )) + return findings diff --git a/tests/unit/test_tr_env.py b/tests/unit/test_tr_env.py index 65a1484..46f8ec7 100644 --- a/tests/unit/test_tr_env.py +++ b/tests/unit/test_tr_env.py @@ -82,3 +82,36 @@ def test_missing_cnf_jwk_fails(): trace = {**_VALID, "cnf": {}} codes = {f.code for f in check(trace) if f.failed()} assert "TR-ENV-004" in codes + + +# --------------------------------------------------------------------------- +# TR-ENV-005 (GHSA-vc4p-h84j-7qxj): cnf.jwk must carry the public half only. +# TR-ENV-004 checks that kty is present, so a record publishing the key that +# signed it passed the whole suite. +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("member", ["d", "p", "q", "dp", "dq", "qi", "k"]) +def test_private_key_material_in_cnf_jwk_fails(member): + trace = {**_VALID, "cnf": {"jwk": {**_VALID["cnf"]["jwk"], member: "SECRET"}}} + + findings = check(trace) + codes = {f.code for f in findings if f.failed()} + + assert "TR-ENV-005" in codes + # TR-ENV-004 still passes, which is exactly why 005 had to exist. + assert "TR-ENV-004" not in codes + + +def test_private_material_finding_names_every_member_found(): + trace = {**_VALID, "cnf": {"jwk": {**_VALID["cnf"]["jwk"], "d": "S", "q": "S"}}} + + detail = next(f.message for f in check(trace) if f.code == "TR-ENV-005") + + assert "d" in detail and "q" in detail + + +def test_public_only_cnf_jwk_passes(): + findings = [f for f in check(_VALID) if f.code == "TR-ENV-005"] + + assert len(findings) == 1 + assert findings[0].passed()