Skip to content

fix(schema): refuse private key material in cnf.jwk - #296

Merged
imran-siddique merged 1 commit into
mainfrom
sec/cnf-jwk-no-private-material
Sep 6, 2026
Merged

fix(schema): refuse private key material in cnf.jwk#296
imran-siddique merged 1 commit into
mainfrom
sec/cnf-jwk-no-private-material

Conversation

@imran-siddique

Copy link
Copy Markdown
Member

Closes GHSA-vc4p-h84j-7qxj. Reproduced against current main before changing anything.

The gap

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 knew that. It refuses d, p, q, dp, dq, qi and k via _JWK_PRIVATE_PARAMS, with the comment "cnf.jwk is a public proof-of-possession key".

But the verification path validates against schema/trace-claim.json, not the model. The schema's jwk block constrained only the kty/crv/x/y shapes through two if/then entries; everything else fell through additionalProperties. So a Trust Record carrying its own private key validated cleanly and verify_record accepted it.

The rule existed, and only in the half that verification does not call.

Why it matters even with no attacker

There is no attacker step here. This is a producer mistake the format did not defend against. What makes it worth fixing rather than documenting is that the consequence is durable: the record is signed, self-authenticating, and typically anchored. Once it is out, the key is out, and the only remedy is to revoke the identity.

sign.sign_record could never produce this, because it builds cnf from key_to_jwk, which returns the public half only. The exposure is a record assembled by hand or by another implementation, which is exactly the population a published schema exists to constrain.

The fix

A not/anyOf constraint over the seven private JWK members, placed alongside the existing allOf shape rules, mirroring _JWK_PRIVATE_PARAMS in the reference model. Public JWKs are unaffected.

Applied to both copies, schema/trace-claim.json (the published artifact) and src/agentrust_trace/schema/trace-v0.2.json (what the package ships). They were byte-identical and still are, with a test that keeps them that way, since drifting copies is how this kind of gap survives a fix.

Tests

Nine in test_provenance_cnf_boundary.py: one per private member, two that ordinary OKP and EC public JWKs still validate, and the copy-equality check. Reverting the schema turns all seven member tests red.

Full suite: 1172 passed, with the same 4 failures that reproduce on a clean main (three fixture-regeneration tests and one schema-classification test, all unrelated).

Not fixed here

agentrust-io/trace-tests carries a third copy of this schema with the same gap, and its conformance modules do not inspect cnf.jwk members (TR-ENV-004 checks only that kty is present, so a record carrying d passes the suite). Tracked separately against that repo.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XbDBXDWWvMFa7c2jGgyq9t

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbDBXDWWvMFa7c2jGgyq9t
@imran-siddique
imran-siddique requested a review from a team as a code owner September 5, 2026 23:59
@imran-siddique
imran-siddique merged commit 891427b into main Sep 6, 2026
6 checks passed
@imran-siddique
imran-siddique deleted the sec/cnf-jwk-no-private-material branch September 6, 2026 00:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant