SDK version
0.11.0 (main at 2ce6c97)
Python version
3.11.15
What happened
_trace.py:210 computes the TRACE signature pre-image with this package's canonicalize():
return canonicalize({k: v for k, v in envelope.items() if k != "signature"})
canonicalize() is not RFC 8785 conformant. The clearest case is object key ordering — _serialize_dict, _canonicalize.py:100-105:
def _serialize_dict(d: dict[str, Any], *, exclude_none: bool, depth: int) -> str:
# RFC 8785 §3.2.3: sort keys by Unicode code point order.
# Python's str comparison uses Unicode code point order by default — no
# special locale or collation needed.
parts: list[str] = []
for k in sorted(d.keys()):
RFC 8785 §3.2.3 sorts by UTF-16 code unit, not code point. The two agree across the BMP and part company once a key holds a supplementary-plane character, because its surrogate pair sorts below every high BMP character while its code point sorts above them.
trace-spec names this trap by name. spec/trace-v0.2.md §3.2.2:
Object keys are sorted by UTF-16 code unit (ascending), per RFC 8785 §3.2.3. [...] Sorting Python str values with sorted() gives code-point order and is wrong here; an RFC 8785 library gets this right.
and, in the same section:
Implementations MUST use an RFC 8785-conformant library.
What you expected
A TRACE record signed by trace-spec's own tooling verifies here. It does not.
Running trace-spec's published canonicalization-boundary corpus (examples/canonicalization-boundary/, four records, each valid against its own declared key over its own RFC 8785 bytes) through trace_signing_pre_image:
vector pre-image bytes trace-spec verify agent-manifest verify
non-ascii-values identical VALID VALID
non-bmp-values identical VALID VALID
utf16-key-order DIFFERENT VALID REJECTED
utf16-key-order-nested DIFFERENT VALID REJECTED
trace-spec's own verifier accepts 4/4
agent-manifest's pre-image accepts 2/4
Same records, same keys, same signatures.
Key order is not the only axis. A differential of canonicalize() against a reference JCS library also finds:
|
this SDK |
RFC 8785 |
| exponent keeps repr's leading zero |
1e-07, 3e-08 |
1e-7, 3e-8 |
| exponential form starts too early |
1e-06, 1e-05 |
0.000001, 0.00001 |
integral shortcut bounded at 1e15, ECMAScript's is 1e21 |
1e+16, 1e+20, 1000000000000000.0 |
10000000000000000, 100000000000000000000, 1000000000000000 |
| integers past 2^53 |
serialized silently |
refused — outside the IEEE-754 domain |
| over-escaping |
\u007f, \u0085, \u2028 |
literal |
_canonicalize.py's own docstring calls it the "single canonicalization entry point for all signing, hashing, and Merkle tree operations". It is also the pre-image for COSE signing, delegation chains, revocation records, memory deltas and plugin bundles — TRACE is just where a published corpus exists to demonstrate it.
One existing test asserts the non-conformant behaviour, which is part of why this survived: test_line_separator_escaped requires U+2028 to be escaped. RFC 8785 §3.2.2.2 defers to ECMAScript JSON.stringify, which escapes the quote, the reverse solidus and U+0000-U+001F and nothing else; U+2028 is a hazard when JSON is pasted into JavaScript source, not a JSON serialization rule.
Reproduction
import base64, json, rfc8785
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from agent_manifest._trace import trace_signing_pre_image
# trace-spec examples/canonicalization-boundary/03-utf16-key-order.json
v = json.load(open("03-utf16-key-order.json"))
record, jwk = v["record"], v["trusted_key"]
b64 = lambda s: base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
body = {k: val for k, val in record.items() if k != "signature"}
assert trace_signing_pre_image(record) == rfc8785.dumps(body) # fails
Ed25519PublicKey.from_public_bytes(b64(jwk["x"])).verify(
b64(record["signature"]), trace_signing_pre_image(record)) # InvalidSignature
I have a patch: UTF-16 code-unit key order, ECMAScript Number::toString (verified byte-identical to a reference JCS library across 250,311 values — 200k random doubles, a 50k decimal grid, and the fixed corpus), the integer-domain refusal, the escaping set corrected, plus trace-spec's four vectors vendored as a cross-repository regression guard. Verified load-bearing by reverting: with the key-order fix removed, 4 of the guard's 9 cases fail; restored, 9 pass. Full suite is unchanged against baseline — same 25 pre-existing environment failures, 1040 → 1054 passed.
Two things worth your call rather than mine, which is why this is an issue and not a PR. It changes the canonical bytes for affected values, so anything this SDK already signed at one of those values stops verifying against itself — though it was already unverifiable everywhere else, which is the point. And you are mid CoSAI WS4 Phase 1 review, so the timing of a change at the signature layer is yours. Say the word and I'll open it.
SDK version
0.11.0(mainat2ce6c97)Python version
3.11.15What happened
_trace.py:210computes the TRACE signature pre-image with this package'scanonicalize():canonicalize()is not RFC 8785 conformant. The clearest case is object key ordering —_serialize_dict,_canonicalize.py:100-105:RFC 8785 §3.2.3 sorts by UTF-16 code unit, not code point. The two agree across the BMP and part company once a key holds a supplementary-plane character, because its surrogate pair sorts below every high BMP character while its code point sorts above them.
trace-spec names this trap by name.
spec/trace-v0.2.md§3.2.2:and, in the same section:
What you expected
A TRACE record signed by trace-spec's own tooling verifies here. It does not.
Running trace-spec's published canonicalization-boundary corpus (
examples/canonicalization-boundary/, four records, each valid against its own declared key over its own RFC 8785 bytes) throughtrace_signing_pre_image:Same records, same keys, same signatures.
Key order is not the only axis. A differential of
canonicalize()against a reference JCS library also finds:1e-07,3e-081e-7,3e-81e-06,1e-050.000001,0.000011e15, ECMAScript's is1e211e+16,1e+20,1000000000000000.010000000000000000,100000000000000000000,1000000000000000\u007f,\u0085,\u2028_canonicalize.py's own docstring calls it the "single canonicalization entry point for all signing, hashing, and Merkle tree operations". It is also the pre-image for COSE signing, delegation chains, revocation records, memory deltas and plugin bundles — TRACE is just where a published corpus exists to demonstrate it.One existing test asserts the non-conformant behaviour, which is part of why this survived:
test_line_separator_escapedrequires U+2028 to be escaped. RFC 8785 §3.2.2.2 defers to ECMAScriptJSON.stringify, which escapes the quote, the reverse solidus and U+0000-U+001F and nothing else; U+2028 is a hazard when JSON is pasted into JavaScript source, not a JSON serialization rule.Reproduction
I have a patch: UTF-16 code-unit key order, ECMAScript
Number::toString(verified byte-identical to a reference JCS library across 250,311 values — 200k random doubles, a 50k decimal grid, and the fixed corpus), the integer-domain refusal, the escaping set corrected, plus trace-spec's four vectors vendored as a cross-repository regression guard. Verified load-bearing by reverting: with the key-order fix removed, 4 of the guard's 9 cases fail; restored, 9 pass. Full suite is unchanged against baseline — same 25 pre-existing environment failures,1040→1054passed.Two things worth your call rather than mine, which is why this is an issue and not a PR. It changes the canonical bytes for affected values, so anything this SDK already signed at one of those values stops verifying against itself — though it was already unverifiable everywhere else, which is the point. And you are mid CoSAI WS4 Phase 1 review, so the timing of a change at the signature layer is yours. Say the word and I'll open it.