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
1 change: 1 addition & 0 deletions docs/error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ All TRACE test failures emit a structured error code of the form `TR-<MODULE>-<N
| TR-ENV-002 | `iat` is missing, not an integer, or out of range | Set `iat` to a Unix timestamp integer (e.g. `int(time.time())`) |
| TR-ENV-003 | `subject` does not match SPIFFE URI or DID pattern | Use `spiffe://<trust-domain>/<path>` 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

Expand Down
1 change: 1 addition & 0 deletions docs/modules/tr-env.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
42 changes: 41 additions & 1 deletion schemas/trace-claim.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions src/trace_tests/modules/tr_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
33 changes: 33 additions & 0 deletions tests/unit/test_tr_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()