Skip to content

test: add non-JCS signature rejection controls - #306

Merged
lywinged merged 1 commit into
agentrust-io:mainfrom
noah-ing:test/canonicalization-negative-controls
Sep 8, 2026
Merged

test: add non-JCS signature rejection controls#306
lywinged merged 1 commit into
agentrust-io:mainfrom
noah-ing:test/canonicalization-negative-controls

Conversation

@noah-ing

@noah-ing noah-ing commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Close the explicitly recorded acceptance-only gap in examples/canonicalization-boundary, under the already accepted section 3.2.2 signature-byte contract.

The four existing fixtures all expect verified. The repository's adequacy loader therefore records canonicalization-boundary in KNOWN_ONE_DIRECTIONAL: an implementation that accepts everything satisfies the whole set. This is the gap described in #186, not a newly proposed verification requirement.

This PR keeps the four positive fixtures byte-identical and adds two portable, schema-valid negative controls:

Fixture Signature actually covers Expected TRACE result
05-ascii-escaped-signature.json Compact JSON with non-ASCII values escaped rejected, signature_invalid
06-codepoint-order-signature.json Compact literal-UTF8 JSON with code-point key ordering rejected, signature_invalid

These are genuine Ed25519 signatures over the wrong preimages, not valid TRACE signatures, malformed records, or random signature corruptions. Their unsigned payloads match positive fixtures 01 and 03 exactly.

Why the new cases are load-bearing

  • Each fixture publishes the exact alternate and RFC 8785 UTF-8 preimages. Tests recompute both independently and check that they differ.
  • Direct cryptography verification succeeds over the declared alternate bytes and raises InvalidSignature over the JCS bytes.
  • Public verify_record() rejects specifically with InvalidSignature, with schema validity, configured trust key, and now=iat controlled.
  • Re-signing the identical payload canonically reproduces the corresponding original positive record exactly and verifies.
  • The accept-everything mutant fails both negatives; reject-everything fails all four positives.
  • A JCS-then-ASCII-fallback mutant incorrectly accepts only 05; a JCS-then-code-point-fallback mutant incorrectly accepts only 06. Both are actually executed, so the two negatives separate distinct implementation defects.
  • Existing ad-hoc-serializer ladder and two-vector margin checks remain restricted to the positive controls. The new negatives cannot inflate that margin.
  • Deterministic generation reproduces all six fixtures. The exact one-directional exemption is removed without relaxing any adequacy criterion.

Scope and relation to #66

The exact-byte action/intent-binding discussion in #66 is why I checked this corpus. This supplies missing portable rejection evidence for a settled prerequisite. It does not implement the two-axis model, decide receipt coverage/cardinality, define a new schema or public API, or close #66/#279. It also does not claim independent-producer interoperability or overlap #245's delegation parent-record-hash evidence.

The production verifier already rejects these cases. This is conformance-test hardening, not a newly discovered production vulnerability. Outer JSON file formatting is not the signature preimage.

Validation

  • Hash-pinned CI requirements on Python 3.11.14 and 3.12.14: 1,314 passed, 1 existing environment-dependent version-metadata skip on each. No new skips/xfails.
  • Targeted set: 48 passed: 20 boundary, 11 corpus adequacy, 17 generator-reproduction tests.
  • Ruff, repository dash/style check, mypy (11 source files), Bandit on the changed generator, sdist/wheel build, Twine metadata validation, and dependency audit passed.
  • Exact-patch Gitleaks: no leaks. Detect-secrets candidates were inspected and are deterministic public fixture keys/signatures, not production credentials.
  • Independent source review confirmed the rejection cause, unchanged positive payloads/fixtures, non-circular outcome assertions, and independently caught fallback mutants.
  • Exact-commit clean clone with hash-pinned dependencies: 1,314 passed, 1 existing metadata skip on Python 3.12.14. Two Git-policy tests require a clone rather than a metadata-free archive; no tests were disabled for the rerun.
  • Built the sdist and wheel from the clean commit export with the hash-pinned build backend. Twine and package-inventory checks passed; the sdist includes all six fixtures, and neither artifact contains forbidden generated paths or private-key PEM blocks.
  • Installed the wheel into a fresh isolated environment and ran outside the checkout: packaged-schema/signing round trip passed, and the public verifier returned four verified positives and two InvalidSignature negatives against the six fixtures.

DCO-signed commit: 1b935ea88bce44eb4fa213b172975c65d8a9f8f8.

Signed-off-by: Noah Ingwers <98993329+noah-ing@users.noreply.github.com>
@noah-ing
noah-ing requested review from a team and lywinged as code owners September 7, 2026 23:20
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

🟡 Contributor Check: MEDIUM

Check Result
Profile MEDIUM
Credential LOW
Overall MEDIUM

Automated check by AgenTrust Contributor Check.

@github-actions github-actions Bot added the needs-review:MEDIUM Contributor check flagged MEDIUM risk label Sep 7, 2026

@lywinged lywinged left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved on 1b935ea over 178e148.

I rebuilt the mutant matrix rather than reading the tests that assert it. Five verifiers written from scratch against the six fixtures, two unconditional and three deciding by trying their own preimage forms with cryptography directly:

verifier disagrees with the set on
JCS only, conformant nothing
accept everything 05, 06
reject everything 01, 02, 03, 04
JCS then ASCII-escaped fallback 05 only
JCS then code-point-order fallback 06 only

Those five rows are what the two mutant tests assert, test_both_unconditional_verifiers_are_killed for the first three and test_each_alternate_fallback_is_a_distinct_killed_defect for the last two, arrived at without running them. The set is two-directional now, and the two negatives are not interchangeable: each kills a different fallback and leaves the other alive.

The property that makes them hold up is that they are signatures over the wrong preimage rather than broken signatures, and the tests assert that rather than assuming it. I checked it the way it can go wrong. Replacing 05's signature with 64 random bytes fails three: test_negative_preimages_are_exact_and_signature_failure_is_isolated[ascii-escaped-signature], test_each_alternate_fallback_is_a_distinct_killed_defect[fallback-accepts-ascii-escaping], and the generator reproduction guard. Substituting 01's valid signature into 05 fails four, including test_both_unconditional_verifiers_are_killed. So a later edit cannot quietly turn either negative into a corrupted-signature vector while the suite stays green.

Also confirmed at this head: both signatures verify over the declared alternate preimage and raise InvalidSignature over the RFC 8785 bytes; the unsigned payloads of 05 and 06 are identical to 01 and 03, and their canonical bytes equal each fixture's own canonical_input_utf8; the four positives are byte-identical to the base; gen_boundary_vectors.py reproduces all six with git status clean, from a seed in the file; all six are schema-valid; verify_record gives four verified and two InvalidSignature with now=iat and the key pinned. Suite 1,314 passed, 1 skipped. Ruff, check_dashes.py and mypy clean.

The adequacy guard still bites with the entry gone. Deleting the two negatives makes test_no_set_is_satisfied_by_an_unconditional_answer[canonicalization-boundary] fail with NO CONTROL: a verifier that answers 'accept' to everything passes this set, so removing the exemption did not remove the check.

The removal is also what that file said would happen. The base docstring recorded the shortfall with the entry deleted when the missing direction is added, and this deletes it; the docstring and the comment above the dict are rewritten to match, so nothing is left claiming an exemption that no longer exists. KNOWN_ONE_DIRECTIONAL is now an empty dict with its branch still live, which is the shape KNOWN_THIN has carried since #186.

Tool-assisted: the runs and this write-up.

@imran-siddique imran-siddique left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving on 1b935ea. I ran the fixtures against the repository's own verifier rather than reading the tests that assert them, because the claim here is about what the corpus can discriminate and that is only checkable by executing it.

Clean venv, editable install, import confirmed from this branch's src/agentrust_trace. verify_record(..., allow_embedded_key=True, max_age_seconds=10**9) over all six fixtures:

fixture result
01-non-ascii-values accepted
02-non-bmp-values accepted
03-utf16-key-order accepted
04-utf16-key-order-nested accepted
05-ascii-escaped-signature rejected, InvalidSignature
06-codepoint-order-signature rejected, InvalidSignature

That is the property the set was missing. Before this, every vector expected acceptance, so an implementation that accepts unconditionally satisfied the whole set and the corpus could not tell it from a conformant one. Two records signed over a non-JCS preimage make the set two-directional, which is why KNOWN_ONE_DIRECTIONAL can go to {} rather than merely shrink. tests/test_canonicalization_boundary.py and tests/test_adequacy_all_sets.py: 31 passed.

Emptying the exemption dict rather than deleting the mechanism is the right call. The assertion still exists to catch the next set that drifts into the same shape.

Worth recording that this is not an isolated gap. #245 has the same defect in a different corpus: all 23 delegation-link vectors are ASCII, so they match under RFC 8785 and under json.dumps(sort_keys=True) alike and discriminate neither rule. A corpus that agrees under the wrong rule is evidence of nothing, and the cure is the same one used here, a vector that only the correct rule accepts. This PR is the worked example for that.

@noah-ing thank you, and @lywinged for rebuilding the mutant matrix from scratch rather than reading the assertions. Merging needs an admin push on this repo, so it lands separately.

@lywinged
lywinged merged commit 7d754cd into agentrust-io:main Sep 8, 2026
8 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-review:MEDIUM Contributor check flagged MEDIUM risk

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[pre-v1.0] Verification depth + action_receipts: two-axis verification model

3 participants