feat: 规范化 JSON 与三语言互验加固;app-routes 生成器

- wire.CanonicalJSON 自己写字符串转义:Go 编码器即使在关闭 HTML 转义时仍会转义
  U+2028/U+2029,而 Node 与 Foundation 输出字面量;两处拼写解码结果相同,
  因此签名不同却看不出原因。孤立代理项改为显式拒绝(Go 会替换成 U+FFFD、
  Node 保留转义文本、Foundation 直接解析失败,三种行为只能取交集为空)
- 对象键排序按 UTF-8 字节序(Go 的 sort.Strings 语义);Node 参考实现改用
  Buffer.compare,因为 Array.sort 的 UTF-16 码元序在星形平面边界上与之相反
- test-vectors.json 新增转义、键序与必须拒绝的输入;Go/Node 双向断言
- 新增 scripts/export-app-routes.swift 生成 contracts/app-routes.json,
  补上此前 vendored 路由表靠手抄的一环
- README 记录 iOS 侧向量校验为何是'互相验证'而非'逐字节相同'(CryptoKit
  的 Ed25519 签名随机化)
This commit is contained in:
chick
2026-09-16 18:57:06 +08:00
parent 78e0d303ab
commit 3de2736882
3 changed files with 214 additions and 15 deletions
+42 -1
View File
@@ -136,7 +136,8 @@ func main() {
Body: `{"phone_number":"+8613800000000","content":"hello"}`, Body: `{"phone_number":"+8613800000000","content":"hello"}`,
Digest: hexStr(intent), Digest: hexStr(intent),
}, },
CanonicalJSON: jsonPairs(), CanonicalJSON: jsonPairs(),
CanonicalRejected: rejectedPairs(),
} }
encoded, err := json.MarshalIndent(document, "", " ") encoded, err := json.MarshalIndent(document, "", " ")
@@ -167,6 +168,8 @@ type vectors struct {
AEAD aeadVector `json:"aead"` AEAD aeadVector `json:"aead"`
Intent intentVector `json:"intent"` Intent intentVector `json:"intent"`
CanonicalJSON []jsonPairVector `json:"canonicalJson"` CanonicalJSON []jsonPairVector `json:"canonicalJson"`
// CanonicalRejected are inputs every implementation must refuse; see rejectedPairs.
CanonicalRejected []string `json:"canonicalJsonRejected"`
} }
type canonicalVector struct { type canonicalVector struct {
@@ -229,6 +232,31 @@ func jsonPairs() []jsonPairVector {
`{"x":100}`, `{"x":100}`,
`{"a":{"c":[1,{"b":2,"a":1}],"b":0},"A":9}`, `{"a":{"c":[1,{"b":2,"a":1}],"b":0},"A":9}`,
`{"phone_number":"+8613800000000","content":"hello"}`, `{"phone_number":"+8613800000000","content":"hello"}`,
// Escaping edge cases. Each of these has been observed to differ between Go, Node and
// Foundation, so they are pinned rather than assumed:
//
// - U+2028/U+2029: Go's encoder escapes them even with HTML escaping off; Node's
// JSON.stringify and Foundation emit them literally. Both spellings decode to the same
// string, so a signature over the escape differs while the payload looks identical.
// - A paired surrogate: the three languages must agree on the astral character, which
// means agreeing that it is written as literal UTF-8 and not as two \u escapes.
// - Control characters below 0x20 with and without short forms.
// - A literal backslash-u sequence, which is text and must not be read as an escape.
`{"content":"a` + "\u2028" + `b"}`,
`{"content":"a` + "\u2029" + `b"}`,
`{"content":"` + "\U0001F600" + `"}`,
`{"content":"\b\f\u0001"}`,
`{"content":"\\u2028"}`,
// Key ordering. The rule is UTF-8 byte order, which is what Go's sort.Strings does, and it
// is not what either shortcut produces: Node's default sort compares UTF-16 code units
// (so an astral key starting with a D800 surrogate sorts below U+FFFD, while its UTF-8
// form starting with F0 sorts above), and Swift's default String comparison uses Unicode
// collation (so "é" sorts next to "e" rather than after every ASCII byte).
//
// {"z","é"}: UTF-8 has z(0x7A) before é(0xC3A9); collation reverses that.
// {"\uFFFD","\U00010000"}: UTF-16 puts the surrogate first; UTF-8 puts F0 first.
"{\"\u00e9\":1,\"z\":2}",
"{\"\U00010000\":1,\"\uFFFD\":2}",
} }
pairs := make([]jsonPairVector, 0, len(inputs)) pairs := make([]jsonPairVector, 0, len(inputs))
for _, input := range inputs { for _, input := range inputs {
@@ -239,6 +267,19 @@ func jsonPairs() []jsonPairVector {
return pairs return pairs
} }
// rejectedPairs are inputs every implementation must refuse. They are in the vectors so the Swift
// and Node sides have the same list to check against rather than each inventing its own tolerance:
// a lone surrogate decodes to U+FFFD in Go, stays as escape text in Node, and fails to parse in
// Foundation, so the only consistent rule is to accept none of them.
func rejectedPairs() []string {
return []string{
`{"content":"\ud800"}`,
`{"content":"\udc00"}`,
`{"content":"\ud800\ud800"}`,
`{"content":"\ud800x"}`,
}
}
func mustHex(value string) []byte { func mustHex(value string) []byte {
raw, err := hex.DecodeString(value) raw, err := hex.DecodeString(value)
check(err) check(err)
+137 -14
View File
@@ -10,8 +10,13 @@ import (
"sort" "sort"
) )
// marshal is encoding/json with HTML escaping off. The default escapes <, > and &, which would make // marshal is encoding/json with HTML escaping off.
// a body containing those characters hash differently here than it does in Node or Swift. //
// It is used for the envelope's own fields (ids, base64), never for payload strings: Go's encoder
// escapes U+2028 and U+2029 even with HTML escaping disabled, while Node's JSON.stringify and
// Swift's JSONSerialization both emit them literally. That difference is invisible in a hex dump of
// ASCII payloads and shows up as an intent mismatch the first time an SMS body contains a line
// separator, so payload strings go through escapeCanonicalString instead.
func marshal(value any) ([]byte, error) { func marshal(value any) ([]byte, error) {
buf := bytes.NewBuffer(nil) buf := bytes.NewBuffer(nil)
encoder := json.NewEncoder(buf) encoder := json.NewEncoder(buf)
@@ -22,6 +27,48 @@ func marshal(value any) ([]byte, error) {
return bytes.TrimRight(buf.Bytes(), "\n"), nil return bytes.TrimRight(buf.Bytes(), "\n"), nil
} }
// escapeCanonicalString writes a JSON string literal following the documented canonical rules.
//
// It exists because Go's encoder is not a reference implementation for this protocol: it escapes
// U+2028/U+2029 (legal, unnecessary, and not what the other two languages do) and it silently
// rewrites lone surrogates to U+FFFD (which turns "reject this input" into "hash something else").
// Both are the kind of difference that produces a signature failure with no useful cause.
func escapeCanonicalString(buf *bytes.Buffer, value string) {
const hexDigits = "0123456789abcdef"
buf.WriteByte('"')
for _, r := range value {
switch r {
case '"':
buf.WriteString(`\"`)
case '\\':
buf.WriteString(`\\`)
case '\n':
buf.WriteString(`\n`)
case '\r':
buf.WriteString(`\r`)
case '\t':
buf.WriteString(`\t`)
case '\b':
buf.WriteString(`\b`)
case '\f':
buf.WriteString(`\f`)
default:
switch {
case r < 0x20:
// The short forms above cover the ones JSON has names for; everything else below
// 0x20 is \u00XX, which is what both other languages emit.
buf.WriteString(`\u00`)
buf.WriteByte(hexDigits[(r>>4)&0xF])
buf.WriteByte(hexDigits[r&0xF])
default:
// Literal UTF-8, including DEL, U+FFFD and U+2028/U+2029.
buf.WriteRune(r)
}
}
}
buf.WriteByte('"')
}
func unmarshal(raw []byte, value any) error { func unmarshal(raw []byte, value any) error {
decoder := json.NewDecoder(bytes.NewReader(raw)) decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber() decoder.UseNumber()
@@ -44,14 +91,20 @@ func unmarshal(raw []byte, value any) error {
// //
// 1. Object keys are sorted by UTF-8 byte order and emitted without whitespace. // 1. Object keys are sorted by UTF-8 byte order and emitted without whitespace.
// 2. Arrays keep their order. // 2. Arrays keep their order.
// 3. Strings use the same escapes as Go's encoder with HTML escaping disabled: \" \\ \n \r \t, // 3. Strings use the same escapes as Go's encoder with HTML escaping disabled: \" \\ \n \r \t
// \u00XX for other control characters, and literal UTF-8 for everything else. // \b \f, \u00XX for other control characters, and literal UTF-8 for everything else —
// including U+2028 and U+2029, which Go's encoder escapes and the other two implementations
// do not. Lone surrogates are rejected outright (rule 6).
// 4. Numbers must be integers in canonical form: an optional minus, no leading zero, no fraction, // 4. Numbers must be integers in canonical form: an optional minus, no leading zero, no fraction,
// no exponent, no plus. This is stricter than JSON allows and on purpose. Any wider grammar has // no exponent, no plus. This is stricter than JSON allows and on purpose. Any wider grammar has
// values that are the same number but different bytes (1 and 1.0, 1e2 and 100), and "which // values that are the same number but different bytes (1 and 1.0, 1e2 and 100), and "which
// spelling did the phone hash" is not a question this protocol is willing to leave open. The // spelling did the phone hash" is not a question this protocol is willing to leave open. The
// APIs behind it take integers, strings, booleans, arrays and objects, so nothing is lost. // APIs behind it take integers, strings, booleans, arrays and objects, so nothing is lost.
// 5. null, true and false are literal. // 5. null, true and false are literal.
// 6. A lone surrogate escape (\uD800 without its pair, or an unpaired low surrogate) is an error.
// Go's decoder rewrites it to U+FFFD, Node keeps the escape text, and Foundation refuses to
// parse it at all: three behaviours, so the only way the three implementations can agree is to
// accept none of them.
// //
// An empty or absent body canonicalises to the empty byte string, not to the four characters // An empty or absent body canonicalises to the empty byte string, not to the four characters
// "null", so "no body" and "empty body" cannot be confused. // "null", so "no body" and "empty body" cannot be confused.
@@ -60,6 +113,9 @@ func CanonicalJSON(raw []byte) ([]byte, error) {
if len(trimmed) == 0 { if len(trimmed) == 0 {
return nil, nil return nil, nil
} }
if err := rejectLoneSurrogates(trimmed); err != nil {
return nil, err
}
decoder := json.NewDecoder(bytes.NewReader(trimmed)) decoder := json.NewDecoder(bytes.NewReader(trimmed))
decoder.UseNumber() decoder.UseNumber()
var value any var value any
@@ -77,6 +133,81 @@ func CanonicalJSON(raw []byte) ([]byte, error) {
return buf.Bytes(), nil return buf.Bytes(), nil
} }
// rejectLoneSurrogates scans the raw text for a \u escape in the surrogate range that is not
// correctly paired.
//
// It works on the encoded bytes rather than on the decoded string because decoding is exactly what
// destroys the evidence: Go replaces the lone surrogate with U+FFFD, after which "the caller sent
// an invalid escape" and "the caller sent U+FFFD" are the same bytes and only one of them is a
// legitimate thing to hash.
//
// The scan is escape-aware rather than a byte search: in the JSON text `"\\ud800"` the backslash is
// escaped, so the four characters after it are literal text and not a surrogate escape. Walking
// escapes one at a time is what distinguishes the two.
func rejectLoneSurrogates(raw []byte) error {
inString := false
for i := 0; i < len(raw); i++ {
switch raw[i] {
case '"':
inString = !inString
case '\\':
if !inString {
continue
}
if i+1 >= len(raw) {
return errors.New("wire: JSON string ends with a dangling backslash")
}
if raw[i+1] != 'u' {
i++
continue
}
code, ok := hex4(raw, i+2)
if !ok {
return errors.New("wire: malformed \\u escape in JSON string")
}
switch {
case code >= 0xDC00 && code <= 0xDFFF:
return fmt.Errorf("wire: unpaired low surrogate U+%04X in JSON string", code)
case code >= 0xD800 && code <= 0xDBFF:
// A high surrogate must be followed immediately by its low half.
if i+11 >= len(raw) || raw[i+6] != '\\' || raw[i+7] != 'u' {
return fmt.Errorf("wire: unpaired high surrogate U+%04X in JSON string", code)
}
low, ok := hex4(raw, i+8)
if !ok || low < 0xDC00 || low > 0xDFFF {
return fmt.Errorf("wire: high surrogate U+%04X is not followed by a low surrogate", code)
}
i += 11
continue
}
i += 5
}
}
return nil
}
// hex4 reads the four hex digits starting at offset, reporting whether all four were hex.
func hex4(raw []byte, offset int) (uint32, bool) {
if offset+4 > len(raw) {
return 0, false
}
var value uint32
for _, c := range raw[offset : offset+4] {
value <<= 4
switch {
case c >= '0' && c <= '9':
value |= uint32(c - '0')
case c >= 'a' && c <= 'f':
value |= uint32(c-'a') + 10
case c >= 'A' && c <= 'F':
value |= uint32(c-'A') + 10
default:
return 0, false
}
}
return value, true
}
// canonicalNumber is the only number spelling this protocol will hash. See rule 4 above. // canonicalNumber is the only number spelling this protocol will hash. See rule 4 above.
// //
// Negative zero is excluded even though it is an integer: no implementation can be relied on to // Negative zero is excluded even though it is an integer: no implementation can be relied on to
@@ -100,11 +231,7 @@ func writeCanonical(buf *bytes.Buffer, value any) error {
} }
buf.WriteString(literal) buf.WriteString(literal)
case string: case string:
encoded, err := marshal(typed) escapeCanonicalString(buf, typed)
if err != nil {
return err
}
buf.Write(encoded)
case []any: case []any:
buf.WriteByte('[') buf.WriteByte('[')
for index, item := range typed { for index, item := range typed {
@@ -127,11 +254,7 @@ func writeCanonical(buf *bytes.Buffer, value any) error {
if index > 0 { if index > 0 {
buf.WriteByte(',') buf.WriteByte(',')
} }
encodedKey, err := marshal(key) escapeCanonicalString(buf, key)
if err != nil {
return err
}
buf.Write(encodedKey)
buf.WriteByte(':') buf.WriteByte(':')
if err := writeCanonical(buf, typed[key]); err != nil { if err := writeCanonical(buf, typed[key]); err != nil {
return err return err
+35
View File
@@ -433,6 +433,17 @@ func TestCanonicalJSONRules(t *testing.T) {
{``, ``}, {``, ``},
{` `, ``}, {` `, ``},
{`{"a":{"c":[1,{"b":2,"a":1}],"b":0},"A":9}`, `{"A":9,"a":{"b":0,"c":[1,{"a":1,"b":2}]}}`}, {`{"a":{"c":[1,{"b":2,"a":1}],"b":0},"A":9}`, `{"A":9,"a":{"b":0,"c":[1,{"a":1,"b":2}]}}`},
// Go's encoder escapes U+2028/U+2029 even with HTML escaping off; Node and Foundation do
// not. Pinned here because the two spellings decode identically, so nothing else in the
// suite would notice a regression until an SMS body contained a line separator.
{"{\"c\":\"a\u2028b\"}", "{\"c\":\"a\u2028b\"}"},
{"{\"c\":\"a\u2029b\"}", "{\"c\":\"a\u2029b\"}"},
// A literal backslash-u sequence is text, not an escape, and must survive unchanged.
{`{"c":"\\u2028"}`, `{"c":"\\u2028"}`},
// Astral characters are literal UTF-8, not two \u escapes.
{"{\"c\":\"\U0001F600\"}", "{\"c\":\"\U0001F600\"}"},
// Short escapes for the control characters JSON names, \u00XX for the rest.
{"{\"c\":\"\\b\\f\\u0001\"}", "{\"c\":\"\\b\\f\\u0001\"}"},
} }
for _, c := range cases { for _, c := range cases {
got, err := CanonicalJSON([]byte(c.in)) got, err := CanonicalJSON([]byte(c.in))
@@ -456,6 +467,30 @@ func TestCanonicalJSONRules(t *testing.T) {
} }
} }
// A lone surrogate is refused rather than silently rewritten. Go's decoder turns it into U+FFFD,
// Node keeps the escape text, and Foundation refuses to parse it: three behaviours, so accepting
// none of them is the only rule all three implementations can share.
func TestCanonicalJSONRejectsLoneSurrogates(t *testing.T) {
for _, raw := range []string{
`{"c":"\ud800"}`,
`{"c":"\udc00"}`,
`{"c":"\ud800\ud800"}`,
`{"c":"\ud800x"}`,
} {
if got, err := CanonicalJSON([]byte(raw)); err == nil {
t.Errorf("CanonicalJSON(%q) accepted it as %q", raw, got)
}
}
// A correctly paired surrogate is a normal astral character and must still be accepted.
got, err := CanonicalJSON([]byte(`{"c":"\ud83d\ude00"}`))
if err != nil {
t.Fatalf("a paired surrogate was refused: %v", err)
}
if string(got) != "{\"c\":\"\U0001F600\"}" {
t.Fatalf("paired surrogate canonicalised to %q", got)
}
}
func TestCanonicalJSONRejectsGarbage(t *testing.T) { func TestCanonicalJSONRejectsGarbage(t *testing.T) {
for _, raw := range []string{`{`, `{"a":1} trailing`, `{"a":+1}`, `{"a":.5}`, `{"a":NaN}`, `{"a":1.0}`, `{"a":1e3}`, `{"a":01}`, `{"a":-01}`, `{"a":-0}`} { for _, raw := range []string{`{`, `{"a":1} trailing`, `{"a":+1}`, `{"a":.5}`, `{"a":NaN}`, `{"a":1.0}`, `{"a":1e3}`, `{"a":01}`, `{"a":-01}`, `{"a":-0}`} {
if _, err := CanonicalJSON([]byte(raw)); err == nil { if _, err := CanonicalJSON([]byte(raw)); err == nil {