|
| 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 | + ) |
0 commit comments