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
lywinged added a commit to lywinged/trace-spec that referenced this pull request Sep 9, 2026
`schema/trace-claim.json` says of the confirmation key that "Keys must carry
actual key material", and enforced it for `OKP` and `EC` and for nothing else. A
`cnf.jwk` of `{"kty": "RSA"}` with no `n` and no `e` validated, and the record
then failed inside the verifier, where `jwk_thumbprint` reports a missing
required thumbprint member. Nothing was accepted that should have been refused,
since every path downstream fails closed. What was wrong is which instrument
spoke: the schema is the artifact an implementation in any language validates
against, and it was not the thing that told the producer the key was unusable.

What it tracks, since a schema change has to track normative text rather than a
description: section 3.2.2 requires the record to be bound by a signature made by
the key in `cnf`, and section 3.3 rule 1 has a verifier check that binding before
any other field is trusted. A confirmation key carrying no key material cannot
satisfy either, so refusing it enforces what those sections already require.
`sign.jwk_thumbprint` already knows `OKP`, `EC` and `RSA`, so naming the RSA
members adds no key type the package does not handle.

The reasoning for both of those sits in the `$comment` beside the constraint
rather than in `description`, which is the convention the private-member fix set
in the same block, and the `n` and `e` declarations are load-bearing rather than
decorative: without them a non-string modulus reaches `additionalProperties`,
which admits any canonicalizable value. A test covers that.

A `kty` enum would be a different act and is deliberately not done here. Section
3.2.1 states signing algorithms per envelope context and fixes no set for the
embedded-signature form of section 3.2.2, so narrowing `kty` in the schema would
add a constraint the specification does not make. That is a normative question,
and RFC 9964's registration of `AKP` for ML-DSA is the reason to answer it
deliberately rather than by hard-coding a list now.

Both copies of the schema move together, the published one and the packaged one,
and the new test asserts they are the same bytes, as `test_provenance_cnf_boundary`
already does for its own reason. The packaged v0.1 schema carries the same two
branches and is left alone, on the grounds agentrust-io#296 left it alone: nothing loads it, and
`test_the_superseded_schema_is_still_loaded_by_nothing` fails if anything starts to.

Signed-off-by: Louie Lu <48041247+lywinged@users.noreply.github.com>
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