Skip to content

Commit a247244

Browse files
authored
fix(schema): hold an RSA confirmation key to carrying its key material (#311)
1 parent a79ad17 commit a247244

6 files changed

Lines changed: 252 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR.
1313

1414
### Fixed
1515

16+
- **`cnf.jwk` accepted an RSA confirmation key carrying no key material.** The schema says "Keys must carry actual key material" and enforced it for `OKP` and `EC` only, so a `cnf.jwk` of `{"kty": "RSA"}` with no `n` and no `e` validated, and the record then failed inside the verifier, where `sign.jwk_thumbprint` reports the missing 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. `RSA` now requires `n` and `e`, which states what the description already claimed and refuses nothing that verifies. A `kty` enum is deliberately not added, because 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` here would add a constraint the specification does not make. `models.JWK`, which is exported and is what a Python caller reaches, carried the same `OKP`/`EC`-only table and is corrected with it; `n` and `e` are declared members there too, so a non-string modulus is refused rather than stored as an untyped extra. A parametrized test now checks the schema and the model against each other on every case, since a key one takes and the other refuses fails somewhere the producer did not choose. Both copies of the schema move together, and a test asserts they are the same bytes.
17+
1618
- **`provenance.verify_record()` and `intent_bridge.verify_bridge()` raised exceptions their own modules do not document when the untrusted `signature` field was malformed.** Both functions decode a caller-supplied signature before its shape has been established. `provenance.verify_record()` never checked that `record["signature"]` was a string at all: `signature + "=" * (-len(signature) % 4)` ran directly on whatever JSON value sat under the key, and a non-string (an int, a bool, a list, a nested object) raised a bare `TypeError` (`object of type 'int' has no len()` for an int; a `TypeError` on `+` for a dict or list) rather than the `ProvenanceError` this function documents for every other malformed input, including its own signature-presence check three lines above. `intent_bridge.verify_bridge()` did check the type, but called `sign._b64url_decode()` unwrapped: that function raises the bare `ValueError` `sign` documents for itself, not an `IntentBridgeError`, so a correctly-typed but undecodable string (too short to pad to a whole byte, or carrying a non-ASCII character) escaped as that `ValueError`. Same shape as the `rfc8785.CanonicalizationError` leak the "Six public functions" fix (below) already closed in this module; that sweep did not cover this call site. Neither is a signature-verification bypass: a malformed signature was always rejected, only with the wrong exception type, so a caller written against the module's own documented exception (as both modules' docstrings instruct) would see an uncaught crash instead of a handled refusal. Both now reuse `sign._b64url_decode()`, already `sign.verify_record()`'s own guard for this exact field, and wrap its `ValueError` in the calling module's documented type. In `provenance.verify_record()` the guard is placed where the crash it replaces was, after the `cnf.jwk` checks, so a record with more than one defect still reports them in the same order it did before. 13 regression tests added across both modules' non-string and malformed-base64-string cases, plus one pinning that check order.
1719

1820
## [0.10.0] - 2026-09-05

schema/trace-claim.json

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,7 @@
412412
"properties": {
413413
"jwk": {
414414
"type": "object",
415-
"description": "JWK (RFC 7517) representing the TEE-sealed public key. Keys must carry actual key material: OKP keys require crv and x; EC keys require crv, x, and y.",
415+
"description": "JWK (RFC 7517) representing the TEE-sealed public key. Keys must carry actual key material: OKP keys require crv and x; EC keys require crv, x, and y; RSA keys require n and e.",
416416
"required": [
417417
"kty"
418418
],
@@ -429,6 +429,12 @@
429429
"y": {
430430
"type": "string"
431431
},
432+
"n": {
433+
"type": "string"
434+
},
435+
"e": {
436+
"type": "string"
437+
},
432438
"kid": {
433439
"type": "string"
434440
}
@@ -470,6 +476,24 @@
470476
"y"
471477
]
472478
}
479+
},
480+
{
481+
"if": {
482+
"required": [
483+
"kty"
484+
],
485+
"properties": {
486+
"kty": {
487+
"const": "RSA"
488+
}
489+
}
490+
},
491+
"then": {
492+
"required": [
493+
"n",
494+
"e"
495+
]
496+
}
473497
}
474498
],
475499
"not": {
@@ -511,7 +535,7 @@
511535
}
512536
]
513537
},
514-
"$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.",
538+
"$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. A kty this schema does not name is not held to a key-material rule 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 an enum here would add a constraint the specification does not make, and such a key is refused later by the verifier. The n and e declarations are load-bearing rather than decorative: without them a non-string modulus reaches additionalProperties, which admits any canonicalizable value.",
515539
"additionalProperties": {
516540
"$ref": "#/$defs/canonicalizableValue"
517541
}

src/agentrust_trace/models.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,12 +315,18 @@ class JWK(_TraceModel):
315315
crv: str | None = None
316316
x: str | None = None
317317
y: str | None = None
318+
n: str | None = None
319+
e: str | None = None
318320
kid: str | None = None
319321

320322
@model_validator(mode="after")
321323
def _require_key_material(self) -> JWK:
322324
"""A confirmation key without key material binds nothing (RFC 7518 §6)."""
323-
required_by_kty = {"OKP": ("crv", "x"), "EC": ("crv", "x", "y")}
325+
required_by_kty = {
326+
"OKP": ("crv", "x"),
327+
"EC": ("crv", "x", "y"),
328+
"RSA": ("n", "e"),
329+
}
324330
required = required_by_kty.get(self.kty, ())
325331
missing = [name for name in required if getattr(self, name) is None]
326332
if missing:

src/agentrust_trace/schema/trace-v0.2.json

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,7 @@
412412
"properties": {
413413
"jwk": {
414414
"type": "object",
415-
"description": "JWK (RFC 7517) representing the TEE-sealed public key. Keys must carry actual key material: OKP keys require crv and x; EC keys require crv, x, and y.",
415+
"description": "JWK (RFC 7517) representing the TEE-sealed public key. Keys must carry actual key material: OKP keys require crv and x; EC keys require crv, x, and y; RSA keys require n and e.",
416416
"required": [
417417
"kty"
418418
],
@@ -429,6 +429,12 @@
429429
"y": {
430430
"type": "string"
431431
},
432+
"n": {
433+
"type": "string"
434+
},
435+
"e": {
436+
"type": "string"
437+
},
432438
"kid": {
433439
"type": "string"
434440
}
@@ -470,6 +476,24 @@
470476
"y"
471477
]
472478
}
479+
},
480+
{
481+
"if": {
482+
"required": [
483+
"kty"
484+
],
485+
"properties": {
486+
"kty": {
487+
"const": "RSA"
488+
}
489+
}
490+
},
491+
"then": {
492+
"required": [
493+
"n",
494+
"e"
495+
]
496+
}
473497
}
474498
],
475499
"not": {
@@ -511,7 +535,7 @@
511535
}
512536
]
513537
},
514-
"$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.",
538+
"$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. A kty this schema does not name is not held to a key-material rule 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 an enum here would add a constraint the specification does not make, and such a key is refused later by the verifier. The n and e declarations are load-bearing rather than decorative: without them a non-string modulus reaches additionalProperties, which admits any canonicalizable value.",
515539
"additionalProperties": {
516540
"$ref": "#/$defs/canonicalizableValue"
517541
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
"""A confirmation key that names a key type has to carry that type's key material.
2+
3+
`schema/trace-claim.json` says of the confirmation key: *"Keys must carry actual key
4+
material"*. It enforced that for `OKP` and `EC` and for nothing else, so a `cnf.jwk` of
5+
`{"kty": "RSA"}` with no `n` and no `e` validated, and the record then failed inside the
6+
verifier with `jwk_thumbprint`'s "missing required thumbprint member 'e'". Nothing was
7+
accepted that should have been refused, since every path downstream fails closed. What was
8+
wrong is which instrument spoke: the schema is the artifact an implementation in any
9+
language validates against, and it was not the thing that told the producer the key was
10+
unusable.
11+
12+
`RSA` is added here rather than a `kty` enum. `sign.jwk_thumbprint` already knows the three
13+
types this now covers, and requiring their members states what the schema already claims.
14+
Narrowing `kty` to a fixed set would be a different act: section 3.2.1 states signing
15+
algorithms per envelope context and fixes no set for the embedded-signature form of section
16+
3.2.2, so a schema-level enum would add a constraint the specification does not make, which
17+
is a normative question and not a schema fix.
18+
19+
`models.JWK` carried the same `OKP`/`EC`-only table and is corrected with it. The schema is
20+
what another language validates against and the model is what a Python caller reaches, so
21+
the last test here checks the two against each other on every case rather than trusting that
22+
a fix applied to one of them reached the other.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import copy
28+
import json
29+
import pathlib
30+
from typing import Any
31+
32+
import jsonschema
33+
import pydantic
34+
import pytest
35+
36+
from agentrust_trace.models import TrustRecord
37+
38+
ROOT = pathlib.Path(__file__).resolve().parents[1]
39+
SCHEMA = json.loads((ROOT / "schema" / "trace-claim.json").read_text(encoding="utf-8"))
40+
VALIDATOR = jsonschema.Draft202012Validator(SCHEMA)
41+
42+
BASE: dict[str, Any] = {
43+
"eat_profile": "tag:agentrust-io.com,2026:trace-v0.2",
44+
"iat": 1750000000,
45+
"subject": "spiffe://factory.example/agent/payments/prod",
46+
"model": {"provider": "anthropic", "model_id": "claude-sonnet-4-6"},
47+
"runtime": {"platform": "software-only", "measurement": "sha256:" + "0" * 64},
48+
"policy": {"bundle_hash": "sha256:" + "a" * 64, "enforcement_mode": "enforce"},
49+
"data_class": "confidential",
50+
"build_provenance": {"slsa_level": 0, "digest": "sha256:" + "b" * 64},
51+
"appraisal": {"status": "affirming", "verifier": "https://agt.example.org/verifier"},
52+
"cnf": {
53+
"jwk": {
54+
"kty": "OKP",
55+
"crv": "Ed25519",
56+
"x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo",
57+
}
58+
},
59+
}
60+
61+
62+
def _with_jwk(jwk: dict[str, Any]) -> dict[str, Any]:
63+
record = copy.deepcopy(BASE)
64+
record["cnf"]["jwk"] = jwk
65+
return record
66+
67+
68+
def test_the_base_record_is_valid() -> None:
69+
"""Without this the refusals below would prove nothing about the key."""
70+
VALIDATOR.validate(BASE)
71+
72+
73+
@pytest.mark.parametrize(
74+
"jwk",
75+
[
76+
pytest.param({"kty": "RSA"}, id="rsa-with-no-material"),
77+
pytest.param({"kty": "RSA", "n": "0vx7ag"}, id="rsa-with-no-exponent"),
78+
pytest.param({"kty": "RSA", "e": "AQAB"}, id="rsa-with-no-modulus"),
79+
],
80+
)
81+
def test_an_rsa_confirmation_key_without_its_material_is_refused(jwk: dict[str, Any]) -> None:
82+
with pytest.raises(jsonschema.ValidationError) as caught:
83+
VALIDATOR.validate(_with_jwk(jwk))
84+
assert "is a required property" in str(caught.value)
85+
86+
87+
def test_the_rsa_members_have_to_be_strings() -> None:
88+
"""The `n` and `e` declarations are load-bearing, not decoration.
89+
90+
Without them a modulus reaches `additionalProperties`, which admits any
91+
canonicalizable value, so `{"n": 123}` would validate and carry an integer where
92+
base64url was meant. Deleting them from the schema has to fail something.
93+
"""
94+
with pytest.raises(jsonschema.ValidationError):
95+
VALIDATOR.validate(_with_jwk({"kty": "RSA", "n": 123, "e": "AQAB"}))
96+
97+
98+
def test_a_complete_rsa_confirmation_key_validates() -> None:
99+
"""The rule is about missing material, not about refusing the key type."""
100+
VALIDATOR.validate(_with_jwk({"kty": "RSA", "n": "0vx7ag", "e": "AQAB"}))
101+
102+
103+
@pytest.mark.parametrize(
104+
"jwk,missing",
105+
[
106+
pytest.param({"kty": "OKP"}, "crv", id="okp"),
107+
pytest.param({"kty": "EC", "crv": "P-256", "x": "f83OJ3D2"}, "y", id="ec"),
108+
],
109+
)
110+
def test_the_two_types_that_were_already_covered_still_are(
111+
jwk: dict[str, Any], missing: str
112+
) -> None:
113+
with pytest.raises(jsonschema.ValidationError) as caught:
114+
VALIDATOR.validate(_with_jwk(jwk))
115+
assert missing in str(caught.value)
116+
117+
118+
def test_the_packaged_schema_is_the_same_bytes() -> None:
119+
"""Two copies ship. A fix applied to one of them is not a fix."""
120+
published = (ROOT / "schema" / "trace-claim.json").read_bytes()
121+
packaged = (ROOT / "src" / "agentrust_trace" / "schema" / "trace-v0.2.json").read_bytes()
122+
assert published == packaged
123+
124+
125+
JWK_CASES = [
126+
pytest.param(BASE["cnf"]["jwk"], True, id="okp-complete"),
127+
pytest.param({"kty": "OKP"}, False, id="okp-bare"),
128+
pytest.param({"kty": "OKP", "crv": "Ed25519"}, False, id="okp-without-x"),
129+
pytest.param(
130+
{"kty": "EC", "crv": "P-256", "x": "f83OJ3D2", "y": "x_FEzRu9"}, True, id="ec-complete"
131+
),
132+
pytest.param({"kty": "EC", "crv": "P-256", "x": "f83OJ3D2"}, False, id="ec-without-y"),
133+
pytest.param({"kty": "RSA", "n": "0vx7ag", "e": "AQAB"}, True, id="rsa-complete"),
134+
pytest.param({"kty": "RSA"}, False, id="rsa-bare"),
135+
pytest.param({"kty": "RSA", "n": "0vx7ag"}, False, id="rsa-without-e"),
136+
pytest.param({"kty": "RSA", "e": "AQAB"}, False, id="rsa-without-n"),
137+
pytest.param({"kty": "RSA", "n": 123, "e": "AQAB"}, False, id="rsa-with-a-non-string-modulus"),
138+
pytest.param({"kty": "AKP", "pub": "x"}, True, id="a-kty-neither-artifact-names"),
139+
]
140+
141+
142+
@pytest.mark.parametrize("jwk,accepted", JWK_CASES)
143+
def test_the_schema_and_the_model_agree_on_every_case(jwk: dict[str, Any], accepted: bool) -> None:
144+
"""Two artifacts decide whether a confirmation key is usable, and they must agree.
145+
146+
`schema/trace-claim.json` is what an implementation in another language validates
147+
against; `models.JWK` is what a Python caller reaches, and it is exported. A producer
148+
meets them in an order nobody controls, so a key one takes and the other refuses fails
149+
somewhere unpredictable. Half of this fix was exactly that state: the schema required
150+
`n` and `e` and the model still did not, which is the disagreement
151+
`test_all_three_layers_draw_the_line_in_the_same_place` was written for on a different
152+
field. This is the same instrument for this one.
153+
154+
The last case is the deliberate open end. Neither artifact holds a `kty` it does not
155+
name to a key-material rule, because section 3.2.1 fixes no set for the
156+
embedded-signature form of section 3.2.2. They agree on that too.
157+
"""
158+
record = _with_jwk(jwk)
159+
schema_ok = not list(VALIDATOR.iter_errors(record))
160+
try:
161+
TrustRecord.model_validate(record)
162+
model_ok = True
163+
except pydantic.ValidationError:
164+
model_ok = False
165+
assert schema_ok == model_ok == accepted, (
166+
f"schema={schema_ok} model={model_ok}, expected {accepted}. A confirmation key one "
167+
"artifact takes and the other refuses fails somewhere the producer did not choose."
168+
)

tests/test_models.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,29 @@ def test_okp_jwk_with_key_material_accepted() -> None:
152152
assert record.cnf.jwk.x is not None
153153

154154

155+
def test_rsa_jwk_without_key_material_rejected() -> None:
156+
"""An RSA confirmation key needs n and e, the members its thumbprint is computed over."""
157+
data = _load("intel-tdx.json")
158+
data["cnf"]["jwk"] = {"kty": "RSA"}
159+
with pytest.raises(ValidationError):
160+
TrustRecord.model_validate(data)
161+
162+
163+
def test_rsa_jwk_without_its_exponent_rejected() -> None:
164+
data = _load("intel-tdx.json")
165+
data["cnf"]["jwk"] = {"kty": "RSA", "n": "0vx7agoebGcQSuuPiLJXZptN"}
166+
with pytest.raises(ValidationError):
167+
TrustRecord.model_validate(data)
168+
169+
170+
def test_rsa_jwk_with_key_material_accepted() -> None:
171+
data = _load("intel-tdx.json")
172+
data["cnf"]["jwk"] = {"kty": "RSA", "n": "0vx7agoebGcQSuuPiLJXZptN", "e": "AQAB"}
173+
record = TrustRecord.model_validate(data)
174+
assert record.cnf.jwk.n is not None
175+
assert record.cnf.jwk.e is not None
176+
177+
155178
def test_jwk_with_private_key_material_rejected() -> None:
156179
"""A cnf.jwk is a public key; private params (d, p, q, ...) must be rejected (#70)."""
157180
data = _load("intel-tdx.json")

0 commit comments

Comments
 (0)