From 24b3c33c6e9f5128cdf4ce5b93961af5d6e3d9ee Mon Sep 17 00:00:00 2001 From: Alex Chernysh <73943355+chernistry@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:57:50 +0300 Subject: [PATCH 1/3] spec(numbers): decide integer-valued members by value, with Python and JavaScript tests (#247) JSON has one number type, and RFC 8785 writes a number by its value, so an iat written 1785000000, 1785000000.0 or 1.785e9 has one pre-image and one signature. The schemas already decided `integer` by value, as JSON Schema 2020-12 defines it. verify_record's freshness check tested for a Python int, and json.loads returns a float for the other two spellings, so a correctly signed record that had passed the schema was rejected. Add "What counts as an integer" to section 3.2.2: whether a number is an integer, and whether it is inside the safe-integer range, is decided by its value, the IEEE 754 double RFC 8785 serializes, and a verifier must make both decisions on that value. In the reference, _integer_value decides a member by value and names the rule a value fails. The iat freshness check and the bridge's authorized_at, expires_at and transcript.after.observed_at checks use it. _canonical_bytes refuses a whole number past the safe-integer range written as a float, as rfc8785 already refused one written as an int; both adapters' transcript hashes now go through it. The model reads a whole float as the integer it is, and refuses a numeric string, which the schema never accepted. Add examples/number-spelling/: seven signed vectors (three spellings of one iat with one signature, a fractional value and a value past the range each rejected in two spellings), their generator, a node:test file with no dependencies, and a scan of published records. Across this repository, trace-tests at 3af2b53 and trace-registry, no integer-typed member is written with a fraction or an exponent, so no published verdict changes. Add tests/test_integer_by_value.py and register the set with the adequacy checks. Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com> --- CHANGELOG.md | 2 + docs/schema.md | 5 + examples/README.md | 6 + .../01-integer-spelling-verified.json | 61 ++ .../02-fraction-spelling-verified.json | 62 ++ .../03-exponent-spelling-verified.json | 62 ++ .../04-fractional-value-rejected.json | 61 ++ ...onal-value-exponent-spelling-rejected.json | 62 ++ ...above-range-integer-spelling-rejected.json | 62 ++ ...bove-range-exponent-spelling-rejected.json | 62 ++ examples/number-spelling/README.md | 130 +++ .../number-spelling/gen_number_spelling.py | 247 ++++++ .../number-spelling/scan_published_numbers.py | 187 +++++ examples/number-spelling/spelling.test.mjs | 202 +++++ spec/trace-v0.2.md | 1 + src/agentrust_trace/adapters/agt.py | 4 +- src/agentrust_trace/adapters/sandbox.py | 5 +- src/agentrust_trace/intent_bridge.py | 40 +- src/agentrust_trace/models.py | 30 +- src/agentrust_trace/sign.py | 76 +- tests/test_adequacy_all_sets.py | 10 + tests/test_integer_by_value.py | 738 ++++++++++++++++++ 22 files changed, 2088 insertions(+), 27 deletions(-) create mode 100644 examples/number-spelling/01-integer-spelling-verified.json create mode 100644 examples/number-spelling/02-fraction-spelling-verified.json create mode 100644 examples/number-spelling/03-exponent-spelling-verified.json create mode 100644 examples/number-spelling/04-fractional-value-rejected.json create mode 100644 examples/number-spelling/05-fractional-value-exponent-spelling-rejected.json create mode 100644 examples/number-spelling/06-above-range-integer-spelling-rejected.json create mode 100644 examples/number-spelling/07-above-range-exponent-spelling-rejected.json create mode 100644 examples/number-spelling/README.md create mode 100644 examples/number-spelling/gen_number_spelling.py create mode 100644 examples/number-spelling/scan_published_numbers.py create mode 100644 examples/number-spelling/spelling.test.mjs create mode 100644 tests/test_integer_by_value.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 78849932..063895e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR. ## [Unreleased] +- **Proposal: integer-typed members are decided by value, not by how they are written (#247).** JSON has one number type, and RFC 8785 writes a number by its value, so `iat` written `1785000000`, `1785000000.0` or `1.785e9` has one pre-image and one signature. The schemas already decided `integer` by value, as JSON Schema 2020-12 defines it. `verify_record` did not: its freshness check tested for a Python `int`, `json.loads` returns a `float` for the other two spellings, and a correctly signed record that had just passed the schema was rejected for having "no valid integer 'iat'". Section 3.2.2 gains a paragraph, "What counts as an integer", stating the rule and that the value is the IEEE 754 double RFC 8785 serializes. In the reference, the freshness check and the bridge's `authorized_at`, `expires_at` and `transcript.after.observed_at` checks accept a whole number however it is written and name the rule a value fails: not an integer value, or outside the safe-integer range. `_canonical_bytes`, and through it signing, every digest and both adapters' transcript hashes, refuses a whole number past the range written as a float, as it already refused one written as an integer. The model reads `1785000000.0` as the integer it is. Two inputs that were accepted are now refused, both by the library and neither in a record that passes the schema: a numeric string such as `"1785000000"`, which the model read as an integer and the schema never accepted, and a whole float past the range in an object the library signs or digests, such as a nanosecond timestamp that went through a double. `examples/number-spelling/` adds seven vectors, a JavaScript test that runs on Node.js with no dependencies, and a scan of published records: of 1,261 numbers in this repository's examples, spec, docs and schemas, in `agentrust-io/trace-tests` at `3af2b53` and in `agentrust-io/trace-registry`, 505 are integer-typed and none is written with a fraction or an exponent, so no published verdict changes. Requested by @imran-siddique on #247; proposed by @chernistry. + - Compare challenge nonces as UTF-8 bytes so non-ASCII mismatches raise the documented ValueError instead of TypeError (#381). Matching strings remain accepted without Unicode normalization. Invalid non-string expected_nonce settings raise a clear ValueError. Reported by @chernistry; follow-up review by @lywinged. - Report revocation bundles without an RFC 8785 form as `unverified_for_revocation` diff --git a/docs/schema.md b/docs/schema.md index da6ecb81..350ceef4 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -10,6 +10,11 @@ unambiguously, since RFC 8785 serializes numbers through an IEEE 754 double and can share one. A value that needs to be larger is carried as a string. The same bound applies to members a `cnf.jwk` carries that this schema does not name. +Whether a number is an integer is decided by its value, not by how it is written (spec section 3.2.2, "What +counts as an integer"). `1785000000.0` and `1.785e9` are the integer 1785000000; `1785000000.5` is not an +integer. JSON Schema defines `integer` the same way, so validating against this schema already gives that +answer. + ## Top-level fields {#trace-fields} diff --git a/examples/README.md b/examples/README.md index b9922e5a..bfeeaacd 100644 --- a/examples/README.md +++ b/examples/README.md @@ -43,6 +43,12 @@ checking any archived record, including these. §3.2.2 requires and names as insufficient. Each file is a test-vector envelope; the Trust Record is under the `record` key and validates against the schema. See that directory's README. +- `number-spelling/`: seven signed Trust Records for a rule proposed on #247 and not yet + accepted: an integer-typed member is decided by its value, not by how it is written. + `iat` written `1785000000`, `1785000000.0` and `1.785e9` carries one signature and + verifies; a fractional value and a value past the safe-integer range are each rejected + in two spellings. Each file is a test-vector envelope with the record under `record`. + See that directory's README, which also counts the published records the rule changes. - `condition-appraisal/`: five signed Trust Records, each citing an independent check's finding through a `references` entry with `rel: "condition-appraisal"`, against an appraisal store committed beside them: confirmed, altered after issue, a fail that diff --git a/examples/number-spelling/01-integer-spelling-verified.json b/examples/number-spelling/01-integer-spelling-verified.json new file mode 100644 index 00000000..85910348 --- /dev/null +++ b/examples/number-spelling/01-integer-spelling-verified.json @@ -0,0 +1,61 @@ +{ + "name": "integer-spelling-verified", + "description": "The record and signature published as examples/verifier-compatibility/01-known-version-verified.json, with iat in the plain integer spelling. The baseline the next two vectors re-spell.", + "spec": "trace-v0.2 section 3.2.2, What counts as an integer", + "profile": "trace.number_spelling.proposal.v0", + "proposal": { + "issue": "agentrust-io/trace-spec#247", + "status": "under review, not accepted normative text" + }, + "member": "iat", + "spelling": "1785000000", + "verifier": { + "verification_time": 1785000100, + "max_age_seconds": 86400, + "max_future_skew_seconds": 300 + }, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "S9f_IqfsGG7jIzI7brZ7VVXCyF-_1jj_Guc1ynPKpuI" + }, + "record": { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://factory.example/agent/payments/prod", + "model": { + "provider": "example-provider", + "model_id": "example-model-1" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "transparency": "https://rekor.example/api/v1/log/entries/0", + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "S9f_IqfsGG7jIzI7brZ7VVXCyF-_1jj_Guc1ynPKpuI" + } + }, + "signature": "yG9rlff2_KlMwnhgSz5u-yAiTl7XVZOu5jXXD3plPm5YWaUCcWq3VmA_xtwcRg3JCDKRRYLZHH-TpSjDMYrKCA" + }, + "expected": { + "outcome": "verified", + "failure": null + } +} diff --git a/examples/number-spelling/02-fraction-spelling-verified.json b/examples/number-spelling/02-fraction-spelling-verified.json new file mode 100644 index 00000000..198fd893 --- /dev/null +++ b/examples/number-spelling/02-fraction-spelling-verified.json @@ -0,0 +1,62 @@ +{ + "name": "fraction-spelling-verified", + "description": "The same record with iat written 1785000000.0. The value is the integer 1785000000: JSON has one number type, and RFC 8785 writes this value as 1785000000, so the pre-image and the signature are 01's, byte for byte. Python's json.loads returns a float here and an int for 01; JSON.parse returns one number for both, so a JavaScript verifier cannot tell this record from 01 at all.", + "spec": "trace-v0.2 section 3.2.2, What counts as an integer", + "profile": "trace.number_spelling.proposal.v0", + "proposal": { + "issue": "agentrust-io/trace-spec#247", + "status": "under review, not accepted normative text" + }, + "member": "iat", + "spelling": "1785000000.0", + "same_signature_as": "01-integer-spelling-verified.json", + "verifier": { + "verification_time": 1785000100, + "max_age_seconds": 86400, + "max_future_skew_seconds": 300 + }, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "S9f_IqfsGG7jIzI7brZ7VVXCyF-_1jj_Guc1ynPKpuI" + }, + "record": { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000.0, + "subject": "spiffe://factory.example/agent/payments/prod", + "model": { + "provider": "example-provider", + "model_id": "example-model-1" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "transparency": "https://rekor.example/api/v1/log/entries/0", + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "S9f_IqfsGG7jIzI7brZ7VVXCyF-_1jj_Guc1ynPKpuI" + } + }, + "signature": "yG9rlff2_KlMwnhgSz5u-yAiTl7XVZOu5jXXD3plPm5YWaUCcWq3VmA_xtwcRg3JCDKRRYLZHH-TpSjDMYrKCA" + }, + "expected": { + "outcome": "verified", + "failure": null + } +} diff --git a/examples/number-spelling/03-exponent-spelling-verified.json b/examples/number-spelling/03-exponent-spelling-verified.json new file mode 100644 index 00000000..1dbf01eb --- /dev/null +++ b/examples/number-spelling/03-exponent-spelling-verified.json @@ -0,0 +1,62 @@ +{ + "name": "exponent-spelling-verified", + "description": "The same record with iat written 1.785e9, the exponent spelling of the same value, with 01's pre-image and 01's signature. Two accepting re-spellings rather than one: an implementation that refuses an exponent, or that accepts 02 by stripping a trailing .0 from the text, verifies 02 and rejects this one.", + "spec": "trace-v0.2 section 3.2.2, What counts as an integer", + "profile": "trace.number_spelling.proposal.v0", + "proposal": { + "issue": "agentrust-io/trace-spec#247", + "status": "under review, not accepted normative text" + }, + "member": "iat", + "spelling": "1.785e9", + "same_signature_as": "01-integer-spelling-verified.json", + "verifier": { + "verification_time": 1785000100, + "max_age_seconds": 86400, + "max_future_skew_seconds": 300 + }, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "S9f_IqfsGG7jIzI7brZ7VVXCyF-_1jj_Guc1ynPKpuI" + }, + "record": { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1.785e9, + "subject": "spiffe://factory.example/agent/payments/prod", + "model": { + "provider": "example-provider", + "model_id": "example-model-1" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "transparency": "https://rekor.example/api/v1/log/entries/0", + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "S9f_IqfsGG7jIzI7brZ7VVXCyF-_1jj_Guc1ynPKpuI" + } + }, + "signature": "yG9rlff2_KlMwnhgSz5u-yAiTl7XVZOu5jXXD3plPm5YWaUCcWq3VmA_xtwcRg3JCDKRRYLZHH-TpSjDMYrKCA" + }, + "expected": { + "outcome": "verified", + "failure": null + } +} diff --git a/examples/number-spelling/04-fractional-value-rejected.json b/examples/number-spelling/04-fractional-value-rejected.json new file mode 100644 index 00000000..a5fa165f --- /dev/null +++ b/examples/number-spelling/04-fractional-value-rejected.json @@ -0,0 +1,61 @@ +{ + "name": "fractional-value-rejected", + "description": "iat written 1785000000.5, a number that is not a whole number and so is not an integer. The signature is valid over this record's own RFC 8785 form, so the only reason to reject it is the rule under test.", + "spec": "trace-v0.2 section 3.2.2, What counts as an integer", + "profile": "trace.number_spelling.proposal.v0", + "proposal": { + "issue": "agentrust-io/trace-spec#247", + "status": "under review, not accepted normative text" + }, + "member": "iat", + "spelling": "1785000000.5", + "verifier": { + "verification_time": 1785000100, + "max_age_seconds": 86400, + "max_future_skew_seconds": 300 + }, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "S9f_IqfsGG7jIzI7brZ7VVXCyF-_1jj_Guc1ynPKpuI" + }, + "record": { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000.5, + "subject": "spiffe://factory.example/agent/payments/prod", + "model": { + "provider": "example-provider", + "model_id": "example-model-1" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "transparency": "https://rekor.example/api/v1/log/entries/0", + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "S9f_IqfsGG7jIzI7brZ7VVXCyF-_1jj_Guc1ynPKpuI" + } + }, + "signature": "pauSvCiX3XmhkLW1Pl0zMHkKbwofRBAwJl3KKuq3cSyVo3PiXwWTML93RreNEf7CsxUA3oBUBb-QsVnB0P9TCw" + }, + "expected": { + "outcome": "rejected", + "failure": "not_an_integer_value" + } +} diff --git a/examples/number-spelling/05-fractional-value-exponent-spelling-rejected.json b/examples/number-spelling/05-fractional-value-exponent-spelling-rejected.json new file mode 100644 index 00000000..c3881499 --- /dev/null +++ b/examples/number-spelling/05-fractional-value-exponent-spelling-rejected.json @@ -0,0 +1,62 @@ +{ + "name": "fractional-value-exponent-spelling-rejected", + "description": "04's value written 17850000005e-1, with no decimal point, and so 04's pre-image and 04's signature. An implementation that looks for a decimal point to find a fraction accepts this spelling of a fractional value; the rule is on the value.", + "spec": "trace-v0.2 section 3.2.2, What counts as an integer", + "profile": "trace.number_spelling.proposal.v0", + "proposal": { + "issue": "agentrust-io/trace-spec#247", + "status": "under review, not accepted normative text" + }, + "member": "iat", + "spelling": "17850000005e-1", + "same_signature_as": "04-fractional-value-rejected.json", + "verifier": { + "verification_time": 1785000100, + "max_age_seconds": 86400, + "max_future_skew_seconds": 300 + }, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "S9f_IqfsGG7jIzI7brZ7VVXCyF-_1jj_Guc1ynPKpuI" + }, + "record": { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 17850000005e-1, + "subject": "spiffe://factory.example/agent/payments/prod", + "model": { + "provider": "example-provider", + "model_id": "example-model-1" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "transparency": "https://rekor.example/api/v1/log/entries/0", + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "S9f_IqfsGG7jIzI7brZ7VVXCyF-_1jj_Guc1ynPKpuI" + } + }, + "signature": "pauSvCiX3XmhkLW1Pl0zMHkKbwofRBAwJl3KKuq3cSyVo3PiXwWTML93RreNEf7CsxUA3oBUBb-QsVnB0P9TCw" + }, + "expected": { + "outcome": "rejected", + "failure": "not_an_integer_value" + } +} diff --git a/examples/number-spelling/06-above-range-integer-spelling-rejected.json b/examples/number-spelling/06-above-range-integer-spelling-rejected.json new file mode 100644 index 00000000..104f076d --- /dev/null +++ b/examples/number-spelling/06-above-range-integer-spelling-rejected.json @@ -0,0 +1,62 @@ +{ + "name": "above-range-integer-spelling-rejected", + "description": "appraisal.timestamp written 9007199254740992, one past the safe-integer range. No freshness rule reads this member, so the range is the only rule that can reject the record. The signature is valid over the pre-image RFC 8785 writes for this record, which converts the number through a double; the rfc8785 Python package refuses to write this integer at all, so the generator computes the same bytes from the double.", + "spec": "trace-v0.2 section 3.2.2, What counts as an integer", + "profile": "trace.number_spelling.proposal.v0", + "proposal": { + "issue": "agentrust-io/trace-spec#247", + "status": "under review, not accepted normative text" + }, + "member": "appraisal.timestamp", + "spelling": "9007199254740992", + "verifier": { + "verification_time": 1785000100, + "max_age_seconds": 86400, + "max_future_skew_seconds": 300 + }, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "S9f_IqfsGG7jIzI7brZ7VVXCyF-_1jj_Guc1ynPKpuI" + }, + "record": { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://factory.example/agent/payments/prod", + "model": { + "provider": "example-provider", + "model_id": "example-model-1" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1", + "timestamp": 9007199254740992 + }, + "transparency": "https://rekor.example/api/v1/log/entries/0", + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "S9f_IqfsGG7jIzI7brZ7VVXCyF-_1jj_Guc1ynPKpuI" + } + }, + "signature": "5QnDjDeLIk8JLYrGGDFS72yayl8_b7hsqBd9MKjphNvDYZn0283ugRgYqOTIyLAz8D-Jd2rLdYSxwr8UabJMBA" + }, + "expected": { + "outcome": "rejected", + "failure": "outside_safe_integer_range" + } +} diff --git a/examples/number-spelling/07-above-range-exponent-spelling-rejected.json b/examples/number-spelling/07-above-range-exponent-spelling-rejected.json new file mode 100644 index 00000000..91a4fbcf --- /dev/null +++ b/examples/number-spelling/07-above-range-exponent-spelling-rejected.json @@ -0,0 +1,62 @@ +{ + "name": "above-range-exponent-spelling-rejected", + "description": "appraisal.timestamp written 1.0e+21, the example on #247: a float in Python, which RFC 8785 writes as 1e+21, and a whole number past the safe-integer range in JavaScript. Whole and out of range, so rejected by the range whatever its spelling. Separate from 06 because the rfc8785 Python package refuses an out-of-range int and writes this float without complaint: an implementation that leaves the range to its canonicalizer rejects 06 and verifies this one. The signature is valid over this record's RFC 8785 form.", + "spec": "trace-v0.2 section 3.2.2, What counts as an integer", + "profile": "trace.number_spelling.proposal.v0", + "proposal": { + "issue": "agentrust-io/trace-spec#247", + "status": "under review, not accepted normative text" + }, + "member": "appraisal.timestamp", + "spelling": "1.0e+21", + "verifier": { + "verification_time": 1785000100, + "max_age_seconds": 86400, + "max_future_skew_seconds": 300 + }, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "S9f_IqfsGG7jIzI7brZ7VVXCyF-_1jj_Guc1ynPKpuI" + }, + "record": { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://factory.example/agent/payments/prod", + "model": { + "provider": "example-provider", + "model_id": "example-model-1" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1", + "timestamp": 1.0e+21 + }, + "transparency": "https://rekor.example/api/v1/log/entries/0", + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "S9f_IqfsGG7jIzI7brZ7VVXCyF-_1jj_Guc1ynPKpuI" + } + }, + "signature": "FhYn2kIg9kPIjRZzX1lMiW8GD6Y7_usxa6-gBJ9PtayDZw_pIS3D4DAKRj7FdoUte_YP2E8im92N6nKYC6mCAg" + }, + "expected": { + "outcome": "rejected", + "failure": "outside_safe_integer_range" + } +} diff --git a/examples/number-spelling/README.md b/examples/number-spelling/README.md new file mode 100644 index 00000000..a16d98ee --- /dev/null +++ b/examples/number-spelling/README.md @@ -0,0 +1,130 @@ +# Number-spelling vectors (proposed, not accepted) + +> These fixtures encode the rule proposed in +> [agentrust-io/trace-spec#247](https://github.com/agentrust-io/trace-spec/issues/247). +> **No normative text for them has been accepted.** Do not read a passing vector here +> as a conformance requirement. + +JSON has one number type. `1785000000`, `1785000000.0` and `1.785e9` are three +spellings of one value, and RFC 8785 writes all three as `1785000000`, so a Trust +Record carrying any of them has one signing pre-image and one signature. JSON Schema +2020-12 agrees: `integer` matches any number with a zero fractional part. + +The Python reference did not. It decided whether `iat` was an integer by Python type, +and `json.loads` returns an `int` for the first spelling and a `float` for the other +two, so it rejected two correctly signed records that its own schema check had just +accepted, and that a JavaScript verifier, which has only the parsed value, cannot tell +apart from the first. + +The rule proposed for section 3.2.2: whether a member is an integer, and whether an +integer is inside the safe-integer range, is decided by the value the number denotes, +not by how it is written. + +## The vectors + +| Fixture | Member | Written as | Outcome | What it pins down | +|---|---|---|---|---| +| `01-integer-spelling-verified.json` | `iat` | `1785000000` | verified | The baseline: the record and signature of `examples/verifier-compatibility/01-known-version-verified.json`, unchanged. | +| `02-fraction-spelling-verified.json` | `iat` | `1785000000.0` | verified | A fraction spelling of 01's value, with 01's signature. | +| `03-exponent-spelling-verified.json` | `iat` | `1.785e9` | verified | An exponent spelling of 01's value, with 01's signature. | +| `04-fractional-value-rejected.json` | `iat` | `1785000000.5` | rejected, `not_an_integer_value` | A value that is not a whole number. | +| `05-fractional-value-exponent-spelling-rejected.json` | `iat` | `17850000005e-1` | rejected, `not_an_integer_value` | 04's value with no decimal point, and 04's signature. | +| `06-above-range-integer-spelling-rejected.json` | `appraisal.timestamp` | `9007199254740992` | rejected, `outside_safe_integer_range` | One past the range, written as an integer. | +| `07-above-range-exponent-spelling-rejected.json` | `appraisal.timestamp` | `1.0e+21` | rejected, `outside_safe_integer_range` | The example on #247: whole, past the range, and a float in Python. | + +**Every signature in the set is valid.** A rejecting vector is signed over its own +record, so the rule it names is the only reason to reject it. The range vectors use +`appraisal.timestamp` rather than `iat` because no freshness rule reads that member, +and a value past the range would otherwise also be a record from the far future. + +## Two vectors per rule, and why these two + +Each rule is carried by two vectors that a plausible shortcut cannot both pass: + +- **02 and 03.** A verifier that refuses an exponent, or one that accepts + `1785000000.0` by stripping a trailing `.0` from the text, verifies 02 and rejects + 03. One that decides on the parsed value cannot tell the two apart. +- **04 and 05.** A verifier that looks for a decimal point to find a fraction accepts + 05, which has none. +- **06 and 07.** A verifier that leaves the range to its canonicalizer, with the + `rfc8785` Python package as that canonicalizer, rejects 06, because the package + refuses an `int` outside the range, and verifies 07, because it writes the float + `1e21` as `1e+21` without complaint. + +`tests/test_integer_by_value.py` runs those shortcuts over the set and checks that +each one fails at least one vector. + +## Format + +Nothing in a fixture names a language or an API: + +```jsonc +{ + "member": "iat", // the member under test, dotted from the record root + "spelling": "1785000000.0", // how it is written in this file, character for character + "same_signature_as": "01-...", // present when the value, and so the signature, is another vector's + "verifier": { "verification_time": 1785000100, "max_age_seconds": 86400, + "max_future_skew_seconds": 300 }, + "trusted_key": { "kty": "OKP", "crv": "Ed25519", "x": "..." }, + "record": { "iat": 1785000000.0, "signature": "..." }, + "expected": { "outcome": "verified" | "rejected", + "failure": null | "not_an_integer_value" | "outside_safe_integer_range" } +} +``` + +`spelling` exists because a parser discards it: the file is the only place the +spelling survives, so the tests read it back out of the file's text and check it +against `spelling`. A fixture reformatted by a tool that rewrites numbers stops +testing anything, and the tests say so. + +`expected.failure` names the rule, not a registered failure code: the Python +reference rejects 04 through 07 at the schema, which reports `is not of type +'integer'` for 04 and 05 and `is greater than the maximum of 9007199254740991` for +06 and 07. `verification_time` is fixed so the set does not expire; `iat` is 100 +seconds before it. + +## The signature on 06 + +The `rfc8785` Python package refuses to write `9007199254740992` at all. RFC 8785 +section 3.2.2.3 writes every number through an IEEE 754 double, and the double of +that integer is exact, so the pre-image an implementation of the RFC writes for this +record is the one the package writes for `9007199254740992.0`. The generator signs +those bytes. A verifier that refuses the value while canonicalizing never reaches the +signature, and that is a rejection for the right reason. + +## Running them + +```bash +python examples/number-spelling/gen_number_spelling.py # rewrites the seven files +node --test examples/number-spelling/spelling.test.mjs # Node.js 20 or later, no dependencies +python -m pytest tests/test_integer_by_value.py +python examples/number-spelling/scan_published_numbers.py # see below +``` + +The generator is deterministic, and `tests/test_generators_reproduce_fixtures.py` +rebuilds the set on every run and compares it byte for byte. The key is the +verifier-compatibility fixture key, from the same published seed: public test +material, and not a new key. + +The JavaScript test parses the three spellings and gets one value, shows the spelling +does not survive `String(Number(x))`, the serialization RFC 8785 adopts, walks the +safe-integer boundary as the double sees it, and verifies every signature in the set +with `node:crypto` over a minimal RFC 8785 serialization of each record. + +## Which published records this changes + +The rule changes a verdict only for an integer-typed member written with a fraction or +an exponent. `scan_published_numbers.py` counts those in any directory it is given, +reading each number's spelling through `json`'s `parse_int` and `parse_float` hooks. +It skips this directory, whose vectors are re-spelled on purpose, unless it is named +itself, which makes the scan find the five re-spelled members here. + +| Scanned | JSON documents | Numbers | Integer-typed | Written with a fraction or an exponent | +|---|---:|---:|---:|---:| +| this repository, `examples/` | 202 | 1064 | 453 | 0 | +| this repository, `spec/`, `docs/`, `schema/` | 19 | 86 | 11 | 0 | +| `agentrust-io/trace-tests` at `3af2b53` | 16 | 47 | 32 | 0 | +| `agentrust-io/trace-registry` at `d8fa885` | 26 | 64 | 9 | 0 | + +No published record, fixture or example carries such a member, so no published +verdict changes. diff --git a/examples/number-spelling/gen_number_spelling.py b/examples/number-spelling/gen_number_spelling.py new file mode 100644 index 00000000..37dc11f3 --- /dev/null +++ b/examples/number-spelling/gen_number_spelling.py @@ -0,0 +1,247 @@ +"""Generate the number-spelling vectors (agentrust-io/trace-spec#247). + +Each fixture is one signed Trust Record with one integer-typed member written in a +particular spelling, and the outcome a verifier that decides integers by value must +produce. Nothing in a fixture names a language or an API. + +A JSON serializer chooses the spelling of every number it writes, so the fixtures +cannot be written by `json.dumps` alone: it would write `1.785e9` back as +`1785000000.0`. The member under test is written as a placeholder string and the +placeholder is replaced, in the serialized text, by the spelling the vector is about. +Every other byte comes from `json.dumps`. + +The key is the verifier-compatibility fixture key, derived from the same published +seed, and the base record is that set's record, so this set introduces no new signing +key and vector 01 carries the record and signature of +`examples/verifier-compatibility/01-known-version-verified.json`, unchanged. +Deterministic, so the set regenerates byte for byte. Public test material. +""" + +from __future__ import annotations + +import base64 +import copy +import hashlib +import json +from pathlib import Path +from typing import Any + +import rfc8785 +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +OUT = Path(__file__).resolve().parent +PROFILE = "trace.number_spelling.proposal.v0" +V0_2 = "tag:agentrust-io.com,2026:trace-v0.2" +SAFE_INTEGER = 2**53 - 1 + +# The verifier-compatibility seed, unchanged: the same key, not a new one. +SEED = hashlib.sha256(b"trace-spec#116 verifier-compatibility fixture key").digest() +KEY = Ed25519PrivateKey.from_private_bytes(SEED) + +PLACEHOLDER = "@number-under-test@" + + +def b64u(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +def public_jwk() -> dict[str, str]: + raw = KEY.public_key().public_bytes( + encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw + ) + return {"kty": "OKP", "crv": "Ed25519", "x": b64u(raw)} + + +# `examples/verifier-compatibility/gen_vectors.py`, BASE_RECORD, member for member. +BASE_RECORD: dict[str, Any] = { + "eat_profile": V0_2, + "iat": 1785000000, + "subject": "spiffe://factory.example/agent/payments/prod", + "model": {"provider": "example-provider", "model_id": "example-model-1"}, + "runtime": {"platform": "software-only", "measurement": "sha256:" + "0" * 64}, + "policy": {"bundle_hash": "sha256:" + "a" * 64, "enforcement_mode": "enforce"}, + "data_class": "confidential", + "build_provenance": {"slsa_level": 0, "digest": "sha256:" + "b" * 64}, + "appraisal": {"status": "affirming", "verifier": "https://verifier.example/v1"}, + "transparency": "https://rekor.example/api/v1/log/entries/0", + "cnf": {"jwk": public_jwk()}, +} + + +def _set(record: dict[str, Any], member: str, value: Any) -> None: + *parents, last = member.split(".") + target = record + for name in parents: + target = target[name] + target[last] = value + + +def signed_record(member: str, spelling: str) -> dict[str, Any]: + """The base record with *member* holding the value *spelling* parses to, signed. + + The signature is over the RFC 8785 form of the parsed record, which is what makes + the spelling invisible to it. One value has no form in the `rfc8785` package: an + integer outside the safe-integer range, which that package refuses rather than + writes. RFC 8785 section 3.2.2.3 converts every number through an IEEE 754 double, + so the pre-image an implementation of it writes for that integer is the one the + package writes for the double; the signature is made over those bytes. The record + keeps the parsed value, so the placeholder is still there to be replaced. + """ + value = json.loads(spelling) + record = copy.deepcopy(BASE_RECORD) + _set(record, member, value) + preimage = copy.deepcopy(record) + if isinstance(value, int) and abs(value) > SAFE_INTEGER: + _set(preimage, member, float(value)) + signature = b64u(KEY.sign(rfc8785.dumps(preimage))) + _set(record, member, PLACEHOLDER) + return {**record, "signature": signature} + + +VECTORS: list[dict[str, Any]] = [ + { + "file": "01-integer-spelling-verified.json", + "name": "integer-spelling-verified", + "description": ( + "The record and signature published as examples/verifier-compatibility/" + "01-known-version-verified.json, with iat in the plain integer spelling. " + "The baseline the next two vectors re-spell." + ), + "member": "iat", + "spelling": "1785000000", + "expected": {"outcome": "verified", "failure": None}, + }, + { + "file": "02-fraction-spelling-verified.json", + "name": "fraction-spelling-verified", + "description": ( + "The same record with iat written 1785000000.0. The value is the integer " + "1785000000: JSON has one number type, and RFC 8785 writes this value as " + "1785000000, so the pre-image and the signature are 01's, byte for byte. " + "Python's json.loads returns a float here and an int for 01; JSON.parse " + "returns one number for both, so a JavaScript verifier cannot tell this " + "record from 01 at all." + ), + "member": "iat", + "spelling": "1785000000.0", + "same_signature_as": "01-integer-spelling-verified.json", + "expected": {"outcome": "verified", "failure": None}, + }, + { + "file": "03-exponent-spelling-verified.json", + "name": "exponent-spelling-verified", + "description": ( + "The same record with iat written 1.785e9, the exponent spelling of the " + "same value, with 01's pre-image and 01's signature. Two accepting " + "re-spellings rather than one: an implementation that refuses an " + "exponent, or that accepts 02 by stripping a trailing .0 from the text, " + "verifies 02 and rejects this one." + ), + "member": "iat", + "spelling": "1.785e9", + "same_signature_as": "01-integer-spelling-verified.json", + "expected": {"outcome": "verified", "failure": None}, + }, + { + "file": "04-fractional-value-rejected.json", + "name": "fractional-value-rejected", + "description": ( + "iat written 1785000000.5, a number that is not a whole number and so is " + "not an integer. The signature is valid over this record's own RFC 8785 " + "form, so the only reason to reject it is the rule under test." + ), + "member": "iat", + "spelling": "1785000000.5", + "expected": {"outcome": "rejected", "failure": "not_an_integer_value"}, + }, + { + "file": "05-fractional-value-exponent-spelling-rejected.json", + "name": "fractional-value-exponent-spelling-rejected", + "description": ( + "04's value written 17850000005e-1, with no decimal point, and so 04's " + "pre-image and 04's signature. An implementation that looks for a decimal " + "point to find a fraction accepts this spelling of a fractional value; " + "the rule is on the value." + ), + "member": "iat", + "spelling": "17850000005e-1", + "same_signature_as": "04-fractional-value-rejected.json", + "expected": {"outcome": "rejected", "failure": "not_an_integer_value"}, + }, + { + "file": "06-above-range-integer-spelling-rejected.json", + "name": "above-range-integer-spelling-rejected", + "description": ( + "appraisal.timestamp written 9007199254740992, one past the safe-integer " + "range. No freshness rule reads this member, so the range is the only " + "rule that can reject the record. The signature is valid over the " + "pre-image RFC 8785 writes for this record, which converts the number " + "through a double; the rfc8785 Python package refuses to write this " + "integer at all, so the generator computes the same bytes from the double." + ), + "member": "appraisal.timestamp", + "spelling": "9007199254740992", + "expected": {"outcome": "rejected", "failure": "outside_safe_integer_range"}, + }, + { + "file": "07-above-range-exponent-spelling-rejected.json", + "name": "above-range-exponent-spelling-rejected", + "description": ( + "appraisal.timestamp written 1.0e+21, the example on #247: a float in " + "Python, which RFC 8785 writes as 1e+21, and a whole number past the " + "safe-integer range in JavaScript. Whole and out of range, so rejected by " + "the range whatever its spelling. Separate from 06 because the rfc8785 " + "Python package refuses an out-of-range int and writes this float without " + "complaint: an implementation that leaves the range to its canonicalizer " + "rejects 06 and verifies this one. The signature is valid over this " + "record's RFC 8785 form." + ), + "member": "appraisal.timestamp", + "spelling": "1.0e+21", + "expected": {"outcome": "rejected", "failure": "outside_safe_integer_range"}, + }, +] + + +def fixture(vector: dict[str, Any]) -> str: + doc: dict[str, Any] = { + "name": vector["name"], + "description": vector["description"], + "spec": "trace-v0.2 section 3.2.2, What counts as an integer", + "profile": PROFILE, + "proposal": { + "issue": "agentrust-io/trace-spec#247", + "status": "under review, not accepted normative text", + }, + "member": vector["member"], + "spelling": vector["spelling"], + } + if "same_signature_as" in vector: + doc["same_signature_as"] = vector["same_signature_as"] + doc.update({ + "verifier": { + "verification_time": 1785000100, + "max_age_seconds": 86400, + "max_future_skew_seconds": 300, + }, + "trusted_key": public_jwk(), + "record": signed_record(vector["member"], vector["spelling"]), + "expected": vector["expected"], + }) + text = json.dumps(doc, indent=2) + "\n" + quoted = json.dumps(PLACEHOLDER) + if text.count(quoted) != 1: + raise RuntimeError(f"{vector['file']}: the placeholder is not written exactly once") + return text.replace(quoted, vector["spelling"]) + + +def main() -> None: + for vector in VECTORS: + path = OUT / vector["file"] + path.write_text(fixture(vector), encoding="utf-8") + print("wrote", path.name) + + +if __name__ == "__main__": + main() diff --git a/examples/number-spelling/scan_published_numbers.py b/examples/number-spelling/scan_published_numbers.py new file mode 100644 index 00000000..61e37557 --- /dev/null +++ b/examples/number-spelling/scan_published_numbers.py @@ -0,0 +1,187 @@ +"""Find integer-typed members written with a fraction or an exponent (agentrust-io/trace-spec#247). + +Deciding integers by value changes a verdict only where an integer-typed member is +written with a fraction or an exponent, such as `1785000000.0` or `1.785e9` for +`1785000000`. This script counts the published documents that carry one. Every +verdict the proposal changes is on such a document, so a count of zero means that no +published verdict changes. + + python examples/number-spelling/scan_published_numbers.py [ROOT ...] + +Each ROOT is a directory, searched for `*.json` files and for fenced blocks labelled +`json` in `*.md` files; the default is this repository's `examples/`. One that does +not parse, such as an elided example, is counted and skipped; `jsonc` blocks are not +read. This directory is skipped when it sits inside a ROOT, because its vectors are +re-spelled on purpose; name it as a ROOT to scan it, which is a check that the scan +finds what it is looking for. Exit status 1 means an integer-typed member was found +written with a fraction or an exponent. + +A member is integer-typed when a schema in `schema/` types it `integer`, matched on +the member's name and its parent's; when it sits in `cnf.jwk`, whose undeclared +members the schema holds to integers; or when a profile types it in prose, as +`observed_at` (docs/integration/pic-trace-bridge-v1.md) and `tool_catalog.tool_count` +(spec/server-provenance-v1.md) are. Python's `json` module keeps the spelling of +every number in reach through its `parse_int` and `parse_float` hooks, which is how +the spelling is read here; a parser that returns only values cannot see it. +Standard library only. +""" + +from __future__ import annotations + +import json +import re +import sys +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +HERE = Path(__file__).resolve().parent +REPO = HERE.parents[1] +SCHEMAS = REPO / "schema" + +# Integer-typed in prose, with no schema to read it from. +PROSE_MEMBERS = {("after", "observed_at"), ("tool_catalog", "tool_count")} +# `cnf.jwk` members the schema does not name are held to `canonicalizableValue`, +# which admits integers and no `number`. +INTEGER_SUBTREE = ("cnf", "jwk") + +FENCE = re.compile(r"^```json[ \t]*\n(.*?)^```", re.MULTILINE | re.DOTALL) +SKIPPED_DIRECTORIES = {"node_modules", "__pycache__", "site"} + + +class Number: + """A JSON number together with the text it was written as.""" + + __slots__ = ("text",) + + def __init__(self, text: str) -> None: + self.text = text + + @property + def fraction_or_exponent(self) -> bool: + return any(c in self.text for c in ".eE") + + +def _integer_chains(node: Any, chain: tuple[str, ...], out: set[tuple[str, ...]]) -> None: + if isinstance(node, dict): + kind = node.get("type") + if chain and (kind == "integer" or (isinstance(kind, list) and "integer" in kind)): + out.add(chain[-2:]) + for key, value in node.items(): + if key == "properties" and isinstance(value, dict): + for name, member in value.items(): + _integer_chains(member, (*chain, name), out) + else: + _integer_chains(value, chain, out) + elif isinstance(node, list): + for item in node: + _integer_chains(item, chain, out) + + +def integer_members() -> set[tuple[str, ...]]: + """Name-and-parent pairs, or a lone name at a schema's top level, typed integer.""" + out: set[tuple[str, ...]] = set(PROSE_MEMBERS) + for path in sorted(SCHEMAS.glob("*.json")): + _integer_chains(json.loads(path.read_text(encoding="utf-8")), (), out) + return out + + +def _parse(text: str) -> Any: + return json.loads(text, parse_int=Number, parse_float=Number) + + +# Where a number sits in a document: member names and array indices from the root. +Location = tuple[str | int, ...] + + +def _numbers(node: Any, path: Location) -> Iterator[tuple[Location, Number]]: + if isinstance(node, Number): + yield path, node + elif isinstance(node, dict): + for key, value in node.items(): + yield from _numbers(value, (*path, key)) + elif isinstance(node, list): + for index, value in enumerate(node): + yield from _numbers(value, (*path, index)) + + +def is_integer_typed(path: Location, members: set[tuple[str, ...]]) -> bool: + names = [p for p in path if isinstance(p, str)] + for i in range(len(names) - 1): + if tuple(names[i:i + 2]) == INTEGER_SUBTREE: + return True + if not path or not isinstance(path[-1], str): + return False + last = path[-1] + parent = path[-2] if len(path) > 1 and isinstance(path[-2], str) else None + return (last,) in members or (parent, last) in members + + +def documents(root: Path) -> Iterator[tuple[str, str]]: + """(label, text) for every JSON file and every fenced `json` block under *root*.""" + for path in sorted(root.rglob("*")): + relative = path.relative_to(root) + if any(part.startswith(".") or part in SKIPPED_DIRECTORIES for part in relative.parts): + continue + if HERE != root and HERE in path.parents: + continue + if path.suffix == ".json" and path.is_file(): + yield str(relative), path.read_text(encoding="utf-8") + elif path.suffix == ".md" and path.is_file(): + text = path.read_text(encoding="utf-8") + for n, match in enumerate(FENCE.finditer(text), start=1): + yield f"{relative} (json block {n})", match.group(1) + + +def scan(root: Path, members: set[tuple[str, ...]]) -> dict[str, Any]: + counts = {"documents": 0, "not_json": 0, "numbers": 0, "integer_typed": 0} + findings: list[str] = [] + other: list[str] = [] + for label, text in documents(root): + try: + parsed = _parse(text) + except ValueError: + counts["not_json"] += 1 + continue + counts["documents"] += 1 + for path, number in _numbers(parsed, ()): + counts["numbers"] += 1 + where = f"{label}: {'.'.join(map(str, path))} = {number.text}" + if is_integer_typed(path, members): + counts["integer_typed"] += 1 + if number.fraction_or_exponent: + findings.append(where) + elif number.fraction_or_exponent: + other.append(where) + return {"counts": counts, "findings": findings, "other": other} + + +def main(argv: list[str]) -> int: + roots = [Path(a).resolve() for a in argv] or [REPO / "examples"] + members = integer_members() + total = 0 + for root in roots: + if not root.is_dir(): + print(f"{root}: not a directory", file=sys.stderr) + return 2 + result = scan(root, members) + c = result["counts"] + print(f"{root.name}/") + if HERE != root and root in HERE.parents: + print(f" skipped {HERE.relative_to(root)}/: its vectors are re-spelled on purpose") + print(f" JSON documents parsed: {c['documents']}") + print(f" unparseable, skipped: {c['not_json']}") + print(f" numbers: {c['numbers']}") + print(f" integer-typed members: {c['integer_typed']}") + print(f" integer-typed, written with a fraction or an exponent: {len(result['findings'])}") + for where in result["findings"]: + print(f" {where}") + print(f" other numbers written with a fraction or an exponent: {len(result['other'])}") + for where in result["other"]: + print(f" {where}") + total += len(result["findings"]) + return 1 if total else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/examples/number-spelling/spelling.test.mjs b/examples/number-spelling/spelling.test.mjs new file mode 100644 index 00000000..a09a000a --- /dev/null +++ b/examples/number-spelling/spelling.test.mjs @@ -0,0 +1,202 @@ +// Number spelling from the JavaScript side (agentrust-io/trace-spec#247). +// +// Run with `node --test examples/number-spelling/`. Nothing to install: node:test, +// node:assert and node:crypto only. +// +// A JavaScript verifier is handed what JSON.parse produced, and JSON.parse keeps the +// value of a number and discards its spelling. The only integer decision it can make is +// on the value, so the rule proposed for section 3.2.2 is the one this runtime already +// follows. These tests show that, and check the vectors beside this file from this side: +// the outcome each expects follows from the value alone, and every signature in the set +// is valid, so no vector is rejected for anything but the rule it names. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createPublicKey, verify } from "node:crypto"; +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const SAFE = Number.MAX_SAFE_INTEGER; // 2 ** 53 - 1, the section 3.2.2 bound + +// The decision a verifier can make on a parsed value, and the two failures it can name. +function integerValue(value) { + if (typeof value !== "number" || !Number.isInteger(value)) { + return { ok: false, failure: "not_an_integer_value" }; + } + if (!Number.isSafeInteger(value)) { + return { ok: false, failure: "outside_safe_integer_range" }; + } + return { ok: true, value }; +} + +// RFC 8785 for the values these records carry: object keys sorted by UTF-16 code unit, +// which is what Array.prototype.sort does to strings, and every string and number written +// by JSON.stringify, the ECMAScript serialization RFC 8785 section 3.2.2 adopts. +function jcs(value) { + if (Array.isArray(value)) { + return `[${value.map(jcs).join(",")}]`; + } + if (value !== null && typeof value === "object") { + const members = Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${jcs(value[k])}`); + return `{${members.join(",")}}`; + } + return JSON.stringify(value); +} + +function vectors() { + return readdirSync(here) + .filter((name) => name.endsWith(".json")) + .sort() + .map((name) => { + const text = readFileSync(join(here, name), "utf8"); + return { name, text, doc: JSON.parse(text) }; + }); +} + +function memberOf(record, path) { + return path.split(".").reduce((node, name) => node[name], record); +} + +test("JSON.parse gives the three spellings of 1785000000 one value", () => { + const [plain, fraction, exponent] = ["1785000000", "1785000000.0", "1.785e9"].map((t) => + JSON.parse(t), + ); + assert.equal(plain, 1785000000); + assert.ok(Object.is(plain, fraction) && Object.is(plain, exponent)); + assert.ok(Number.isSafeInteger(fraction) && Number.isSafeInteger(exponent)); +}); + +test("the spelling does not survive re-serialization", () => { + // String(Number(x)) is the number serialization RFC 8785 applies. + assert.equal(String(Number("1785000000.0")), "1785000000"); + assert.equal(String(Number("1.785e9")), "1785000000"); + assert.equal(String(Number("17850000005e-1")), "1785000000.5"); + assert.equal(String(Number("1.0e+21")), "1e+21"); + assert.equal(String(Number("-0.0")), "0"); + assert.equal(String(Number("1785000000.0000000001")), "1785000000"); + const forms = ["1785000000", "1785000000.0", "1.785e9"].map((t) => + jcs({ iat: JSON.parse(t), subject: "x" }), + ); + assert.equal(new Set(forms).size, 1); + assert.equal(forms[0], '{"iat":1785000000,"subject":"x"}'); +}); + +test("the safe-integer boundary, as the double sees it", () => { + assert.equal(SAFE, 2 ** 53 - 1); + assert.ok(Number.isSafeInteger(2 ** 53 - 1)); + assert.ok(!Number.isSafeInteger(2 ** 53)); + assert.ok(Number.isSafeInteger(JSON.parse("9007199254740991.0"))); + // Both parse to 2 ** 53: the written value and the parsed one differ, and both are out. + assert.equal(JSON.parse("9.007199254740993e15"), 2 ** 53); + assert.equal(JSON.parse("9007199254740993"), 2 ** 53); + assert.ok(!Number.isSafeInteger(JSON.parse("9.007199254740993e15"))); + assert.ok(Number.isInteger(1e21) && !Number.isSafeInteger(1e21)); + assert.ok(!Number.isInteger(JSON.parse("1785000000.5"))); + assert.ok(Object.is(JSON.parse("-0.0"), -0) && Number.isSafeInteger(-0) && -0 === 0); +}); + +test("boundary cases, decided by value", () => { + const cases = [ + ["1785000000", true], + ["1785000000.0", true], + ["1.785e9", true], + ["1785000000.0000000001", true], // more digits than a double carries: the double decides + ["1785000000.5", "not_an_integer_value"], + ["17850000005e-1", "not_an_integer_value"], + ["9007199254740991", true], + ["9007199254740991.0", true], + ["-9007199254740991", true], + ["9007199254740992", "outside_safe_integer_range"], + ["-9007199254740992", "outside_safe_integer_range"], + ["9.007199254740993e15", "outside_safe_integer_range"], + ["1e21", "outside_safe_integer_range"], + ["1.0e+21", "outside_safe_integer_range"], + ["0", true], + ["-0.0", true], + ["true", "not_an_integer_value"], + ["false", "not_an_integer_value"], + ['"1785000000"', "not_an_integer_value"], + ["null", "not_an_integer_value"], + ]; + for (const [text, expected] of cases) { + const decided = integerValue(JSON.parse(text)); + if (expected === true) { + assert.ok(decided.ok, `${text} should be an integer`); + } else { + assert.deepEqual(decided, { ok: false, failure: expected }, text); + } + } + // A floor is a bound on the value, so 0 and -0.0 fall below iat's in the same way. + const iatFloor = 1700000000; + for (const text of ["0", "-0.0"]) { + const decided = integerValue(JSON.parse(text)); + assert.ok(decided.ok && decided.value < iatFloor, text); + } +}); + +test("the vector set is complete", () => { + assert.deepEqual( + vectors().map((v) => v.name), + [ + "01-integer-spelling-verified.json", + "02-fraction-spelling-verified.json", + "03-exponent-spelling-verified.json", + "04-fractional-value-rejected.json", + "05-fractional-value-exponent-spelling-rejected.json", + "06-above-range-integer-spelling-rejected.json", + "07-above-range-exponent-spelling-rejected.json", + ], + ); +}); + +test("each file carries its spelling in the text, which JSON.parse then discards", () => { + for (const { name, text, doc } of vectors()) { + const last = doc.member.split(".").at(-1); + const written = text.match(new RegExp(`"${last}": ([^,\\n]+)`)); + assert.ok(written, name); + assert.equal(written[1], doc.spelling, name); + assert.ok(Object.is(memberOf(doc.record, doc.member), JSON.parse(doc.spelling)), name); + } +}); + +test("each vector's outcome follows from the value alone", () => { + for (const { name, doc } of vectors()) { + const decided = integerValue(memberOf(doc.record, doc.member)); + if (doc.expected.outcome === "verified") { + assert.ok(decided.ok, name); + assert.equal(doc.expected.failure, null, name); + } else { + assert.equal(doc.expected.outcome, "rejected", name); + assert.deepEqual(decided, { ok: false, failure: doc.expected.failure }, name); + } + } +}); + +test("the three accepted records are one record here, with one signature", () => { + const byName = Object.fromEntries(vectors().map((v) => [v.name, v.doc])); + const first = byName["01-integer-spelling-verified.json"].record; + for (const other of ["02-fraction-spelling-verified.json", "03-exponent-spelling-verified.json"]) { + assert.deepStrictEqual(byName[other].record, first, other); + } + // 05 re-spells 04's value, so it is 04's record and 04's signature too. + assert.deepStrictEqual( + byName["05-fractional-value-exponent-spelling-rejected.json"].record, + byName["04-fractional-value-rejected.json"].record, + ); +}); + +test("every signature in the set is valid over its record's RFC 8785 form", () => { + for (const { name, doc } of vectors()) { + const { signature, ...unsigned } = doc.record; + const key = createPublicKey({ key: doc.trusted_key, format: "jwk" }); + const valid = verify( + null, + Buffer.from(jcs(unsigned), "utf8"), + key, + Buffer.from(signature, "base64url"), + ); + assert.ok(valid, `${name}: the signature does not verify, so a rejection proves nothing`); + } +}); diff --git a/spec/trace-v0.2.md b/spec/trace-v0.2.md index 1edae66c..1099bcdb 100644 --- a/spec/trace-v0.2.md +++ b/spec/trace-v0.2.md @@ -409,6 +409,7 @@ Implementations MUST use an RFC 8785-conformant library. Using `json.dumps(sort_ **What the rule covers.** It is on the object, not only on its declared fields: the pre-image covers every member, including the members RFC 7517 permits a `cnf.jwk` to carry that this schema does not name, and the schema holds those to the same range. It is also not only about a Trust Record. It covers the revocation statements and bundles of section 3.2.3, and it covers any object whose digest is taken over its canonical form, such as a bridge profile's declaration and tool-call digests, where no schema constrains the input and this rule is the only thing standing between two different objects and one digest. +**What counts as an integer.** JSON has one kind of number. RFC 8259 §6 gives it a single grammar in which a fraction and an exponent are optional parts, so `1785000000`, `1785000000.0` and `1.785e9` are three ways of writing one value. Whether a number is an integer, for a member typed `integer` and for the range above, is decided by that value and not by how it is written: `1785000000.0` and `1.785e9` are the integer 1785000000, `1785000000.5` is not an integer, and `1e21` is an integer outside the range. The value is the IEEE 754 double the number parses to, which is what RFC 8785 serializes and so what a signature covers; a spelling with more digits than a double carries, such as `1785000000.0000000001`, is the integer 1785000000. A verifier MUST make both decisions on that value. JSON Schema 2020-12, in which the schemas of this specification are written, defines `integer` the same way (Validation §6.1.1: any number with a zero fractional part). RFC 8785 writes the value and not the text it was read from, so the three spellings above have one pre-image and one signature, and the spelling is not something a signature can vouch for. A verifier whose parser returns only the value, as `JSON.parse` does, has no spelling to decide by; a verifier that decided by spelling would reject records that such a verifier accepts, over the same signed bytes. Each profile MUST declare which binding form it uses. A record with no verifiable signature binding is not a Trust Record: verifiers MUST reject it. Schema validity alone confers no trust. diff --git a/src/agentrust_trace/adapters/agt.py b/src/agentrust_trace/adapters/agt.py index 6ae59f6e..542451bc 100644 --- a/src/agentrust_trace/adapters/agt.py +++ b/src/agentrust_trace/adapters/agt.py @@ -11,7 +11,6 @@ from dataclasses import dataclass, field from typing import Any, Literal -import rfc8785 from agentrust_trace.models import ( JCS_SAFE_INTEGER, Appraisal, @@ -21,6 +20,7 @@ RuntimeInfo, ToolTranscript, ) +from agentrust_trace.sign import _canonical_bytes # TrustRecord.iat is Field(ge=1700000000) in models.py, matching "minimum" in # schema/trace-claim.json. Below it a record is schema-invalid, which is the whole @@ -231,7 +231,7 @@ def _bundle_hash(policy_bundle_bytes: bytes) -> str: def _transcript_hash(audit_entries: list[dict[str, Any]]) -> str: # RFC 8785 (JCS): same canonicalization TraceSandboxAdapter already uses # for the same field, and what docs/schema.md calls "canonical JSON". - return "sha256:" + hashlib.sha256(rfc8785.dumps(audit_entries)).hexdigest() + return "sha256:" + hashlib.sha256(_canonical_bytes(audit_entries)).hexdigest() @staticmethod def _measurement(merkle_chain_tip: str) -> str: diff --git a/src/agentrust_trace/adapters/sandbox.py b/src/agentrust_trace/adapters/sandbox.py index 5cae8d32..f1a009e9 100644 --- a/src/agentrust_trace/adapters/sandbox.py +++ b/src/agentrust_trace/adapters/sandbox.py @@ -77,8 +77,6 @@ from dataclasses import dataclass, field from typing import Any, Literal, get_args -import rfc8785 - from agentrust_trace.models import ( JCS_SAFE_INTEGER, Appraisal, @@ -88,6 +86,7 @@ RuntimeInfo, ToolTranscript, ) +from agentrust_trace.sign import _canonical_bytes # TrustRecord.iat is Field(ge=1700000000) in models.py, matching "minimum" in # schema/trace-claim.json. Below it a record is schema-invalid, which is the whole # failure this check exists to stop -- so the floor is the contract's, not zero. @@ -410,7 +409,7 @@ def transcript_hash(decisions: list[dict[str, Any]]) -> str: pre-image (see :func:`~agentrust_trace.sign.sign_record`) keeps a record reproducible by an implementation in another language. """ - return "sha256:" + hashlib.sha256(rfc8785.dumps(decisions)).hexdigest() + return "sha256:" + hashlib.sha256(_canonical_bytes(decisions)).hexdigest() @staticmethod def software_measurement(image_digest: str, bundle_hash: str) -> str: diff --git a/src/agentrust_trace/intent_bridge.py b/src/agentrust_trace/intent_bridge.py index 7bda743f..a3d9e526 100644 --- a/src/agentrust_trace/intent_bridge.py +++ b/src/agentrust_trace/intent_bridge.py @@ -12,9 +12,9 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from agentrust_trace.sign import ( - JCS_SAFE_INTEGER, _b64url_decode, _canonical_bytes, + _integer_value, _pubkey_from_jwk, ) @@ -135,16 +135,19 @@ def _bind_successor_observation( if not isinstance(after["observation"], dict): raise AuthorizationMismatch("transcript.after.observation must be an object") _nonempty_string(after["observer"], "transcript.after.observer") - observed_at = after["observed_at"] - if ( - not isinstance(observed_at, int) - or isinstance(observed_at, bool) - or observed_at < 0 - or observed_at > JCS_SAFE_INTEGER - ): + # By value, not by spelling (spec section 3.2.2): the digest below is taken over + # the RFC 8785 form, which writes `150.0` as `150`, so both are the one value it binds. + try: + observed_at = _integer_value(after["observed_at"], "transcript.after.observed_at") + except ValueError as exc: raise IntentBridgeError( "transcript.after.observed_at must be a non-negative integer within " - "the JCS safe-integer range" + f"the JCS safe-integer range: {exc}" + ) from exc + if observed_at < 0: + raise IntentBridgeError( + "transcript.after.observed_at must be a non-negative integer within " + f"the JCS safe-integer range: it is {observed_at}" ) expected = _digest(expected_successor_digest, "expected_successor_digest") try: @@ -238,13 +241,20 @@ def verify_bridge( raise IntentBridgeError("authorizer_key_id does not identify the trusted key") if authorization["decision"] != "allow": raise AuthorizationDenied("the signed decision is not allow") + # By value, not by spelling (spec section 3.2.2): `100.0` is the timestamp 100, which + # is what the signature above covers. A value outside the safe-integer range does + # not get this far, because `_jcs` refused it before the signature was checked. for field in ("authorized_at", "expires_at"): - if ( - not isinstance(authorization[field], int) - or isinstance(authorization[field], bool) - or authorization[field] < 0 - ): - raise IntentBridgeError(f"{field} must be a non-negative integer Unix timestamp") + try: + timestamp = _integer_value(authorization[field], f"authorization.{field}") + except ValueError as exc: + raise IntentBridgeError( + f"{field} must be a non-negative integer Unix timestamp: {exc}" + ) from exc + if timestamp < 0: + raise IntentBridgeError( + f"{field} must be a non-negative integer Unix timestamp: it is {timestamp}" + ) invalid_now_type = not isinstance(now, (int, type(None))) or isinstance(now, bool) if invalid_now_type or (now is not None and now < 0): raise IntentBridgeError("now must be a non-negative integer Unix timestamp") diff --git a/src/agentrust_trace/models.py b/src/agentrust_trace/models.py index 3584220e..c74e4cbb 100644 --- a/src/agentrust_trace/models.py +++ b/src/agentrust_trace/models.py @@ -41,8 +41,8 @@ JCS_SAFE_INTEGER = 9007199254740991 -def _not_a_boolean(value: Any) -> Any: - """Reject ``True`` and ``False`` where JSON says integer. +def _json_integer(value: Any) -> Any: + """Decide an integer as JSON does: by its value, not by Python's type or its spelling. ``isinstance(True, int)`` is a Python fact and not a JSON one. JSON Schema's ``"type": "integer"`` does not match a boolean, so ``schema/trace-claim.json`` @@ -54,6 +54,18 @@ def _not_a_boolean(value: Any) -> Any: were safe by accident rather than by design: their lower bound is above 1, so the coerced value failed the range check afterwards. ``appraisal.timestamp`` allows 1 and turned ``true`` into 1 January 1970. + + A string had the same hole: pydantic's lax mode read ``"1785000000"`` as the + integer 1785000000, which the schema rejects as a string. It is refused here. + + A number is the other direction (spec section 3.2.2). ``1785000000.0`` and + ``1.785e9`` parse to a Python ``float`` and are the integer 1785000000: JSON + has one number type, JSON Schema 2020-12 matches ``integer`` to any number with + a zero fractional part, and RFC 8785 writes the value back out as + ``1785000000``, so the signature cannot tell the spellings apart. Such a float + is converted to the ``int`` it stands for, so each member's bounds judge the + value, and a record dumped from the model carries the plain integer. A float + that is not a whole number is not an integer. """ if isinstance(value, bool): raise ValueError( @@ -61,11 +73,23 @@ def _not_a_boolean(value: Any) -> Any: "not match true or false, so a record carrying one is rejected by " "schema/trace-claim.json and by any implementation validating against it." ) + if isinstance(value, (str, bytes, bytearray)): + raise ValueError( + f"expected an integer, got a {type(value).__name__}. JSON Schema type " + "'integer' does not match a string, even one that spells a number, so a " + "record carrying one is rejected by schema/trace-claim.json." + ) + if isinstance(value, float): + if not value.is_integer(): + raise ValueError( + f"expected an integer value, got {value!r}, which is not a whole number" + ) + return int(value) return value #: An integer as JSON means it, rather than as Python's type hierarchy means it. -JsonInt = Annotated[int, BeforeValidator(_not_a_boolean)] +JsonInt = Annotated[int, BeforeValidator(_json_integer)] DigestStr = Annotated[str, Field(pattern=_DIGEST_RE)] diff --git a/src/agentrust_trace/sign.py b/src/agentrust_trace/sign.py index 20fbb6df..4a15bd0b 100644 --- a/src/agentrust_trace/sign.py +++ b/src/agentrust_trace/sign.py @@ -267,7 +267,7 @@ def _check_not_revoked(jwk: dict[str, Any], revocation: RevocationStore) -> None ) -def _canonical_bytes(d: dict[str, Any]) -> bytes: +def _canonical_bytes(d: Any) -> bytes: """Return the RFC 8785 (JCS) canonical UTF-8 byte sequence for *d*. This is the signature pre-image mandated by spec/trace-v0.2.md §3.2.2. JCS @@ -278,7 +278,15 @@ def _canonical_bytes(d: dict[str, Any]) -> bytes: ``json.dumps(sort_keys=True)`` diverges from JCS for non-ASCII strings and for IEEE 754 number formatting, which would break cross-implementation verification, so a conformant library is used instead. + + The safe-integer range of section 3.2.2 is checked on the value, which is how + that section decides what an integer is. `rfc8785` refuses an `int` outside + the range and writes a `float` as it stands, and `json.loads` returns a float + for `9.007199254740993e15` and `1e21`, both whole numbers past the bound. Such + a float is refused here, with the error `rfc8785` raises for the same value + parsed as an `int`. """ + _refuse_whole_floats_out_of_range(d) return rfc8785.dumps(d) @@ -287,6 +295,63 @@ def _canonical_bytes(d: dict[str, Any]) -> bytes: JCS_SAFE_INTEGER = 9007199254740991 +def _refuse_whole_floats_out_of_range(value: Any) -> None: + """Raise ``rfc8785.IntegerDomainError`` for a whole-valued float outside the range. + + Walks the containers `rfc8785` serializes. Every finite double of magnitude + 2^53 or more is a whole number, so this is every such float in *value*; a + non-finite one is left to `rfc8785`, which refuses it as a float. + """ + if isinstance(value, float): + if value.is_integer() and not -JCS_SAFE_INTEGER <= value <= JCS_SAFE_INTEGER: + raise rfc8785.IntegerDomainError(int(value)) + elif isinstance(value, (list, tuple)): + for item in value: + _refuse_whole_floats_out_of_range(item) + elif isinstance(value, dict): + for item in value.values(): + _refuse_whole_floats_out_of_range(item) + + +def _integer_value(value: Any, name: str) -> int: + """The integer a parsed JSON number stands for, decided by value (spec section 3.2.2). + + JSON has one number type, and a fraction or an exponent is a way of writing a + value, not a second type: `1785000000`, `1785000000.0` and `1.785e9` are one + number. RFC 8785 writes a number back out by value, so the three have one + canonical form and one signature, and a JavaScript parser, having only the + double, never sees which spelling it was given. Python's `json` module does: + it returns an `int` for the first and a `float` for the other two. A type test + here therefore rejected two spellings of a value the signature cannot tell + apart, and only in Python. + + Returns the value as an `int`. Raises ``ValueError`` naming which of the two + rules failed: the value is not an integer (a boolean, a string, anything else + that is not a JSON number, or a number that is not a whole number), or it is a + whole number outside the safe-integer range. ``bool`` is refused explicitly: it + is an ``int`` subclass in Python and not a number in JSON. The range is checked + here as well as in `_canonical_bytes`, so that a member read before anything + is canonicalized, such as `iat` for the freshness check, reports it itself. + """ + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError( + f"{name} is not an integer value: it is a {type(value).__name__}, not a " + "JSON number" + ) + if isinstance(value, float): + if not value.is_integer(): + raise ValueError( + f"{name} is not an integer value: {value!r} is not a whole number" + ) + value = int(value) + if not -JCS_SAFE_INTEGER <= value <= JCS_SAFE_INTEGER: + raise ValueError( + f"{name} is {value}, outside the safe-integer range -{JCS_SAFE_INTEGER} " + f"to {JCS_SAFE_INTEGER}" + ) + return int(value) + + class UnanchorableValue(ValueError): """A value the registry-anchor profile excludes, found before it was hashed.""" @@ -776,9 +841,12 @@ def verify_record( # Both bounds are verifier configuration, not record data, and a malformed # one is checked before it is used -- see `_check_seconds`. _check_seconds("max_age_seconds", max_age_seconds, optional=True) - iat = record.get("iat") - if not isinstance(iat, int) or isinstance(iat, bool): - raise ValueError("record has no valid integer 'iat' for freshness check") + # Decided by value, not by spelling (section 3.2.2): `json.loads` returns a float + # for `1785000000.0` and `1.785e9`, and both are the integer the signature covers. + try: + iat = _integer_value(record.get("iat"), "'iat'") + except ValueError as exc: + raise ValueError(f"record has no valid integer 'iat' for freshness check: {exc}") from exc age = verification_time - iat if age < -max_future_skew_seconds: raise ValueError( diff --git a/tests/test_adequacy_all_sets.py b/tests/test_adequacy_all_sets.py index e0596d30..4b11b31b 100644 --- a/tests/test_adequacy_all_sets.py +++ b/tests/test_adequacy_all_sets.py @@ -138,6 +138,15 @@ def reproducibility_claim() -> list[Vector]: lambda e: e["outcome"], lambda e: list(e.get("codes") or [])) +def number_spelling() -> list[Vector]: + """The number-spelling set (#247, proposed). Boundaries are its failure codes, one + per rule an integer-typed member is held to once it is decided by value: two + vectors each, spelled differently, and three accepting spellings of one value. + `tests/test_integer_by_value.py` runs plausible defective verifiers over it.""" + return _load("number-spelling", + lambda e: e["outcome"], lambda e: [e.get("failure")]) + + SETS = { "build-provenance-depth": (build_provenance_depth, _depth_boundary), "reproducibility-claim": (reproducibility_claim, None), @@ -145,6 +154,7 @@ def reproducibility_claim() -> list[Vector]: "canonicalization-boundary": (canonicalization_boundary, None), "delegation-link": (delegation_link, None), "verifier-compatibility": (verifier_compatibility, None), + "number-spelling": (number_spelling, None), } # Every set must be able to fail both unconditional implementations. A set that diff --git a/tests/test_integer_by_value.py b/tests/test_integer_by_value.py new file mode 100644 index 00000000..ba15ba91 --- /dev/null +++ b/tests/test_integer_by_value.py @@ -0,0 +1,738 @@ +"""Integer-typed members are decided by value, not by spelling (agentrust-io/trace-spec#247). + +Proposed, not accepted: see `examples/number-spelling/README.md`. + +JSON has one number type. `1785000000`, `1785000000.0` and `1.785e9` are one value, +RFC 8785 writes all three as `1785000000`, and a record carrying any of them has one +signing pre-image and one signature. `json.loads` returns an `int` for the first +spelling and a `float` for the other two, so a Python type test decides what the +signature cannot see. This module holds every layer to the value instead: + +- the decision itself, `_integer_value`, over the boundary cases; +- the schema, which already decided by value, and the model, which now does; +- the freshness check in `verify_record`, which rejected a correctly signed record + whose `iat` was written `1785000000.0`; +- the canonicalizer, which refuses a whole number past the safe-integer range however + it is written, and every digest that goes through it; +- the bridge and the revocation bundle, whose integer members are decided the same way; +- the vectors in `examples/number-spelling/`, recomputed from their committed bytes, + graded against plausible shortcuts, and run from JavaScript when Node is present; +- the scan that counts which published records the rule changes. +""" + +from __future__ import annotations + +import base64 +import copy +import importlib.util +import json +import re +import shutil +import subprocess +from collections.abc import Callable +from pathlib import Path +from types import ModuleType +from typing import Any + +import jsonschema +import pydantic +import pytest +import rfc8785 +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from agentrust_trace import TraceAGTAdapter, TraceSandboxAdapter +from agentrust_trace.intent_bridge import IntentBridgeError, digest_jcs, sign_bridge, verify_bridge +from agentrust_trace.models import TrustRecord +from agentrust_trace.revocation import bundle_digest +from agentrust_trace.sign import ( + JCS_SAFE_INTEGER, + _canonical_bytes, + _integer_value, + _pubkey_from_jwk, + key_to_jwk, + sign_record, + verify_record, +) +from agentrust_trace.validate import validate_json +from tests.test_intent_bridge import _fixture as _bridge_fixture + +ROOT = Path(__file__).resolve().parents[1] +VECTORS = ROOT / "examples" / "number-spelling" +FILES = sorted(VECTORS.glob("*.json")) +assert FILES, "no number-spelling vectors on disk; this module would measure nothing" + +NOW = 1785000100 + + +# ---- the decision --------------------------------------------------------------- + +# Spellings as they arrive in a document, parsed by `json.loads` exactly as a verifier +# reading one would parse them. +WHOLE = [ + ("1785000000", 1785000000), + ("1785000000.0", 1785000000), + ("1.785e9", 1785000000), + ("17850000000e-1", 1785000000), + # More digits than a double carries: the value is the double, which is what RFC 8785 + # writes and a signature covers. + ("1785000000.0000000001", 1785000000), + ("9007199254740991", JCS_SAFE_INTEGER), + ("9007199254740991.0", JCS_SAFE_INTEGER), + ("-9007199254740991", -JCS_SAFE_INTEGER), + ("0", 0), + ("-0.0", 0), +] +NOT_AN_INTEGER = [ + "1785000000.5", "17850000005e-1", "0.5", "true", "false", '"1785000000"', "null", + "[]", "{}", "NaN", "Infinity", +] +# `9.007199254740993e15` parses to 2**53: the value outside the range is the parsed +# one, and the message reports it rather than the digits that were written. +OUTSIDE_THE_RANGE = [ + ("9007199254740992", 2**53), + ("-9007199254740992", -(2**53)), + ("9007199254740992.0", 2**53), + ("9.007199254740993e15", 2**53), + ("1e21", 10**21), + ("1.0e+21", 10**21), +] + + +@pytest.mark.parametrize(("text", "value"), WHOLE, ids=[t for t, _ in WHOLE]) +def test_a_whole_number_is_the_integer_it_denotes(text: str, value: int) -> None: + decided = _integer_value(json.loads(text), "m") + assert decided == value + assert type(decided) is int + + +@pytest.mark.parametrize("text", NOT_AN_INTEGER) +def test_anything_else_is_not_an_integer_value(text: str) -> None: + with pytest.raises(ValueError, match=r"^m is not an integer value: "): + _integer_value(json.loads(text), "m") + + +@pytest.mark.parametrize(("text", "value"), OUTSIDE_THE_RANGE, + ids=[t for t, _ in OUTSIDE_THE_RANGE]) +def test_a_whole_number_past_the_range_is_refused_as_that(text: str, value: int) -> None: + with pytest.raises(ValueError, match=rf"^m is {value}, outside the safe-integer range "): + _integer_value(json.loads(text), "m") + + +def test_a_boolean_is_refused_although_python_calls_it_an_int() -> None: + assert isinstance(True, int) + for value in (True, False): + with pytest.raises(ValueError, match="it is a bool, not a JSON number"): + _integer_value(value, "m") + + +# ---- the schema and the model --------------------------------------------------- + + +def _base() -> dict[str, Any]: + """Vector 01's record, unsigned, carrying all five integer members of the schema.""" + record = json.loads((VECTORS / "01-integer-spelling-verified.json").read_text())["record"] + record.pop("signature") + record["tool_transcript"] = {"hash": "sha256:" + "c" * 64, "call_count": 7} + record["origin"] = {"kind": "self", "producer": "producer.example", + "ingested_at": 1785000000} + record["appraisal"]["timestamp"] = 1785000000 + return record + + +def _set(record: dict[str, Any], member: str, value: Any) -> dict[str, Any]: + out = copy.deepcopy(record) + *parents, last = member.split(".") + target = out + for name in parents: + target = target[name] + target[last] = value + return out + + +def _get(record: dict[str, Any], member: str) -> Any: + target: Any = record + for name in member.split("."): + target = target[name] + return target + + +MEMBERS = ["iat", "origin.ingested_at", "tool_transcript.call_count", + "build_provenance.slsa_level", "appraisal.timestamp"] + + +def test_the_base_record_is_valid_as_it_stands() -> None: + validate_json(_base()) + TrustRecord.model_validate(_base()) + + +@pytest.mark.parametrize("member", MEMBERS) +def test_every_integer_member_takes_its_value_written_as_a_float(member: str) -> None: + whole = float(_get(_base(), member)) + record = _set(_base(), member, whole) + validate_json(record) + dumped = TrustRecord.model_validate(record).model_dump(mode="json", exclude_none=True) + assert _get(dumped, member) == whole + assert type(_get(dumped, member)) is int, "the model writes the plain integer back out" + + +@pytest.mark.parametrize("member", MEMBERS) +def test_no_integer_member_takes_a_fraction(member: str) -> None: + record = _set(_base(), member, _get(_base(), member) + 0.5) + with pytest.raises(jsonschema.ValidationError, match="is not of type 'integer'"): + validate_json(record) + with pytest.raises(pydantic.ValidationError, match="which is not a whole number"): + TrustRecord.model_validate(record) + + +# (member, spelling, the schema keyword that refuses it or None, the model's message) +BOUNDARIES = [ + ("iat", "1785000000", None, None), + ("iat", "1785000000.0", None, None), + ("iat", "1.785e9", None, None), + ("iat", "1785000000.0000000001", None, None), + ("iat", "1785000000.5", "type", "which is not a whole number"), + ("iat", "true", "type", "got a boolean"), + ("iat", "false", "type", "got a boolean"), + ("iat", '"1785000000"', "type", "got a str"), + ("iat", "0", "minimum", "greater than or equal to 1700000000"), + ("iat", "-0.0", "minimum", "greater than or equal to 1700000000"), + ("tool_transcript.call_count", "-0.0", None, None), + ("build_provenance.slsa_level", "3.0", None, None), + ("build_provenance.slsa_level", "4.0", "maximum", "less than or equal to 3"), + ("appraisal.timestamp", "9007199254740991", None, None), + ("appraisal.timestamp", "9007199254740991.0", None, None), + ("appraisal.timestamp", "-9007199254740991.0", None, None), + ("appraisal.timestamp", "9007199254740992", "maximum", + "less than or equal to 9007199254740991"), + ("appraisal.timestamp", "9.007199254740993e15", "maximum", + "less than or equal to 9007199254740991"), + ("appraisal.timestamp", "1e21", "maximum", "less than or equal to 9007199254740991"), + ("appraisal.timestamp", "-1e21", "minimum", "greater than or equal to -9007199254740991"), +] + + +@pytest.mark.parametrize(("member", "text", "keyword", "message"), BOUNDARIES, + ids=[f"{m}={t}" for m, t, _, _ in BOUNDARIES]) +def test_the_schema_and_the_model_draw_each_boundary_in_the_same_place( + member: str, text: str, keyword: str | None, message: str | None +) -> None: + """The schema decided by value before this change; `jsonschema` follows JSON Schema + 2020-12, whose `integer` matches any number with a zero fractional part. The model + decided by Python type, and read a numeric string as the number it spells.""" + record = _set(_base(), member, json.loads(text)) + errors = list(jsonschema.Draft202012Validator( + json.loads((ROOT / "schema" / "trace-claim.json").read_text()) + ).iter_errors(record)) + if keyword is None: + assert errors == [] + model = TrustRecord.model_validate(record) + assert _get(model.model_dump(mode="json", exclude_none=True), member) == json.loads(text) + return + assert [e.validator for e in errors] == [keyword] + with pytest.raises(pydantic.ValidationError, match=re.escape(message or "")): + TrustRecord.model_validate(record) + + +def test_the_model_refuses_bytes_that_spell_a_number() -> None: + with pytest.raises(pydantic.ValidationError, match="got a bytes"): + TrustRecord.model_validate(_set(_base(), "iat", b"1785000000")) + + +# ---- a signed record, re-spelled ------------------------------------------------ + + +def _unsigned(record: dict[str, Any]) -> dict[str, Any]: + return {k: v for k, v in record.items() if k != "signature"} + + +def _respell(record: dict[str, Any], spelling: str) -> dict[str, Any]: + """*record* with its `iat` rewritten in the text, as a producer's serializer might.""" + text = json.dumps(record) + written = f'"iat": {record["iat"]},' + assert text.count(written) == 1 + return json.loads(text.replace(written, f'"iat": {spelling},')) + + +@pytest.mark.parametrize("spelling", ["1785000000.0", "1.785e9"]) +def test_a_signed_record_verifies_however_iat_is_written(spelling: str) -> None: + key = Ed25519PrivateKey.generate() + record = sign_record(_base(), key) + respelled = _respell(record, spelling) + assert type(respelled["iat"]) is float + assert respelled["signature"] == record["signature"] + assert _canonical_bytes(_unsigned(respelled)) == _canonical_bytes(_unsigned(record)) + verify_record(respelled, key_to_jwk(key), now=NOW) + assert sign_record(_unsigned(respelled), key)["signature"] == record["signature"], ( + "a producer writing either spelling signs the same bytes" + ) + + +@pytest.mark.parametrize("spelling", ["1785000000.5", "17850000005e-1"]) +def test_a_signed_record_whose_iat_is_not_whole_is_rejected(spelling: str) -> None: + key = Ed25519PrivateKey.generate() + record = sign_record(_set(_base(), "iat", json.loads(spelling)), key) + with pytest.raises(ValueError, match="at iat: 1785000000.5 is not of type 'integer'"): + verify_record(record, key_to_jwk(key), now=NOW) + + +@pytest.mark.parametrize(("value", "reason"), [ + (1785000000.5, "'iat' is not an integer value: 1785000000.5 is not a whole number"), + ("1785000000", "'iat' is not an integer value: it is a str, not a JSON number"), + (True, "'iat' is not an integer value: it is a bool, not a JSON number"), + (1e21, "'iat' is 1000000000000000000000, outside the safe-integer range"), +]) +def test_the_freshness_check_names_the_rule_it_applies( + monkeypatch: pytest.MonkeyPatch, value: Any, reason: str +) -> None: + """With the schema out of the way, the freshness check is the one deciding. It used + to say only that `iat` was not a valid integer, and said it of `1785000000.0` too.""" + import agentrust_trace.validate as validate + + monkeypatch.setattr(validate, "validate_json", lambda record: None) + key = Ed25519PrivateKey.generate() + record = {**sign_record(_base(), key), "iat": value} + with pytest.raises(ValueError, match=re.escape( + f"record has no valid integer 'iat' for freshness check: {reason}" + )): + verify_record(record, key_to_jwk(key), now=NOW) + + +# ---- the canonicalizer, and every digest taken through it ----------------------- + + +def test_the_canonical_form_is_written_by_value() -> None: + assert _canonical_bytes({"n": 1785000000.0}) == b'{"n":1785000000}' + assert _canonical_bytes({"n": 1.785e9}) == _canonical_bytes({"n": 1785000000}) + excess = json.loads("1785000000.0000000001") + assert _canonical_bytes({"n": excess}) == _canonical_bytes({"n": 1785000000}) + assert _canonical_bytes({"n": -0.0}) == b'{"n":0}' + + +@pytest.mark.parametrize(("text", "value"), OUTSIDE_THE_RANGE, + ids=[t for t, _ in OUTSIDE_THE_RANGE]) +def test_a_whole_number_past_the_range_has_no_canonical_form_however_written( + text: str, value: int +) -> None: + parsed = json.loads(text) + for container in ({"n": parsed}, {"a": [{"n": parsed}]}, [parsed], ({"n": parsed},)): + with pytest.raises(rfc8785.IntegerDomainError, match=rf"^{value} exceeds"): + _canonical_bytes(container) + + +def test_the_rfc8785_package_refuses_the_int_and_writes_the_float() -> None: + """Why `_canonical_bytes` checks the range itself. Pinned, so a release of the + package that changes either half is noticed here rather than in a verdict.""" + with pytest.raises(rfc8785.IntegerDomainError): + rfc8785.dumps({"n": 2**53}) + assert rfc8785.dumps({"n": float(2**53)}) == b'{"n":9007199254740992}' + assert rfc8785.dumps({"n": 1e21}) == b'{"n":1e+21}' + + +def test_a_float_that_is_not_whole_is_left_to_rfc8785_as_before() -> None: + assert _canonical_bytes({"n": 0.5}) == b'{"n":0.5}' + assert _canonical_bytes({"n": 1785000000.5}) == b'{"n":1785000000.5}' + for value in (float("inf"), float("-inf"), float("nan")): + with pytest.raises(rfc8785.FloatDomainError): + _canonical_bytes({"n": value}) + + +def test_every_digest_refuses_a_whole_number_past_the_range_written_as_a_float() -> None: + """A nanosecond timestamp that went through a double is a float past the range. The + same value written as an integer was already refused by all three digests.""" + nanoseconds = 1.7850000001234568e18 + with pytest.raises(IntentBridgeError, match="no RFC 8785 canonical form"): + digest_jcs({"ts_ns": nanoseconds}) + assert digest_jcs({"ts": 1785000000.0}) == digest_jcs({"ts": 1785000000}) + hashes: list[Callable[[list[dict[str, Any]]], str]] = [ + TraceAGTAdapter._transcript_hash, TraceSandboxAdapter.transcript_hash, + ] + for transcript_hash in hashes: + with pytest.raises(rfc8785.IntegerDomainError): + transcript_hash([{"ts_ns": nanoseconds}]) + assert transcript_hash([{"ts": 1785000000.0}]) == transcript_hash([{"ts": 1785000000}]) + assert transcript_hash([{"risk": 0.5}]).startswith("sha256:") + + +# ---- the bridge ----------------------------------------------------------------- + + +def _verify_bridge(bridge: dict[str, Any], key: Ed25519PrivateKey, declaration: dict, + intent: str, args: str, tool_call: dict, transcript: dict) -> dict: + return verify_bridge( + bridge, {**key_to_jwk(key), "kid": "key-7"}, declaration=declaration, + pic_intent_digest=intent, pic_args_digest=args, tool_call=tool_call, + transcript=transcript, now=150, + ) + + +def _resigned(bridge: dict[str, Any], key: Ed25519PrivateKey, **changes: Any) -> dict: + return sign_bridge({**copy.deepcopy(bridge["authorization"]), **changes}, key) + + +def test_bridge_times_written_as_whole_floats_are_the_times_signed() -> None: + bridge, key, *rest = _bridge_fixture() + respelled = _resigned(bridge, key, authorized_at=100.0, expires_at=200.0) + assert respelled["signature"] == bridge["signature"] + assert _verify_bridge(respelled, key, *rest)["authorization_id"] == "auth-7" + + +@pytest.mark.parametrize(("value", "reason"), [ + (100.5, "authorization.authorized_at is not an integer value: 100.5 is not a whole number"), + ("100", "authorization.authorized_at is not an integer value: it is a str"), + (True, "authorization.authorized_at is not an integer value: it is a bool"), + (-1, "it is -1"), + (-1.0, "it is -1"), +]) +def test_a_bridge_time_that_is_not_a_non_negative_integer_is_refused_with_its_reason( + value: Any, reason: str +) -> None: + bridge, key, *rest = _bridge_fixture() + with pytest.raises(IntentBridgeError, match=re.escape( + f"authorized_at must be a non-negative integer Unix timestamp: {reason}" + )): + _verify_bridge(_resigned(bridge, key, authorized_at=value), key, *rest) + + +def test_a_bridge_time_past_the_range_has_no_canonical_form_to_check_a_signature_over() -> None: + bridge, key, *rest = _bridge_fixture() + tampered = copy.deepcopy(bridge) + tampered["authorization"]["expires_at"] = 1e21 + with pytest.raises(IntentBridgeError, match="no RFC 8785 canonical form"): + _verify_bridge(tampered, key, *rest) + + +def test_an_observation_time_written_as_a_whole_float_binds_to_the_signed_digest() -> None: + bridge, key, declaration, intent, args, tool_call, transcript = _bridge_fixture() + respelled = copy.deepcopy(transcript) + respelled["after"]["observed_at"] = 150.0 + signed = bridge["authorization"]["successor_observation_digest"] + assert digest_jcs(respelled["after"]) == signed + _verify_bridge(bridge, key, declaration, intent, args, tool_call, respelled) + + +@pytest.mark.parametrize(("value", "reason"), [ + (150.5, "is not a whole number"), + ("150", "it is a str"), + (1e21, "outside the safe-integer range"), + (-1.0, "it is -1"), +]) +def test_an_observation_time_that_is_not_an_integer_is_refused(value: Any, reason: str) -> None: + bridge, key, declaration, intent, args, tool_call, transcript = _bridge_fixture() + changed = copy.deepcopy(transcript) + changed["after"]["observed_at"] = value + with pytest.raises(IntentBridgeError, match=re.escape(reason)): + _verify_bridge(bridge, key, declaration, intent, args, tool_call, changed) + + +# ---- the revocation bundle ------------------------------------------------------ + + +def test_a_revocation_bundle_decides_its_times_by_value() -> None: + doc = json.loads( + (ROOT / "examples" / "revocation-bundle" / "01-fresh-well-inside-both-bounds.json") + .read_text() + ) + ctx = doc["context"] + + def run(bundle: dict[str, Any]) -> Any: + return verify_record( + doc["records"][0], ctx["trusted_key"], now=ctx["now"], + max_bundle_age_seconds=ctx["max_bundle_age_seconds"], + max_future_skew_seconds=ctx["max_future_skew_seconds"], + revocation_bundle=bundle, trusted_bundle_keys=ctx["trusted_bundle_keys"], + ) + + bundle = ctx["bundle"] + respelled = {**bundle, "issued_at": float(bundle["issued_at"]), + "valid_until": float(bundle["valid_until"])} + assert run(respelled).revocation.outcome == "verified" + assert bundle_digest(respelled) == bundle_digest(bundle) + fractional = run({**bundle, "issued_at": bundle["issued_at"] + 0.5}).revocation + assert (fractional.outcome, fractional.cause) == ("unverified_for_revocation", + "bundle_malformed") + + +# ---- the vectors, from their committed bytes ------------------------------------ + + +def _doc(name: str) -> dict[str, Any]: + return json.loads((VECTORS / name).read_text(encoding="utf-8")) + + +def _through_a_double(value: Any) -> Any: + """*value* as RFC 8785 section 3.2.2.3 reads it: every number through a double.""" + if isinstance(value, bool): + return value + if isinstance(value, int) and abs(value) > JCS_SAFE_INTEGER: + return float(value) + if isinstance(value, dict): + return {k: _through_a_double(v) for k, v in value.items()} + if isinstance(value, list): + return [_through_a_double(v) for v in value] + return value + + +def _preimage(record: dict[str, Any]) -> bytes: + return rfc8785.dumps(_through_a_double(_unsigned(record))) + + +def _expected(doc: dict[str, Any]) -> str | None: + return None if doc["expected"]["outcome"] == "verified" else doc["expected"]["failure"] + + +def test_the_vector_set_is_complete() -> None: + assert [p.name for p in FILES] == [ + "01-integer-spelling-verified.json", + "02-fraction-spelling-verified.json", + "03-exponent-spelling-verified.json", + "04-fractional-value-rejected.json", + "05-fractional-value-exponent-spelling-rejected.json", + "06-above-range-integer-spelling-rejected.json", + "07-above-range-exponent-spelling-rejected.json", + ] + + +@pytest.mark.parametrize("path", FILES, ids=[p.stem for p in FILES]) +def test_each_file_writes_its_member_in_the_spelling_it_declares(path: Path) -> None: + """The spelling survives only in the file's text, so it is read from there. A tool + that rewrites numbers while reformatting a fixture fails this, loudly.""" + text = path.read_text(encoding="utf-8") + doc = json.loads(text) + last = doc["member"].rsplit(".", 1)[-1] + assert re.findall(rf'"{re.escape(last)}": ([^,\n]+)', text) == [doc["spelling"]] + value, declared = _get(doc["record"], doc["member"]), json.loads(doc["spelling"]) + assert value == declared and type(value) is type(declared) + + +@pytest.mark.parametrize("path", FILES, ids=[p.stem for p in FILES]) +def test_every_signature_in_the_set_is_valid(path: Path) -> None: + """A rejecting vector with a broken signature would be rejected for the wrong reason + and stop testing the rule it names.""" + doc = json.loads(path.read_text(encoding="utf-8")) + signature = doc["record"]["signature"] + raw = base64.urlsafe_b64decode(signature + "=" * (-len(signature) % 4)) + _pubkey_from_jwk(doc["trusted_key"]).verify(raw, _preimage(doc["record"])) + + +def test_a_re_spelled_vector_is_its_twin_once_parsed() -> None: + twins = {p.name: _doc(p.name)["same_signature_as"] for p in FILES + if "same_signature_as" in _doc(p.name)} + assert twins == { + "02-fraction-spelling-verified.json": "01-integer-spelling-verified.json", + "03-exponent-spelling-verified.json": "01-integer-spelling-verified.json", + "05-fractional-value-exponent-spelling-rejected.json": + "04-fractional-value-rejected.json", + } + for name, twin in twins.items(): + assert _doc(name)["record"] == _doc(twin)["record"] + assert _preimage(_doc(name)["record"]) == _preimage(_doc(twin)["record"]) + assert (VECTORS / name).read_bytes() != (VECTORS / twin).read_bytes() + + +def test_each_vector_differs_from_01_in_its_member_alone() -> None: + """With valid signatures, this leaves the member under test as the only reason a + verifier can have to reject a vector that 01's verdict does not also cover.""" + def without(doc: dict[str, Any]) -> dict[str, Any]: + record = _unsigned(copy.deepcopy(doc["record"])) + *parents, last = doc["member"].split(".") + target = record + for name in parents: + target = target[name] + target.pop(last, None) + return record + + first = _doc("01-integer-spelling-verified.json") + for path in FILES: + doc = _doc(path.name) + assert without(doc) == without({**first, "member": doc["member"]}), path.name + + +def test_vector_01_is_the_verifier_compatibility_record_unchanged() -> None: + ours = _doc("01-integer-spelling-verified.json") + theirs = json.loads( + (ROOT / "examples" / "verifier-compatibility" / "01-known-version-verified.json") + .read_text(encoding="utf-8") + ) + assert json.dumps(ours["record"]) == json.dumps(theirs["record"]) + assert ours["trusted_key"] == theirs["trusted_key"] + + +SCHEMA_SAYS = { + "not_an_integer_value": "is not of type 'integer'", + "outside_safe_integer_range": "is greater than the maximum of 9007199254740991", +} + + +@pytest.mark.parametrize("path", FILES, ids=[p.stem for p in FILES]) +def test_the_reference_reaches_each_expected_outcome(path: Path) -> None: + doc = json.loads(path.read_text(encoding="utf-8")) + verifier = doc["verifier"] + + def run() -> Any: + return verify_record( + doc["record"], doc["trusted_key"], now=verifier["verification_time"], + max_age_seconds=verifier["max_age_seconds"], + max_future_skew_seconds=verifier["max_future_skew_seconds"], + ) + + failure = _expected(doc) + if failure is None: + run() + return + with pytest.raises(ValueError, match=re.escape(SCHEMA_SAYS[failure])): + run() + + +# ---- adequacy: shortcuts the set has to catch ----------------------------------- +# Criteria 3 and 4 of `tests/adequacy.py`, which each set implements for itself: a rule +# nothing pins, and a weakness shared by a rule's two vectors. Each shortcut below is a +# plausible way to implement "an integer member" that is not the value rule. + + +def _by_value(value: Any, spelling: str) -> str | None: + try: + _integer_value(value, "m") + except ValueError as exc: + return ("outside_safe_integer_range" if "outside the safe-integer range" in str(exc) + else "not_an_integer_value") + return None + + +def _range(n: int) -> str | None: + return None if abs(n) <= JCS_SAFE_INTEGER else "outside_safe_integer_range" + + +def _by_python_type(value: Any, spelling: str) -> str | None: + """The reference's freshness check before this change.""" + if isinstance(value, bool) or not isinstance(value, int): + return "not_an_integer_value" + return _range(value) + + +def _refuses_an_exponent(value: Any, spelling: str) -> str | None: + return "not_an_integer_value" if "e" in spelling.lower() else _by_value(value, spelling) + + +def _strips_a_trailing_point_zero(value: Any, spelling: str) -> str | None: + text = spelling[:-2] if spelling.endswith(".0") else spelling + try: + return _range(int(text)) + except ValueError: + return "not_an_integer_value" + + +def _finds_a_fraction_by_its_decimal_point(value: Any, spelling: str) -> str | None: + mantissa = spelling.lower().split("e")[0] + if "." in mantissa and mantissa.split(".")[1].strip("0"): + return "not_an_integer_value" + return _range(int(value)) + + +def _leaves_the_range_to_rfc8785(value: Any, spelling: str) -> str | None: + """The range as the `rfc8785` package enforces it: on an `int` only.""" + failure = _by_value(value, spelling) + return None if failure == "outside_safe_integer_range" and type(value) is float else failure + + +def _truncates(value: Any, spelling: str) -> str | None: + """`int(value)`, with nothing checking what was cut off.""" + return _range(int(value)) + + +SHORTCUTS: dict[str, Callable[[Any, str], str | None]] = { + "by Python type": _by_python_type, + "refuses an exponent": _refuses_an_exponent, + "strips a trailing .0": _strips_a_trailing_point_zero, + "finds a fraction by its decimal point": _finds_a_fraction_by_its_decimal_point, + "leaves the range to rfc8785": _leaves_the_range_to_rfc8785, + "truncates": _truncates, +} + +# Which vectors each shortcut gets wrong: the README's claims, held to the set. +CAUGHT_BY = { + "by Python type": ["02", "03", "07"], + "refuses an exponent": ["03", "07"], + "strips a trailing .0": ["03", "07"], + "finds a fraction by its decimal point": ["03", "05"], + "leaves the range to rfc8785": ["07"], + "truncates": ["04", "05"], +} + + +def _wrong(decide: Callable[[Any, str], str | None]) -> list[str]: + wrong = [] + for path in FILES: + doc = _doc(path.name) + if decide(_get(doc["record"], doc["member"]), doc["spelling"]) != _expected(doc): + wrong.append(path.name[:2]) + return wrong + + +def test_the_value_rule_reproduces_every_vector() -> None: + assert _wrong(_by_value) == [] + + +@pytest.mark.parametrize("name", sorted(SHORTCUTS)) +def test_each_shortcut_fails_the_vectors_the_readme_says_it_fails(name: str) -> None: + assert _wrong(SHORTCUTS[name]) == CAUGHT_BY[name] + + +def test_each_rule_has_two_vectors_that_a_shortcut_tells_apart() -> None: + """Two vectors for one rule are independent when some plausible shortcut is caught + by one and not the other; two copies of one vector would never be told apart.""" + pairs = {"whole, however written": ("02", "03"), + "not_an_integer_value": ("04", "05"), + "outside_safe_integer_range": ("06", "07")} + for rule, (a, b) in pairs.items(): + assert any((a in caught) != (b in caught) for caught in CAUGHT_BY.values()), rule + + +# ---- the scan and the JavaScript side ------------------------------------------- + + +def _scan_module() -> ModuleType: + spec = importlib.util.spec_from_file_location( + "scan_published_numbers", VECTORS / "scan_published_numbers.py" + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_the_scan_knows_every_integer_member() -> None: + assert _scan_module().integer_members() == { + ("iat",), ("origin", "ingested_at"), ("tool_transcript", "call_count"), + ("build_provenance", "slsa_level"), ("appraisal", "timestamp"), + ("authorization", "authorized_at"), ("authorization", "expires_at"), + ("issued_at",), ("valid_until",), ("revoked_at",), + ("after", "observed_at"), ("tool_catalog", "tool_count"), + } + + +def test_the_scan_finds_no_published_example_the_rule_changes( + capsys: pytest.CaptureFixture[str], +) -> None: + assert _scan_module().main([str(ROOT / "examples")]) == 0 + assert "skipped number-spelling/" in capsys.readouterr().out + + +def test_the_scan_finds_the_five_members_re_spelled_here() -> None: + scan = _scan_module() + found = scan.scan(VECTORS, scan.integer_members())["findings"] + assert [f.split(":")[0][:2] for f in found] == ["02", "03", "04", "05", "07"] + assert scan.main([str(VECTORS)]) == 1 + + +def test_the_javascript_side_agrees() -> None: + node = shutil.which("node") + if node is None: + pytest.skip("Node is needed to run the JavaScript side of the proposal") + version = subprocess.run([node, "--version"], capture_output=True, text=True, check=True) + if int(version.stdout.strip().lstrip("v").split(".")[0]) < 20: + pytest.skip("the JavaScript test runs on Node.js 20 or later") + result = subprocess.run( + [node, "--test", str(VECTORS / "spelling.test.mjs")], + capture_output=True, text=True, cwd=ROOT, + ) + assert result.returncode == 0, result.stdout + result.stderr From 2778d941601ed5ea027d737f7b1e9b8e99717603 Mon Sep 17 00:00:00 2001 From: Alex Chernysh <73943355+chernistry@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:00:21 +0300 Subject: [PATCH 2/3] spec(numbers): bound the by-value rule to the objects section 3.2.2 covers; document the Python API changes (#247) Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com> --- CHANGELOG.md | 2 +- spec/trace-v0.2.md | 2 +- src/agentrust_trace/provenance.py | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 063895e3..249876b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR. ## [Unreleased] -- **Proposal: integer-typed members are decided by value, not by how they are written (#247).** JSON has one number type, and RFC 8785 writes a number by its value, so `iat` written `1785000000`, `1785000000.0` or `1.785e9` has one pre-image and one signature. The schemas already decided `integer` by value, as JSON Schema 2020-12 defines it. `verify_record` did not: its freshness check tested for a Python `int`, `json.loads` returns a `float` for the other two spellings, and a correctly signed record that had just passed the schema was rejected for having "no valid integer 'iat'". Section 3.2.2 gains a paragraph, "What counts as an integer", stating the rule and that the value is the IEEE 754 double RFC 8785 serializes. In the reference, the freshness check and the bridge's `authorized_at`, `expires_at` and `transcript.after.observed_at` checks accept a whole number however it is written and name the rule a value fails: not an integer value, or outside the safe-integer range. `_canonical_bytes`, and through it signing, every digest and both adapters' transcript hashes, refuses a whole number past the range written as a float, as it already refused one written as an integer. The model reads `1785000000.0` as the integer it is. Two inputs that were accepted are now refused, both by the library and neither in a record that passes the schema: a numeric string such as `"1785000000"`, which the model read as an integer and the schema never accepted, and a whole float past the range in an object the library signs or digests, such as a nanosecond timestamp that went through a double. `examples/number-spelling/` adds seven vectors, a JavaScript test that runs on Node.js with no dependencies, and a scan of published records: of 1,261 numbers in this repository's examples, spec, docs and schemas, in `agentrust-io/trace-tests` at `3af2b53` and in `agentrust-io/trace-registry`, 505 are integer-typed and none is written with a fraction or an exponent, so no published verdict changes. Requested by @imran-siddique on #247; proposed by @chernistry. +- **Proposal: integer-typed members are decided by value, not by how they are written (#247).** JSON has one number type, and RFC 8785 writes a number by its value, so `iat` written `1785000000`, `1785000000.0` or `1.785e9` has one pre-image and one signature. The schemas already decided `integer` by value, as JSON Schema 2020-12 defines it. `verify_record` did not: its freshness check tested for a Python `int`, `json.loads` returns a `float` for the other two spellings, and a correctly signed record that had just passed the schema was rejected for having "no valid integer 'iat'". Section 3.2.2 gains a paragraph, "What counts as an integer", stating the rule and that the value is the IEEE 754 double RFC 8785 serializes. In the reference, the freshness check and the bridge's `authorized_at`, `expires_at` and `transcript.after.observed_at` checks accept a whole number however it is written and name the rule a value fails: not an integer value, or outside the safe-integer range. `_canonical_bytes`, and through it signing, every digest and both adapters' transcript hashes, refuses a whole number past the range written as a float, as it already refused one written as an integer. The model reads `1785000000.0` as the integer it is. Two inputs that were accepted are now refused, both by the library and neither in a record that passes the schema: a numeric string such as `"1785000000"`, which the model read as an integer and the schema never accepted, and a whole float past the range in an object the library signs or digests, such as a nanosecond timestamp that went through a double. `examples/number-spelling/` adds seven vectors, a JavaScript test that runs on Node.js with no dependencies, and a scan of published records: of 1,261 numbers in this repository's examples, spec, docs and schemas, in `agentrust-io/trace-tests` at `3af2b53` and in `agentrust-io/trace-registry`, 505 are integer-typed and none is written with a fraction or an exponent, so no published verdict changes. **Scope.** The paragraph is bound to the objects section 3.2.2 covers: Trust Records, revocation statements and bundles, and the objects a bridge profile digests. The MCP Server Provenance Record (`spec/server-provenance-v1.md`, `provenance.py`) is a separate format and keeps its type-based checks on `issued_at` and `tool_count`; whether it should follow the same rule is tracked separately. Registry-anchor encoding is not touched. **Python API compatibility (breaking for callers, not for records that pass the schema).** (1) `TrustRecord` and the other models no longer read a numeric string as a number: `"iat": "1785000000"` raised nothing before and raises a validation error now (`tests/test_integer_by_value.py`, the `got a str` and `is a str, not a JSON number` cases). The schemas never accepted it, so no record that passed `validate` is affected. (2) `_canonical_bytes`, and so `sign_claim`, every digest and both adapters' transcript hashes, raise `rfc8785.IntegerDomainError` for a whole float past the safe-integer range, such as `1e21` or a nanosecond timestamp that went through a double; before, the float was written as it was, giving a pre-image no conformant verifier could re-derive (`test_every_digest_refuses_a_whole_number_past_the_range_written_as_a_float`). (3) `verify_record`'s freshness check and the bridge's time checks accept a whole float where they demanded a Python `int`; a call that was rejected with `no valid integer 'iat'` now verifies, and the rejection message for a non-integer names the value that failed. Nothing else in the public API changes signature or return type. Requested by @imran-siddique on #247; proposed by @chernistry. - Compare challenge nonces as UTF-8 bytes so non-ASCII mismatches raise the documented ValueError instead of TypeError (#381). Matching strings remain accepted without Unicode normalization. Invalid non-string expected_nonce settings raise a clear ValueError. Reported by @chernistry; follow-up review by @lywinged. diff --git a/spec/trace-v0.2.md b/spec/trace-v0.2.md index 1099bcdb..6114be90 100644 --- a/spec/trace-v0.2.md +++ b/spec/trace-v0.2.md @@ -409,7 +409,7 @@ Implementations MUST use an RFC 8785-conformant library. Using `json.dumps(sort_ **What the rule covers.** It is on the object, not only on its declared fields: the pre-image covers every member, including the members RFC 7517 permits a `cnf.jwk` to carry that this schema does not name, and the schema holds those to the same range. It is also not only about a Trust Record. It covers the revocation statements and bundles of section 3.2.3, and it covers any object whose digest is taken over its canonical form, such as a bridge profile's declaration and tool-call digests, where no schema constrains the input and this rule is the only thing standing between two different objects and one digest. -**What counts as an integer.** JSON has one kind of number. RFC 8259 §6 gives it a single grammar in which a fraction and an exponent are optional parts, so `1785000000`, `1785000000.0` and `1.785e9` are three ways of writing one value. Whether a number is an integer, for a member typed `integer` and for the range above, is decided by that value and not by how it is written: `1785000000.0` and `1.785e9` are the integer 1785000000, `1785000000.5` is not an integer, and `1e21` is an integer outside the range. The value is the IEEE 754 double the number parses to, which is what RFC 8785 serializes and so what a signature covers; a spelling with more digits than a double carries, such as `1785000000.0000000001`, is the integer 1785000000. A verifier MUST make both decisions on that value. JSON Schema 2020-12, in which the schemas of this specification are written, defines `integer` the same way (Validation §6.1.1: any number with a zero fractional part). RFC 8785 writes the value and not the text it was read from, so the three spellings above have one pre-image and one signature, and the spelling is not something a signature can vouch for. A verifier whose parser returns only the value, as `JSON.parse` does, has no spelling to decide by; a verifier that decided by spelling would reject records that such a verifier accepts, over the same signed bytes. +**What counts as an integer.** JSON has one kind of number. RFC 8259 §6 gives it a single grammar in which a fraction and an exponent are optional parts, so `1785000000`, `1785000000.0` and `1.785e9` are three ways of writing one value. Whether a number is an integer, for a member typed `integer` and for the range above, is decided by that value and not by how it is written: `1785000000.0` and `1.785e9` are the integer 1785000000, `1785000000.5` is not an integer, and `1e21` is an integer outside the range. The value is the IEEE 754 double the number parses to, which is what RFC 8785 serializes and so what a signature covers; a spelling with more digits than a double carries, such as `1785000000.0000000001`, is the integer 1785000000. A verifier MUST make both decisions on that value. JSON Schema 2020-12, in which the schemas of this specification are written, defines `integer` the same way (Validation §6.1.1: any number with a zero fractional part). RFC 8785 writes the value and not the text it was read from, so the three spellings above have one pre-image and one signature, and the spelling is not something a signature can vouch for. A verifier whose parser returns only the value, as `JSON.parse` does, has no spelling to decide by; a verifier that decided by spelling would reject records that such a verifier accepts, over the same signed bytes. This paragraph governs the objects the rule above covers: Trust Records, the revocation statements and bundles of section 3.2.3, and the objects a bridge profile digests. The MCP Server Provenance Record of `spec/server-provenance-v1.md` is a separate format with its own verification procedure and is not changed by this paragraph. Each profile MUST declare which binding form it uses. A record with no verifiable signature binding is not a Trust Record: verifiers MUST reject it. Schema validity alone confers no trust. diff --git a/src/agentrust_trace/provenance.py b/src/agentrust_trace/provenance.py index 642677cb..5a03f7b3 100644 --- a/src/agentrust_trace/provenance.py +++ b/src/agentrust_trace/provenance.py @@ -235,6 +235,12 @@ def _check_structure( f"kind={kind!r} carries attestation evidence. Evidence that is present but " "not claimed invites a consumer to read it as an attestation that was made." ) + # Type-based on purpose. Section 3.2.2's "What counts as an integer" decides + # Trust Record members by value, so ``1760000000.0`` is the integer there; it is + # scoped to the objects that section covers, and this record is a separate format + # under spec/server-provenance-v1.md. Whether ``issued_at`` and ``tool_count`` should + # follow the same rule is tracked separately from #247; until then a float is + # refused here as it always was. # bool is an int subclass, and True would otherwise pass as a timestamp. The upper # bound is the same JCS safe-integer limit #219 applies to every other signed integer # in this package: above it there is no portable canonical form, so the producer would From 08b1d764dcb6d8f968ae33b306bf89ee4feb6440 Mon Sep 17 00:00:00 2001 From: Alex Chernysh <73943355+chernistry@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:41:13 +0300 Subject: [PATCH 3/3] fix(integer-by-value): the range reaches the provenance record; name each caller's error; pin both bounds (#247) Review points on #404. Scope. "What counts as an integer" said the MCP Server Provenance Record was not changed by it, while provenance.sign_record and provenance.verify_record canonicalize through _canonical_bytes and so refuse a whole float past the safe-integer range. The paragraph now governs every object the range rule covers and says so: for the range that includes the provenance record, v1 and v2, which is signed under section 3.2, and the transcript behind section 3.1.4's transcript_digest. It retypes no provenance member: issued_at and tool_catalog.tool_count keep their type-based checks, and whether they are decided by value is left as a separate question. CHANGELOG. The Python API list named sign_claim, which does not exist, and gave one exception for every caller. It now names sign.sign_record, revocation.bundle_digest and the adapters' transcript hashes (rfc8785.IntegerDomainError), intent_bridge.digest_jcs, sign_bridge and verify_bridge (IntentBridgeError), and provenance.sign_record and verify_record (ProvenanceError), and records the provenance change: a record carrying 1e21, 9007199254740992.0, -1e21 or -9007199254740992.0 in a member its verifier does not type signed and verified through 0.11.0 and is refused now. Tests. -9007199254740992.0 and -1e21 join the out-of-range spellings, and 9007199254740991.0 and -9007199254740991.0 are canonicalized, so the check in _canonical_bytes is held at both bounds from both sides. Each signing function is run with a whole float past the range and checked for its own error class. Provenance records of both formats are refused at signing and at verification, sign and verify with a whole float inside the range, and keep refusing issued_at and tool_count written as floats. Scan. Re-run at today's revisions: trace-tests at da4369b and trace-registry at e26b85a. 1,341 numbers, 549 integer-typed, none written with a fraction or an exponent; the one other number written that way is 1e400 in a trace-tests vector built to be refused. Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com> --- CHANGELOG.md | 2 +- examples/number-spelling/README.md | 31 +++++--- spec/trace-v0.2.md | 2 +- src/agentrust_trace/provenance.py | 15 ++-- tests/test_integer_by_value.py | 114 ++++++++++++++++++++++++++++- 5 files changed, 144 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69156f99..17242ce0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR. ## [Unreleased] -- **Proposal: integer-typed members are decided by value, not by how they are written (#247).** JSON has one number type, and RFC 8785 writes a number by its value, so `iat` written `1785000000`, `1785000000.0` or `1.785e9` has one pre-image and one signature. The schemas already decided `integer` by value, as JSON Schema 2020-12 defines it. `verify_record` did not: its freshness check tested for a Python `int`, `json.loads` returns a `float` for the other two spellings, and a correctly signed record that had just passed the schema was rejected for having "no valid integer 'iat'". Section 3.2.2 gains a paragraph, "What counts as an integer", stating the rule and that the value is the IEEE 754 double RFC 8785 serializes. In the reference, the freshness check and the bridge's `authorized_at`, `expires_at` and `transcript.after.observed_at` checks accept a whole number however it is written and name the rule a value fails: not an integer value, or outside the safe-integer range. `_canonical_bytes`, and through it signing, every digest and both adapters' transcript hashes, refuses a whole number past the range written as a float, as it already refused one written as an integer. The model reads `1785000000.0` as the integer it is. Two inputs that were accepted are now refused, both by the library and neither in a record that passes the schema: a numeric string such as `"1785000000"`, which the model read as an integer and the schema never accepted, and a whole float past the range in an object the library signs or digests, such as a nanosecond timestamp that went through a double. `examples/number-spelling/` adds seven vectors, a JavaScript test that runs on Node.js with no dependencies, and a scan of published records: of 1,261 numbers in this repository's examples, spec, docs and schemas, in `agentrust-io/trace-tests` at `3af2b53` and in `agentrust-io/trace-registry`, 505 are integer-typed and none is written with a fraction or an exponent, so no published verdict changes. **Scope.** The paragraph is bound to the objects section 3.2.2 covers: Trust Records, revocation statements and bundles, and the objects a bridge profile digests. The MCP Server Provenance Record (`spec/server-provenance-v1.md`, `provenance.py`) is a separate format and keeps its type-based checks on `issued_at` and `tool_count`; whether it should follow the same rule is tracked separately. Registry-anchor encoding is not touched. **Python API compatibility (breaking for callers, not for records that pass the schema).** (1) `TrustRecord` and the other models no longer read a numeric string as a number: `"iat": "1785000000"` raised nothing before and raises a validation error now (`tests/test_integer_by_value.py`, the `got a str` and `is a str, not a JSON number` cases). The schemas never accepted it, so no record that passed `validate` is affected. (2) `_canonical_bytes`, and so `sign_claim`, every digest and both adapters' transcript hashes, raise `rfc8785.IntegerDomainError` for a whole float past the safe-integer range, such as `1e21` or a nanosecond timestamp that went through a double; before, the float was written as it was, giving a pre-image no conformant verifier could re-derive (`test_every_digest_refuses_a_whole_number_past_the_range_written_as_a_float`). (3) `verify_record`'s freshness check and the bridge's time checks accept a whole float where they demanded a Python `int`; a call that was rejected with `no valid integer 'iat'` now verifies, and the rejection message for a non-integer names the value that failed. Nothing else in the public API changes signature or return type. Requested by @imran-siddique on #247; proposed by @chernistry. +- **Proposal: integer-typed members are decided by value, not by how they are written (#247).** JSON has one number type, and RFC 8785 writes a number by its value, so `iat` written `1785000000`, `1785000000.0` or `1.785e9` has one pre-image and one signature. The schemas already decided `integer` by value, as JSON Schema 2020-12 defines it. `verify_record` did not: its freshness check tested for a Python `int`, `json.loads` returns a `float` for the other two spellings, and a correctly signed record that had just passed the schema was rejected for having "no valid integer 'iat'". Section 3.2.2 gains a paragraph, "What counts as an integer", stating the rule and that the value is the IEEE 754 double RFC 8785 serializes. In the reference, the freshness check and the bridge's `authorized_at`, `expires_at` and `transcript.after.observed_at` checks accept a whole number however it is written and name the rule a value fails: not an integer value, or outside the safe-integer range. `_canonical_bytes`, and through it signing, every digest and both adapters' transcript hashes, refuses a whole number past the range written as a float, as it already refused one written as an integer. The model reads `1785000000.0` as the integer it is. Two inputs that were accepted are now refused by the library: a numeric string such as `"1785000000"`, which the model read as an integer and the schema never accepted, and a whole float past the range in an object the library signs or digests, such as a nanosecond timestamp that went through a double. Neither occurs in a Trust Record that passes the schema. The second changes a verdict where no schema stands in front of the canonicalizer, which Scope below spells out. `examples/number-spelling/` adds seven vectors, a JavaScript test that runs on Node.js with no dependencies, and a scan of published records: of 1,341 numbers in this repository's examples, spec, docs and schemas, in `agentrust-io/trace-tests` at `da4369b` and in `agentrust-io/trace-registry` at `e26b85a`, 549 are integer-typed and none is written with a fraction or an exponent; the one other number written that way, `1e400` in a `trace-tests` vector built to be refused, has no finite value and no canonical form before or after. No published verdict changes. **Scope.** The paragraph governs every object the range rule of section 3.2.2 covers. For the range, that includes the MCP Server Provenance Record (`spec/server-provenance-v1.md` and `spec/server-provenance-v2.md`), which is signed under section 3.2, and the transcript behind section 3.1.4's `transcript_digest`. `provenance.sign_record` and `provenance.verify_record` canonicalize through `_canonical_bytes`, so a provenance record of either format that carries `1e21`, `9007199254740992.0`, `-1e21` or `-9007199254740992.0` in a member its verifier does not type, which signed and verified through 0.11.0, is refused by both. The paragraph retypes no provenance member: `provenance.py` keeps its type-based checks on `issued_at` and `tool_count`, and whether those are decided by value is a separate question, not settled here. Registry-anchor encoding is not touched. **Python API compatibility (breaking for callers, not for records that pass the schema).** (1) `TrustRecord` and the other models no longer read a numeric string as a number: `"iat": "1785000000"` raised nothing before and raises a validation error now (`tests/test_integer_by_value.py`, the `got a str` and `is a str, not a JSON number` cases). The schemas never accepted it, so no record that passed `validate` is affected. (2) `_canonical_bytes` refuses a whole float past the safe-integer range, such as `1e21` or a nanosecond timestamp that went through a double; before, the float was written as it was, giving a pre-image no conformant verifier could re-derive. Each caller reports the refusal in its own error type: `sign.sign_record`, `revocation.bundle_digest` and both adapters' transcript hashes raise `rfc8785.IntegerDomainError`, a `ValueError`; `intent_bridge.digest_jcs`, `sign_bridge` and `verify_bridge` raise `IntentBridgeError`; `provenance.sign_record` and `provenance.verify_record` raise `ProvenanceError`, for both provenance formats (`test_every_digest_refuses_a_whole_number_past_the_range_written_as_a_float`, `test_each_signing_function_refuses_a_whole_float_past_the_range_in_its_own_error_type`, `test_a_provenance_record_refuses_a_whole_float_past_the_range_when_signing_and_verifying`). (3) `verify_record`'s freshness check and the bridge's time checks accept a whole float where they demanded a Python `int`; a call that was rejected with `no valid integer 'iat'` now verifies, and the rejection message for a non-integer names the value that failed. Nothing else in the public API changes signature or return type. Requested by @imran-siddique on #247; proposed by @chernistry. - Propose explicit verifier profile declarations and verifier-result field requirements for obligations 2 and 3 of #116, carried by Imran Siddique following the discussion with @lywinged. Obligations 1 and 4 remain deferred; the v0.1 cutover is unchanged. - **Breaking, proposed: `silent` mode allows a policy-denied action (#143, following #28).** Section 4.3 now says a deny MUST NOT block in `silent` mode and the audit chain MUST record every would-have-denied decision. v0.9.0 and v0.10.0 described `silent` as evaluated and enforced under the same `eat_profile`, so a producer that followed that text and blocks on deny no longer conforms, and a `silent` record does not say which meaning it was written under. Classified breaking under GOVERNANCE.md, so adoption needs the 30-day comment period and Project Lead sign-off. Section 4.3 also orders the modes: `advisory` is not weaker than `silent`. Enforce remains the default; schema constraints and runtime behavior are unchanged. Classification raised by @lywinged. diff --git a/examples/number-spelling/README.md b/examples/number-spelling/README.md index a16d98ee..27c26294 100644 --- a/examples/number-spelling/README.md +++ b/examples/number-spelling/README.md @@ -113,18 +113,27 @@ with `node:crypto` over a minimal RFC 8785 serialization of each record. ## Which published records this changes -The rule changes a verdict only for an integer-typed member written with a fraction or -an exponent. `scan_published_numbers.py` counts those in any directory it is given, -reading each number's spelling through `json`'s `parse_int` and `parse_float` hooks. -It skips this directory, whose vectors are re-spelled on purpose, unless it is named -itself, which makes the scan find the five re-spelled members here. +The rule changes a verdict in two cases, and both need a number written with a +fraction or an exponent. An integer-typed member written that way is now read as the +integer it is. A whole number past the range written that way, anywhere in an object +that is signed or digested under section 3.2, is now refused, where the reference +wrote it out as it stood: that reaches objects no schema constrains, such as an MCP +Server Provenance Record carrying `1e21` in a member its verifier does not type. +`scan_published_numbers.py` counts both in any directory it is given, the first as +integer-typed members and the second among the other numbers it lists, reading each +number's spelling through `json`'s `parse_int` and `parse_float` hooks. It skips this +directory, whose vectors are re-spelled on purpose, unless it is named itself, which +makes the scan find the five re-spelled members here. | Scanned | JSON documents | Numbers | Integer-typed | Written with a fraction or an exponent | |---|---:|---:|---:|---:| | this repository, `examples/` | 202 | 1064 | 453 | 0 | -| this repository, `spec/`, `docs/`, `schema/` | 19 | 86 | 11 | 0 | -| `agentrust-io/trace-tests` at `3af2b53` | 16 | 47 | 32 | 0 | -| `agentrust-io/trace-registry` at `d8fa885` | 26 | 64 | 9 | 0 | - -No published record, fixture or example carries such a member, so no published -verdict changes. +| this repository, `spec/`, `docs/`, `schema/` | 20 | 86 | 11 | 0 | +| `agentrust-io/trace-tests` at `da4369b` | 37 | 126 | 76 | 0 | +| `agentrust-io/trace-registry` at `e26b85a` | 27 | 65 | 9 | 0 | + +No published record, fixture or example carries such a member. Outside the +integer-typed members, the scan finds one number written with a fraction or an +exponent: `1e400` in `trace-tests`' `tests/vectors/invalid_canonical_non_finite_float.json`, +a vector built to be refused. It has no finite value, so RFC 8785 has no form for it +before this change or after. No published verdict changes. diff --git a/spec/trace-v0.2.md b/spec/trace-v0.2.md index 7e3408d8..d73164bc 100644 --- a/spec/trace-v0.2.md +++ b/spec/trace-v0.2.md @@ -452,7 +452,7 @@ Implementations MUST use an RFC 8785-conformant library. Using `json.dumps(sort_ **What the rule covers.** It is on the object, not only on its declared fields: the pre-image covers every member, including the members RFC 7517 permits a `cnf.jwk` to carry that this schema does not name, and the schema holds those to the same range. It is also not only about a Trust Record. It covers the revocation statements and bundles of section 3.2.3, and it covers any object whose digest is taken over its canonical form, such as a bridge profile's declaration and tool-call digests, where no schema constrains the input and this rule is the only thing standing between two different objects and one digest. -**What counts as an integer.** JSON has one kind of number. RFC 8259 §6 gives it a single grammar in which a fraction and an exponent are optional parts, so `1785000000`, `1785000000.0` and `1.785e9` are three ways of writing one value. Whether a number is an integer, for a member typed `integer` and for the range above, is decided by that value and not by how it is written: `1785000000.0` and `1.785e9` are the integer 1785000000, `1785000000.5` is not an integer, and `1e21` is an integer outside the range. The value is the IEEE 754 double the number parses to, which is what RFC 8785 serializes and so what a signature covers; a spelling with more digits than a double carries, such as `1785000000.0000000001`, is the integer 1785000000. A verifier MUST make both decisions on that value. JSON Schema 2020-12, in which the schemas of this specification are written, defines `integer` the same way (Validation §6.1.1: any number with a zero fractional part). RFC 8785 writes the value and not the text it was read from, so the three spellings above have one pre-image and one signature, and the spelling is not something a signature can vouch for. A verifier whose parser returns only the value, as `JSON.parse` does, has no spelling to decide by; a verifier that decided by spelling would reject records that such a verifier accepts, over the same signed bytes. This paragraph governs the objects the rule above covers: Trust Records, the revocation statements and bundles of section 3.2.3, and the objects a bridge profile digests. The MCP Server Provenance Record of `spec/server-provenance-v1.md` is a separate format with its own verification procedure and is not changed by this paragraph. +**What counts as an integer.** JSON has one kind of number. RFC 8259 §6 gives it a single grammar in which a fraction and an exponent are optional parts, so `1785000000`, `1785000000.0` and `1.785e9` are three ways of writing one value. Whether a number is an integer, for a member typed `integer` and for the range above, is decided by that value and not by how it is written: `1785000000.0` and `1.785e9` are the integer 1785000000, `1785000000.5` is not an integer, and `1e21` is an integer outside the range. The value is the IEEE 754 double the number parses to, which is what RFC 8785 serializes and so what a signature covers; a spelling with more digits than a double carries, such as `1785000000.0000000001`, is the integer 1785000000. A verifier MUST make both decisions on that value. JSON Schema 2020-12, in which the schemas of this specification are written, defines `integer` the same way (Validation §6.1.1: any number with a zero fractional part). RFC 8785 writes the value and not the text it was read from, so the three spellings above have one pre-image and one signature, and the spelling is not something a signature can vouch for. A verifier whose parser returns only the value, as `JSON.parse` does, has no spelling to decide by; a verifier that decided by spelling would reject records that such a verifier accepts, over the same signed bytes. This paragraph governs every object the rule above covers. For the range, that includes two objects that are canonicalized under this section and whose members this specification does not type: the MCP Server Provenance Record of `spec/server-provenance-v1.md` and `spec/server-provenance-v2.md`, which is signed under section 3.2, and the transcript over whose canonical bytes section 3.1.4's `transcript_digest` is taken. A whole number outside the range in either is rejected however it is written. Which members of such an object are typed `integer` is for the document that defines the object to say, and this paragraph retypes none of them: whether the provenance record's `issued_at` and `tool_catalog.tool_count` are decided by value or by spelling is a separate question, which this paragraph does not settle. Each profile MUST declare which binding form it uses. A record with no verifiable signature binding is not a Trust Record: verifiers MUST reject it. Schema validity alone confers no trust. diff --git a/src/agentrust_trace/provenance.py b/src/agentrust_trace/provenance.py index 96d7ae2a..ad79b856 100644 --- a/src/agentrust_trace/provenance.py +++ b/src/agentrust_trace/provenance.py @@ -299,12 +299,15 @@ def _check_structure( f"kind={kind!r} carries attestation evidence. Evidence that is present but " "not claimed invites a consumer to read it as an attestation that was made." ) - # Type-based on purpose. Section 3.2.2's "What counts as an integer" decides - # Trust Record members by value, so ``1760000000.0`` is the integer there; it is - # scoped to the objects that section covers, and this record is a separate format - # under spec/server-provenance-v1.md. Whether ``issued_at`` and ``tool_count`` should - # follow the same rule is tracked separately from #247; until then a float is - # refused here as it always was. + # Type-based on purpose. Section 3.2.2's "What counts as an integer" does two + # things. It decides by value which numbers are integers for the range, and that + # half reaches this record through `_canonical_bytes`: a whole number past the + # safe-integer range is refused at signing and at verification wherever it sits + # and however it is written. It does not say which members of this record are + # typed `integer`; that is for spec/server-provenance-v1.md and -v2.md. Whether + # ``issued_at`` and ``tool_count`` take a whole number written as a float is a + # separate question from #247, and until it is settled a float is refused here + # as it always was. # bool is an int subclass, and True would otherwise pass as a timestamp. The upper # bound is the same JCS safe-integer limit #219 applies to every other signed integer # in this package: above it there is no portable canonical form, so the producer would diff --git a/tests/test_integer_by_value.py b/tests/test_integer_by_value.py index ba15ba91..1046b5f6 100644 --- a/tests/test_integer_by_value.py +++ b/tests/test_integer_by_value.py @@ -13,7 +13,8 @@ - the freshness check in `verify_record`, which rejected a correctly signed record whose `iat` was written `1785000000.0`; - the canonicalizer, which refuses a whole number past the safe-integer range however - it is written, and every digest that goes through it; + it is written, and every digest and signing function that goes through it, the MCP + Server Provenance Record's included; - the bridge and the revocation bundle, whose integer members are decided the same way; - the vectors in `examples/number-spelling/`, recomputed from their committed bytes, graded against plausible shortcuts, and run from JavaScript when Node is present; @@ -43,6 +44,9 @@ from agentrust_trace import TraceAGTAdapter, TraceSandboxAdapter from agentrust_trace.intent_bridge import IntentBridgeError, digest_jcs, sign_bridge, verify_bridge from agentrust_trace.models import TrustRecord +from agentrust_trace.provenance import FORMAT, FORMAT_V2, ProvenanceError, build_record +from agentrust_trace.provenance import sign_record as sign_provenance_record +from agentrust_trace.provenance import verify_record as verify_provenance_record from agentrust_trace.revocation import bundle_digest from agentrust_trace.sign import ( JCS_SAFE_INTEGER, @@ -95,6 +99,11 @@ ("9.007199254740993e15", 2**53), ("1e21", 10**21), ("1.0e+21", 10**21), + # Below the range, written as floats. `rfc8785` refuses `-9007199254740992` by + # itself, so that spelling says nothing about the lower bound of the check + # `_canonical_bytes` makes; these two are refused by that check alone. + ("-9007199254740992.0", -(2**53)), + ("-1e21", -(10**21)), ] @@ -336,6 +345,14 @@ def test_a_float_that_is_not_whole_is_left_to_rfc8785_as_before() -> None: _canonical_bytes({"n": value}) +def test_each_end_of_the_range_is_inside_it_written_as_a_float() -> None: + """For a float, the check in `_canonical_bytes` is the only one there is: `rfc8785` + writes any finite float. OUTSIDE_THE_RANGE holds each bound from outside, on both + sides of zero; these two hold it from inside.""" + assert _canonical_bytes({"n": 9007199254740991.0}) == b'{"n":9007199254740991}' + assert _canonical_bytes({"n": -9007199254740991.0}) == b'{"n":-9007199254740991}' + + def test_every_digest_refuses_a_whole_number_past_the_range_written_as_a_float() -> None: """A nanosecond timestamp that went through a double is a float past the range. The same value written as an integer was already refused by all three digests.""" @@ -353,6 +370,101 @@ def test_every_digest_refuses_a_whole_number_past_the_range_written_as_a_float() assert transcript_hash([{"risk": 0.5}]).startswith("sha256:") +PAST_THE_RANGE_AS_FLOATS = [1e21, 9007199254740992.0, -1e21, -9007199254740992.0] + + +@pytest.mark.parametrize("value", PAST_THE_RANGE_AS_FLOATS) +def test_each_signing_function_refuses_a_whole_float_past_the_range_in_its_own_error_type( + value: float, +) -> None: + """One refusal in `_canonical_bytes`, reported by each caller in the class that + caller documents.""" + key = Ed25519PrivateKey.generate() + with pytest.raises(rfc8785.IntegerDomainError): + sign_record({**_base(), "extension": value}, key) + with pytest.raises(rfc8785.IntegerDomainError): + bundle_digest({"extension": value}) + with pytest.raises(IntentBridgeError, match="no RFC 8785 canonical form"): + sign_bridge({"extension": value}, key) + with pytest.raises(ProvenanceError, match="no RFC 8785 canonical form"): + sign_provenance_record({**_provenance_record(FORMAT), "extension": value}, key) + + +# ---- the provenance record ------------------------------------------------------ +# +# spec/server-provenance-v1.md signs "following TRACE v0.2 section 3.2 exactly, +# including the canonicalization", and -v2.md keeps v1's signatures. The range of +# section 3.2.2 therefore reaches the record; which of its members are integers is +# its own specification's to say. + + +def _provenance_record(fmt: str, **over: Any) -> dict[str, Any]: + return build_record( + kind="publisher-asserted", + publisher="did:web:acme.example", + tools=[{"name": "search", "description": "search the docs", + "input_schema": {"type": "object"}}], + artifact={"package": "pkg:npm/%40acme/mcp-search@2.1.0", + "digest": "sha256:" + "a" * 64}, + format=fmt, + **over, + ) + + +def _signed_as_rfc8785_writes_it(record: dict[str, Any], key: Ed25519PrivateKey) -> dict: + """*record* signed over the bytes `rfc8785` writes, with no range check on floats: + what `provenance.sign_record` produced through 0.11.0.""" + payload = {**record, "cnf": {"jwk": key_to_jwk(key)}} + signature = base64.urlsafe_b64encode(key.sign(rfc8785.dumps(payload))) + return {**payload, "signature": signature.rstrip(b"=").decode()} + + +@pytest.mark.parametrize("fmt", [FORMAT, FORMAT_V2], ids=["v1", "v2"]) +@pytest.mark.parametrize("value", PAST_THE_RANGE_AS_FLOATS) +def test_a_provenance_record_refuses_a_whole_float_past_the_range_when_signing_and_verifying( + fmt: str, value: float +) -> None: + key = Ed25519PrivateKey.generate() + record = {**_provenance_record(fmt), "extension": value} + with pytest.raises(ProvenanceError, match="no RFC 8785 canonical form, so it cannot"): + sign_provenance_record(record, key) + # The signature below is valid over the bytes it was made over, so the range is + # the only reason to refuse the record. + signed = _signed_as_rfc8785_writes_it(record, key) + with pytest.raises(ProvenanceError, match="no RFC 8785 canonical form, so its sig"): + verify_provenance_record(signed, key_to_jwk(key)) + + +@pytest.mark.parametrize("fmt", [FORMAT, FORMAT_V2], ids=["v1", "v2"]) +@pytest.mark.parametrize("value", [9007199254740991.0, -9007199254740991.0, 1760000000.0]) +def test_a_provenance_record_with_a_whole_float_inside_the_range_signs_and_verifies( + fmt: str, value: float +) -> None: + key = Ed25519PrivateKey.generate() + record = {**_provenance_record(fmt), "extension": value} + signed = sign_provenance_record(record, key) + assert signed["signature"] == _signed_as_rfc8785_writes_it(record, key)["signature"] + verify_provenance_record(signed, key_to_jwk(key)) + + +def test_the_provenance_record_keeps_its_own_typing_of_its_integer_members() -> None: + """Section 3.2.2 carries the range to this record and retypes none of its members. + Whether `issued_at` and `tool_catalog.tool_count` written as whole floats are the + integers they denote is for the provenance specification to say; until it does, + the reference refuses the float as it always did.""" + with pytest.raises(ProvenanceError, match="issued_at must be a non-negative integer"): + _provenance_record(FORMAT, issued_at=1760000000.0) + assert _provenance_record(FORMAT, issued_at=1760000000)["issued_at"] == 1760000000 + key = Ed25519PrivateKey.generate() + record = _provenance_record(FORMAT) + count = record["tool_catalog"]["tool_count"] + assert type(count) is int + respelled = {**record, "tool_catalog": {**record["tool_catalog"], "tool_count": float(count)}} + with pytest.raises(ProvenanceError, match="tool_count"): + verify_provenance_record(sign_provenance_record(respelled, key), key_to_jwk(key)) + verify_provenance_record(sign_provenance_record(record, key), key_to_jwk(key)) + + # ---- the bridge -----------------------------------------------------------------