Skip to content

fix(schema): hold an RSA confirmation key to carrying its key material - #311

Open
lywinged wants to merge 2 commits into
agentrust-io:mainfrom
lywinged:fix/cnf-jwk-key-material
Open

fix(schema): hold an RSA confirmation key to carrying its key material#311
lywinged wants to merge 2 commits into
agentrust-io:mainfrom
lywinged:fix/cnf-jwk-key-material

Conversation

@lywinged

@lywinged lywinged commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

What this changes

The confirmation key says of itself that "Keys must carry actual key material: OKP keys require crv and x; EC keys require crv, x, and y", and the allOf behind that sentence carried branches for OKP and EC and for nothing else. So this validated:

"cnf": { "jwk": { "kty": "RSA" } }

and the record then failed inside the verifier, where sign.jwk_thumbprint reports jwk with kty='RSA' is missing required thumbprint member 'e', and in the conformance suite, where TR-SIG accepts OKP and EC.

Nothing was accepted that should have been refused: 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. A producer working in another language saw a valid record and learned otherwise from a Python traceback, from a conformance run, or not at all.

RSA now requires n and e, declared as strings. Those declarations are load-bearing rather than decorative: without them a non-string modulus reaches additionalProperties, which admits any canonicalizable value, and a test covers that. The reasoning for the constraint sits in the $comment beside it rather than in description, which is the convention the private-member fix set in the same block. Both copies of the schema move together, the published schema/trace-claim.json and the packaged src/agentrust_trace/schema/trace-v0.2.json, and a test asserts they are the same bytes, as test_provenance_cnf_boundary already does for its own reason. The v0.1 schema in the package carries the same two branches and is left alone, on the grounds #296 left it alone: nothing loads it, and test_the_superseded_schema_is_still_loaded_by_nothing fails if anything starts to.

What it tracks

A schema change has to track normative text rather than a description, so: 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. sign.jwk_thumbprint already knows OKP, EC and RSA, so naming the RSA members adds no key type the package does not handle, and nothing that verifies today is refused.

What this deliberately does not do

A kty enum would also close the unrecognised-key-type case, and it is not proposed here. Section 3.2.1 states signing algorithms per envelope context, ES256, ES384 or EdDSA for JWT and COSE_Sign1 or COSE_Sign for CBOR-COSE, and the embedded-signature form in section 3.2.2 names no algorithm set at all. A schema enum would add a constraint the specification does not make, which is a normative question and belongs in a spec change proposal rather than in a schema fix.

It is worth answering deliberately rather than by hard-coding a list now: RFC 9964 registered AKP as a JWK key type for ML-DSA in JOSE and COSE, so the set of key types someone might reasonably put here is not the set it was a year ago.

Found while answering #307, which is unrelated in substance: the reporter there is asking about a different member of cnf.

Checked on the branch: pytest 1328 passed and 1 skipped on 3.11 and on 3.12, with ruff check src tests scripts, python tools/check_dashes.py and mypy src/agentrust_trace clean, on a fresh clone with only pip install -e ".[dev]". Every committed example record still validates, and the private-member refusal from #296 still holds. Removing the RSA branch fails three of the new tests, and removing the n and e declarations fails a fourth.

Type of change

  • Editorial (typo, link fix, clarification: no normative effect)
  • Non-breaking spec change (new optional field, new platform profile, informative addition)
  • Breaking spec change (requires 14-day comment period and Project Lead sign-off)
  • Schema change
  • Example addition

Spec section

None changed. The schema is brought into line with sections 3.2.2 and 3.3, both already merged.

Checklist

  • DCO sign-off on all commits (git commit -s)
  • CHANGELOG.md updated (for any normative change): entry added under Unreleased, Fixed
  • Breaking changes marked with <!-- CHANGED: #NNN: description --> in spec text: not a breaking change
  • Backward compatibility statement included (for breaking changes): not a breaking change

`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>
@lywinged
lywinged requested a review from a team as a code owner September 9, 2026 17:58
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Contributor Check: UNKNOWN

Check Result
Profile UNKNOWN
Credential LOW
Overall UNKNOWN

Automated check by AgenTrust Contributor Check.

@github-actions github-actions Bot added the needs-review:UNKNOWN Contributor check flagged UNKNOWN risk label Sep 9, 2026
@rajnisht7

rajnisht7 commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

This PR corrects the JSON schema, now n and e are required in RSA Key, but python model is still old 'models.JWK, it still accepts keys as {"kty": "RSA"} that is without n and e. And also the new tests validate the schema but not the complete producer/model path.

Review on agentrust-io#311 by @rajnisht7: the schema was corrected and `models.JWK` was not,
so `JWK(kty="RSA")` still constructed, and the new tests measured only the schema.
Both points hold. `models.JWK` carried `required_by_kty = {"OKP": (...), "EC":
(...)}`, the same two-entry table the schema's `allOf` carried, and `.get(kty, ())`
gave `RSA` no requirement at all.

That half-made state is the one `test_all_three_layers_draw_the_line_in_the_same_place`
was written for on `iat`: "while it was half made, the model and the schema
disagreed". The schema is what an implementation in another language validates
against; `models.JWK` is exported and is what a Python caller reaches. A producer
meets them in an order nobody controls.

`RSA` is added to the model's table, and `n` and `e` are declared members rather
than left to `extra="allow"`, for two reasons a control demonstrates: without the
declarations the validator raises `AttributeError` instead of refusing the key,
and a non-string modulus is stored as an untyped extra rather than refused. The
same two declarations are load-bearing in the schema for the same second reason.

Three tests in `test_models.py` mirror the `OKP` and `EC` pairs already there. The
instrument that would have caught this is the parametrized case table in
`test_cnf_jwk_carries_key_material.py`: eleven confirmation keys checked against
the schema and the model together, asserting the two agree, including on the
`kty` neither artifact names, where they agree to accept.

Declaring two optional members on a model whose output is signed is the risk this
carries, and it is already instrumented: `_omit_absent_optionals` keeps them out
of a dump, `test_the_round_trip_returns_the_same_record` compares the whole record
including `cnf`, and disabling that serializer makes both new members appear as
`null` and fails it. A record round-trips to identical canonical bytes.

Signed-off-by: Louie Lu <48041247+lywinged@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TGTKY8Fut5trnDKm8TReTa
@lywinged

lywinged commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

@rajnisht7 Both points are right, and the second one is the sharper of the two. Fixed in 33dbe14.

models.JWK carried this:

required_by_kty = {"OKP": ("crv", "x"), "EC": ("crv", "x", "y")}
required = required_by_kty.get(self.kty, ())

which is the same two-entry table the schema's allOf carried, and .get(kty, ()) gives RSA no requirement at all. So JWK(kty="RSA") constructed, and JWK is exported. I corrected one of the two artifacts that make this decision and left the other saying what it said before, which is the failure this PR is about, one layer up.

The repository already names that state. test_all_three_layers_draw_the_line_in_the_same_place was written for the same shape on iat, and its docstring says it: "while it was half made, the model and the schema disagreed."

What changed:

  • RSA: ("n", "e") added to the model's table.
  • n and e declared as members rather than left to extra="allow". Two controls say the declarations are load-bearing rather than tidiness. Without them the validator raises AttributeError on a missing member instead of refusing the key, because getattr on an absent extra is not None. And {"kty": "RSA", "n": 123} is stored as an untyped extra rather than refused, which is the second reason the same two declarations are load-bearing in the schema.
  • Three tests in test_models.py mirroring the OKP and EC pairs already there.

On your second point, the answer I think is the real one is not more cases but a different instrument. test_cnf_jwk_carries_key_material.py now ends with a parametrized table of eleven confirmation keys checked against the schema and the model together, asserting the two agree on each. Removing either half of the fix fails it. It also pins the deliberate open end: a kty neither artifact names is accepted by both, because section 3.2.1 fixes no algorithm set for the embedded-signature form of section 3.2.2, so refusing an unnamed kty would be a normative change and not this PR.

Where I would push back slightly is on "the complete producer path", not because the concern is wrong but because this package cannot host that test honestly. sign_record takes an Ed25519PrivateKey and _pubkey_from_jwk refuses any kty that is not OKP, so there is no RSA record this package can produce or verify end to end. The layer an RSA key actually reaches here is the model, which is exactly the layer you found unguarded. A producer path test would have to assert something the reference implementation does not do.

One risk this carries that is worth stating: declaring two optional members on a model whose output gets signed. That is already instrumented rather than argued. _omit_absent_optionals keeps them out of a dump, test_the_round_trip_returns_the_same_record compares the whole record including cnf, and disabling that serializer makes both new members appear as null and fails it. A record round-trips to identical canonical bytes under RFC 8785.

1342 passed and 1 skipped on 3.11 and on 3.12, up 14 from the previous commit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-review:UNKNOWN Contributor check flagged UNKNOWN risk

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants