RFC 8785 integer domain fix and unsafe-integer write policy - #6
Merged
Conversation
…main canonicalize_jcs emitted a Python int verbatim through json.dumps, so the canonical form preserved the caller's decimal spelling. RFC 8785 section 3.2.2.3 defines the JCS number domain as IEEE-754 binary64 serialized under ECMAScript Number::toString, which is shortest-round-trip and not exact-decimal, so the two part company for any integer above 2^53 whose shortest form differs from its exact form. 2^60 serialized as 1152921504606846976 where the TypeScript, Go and Rust SDKs all emit 1152921504606847000. An int is now widened to binary64 and takes the existing _es_number path, the same one floats take, which is already differentially tested against Node's JSON.stringify. An int too large to become a finite double raises the module's typed JCSCanonicalizationError rather than emitting anything. Two supporting details. The bool branch stays ahead of the int branch, because bool is a subclass of int and reordering would serialize True as 1. The redundant function-local "import math" in the float branch is removed: a local import binds the name for the whole function scope and shadowed the module-level math from the int branch above it. The legacy canonicalize() is deliberately unchanged. It has the same int-verbatim behavior, but its bytes are consumed by shipped artifacts and changing them is out of scope here. Adds two positive vectors to the cross-implementation corpus, spliced in as literal text so the integer spellings survive: 2^60 inside signed 64-bit range and 2^68 above it. No expected value among the existing eight was edited. No plus or minus 2^53 signing restriction is introduced here. That is APS policy and does not belong in a generic RFC 8785 implementation. Signed-off-by: Tymofii Pidlisnyi <171286556+aeoess@users.noreply.github.com>
…ad and write twins Phase 2B of the APS unsafe-integer signing policy. Thirteen helpers canonicalized on behalf of BOTH a signing path and a verification path. Guarding them in place would have refused to rebuild the preimage of an artifact signed before the rule existed, so each gained a *_for_write twin that the constructing callers use while the original stays unrestricted for verifiers. Two further helpers reach a canonicalizer indirectly and so were invisible to a call-site census: build_merkle_frame (construct twice, project once) and compute_attribution_action_ref. Both are split the same way. The action_ref twin is module-internal on purpose and is absent from the package barrel, so this adds no public API. Each split shares one implementation body between the twins, so the two can never drift apart on a field list. Deliberately unchanged: verify_endorsement, verify_disclosure, verify.py recompute paths, contributor_query, and settlement_record_hash all keep the unrestricted canonicalizers. receipt_core/jcs.py strict_jcs is untouched because assert_i_json already enforces the identical bound one line above it. Recorded limitation: envelope_bytes canonicalizes four string members, so its twin cannot ever fire the rule. It exists for symmetry, not for enforcement. Verified: 816 passed, exit 0. Canonicalization baselines 93ae6ad1 (legacy) and b317e1be (JCS) unchanged; the create_delegation signing preimage 90fe1949 and its signature are byte-identical to the base commit; a pre-rule artifact carrying 9007199254740992 still verifies True through the public verifier. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Tymofii Pidlisnyi <171286556+aeoess@users.noreply.github.com>
Phase 2C of the APS unsafe-integer signing policy, Python half. Three call sites that construct new protocol state and were missed by the earlier sweep now canonicalize through the write variant: two in attribution_settlement aggregate (_finalize_axis builds the merkle leaf and the pooled contributors hash of a new axis index) and one in mutual_auth handshake (derive_session mints a new session identifier). verify_endorsement and verify_disclosure remain on the unrestricted canonicalizer, as do every recompute path in verify.py and contributor_query. Verified: 816 passed, exit 0. Canonicalization baselines 93ae6ad1 and b317e1be unchanged; the create_delegation signing preimage 90fe1949 and its signature are byte-identical to Job 1 base 1b87966; an unsafe integer is refused at $.spendLimit before signing; a pre-rule artifact still verifies True. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Tymofii Pidlisnyi <171286556+aeoess@users.noreply.github.com>
…egression Phase 2D of the APS unsafe-integer signing policy, Python half. Adds tests/fixtures/write-policy-admissibility-v1.json, byte-identical to the TypeScript copy, sha256 97db9ed8bfeab81ac50187c161ea80953f5878092530ff3fa1912d7eeb985f67. The digest is pinned in both suites so a drift fails loudly. The corpus is deliberately separate from the RFC 8785 canonical-bytes vectors. The same five cases ran through the Go SDK's receiptcore validator and it agreed on all five, including the nested path $.a.b[0].c. Each case is asserted against both write canonicalizers and against both unrestricted canonicalizers, which must keep accepting every case including the rejected ones. Verification regression, kept permanently: verify_endorsement accepts an endorsement signed through the unrestricted canonicalizer carrying 9007199254740992, it returns a verdict rather than raising on a tampered value, and verify_disclosure accepts a pre-rule disclosure. Both are named explicitly because an earlier classification pass wrongly listed them as signing paths; guarding them would have broken every endorsement and disclosure already published. Verified: 842 passed, exit 0; wheel builds and installs clean. Through the installed package: no write-policy name is public, canonicalize and canonicalize_jcs stay public and still emit 9007199254740992, a safe delegation signs and verifies, an unsafe one is refused at $.spendLimit before signing, and a pre-rule artifact still verifies. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Tymofii Pidlisnyi <171286556+aeoess@users.noreply.github.com>
…ite twin
Findings from the independent read-only audit of the complete Job 2 diff, each
reproduced before being fixed.
1. BYPASS. Both write canonicalizers dispatch on isinstance(obj, list), so a
tuple-valued member fell through every guarded branch to the terminal json.dumps
fallback and was emitted unchecked. An integer beyond 2**53-1 inside a tuple could
be signed. Reproduced: canonicalize_for_write({"v": (9007199254740992,)}) returned
bytes instead of raising.
The fix validates at that fallback but still EMITS through it. That detail matters:
the READ twins serialize a tuple through the same json.dumps, which produces
"[1, 2]" with a space rather than canonical "[1,2]". Recursing into tuples in the
write twin would have emitted different bytes from the read twin, and a signed
artifact would then fail verification. Validating in place closes the bypass and
moves no byte. A second read is safe here precisely because the values that reach
this branch are immutable.
2. DEAD TWIN. canonicalize_attestation_for_write was created in phase 2B but
sign_attestation was never switched to it, so the twin had no caller and the
signing boundary stayed unrestricted. sign_attestation now uses it.
Permanent regression tests added: a tuple cannot smuggle an unsafe integer past either
write canonicalizer, the refusal names the exact path $.v[0], safe tuple bytes are
identical between the read and write twins, and both read twins still accept a tuple
carrying an unsafe integer so historical bytes stay reproducible.
Verified: 845 passed, exit 0. Canonicalization baselines 93ae6ad1 and b317e1be
unchanged; the create_delegation signing preimage 90fe1949 and its signature still
byte-identical to Job 1 base 1b87966; pre-rule artifact still verifies True.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Tymofii Pidlisnyi <171286556+aeoess@users.noreply.github.com>
Job 2E, Python. _pooled_hash mints the pooled_contributors_hash of a NEW ResidualBucket in all three aggregate_*_axis constructors, and no verification path calls it. It was carried as a reclassify-to-A decision in phase 2B but the edit was never applied. Verified: 845 passed, exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Tymofii Pidlisnyi <171286556+aeoess@users.noreply.github.com>
Second closeout audit pass, high finding, and a gap I created by closing only the TypeScript half. request_owner_confirmation mints the action_details_hash commitment through hash_action_details, which is sha256 over json.dumps output. It canonicalizes nothing, so no canonicalizer census could ever see it, yet the commitment is copied into a signed OwnerConfirmation. TypeScript gained hashActionDetailsForWrite in Job 2E; Python did not, leaving the two SDKs disagreeing on admissibility at the same protocol boundary. hash_action_details_for_write VALIDATES ONLY and delegates to the original for serialization, so the bytes are identical and every existing commitment stays reproducible. is_confirmation_valid keeps calling the unrestricted form. Verified: 845 passed, exit 0; safe input hashes identically through both twins; an out-of-range integer is refused at $.amount. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Tymofii Pidlisnyi <171286556+aeoess@users.noreply.github.com>
Merged
aeoess
added a commit
that referenced
this pull request
Aug 20, 2026
RFC 8785 integer domain alignment in canonicalize_jcs and the unsafe-integer write policy at signing and new-write boundaries (#6). Canonical JCS bytes move for integers whose decimal spelling differs from the binary64 serialization of the same value, which is why the minor version moves. Signed-off-by: Tymofii Pidlisnyi <tima@aeoess.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two changes ride together; the second depends on the first.
1. canonicalize_jcs serializes int through the RFC 8785 number domain
RFC 8785 section 3.2.2.3 defines the JCS number domain as IEEE 754 binary64
serialized under ECMAScript Number::toString. Python's int is arbitrary precision,
and the previous code emitted it verbatim, preserving a decimal spelling the double
does not have: 2^60 serialized as 1152921504606846976 where the binary64 form is
1152921504606847000. The int branch now widens to binary64 first and takes the same
path a float takes. An integer beyond the binary64 range raises
JCSCanonicalizationError with reason "number_out_of_double_range", since no RFC 8785
representation exists for it.
2. Unsafe integers refused at signing and new-write boundaries
At signing and new-write boundaries only, the SDK refuses an integer-valued JSON
number whose absolute value exceeds 9007199254740991 (2^53-1), per RFC 7493 section
2.2. UnsafeIntegerError subclasses ValueError and carries a JSONPath to the
offending value. Verification, recompute, and every path rebuilding the preimage of
an existing artifact keep calling the unrestricted canonicalizers, so pre-rule
artifacts keep verifying; the built wheel proves it.
canonicalize_for_write and canonicalize_jcs_for_write are byte-identical to their
read twins for every value they accept and read each key exactly once, so a Mapping
subclass overriding getitem cannot answer safe on a validating pass and unsafe
on the emitting pass. The json.dumps fallback both twins share is validated in
place, so a tuple cannot carry an unsafe integer past the rule while the emitted
bytes stay identical to the read twin. No write-policy name is exported from
agent_passport; write_policy.py ships in the wheel for internal use.
Proof
TypeScript copy, agreed 5/5 by the Python, TypeScript, and Go implementations.
preimage sha256 unchanged and the Ed25519 signature unchanged.