Skip to content

fix(jcs): reject lone surrogates in canonicalization (RFC 8785)#3

Merged
aeoess merged 3 commits into
mainfrom
fix/jcs-lone-surrogate
Jul 14, 2026
Merged

fix(jcs): reject lone surrogates in canonicalization (RFC 8785)#3
aeoess merged 3 commits into
mainfrom
fix/jcs-lone-surrogate

Conversation

@aeoess

@aeoess aeoess commented Jul 14, 2026

Copy link
Copy Markdown
Owner

Rejects strings containing an unpaired UTF-16 surrogate during JCS canonicalization, matching RFC 8785 and I-JSON: a lone surrogate has no valid UTF-8 encoding, so canonicalization terminates with a typed, catchable error. A valid surrogate pair and a genuine U+FFFD are unchanged.

Motivation: the three SDK canonicalizers previously diverged on this input (TypeScript emitted a \ud800 escape, Go substituted U+FFFD, Python raised at UTF-8 encode), so the same payload produced different canonical bytes across languages, which breaks the byte-identical property that signing relies on. All three now reject, restoring cross-language agreement.

Coverage:

  • Raw-JSON ingestion: raw text is validated before decode, since Go's encoding/json substitutes U+FFFD during unmarshal.
  • Typed-value marshaling (Go): strings are validated before json.Marshal, covering map keys, nested and embedded fields, slice elements, and custom json.Marshaler / TextMarshaler output; the walker matches json.Marshal field selection (embedded recursed, json:"-" and unexported skipped).
  • Error contract: the typed error is catchable by existing handlers (errors.Is/As, instanceof, ValueError subclass).

Tested: adversarial scanner cases (non-adjacent escapes, low-surrogate-first, lone-in-key, case variants, backslash parity), cross-language agreement on both reject and valid inputs, and full suites per repo with no existing valid-input hash changed. Reviewed before opening.

Part of a coordinated fix across the TypeScript, Python, and Go SDKs.

aeoess and others added 3 commits July 13, 2026 18:31
A lone/unpaired UTF-16 surrogate has no valid UTF-8 encoding, so RFC 8785
requires rejecting the input. canonicalize_jcs previously returned a surrogate
string that crashed later at UTF-8 encoding. Add _assert_no_lone_surrogate
(typed JCSCanonicalizationError, a ValueError subclass so fail-closed handlers
keep working) on every string value and object key; keys are checked before the
utf-16-be sort that itself raises on surrogates. A valid surrogate pair is
unchanged. Full suite 725 -> 732 passed (+7 new), 3 skipped unchanged; no
existing hash changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…785)

Python already rejects lone surrogates soundly (json.loads preserves lone
surrogate escapes, so the detector sees them). This v2 adds the cross-language
error contract and the consilium's test list; it does not change detection.

- JCSCanonicalizationError gains a stable category ('invalid_unicode') and
  reason ('lone_surrogate'), matching the TypeScript and Go SDKs. It still
  subclasses ValueError, so existing fail-closed handlers keep working.
- Tests: lone surrogate rejected in nested value, array element, and nested key;
  valid-pair-then-lone-low still rejected; error-contract asserts ValueError
  catch + category/reason + no leaked offending string; signing-boundary asserts
  a real content-id API (compute_action_ref) fails closed.

No detection change; full suite green (738 passed, 3 skipped), no hash changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pins the raw JSON payloads from the Go scanner tests through json.loads +
canonicalize_jcs: non-adjacent (space/newline) surrogate escapes, low-first,
high-then-literal-low, lone-in-key, lowercase hex, and literal-backslash+lone
all reject; valid pair, escaped-backslash and double-backslash literals, and a
genuine U+FFFD accept. Python preserves a lone surrogate through parsing and
rejects at canonicalization, matching the Go raw-JSON path. Test-only; full
suite green (750 passed, 3 skipped).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@aeoess
aeoess merged commit f4cb911 into main Jul 14, 2026
2 checks passed
@aeoess
aeoess deleted the fix/jcs-lone-surrogate branch July 14, 2026 04:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant