Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion conformance/docs/modules/tr-apr.md
Original file line number Diff line number Diff line change
Expand Up @@ -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+

Expand Down
16 changes: 13 additions & 3 deletions conformance/src/trace_tests/modules/tr_apr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down
18 changes: 16 additions & 2 deletions conformance/src/trace_tests/modules/tr_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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})"))
Expand All @@ -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):
Expand Down
28 changes: 28 additions & 0 deletions conformance/src/trace_tests/modules/tr_sig.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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)


Expand Down Expand Up @@ -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
Expand Down
22 changes: 18 additions & 4 deletions conformance/src/trace_tests/modules/tr_txn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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
53 changes: 51 additions & 2 deletions conformance/tests/unit/test_tr_apr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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)

Expand Down
44 changes: 44 additions & 0 deletions conformance/tests/unit/test_tr_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()}
Expand Down
86 changes: 86 additions & 0 deletions conformance/tests/unit/test_tr_sig.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading