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"}`,
Digest: hexStr(intent),
},
CanonicalJSON: jsonPairs(),
CanonicalJSON: jsonPairs(),
CanonicalRejected: rejectedPairs(),
}
encoded, err := json.MarshalIndent(document, "", " ")
@@ -167,6 +168,8 @@ type vectors struct {
AEAD aeadVector `json:"aead"`
Intent intentVector `json:"intent"`
CanonicalJSON []jsonPairVector `json:"canonicalJson"`
// CanonicalRejected are inputs every implementation must refuse; see rejectedPairs.
CanonicalRejected []string `json:"canonicalJsonRejected"`
}
type canonicalVector struct {
@@ -229,6 +232,31 @@ func jsonPairs() []jsonPairVector {
`{"x":100}`,
`{"a":{"c":[1,{"b":2,"a":1}],"b":0},"A":9}`,
`{"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))
for _, input := range inputs {
@@ -239,6 +267,19 @@ func jsonPairs() []jsonPairVector {
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 {
raw, err := hex.DecodeString(value)
check(err)