From 32ed8d68c71a7eb92a391129ea01878f9363826c Mon Sep 17 00:00:00 2001 From: Raghuvar Durga <177662415+drk-ram@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:00:55 -0500 Subject: [PATCH] =?UTF-8?q?conformance:=20align=20TR-ENV,=20TR-APR,=20TR-T?= =?UTF-8?q?XN=20and=20TR-SIG=20with=20=C2=A73.2.2=20integer-by-value=20and?= =?UTF-8?q?=20safe-integer=20bounds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Decide `iat` (`TR-ENV-002`), `appraisal.timestamp` (`TR-APR-004`), and `tool_transcript.call_count` (`TR-TXN-002`) by mathematical integer value within the RFC 8785 safe-integer range `[-9007199254740991, 9007199254740991]` (accepting whole-valued floats such as `1785000000.0` and `1.785e9` and excluding booleans, fractional floats, non-finite floats, and values past `2^53 - 1`). - Refuse whole-valued floats outside `[-9007199254740991, 9007199254740991]` in `tr_sig._canonical_json` before `rfc8785.dumps` and catch `RecursionError` in `tr_sig._canonical_body`, matching `agentrust_trace.sign._canonical_bytes`. - Update `conformance/docs/modules/tr-apr.md` (`TR-APR-004`) to distinguish whole-valued floats from fractional floats and numbers outside the safe range (#448 item 24). - Add unit tests and boundary coverage across `test_tr_env.py`, `test_tr_apr.py`, `test_tr_txn.py`, and `test_tr_sig.py`. Closes #516 Signed-off-by: Raghuvar Durga <177662415+drk-ram@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- conformance/docs/modules/tr-apr.md | 2 +- conformance/src/trace_tests/modules/tr_apr.py | 16 +++- conformance/src/trace_tests/modules/tr_env.py | 18 +++- conformance/src/trace_tests/modules/tr_sig.py | 28 ++++++ conformance/src/trace_tests/modules/tr_txn.py | 22 ++++- conformance/tests/unit/test_tr_apr.py | 53 +++++++++++- conformance/tests/unit/test_tr_env.py | 44 ++++++++++ conformance/tests/unit/test_tr_sig.py | 86 +++++++++++++++++++ conformance/tests/unit/test_tr_txn.py | 24 ++++++ 9 files changed, 281 insertions(+), 12 deletions(-) diff --git a/conformance/docs/modules/tr-apr.md b/conformance/docs/modules/tr-apr.md index 77dbd7ec..d8c1ae92 100644 --- a/conformance/docs/modules/tr-apr.md +++ b/conformance/docs/modules/tr-apr.md @@ -12,7 +12,7 @@ issued it, which policy it was issued under, and when. | TR-APR-001 | `appraisal.status` is one of the four values `schemas/trace-claim.json` enumerates | `affirming`, `warning`, `contraindicated`, `none` | `"verified"`, `"AFFIRMING"`, `7`, absent; also an absent or non-object `appraisal` | | TR-APR-002 | `appraisal.verifier` is an absolute URI | `https://verifier.example.org` | `nvidia-openshell/0.3.0` (no scheme), a URI carrying whitespace or a control character, absent | | TR-APR-003 | `appraisal.policy_ref`, when present, is an absolute URI | `https://policies.example.org/appraisal/v3`; absent (optional, skipped) | `./policies/v1`, `ht tp://x` | -| TR-APR-004 | `appraisal.timestamp`, when present, is an integer of epoch seconds and is not in the future | a past epoch second; absent (optional, skipped) | `"1748000042"` (string), a float, a boolean, an epoch second in the future | +| TR-APR-004 | `appraisal.timestamp`, when present, is an integer of epoch seconds (decided by mathematical value within the RFC 8785 safe-integer range per §3.2.2) and is not in the future | a past epoch second (`1748000042` or `1748000042.0`); absent (optional, skipped) | `"1748000042"` (string), a fractional number (`1748000042.5`), a number outside `[-9007199254740991, 9007199254740991]`, a boolean, an epoch second in the future | ## Required at Level 1+ diff --git a/conformance/src/trace_tests/modules/tr_apr.py b/conformance/src/trace_tests/modules/tr_apr.py index be13931c..2fbd39ce 100644 --- a/conformance/src/trace_tests/modules/tr_apr.py +++ b/conformance/src/trace_tests/modules/tr_apr.py @@ -42,6 +42,8 @@ #: `tr_env.py:36`, which is likewise an inline literal rather than a named #: constant; `test_tr_apr` asserts the two stay equal. _MAX_FUTURE_SKEW_SECONDS = 60 +#: RFC 8785 safe-integer bound (spec §3.2.2). +_JCS_SAFE_INTEGER = 9007199254740991 _ACCOUNTING_SPEC = ObligationSpec( "TR-APR-001", @@ -123,13 +125,21 @@ def _timestamp_finding(appraisal: dict[str, Any]) -> Finding: timestamp = appraisal.get("timestamp") # bool is a subclass of int; a JSON `true` is not an epoch second. - if not isinstance(timestamp, int) or isinstance(timestamp, bool): + # Integer-typed members are decided by mathematical value within the RFC 8785 + # safe-integer range (spec §3.2.2). + if ( + isinstance(timestamp, bool) + or not isinstance(timestamp, (int, float)) + or (isinstance(timestamp, float) and not timestamp.is_integer()) + or not (-_JCS_SAFE_INTEGER <= int(timestamp) <= _JCS_SAFE_INTEGER) + ): return Finding( "TR-APR-004", Status.FAIL, - f"TR-APR-004: appraisal.timestamp must be an integer of epoch seconds, " - f"got {type(timestamp).__name__}", + f"TR-APR-004: appraisal.timestamp must be an integer of epoch seconds in " + f"[{-_JCS_SAFE_INTEGER}, {_JCS_SAFE_INTEGER}], got {timestamp!r}", ) + timestamp = int(timestamp) now = int(time.time()) if timestamp > now + _MAX_FUTURE_SKEW_SECONDS: return Finding( diff --git a/conformance/src/trace_tests/modules/tr_env.py b/conformance/src/trace_tests/modules/tr_env.py index e26f3687..6ea29323 100644 --- a/conformance/src/trace_tests/modules/tr_env.py +++ b/conformance/src/trace_tests/modules/tr_env.py @@ -16,6 +16,7 @@ # Applied with fullmatch: $ alone also matches before a trailing newline. _SUBJECT_RE = re.compile(r'^(spiffe://[^/]+/.+|did:[a-z0-9]+:.+)$') _IAT_MIN = 1_700_000_000 +_JCS_SAFE_INTEGER = 9007199254740991 #: Default maximum record age (seconds). Records older than this fail freshness. DEFAULT_MAX_AGE_SECONDS = 24 * 60 * 60 @@ -36,7 +37,16 @@ def check(trace: dict[str, Any], max_age_seconds: int = DEFAULT_MAX_AGE_SECONDS) findings.append(Finding("TR-ENV-001", Status.FAIL, f"eat_profile must be {_PROFILE!r}, got {profile!r}")) iat = trace.get("iat") - if isinstance(iat, int) and iat >= _IAT_MIN: + # Integer-typed members are decided by mathematical value within the RFC 8785 + # safe-integer range (spec §3.2.2); bool is an int subclass in Python and is + # excluded explicitly. + if ( + isinstance(iat, (int, float)) + and not isinstance(iat, bool) + and (isinstance(iat, int) or iat.is_integer()) + and _IAT_MIN <= int(iat) <= _JCS_SAFE_INTEGER + ): + iat = int(iat) now = int(time.time()) if iat > now + 60: findings.append(Finding("TR-ENV-002", Status.FAIL, f"iat {iat} is in the future (now={now})")) @@ -49,7 +59,11 @@ def check(trace: dict[str, Any], max_age_seconds: int = DEFAULT_MAX_AGE_SECONDS) else: findings.append(Finding("TR-ENV-002", Status.PASS, f"iat is valid and fresh ({iat})")) else: - findings.append(Finding("TR-ENV-002", Status.FAIL, f"iat must be a Unix timestamp >= {_IAT_MIN}, got {iat!r}")) + findings.append(Finding( + "TR-ENV-002", Status.FAIL, + f"iat must be a Unix timestamp integer in " + f"[{_IAT_MIN}, {_JCS_SAFE_INTEGER}], got {iat!r}", + )) subject = trace.get("subject", "") if isinstance(subject, str) and _SUBJECT_RE.fullmatch(subject): diff --git a/conformance/src/trace_tests/modules/tr_sig.py b/conformance/src/trace_tests/modules/tr_sig.py index 9d2aa029..3ecef5ed 100644 --- a/conformance/src/trace_tests/modules/tr_sig.py +++ b/conformance/src/trace_tests/modules/tr_sig.py @@ -30,11 +30,30 @@ _SUPPORTED_KTY = {"OKP", "EC"} _ED25519_CRV = "Ed25519" +_JCS_SAFE_INTEGER = 9007199254740991 _B64URL_RE = re.compile(r"[A-Za-z0-9_-]*\Z") +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 _b64url_decode(s: str) -> bytes: """Decode unpadded base64url (RFC 7515 section 2), accepting one spelling only. @@ -61,7 +80,14 @@ def _canonical_json(d: dict[str, Any]) -> bytes: a verifier that gets either wrong computes different bytes and rejects a valid record. ``tests/test_canonicalization_boundary.py`` holds this to records where the difference is observable. + + Refuses whole-valued floats outside ``[-9007199254740991, 9007199254740991]`` + before calling ``rfc8785.dumps``, matching ``agentrust_trace.sign._canonical_bytes``: + ``json.loads`` parses ``1.0e+21`` and ``9.007199254740993e15`` as ``float``, which + ``rfc8785.dumps`` serializes without error even though §3.2.2 decides integers by + mathematical value. """ + _refuse_whole_floats_out_of_range(d) return rfc8785.dumps(d) @@ -89,6 +115,8 @@ def _canonical_body(d: dict[str, Any]) -> tuple[bytes | None, str]: return _canonical_json(d), "" except rfc8785.CanonicalizationError as exc: return None, str(exc) + except RecursionError: + return None, "record nests too deeply to canonicalize" except UnicodeError as exc: # A lone surrogate in an object *key* fails inside rfc8785's UTF-16 key # sort with UnicodeEncodeError, outside its own hierarchy. The same diff --git a/conformance/src/trace_tests/modules/tr_txn.py b/conformance/src/trace_tests/modules/tr_txn.py index 5f3d3ebf..a544b596 100644 --- a/conformance/src/trace_tests/modules/tr_txn.py +++ b/conformance/src/trace_tests/modules/tr_txn.py @@ -10,6 +10,7 @@ # Byte-equal to the schema pattern (test_digest_parity) and always applied with # fullmatch: Python's $ also matches before a trailing newline, ECMA-262's does not. _DIGEST_RE = re.compile(r"^sha(256:[0-9a-f]{64}|384:[0-9a-f]{96})$") +_JCS_SAFE_INTEGER = 9007199254740991 def check(trace: dict[str, Any]) -> list[Finding]: @@ -38,13 +39,26 @@ def check(trace: dict[str, Any]) -> list[Finding]: # bool is an int subclass, so True would otherwise report as a call count of # one and False as zero. A record that says true here is malformed, and a # conformance suite that silently reads it as a number is answering a - # question nobody asked. - elif isinstance(call_count, int) and not isinstance(call_count, bool) and call_count >= 0: - findings.append(Finding("TR-TXN-002", Status.PASS, f"tool_transcript.call_count is non-negative ({call_count})")) + # question nobody asked. Integer-typed members are decided by mathematical + # value within the RFC 8785 safe-integer range (spec §3.2.2). + elif ( + isinstance(call_count, (int, float)) + and not isinstance(call_count, bool) + and (isinstance(call_count, int) or call_count.is_integer()) + and 0 <= int(call_count) <= _JCS_SAFE_INTEGER + ): + findings.append( + Finding( + "TR-TXN-002", + Status.PASS, + f"tool_transcript.call_count is non-negative ({int(call_count)})", + ) + ) else: findings.append(Finding( "TR-TXN-002", Status.FAIL, - f"TR-TXN-002: tool_transcript.call_count must be a non-negative integer, got {call_count!r}", + f"TR-TXN-002: tool_transcript.call_count must be a non-negative integer in " + f"[0, {_JCS_SAFE_INTEGER}], got {call_count!r}", )) return findings diff --git a/conformance/tests/unit/test_tr_apr.py b/conformance/tests/unit/test_tr_apr.py index 926e73a7..0df4ead7 100644 --- a/conformance/tests/unit/test_tr_apr.py +++ b/conformance/tests/unit/test_tr_apr.py @@ -140,13 +140,62 @@ def test_a_timestamp_inside_the_skew_window_passes(): assert _by_code(check(trace, 0))["TR-APR-004"].status is Status.PASS -@pytest.mark.parametrize("timestamp", ["1748000042", 1748000042.0, None, True, False, []]) +@pytest.mark.parametrize( + "timestamp", + [1748000042.0, 1.748e9, -9007199254740991, -9007199254740991.0], +) +def test_a_whole_float_or_in_range_timestamp_passes(timestamp): + """Integer-typed fields are decided by mathematical value within the safe range (§3.2.2).""" + trace = {"appraisal": {**_VALID["appraisal"], "timestamp": timestamp}} + assert _by_code(check(trace, 0))["TR-APR-004"].status is Status.PASS + + +@pytest.mark.parametrize( + "timestamp", + [ + "1748000042", + 1748000042.5, + None, + True, + False, + [], + float("nan"), + float("inf"), + float("-inf"), + ], +) def test_a_non_integer_timestamp_fails(timestamp): """`bool` is an `int` subclass in Python; a JSON `true` is not epoch seconds.""" trace = {"appraisal": {**_VALID["appraisal"], "timestamp": timestamp}} assert _by_code(check(trace, 0))["TR-APR-004"].status is Status.FAIL +@pytest.mark.parametrize( + "timestamp", + [ + 9007199254740992, + -9007199254740992, + 9007199254740992.0, + -9007199254740992.0, + 1.0e21, + -1.0e21, + ], +) +def test_a_timestamp_outside_the_safe_integer_range_fails(timestamp): + trace = {"appraisal": {**_VALID["appraisal"], "timestamp": timestamp}} + finding = _by_code(check(trace, 0))["TR-APR-004"] + assert finding.status is Status.FAIL + assert "9007199254740991" in finding.message + + +@pytest.mark.parametrize("timestamp", [9007199254740991, 9007199254740991.0]) +def test_a_timestamp_at_safe_integer_upper_bound_reaches_future_check(timestamp): + trace = {"appraisal": {**_VALID["appraisal"], "timestamp": timestamp}} + finding = _by_code(check(trace, 0))["TR-APR-004"] + assert finding.status is Status.FAIL + assert "is in the future" in finding.message + + def test_the_skew_matches_the_one_tr_env_applies_to_a_future_iat(): """Held equal to `tr_env.py:36` by reading the source, not by restating it. @@ -218,7 +267,7 @@ def test_the_duplicated_uri_predicate_agrees_with_the_one_in_tr_pol(value): rather than imported: no module in this tree imports another, and a cross-module import would be a structural change this module has no mandate to make. Duplication without a guard is how two copies of one rule drift, so - this is the guard — the same shape `test_enum_parity` uses for the enums. + this is the guard: the same shape `test_enum_parity` uses for the enums. """ assert _not_absolute_uri(value) == _tr_pol_not_absolute_uri(value) diff --git a/conformance/tests/unit/test_tr_env.py b/conformance/tests/unit/test_tr_env.py index 46f8ec71..5a99cb26 100644 --- a/conformance/tests/unit/test_tr_env.py +++ b/conformance/tests/unit/test_tr_env.py @@ -36,6 +36,50 @@ def test_iat_too_small_fails(): assert "TR-ENV-002" in codes +@pytest.mark.parametrize("iat_Fn", [ + lambda now: float(now - 600), + lambda _now: 1.785e9, + lambda _now: 1_700_000_000, + lambda _now: 1_700_000_000.0, +]) +def test_iat_whole_float_passes(iat_Fn): + now = int(time.time()) + iat = iat_Fn(now) + trace = {**_VALID, "iat": iat} + findings = check(trace, max_age_seconds=max(86400, abs(now - int(iat)) + 3600)) + assert all(f.passed() for f in findings), [f for f in findings if not f.passed()] + + +@pytest.mark.parametrize("iat", [9007199254740991, 9007199254740991.0]) +def test_iat_at_safe_integer_upper_bound_reaches_future_check(iat): + trace = {**_VALID, "iat": iat} + failed = [f for f in check(trace) if f.code == "TR-ENV-002" and f.failed()] + assert len(failed) == 1 + assert "is in the future" in failed[0].message + + +@pytest.mark.parametrize("bad_iat", [ + 1_699_999_999, + 1_699_999_999.0, + 1785000000.5, + True, + False, + "1785000000", + 9007199254740992, + 9007199254740992.0, + 1.0e21, + -9007199254740992, + float("nan"), + float("inf"), + float("-inf"), +]) +def test_iat_non_integer_or_outside_safe_integer_range_fails(bad_iat): + trace = {**_VALID, "iat": bad_iat} + failed = [f for f in check(trace) if f.code == "TR-ENV-002" and f.failed()] + assert len(failed) == 1 + assert "Unix timestamp integer" in failed[0].message + + def test_iat_in_future_fails(): trace = {**_VALID, "iat": int(time.time()) + 3600} codes = {f.code for f in check(trace) if f.failed()} diff --git a/conformance/tests/unit/test_tr_sig.py b/conformance/tests/unit/test_tr_sig.py index c2756c3e..e8b4d30f 100644 --- a/conformance/tests/unit/test_tr_sig.py +++ b/conformance/tests/unit/test_tr_sig.py @@ -200,3 +200,89 @@ def test_signature_present_with_wrong_key_type_fails(): assert sig_findings, "TR-SIG-005 must be reported when a signature is present" assert any(f.failed() for f in sig_findings), sig_findings assert not any(f.status == Status.UNVERIFIED for f in sig_findings) + + +@pytest.mark.parametrize("value", [9007199254740992.0, -9007199254740992.0, 1.0e21, -1.0e21]) +def test_whole_float_past_safe_integer_range_is_refused_in_canonicalization(value): + import rfc8785 + + from trace_tests.modules.tr_sig import _canonical_json + + for container in ({"n": value}, {"a": [{"n": value}]}, {"a": ({"n": value},)}): + with pytest.raises(rfc8785.IntegerDomainError): + _canonical_json(container) + + record = _make_signed_record() + record["trace"]["extra"] = value + cmcp_findings = [ + f for f in check(record["trace"], record, "cmcp-runtime") if f.code == "TR-SIG-001" + ] + assert len(cmcp_findings) == 1 + assert cmcp_findings[0].failed() + assert "no RFC 8785 canonical form" in cmcp_findings[0].message + + trace = dict(record["trace"]) + trace["signature"] = record["signature"] + plain_findings = [f for f in check(trace, trace, "trace") if f.code == "TR-SIG-005"] + assert len(plain_findings) == 1 + assert plain_findings[0].failed() + assert "no RFC 8785 canonical form" in plain_findings[0].message + + +def test_whole_float_inside_safe_integer_range_canonicalizes_by_value(): + from trace_tests.modules.tr_sig import _canonical_json + + assert _canonical_json({"n": 9007199254740991.0}) == b'{"n":9007199254740991}' + assert _canonical_json({"n": -9007199254740991.0}) == b'{"n":-9007199254740991}' + assert _canonical_json({"n": 1785000000.0}) == b'{"n":1785000000}' + assert _canonical_json({"n": 1.785e9}) == b'{"n":1785000000}' + + +def test_deeply_nested_record_fails_closed_without_raising(): + nested: dict = {} + cursor = nested + for _ in range(1500): + cursor["a"] = {} + cursor = cursor["a"] + + record = _make_signed_record() + record["trace"]["nested"] = nested + cmcp_findings = [ + f for f in check(record["trace"], record, "cmcp-runtime") if f.code == "TR-SIG-001" + ] + assert len(cmcp_findings) == 1 + assert cmcp_findings[0].failed() + assert "nests too deeply" in cmcp_findings[0].message + + trace = dict(record["trace"]) + trace["signature"] = record["signature"] + plain_findings = [f for f in check(trace, trace, "trace") if f.code == "TR-SIG-005"] + assert len(plain_findings) == 1 + assert plain_findings[0].failed() + assert "nests too deeply" in plain_findings[0].message + + +def test_number_spelling_vectors_match_expected_conformance_verdicts(): + import pathlib + + from trace_tests.runner import run + + vectors_dir = pathlib.Path(__file__).resolve().parents[3] / "examples" / "number-spelling" + expected_by_name = { + "01-integer-spelling-verified.json": ("pass", "skip", "pass"), + "02-fraction-spelling-verified.json": ("pass", "skip", "pass"), + "03-exponent-spelling-verified.json": ("pass", "skip", "pass"), + "04-fractional-value-rejected.json": ("fail", "skip", "pass"), + "05-fractional-value-exponent-spelling-rejected.json": ("fail", "skip", "pass"), + "06-above-range-integer-spelling-rejected.json": ("pass", "fail", "fail"), + "07-above-range-exponent-spelling-rejected.json": ("pass", "fail", "fail"), + } + paths = sorted(vectors_dir.glob("*.json")) + assert [p.name for p in paths] == list(expected_by_name) + for path in paths: + vec = json.loads(path.read_text(encoding="utf-8")) + res = run(dict(vec["record"]), "trace", level=0, max_age_seconds=10**12) + env_iat = next(f for f in res["TR-ENV"] if f.code == "TR-ENV-002").status.value + apr_ts = next(f for f in res["TR-APR"] if f.code == "TR-APR-004").status.value + sig = next(f for f in res["TR-SIG"] if f.code == "TR-SIG-005").status.value + assert (env_iat, apr_ts, sig) == expected_by_name[path.name], path.name diff --git a/conformance/tests/unit/test_tr_txn.py b/conformance/tests/unit/test_tr_txn.py index 4c814704..4f7aeb0e 100644 --- a/conformance/tests/unit/test_tr_txn.py +++ b/conformance/tests/unit/test_tr_txn.py @@ -100,3 +100,27 @@ def test_zero_call_count_is_valid(): """A transcript with no calls is a real outcome, not an error.""" failed = [f for f in check(_txn(call_count=0)) if f.failed()] assert not failed, failed + + +def test_whole_float_and_safe_bound_call_counts_are_valid(): + """Integer-typed fields are decided by value within [0, 9007199254740991] (§3.2.2).""" + for count in (0.0, -0.0, 3.0, 9007199254740991, 9007199254740991.0): + failed = [f for f in check(_txn(call_count=count)) if f.failed()] + assert not failed, (count, failed) + + +def test_fractional_or_out_of_safe_range_call_count_fails(): + for count in ( + 0.5, + 3.5, + -1.0, + 9007199254740992, + 9007199254740992.0, + 1.0e21, + -9007199254740992, + float("nan"), + float("inf"), + float("-inf"), + ): + failed = [f for f in check(_txn(call_count=count)) if f.failed()] + assert any(f.code == "TR-TXN-002" for f in failed), count