Files
msa-agent/internal/wire/wire_test.go
T
chick 3de2736882 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 签名随机化)
2026-09-16 18:57:06 +08:00

510 lines
18 KiB
Go

package wire
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"testing"
)
func fixedEnvelope(t *testing.T) Envelope {
t.Helper()
ek, err := hex.DecodeString("8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a")
if err != nil {
t.Fatal(err)
}
return Envelope{
V: Version,
Node: "nd_7f3a9c2e",
Req: "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
App: "ap_5b1d",
TS: 1727289600,
EK: ek,
Alg: Suite,
P: []byte{0x00, 0x01, 0x02, 0xfe, 0xff},
}
}
// TestCanonicalRequestVector pins the exact bytes. This is the file every implementation must agree
// with: if the Node relay or the Swift app produces anything else here, signatures will fail in
// production with an error that says nothing about why. Keeping the vector in the repo turns that
// into a failing test instead.
func TestCanonicalRequestVector(t *testing.T) {
canonical, err := CanonicalRequest(fixedEnvelope(t))
if err != nil {
t.Fatal(err)
}
got := hex.EncodeToString(canonical)
const want = "" +
"6d73612d72656c61792d76312d72657100" + // "msa-relay-v1-req" NUL
"000000080000000000000001" + // v = 1 as uint64be, length-prefixed
"0000000b6e645f3766336139633265" + // node
"0000002433663235303465302d346638392d343164332d396130632d303330356538326333333031" + // req
"0000000661705f35623164" + // app
"000000080000000000000000" + // ts placeholder, replaced below
"000000208520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a" + // ek
"00000005000102feff" // payload
_ = want
// The literal above is easier to get wrong than to derive, so assert the structural properties
// that actually matter, then pin the whole thing against a recomputed expectation.
if !bytes.HasPrefix(canonical, []byte("msa-relay-v1-req\x00")) {
t.Fatalf("canonical does not start with the domain: %x", canonical[:20])
}
rawEK := fixedEnvelope(t).EK
if !bytes.Contains(canonical, rawEK) {
t.Fatal("canonical is missing the raw ek bytes")
}
if bytes.Contains(canonical, []byte(base64.StdEncoding.EncodeToString(rawEK))) {
t.Fatal("canonical contains base64 ek text; the signature must cover raw bytes")
}
// Field order is part of the contract, so check each marker's offset is increasing.
positions := []int{
bytes.Index(canonical, []byte("msa-relay-v1-req")),
bytes.Index(canonical, []byte("nd_7f3a9c2e")),
bytes.Index(canonical, []byte("3f2504e0-4f89-41d3-9a0c-0305e82c3301")),
bytes.Index(canonical, []byte("ap_5b1d")),
}
for index := 1; index < len(positions); index++ {
if positions[index] <= positions[index-1] {
t.Fatalf("field order is wrong: %v", positions)
}
}
if got != expectCanonicalRequest() {
t.Fatalf("canonical request changed:\n got %s\nwant %s", got, expectCanonicalRequest())
}
}
// expectCanonicalRequest is the frozen vector. It is spelled out rather than computed so that a
// change to the encoding shows up as a diff here instead of silently invalidating every paired
// device in the field.
func expectCanonicalRequest() string {
parts := []string{
hex.EncodeToString([]byte("msa-relay-v1-req")),
"00",
"00000008", "0000000000000001",
"0000000b", hex.EncodeToString([]byte("nd_7f3a9c2e")),
"00000024", hex.EncodeToString([]byte("3f2504e0-4f89-41d3-9a0c-0305e82c3301")),
"00000007", hex.EncodeToString([]byte("ap_5b1d")),
"00000008", "0000000066f45900",
"00000020", "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a",
"00000005", "000102feff",
}
out := ""
for _, part := range parts {
out += part
}
return out
}
func TestCanonicalResponseOmitsAppID(t *testing.T) {
envelope := fixedEnvelope(t)
request, err := CanonicalRequest(envelope)
if err != nil {
t.Fatal(err)
}
response, err := CanonicalResponse(envelope)
if err != nil {
t.Fatal(err)
}
if bytes.Equal(request, response) {
t.Fatal("request and response canonical forms are identical; a signature could be replayed across directions")
}
if bytes.Contains(response, []byte("ap_5b1d")) {
t.Fatal("response canonical form includes the app id")
}
if !bytes.HasPrefix(response, []byte("msa-relay-v1-resp\x00")) {
t.Fatalf("response canonical form has the wrong domain: %x", response[:20])
}
}
func TestCanonicalRejectsMalformedEnvelopes(t *testing.T) {
cases := map[string]func(*Envelope){
"missing node": func(e *Envelope) { e.Node = "" },
"missing req": func(e *Envelope) { e.Req = " " },
"missing suite": func(e *Envelope) { e.Alg = "" },
"unknown suite": func(e *Envelope) { e.Alg = "AES128" },
"short ek": func(e *Envelope) { e.EK = e.EK[:16] },
"empty ek": func(e *Envelope) { e.EK = nil },
}
for name, mutate := range cases {
envelope := fixedEnvelope(t)
mutate(&envelope)
if _, err := CanonicalRequest(envelope); err == nil {
t.Errorf("%s: canonical form accepted it", name)
}
}
}
func TestSignAndVerifyRoundTrip(t *testing.T) {
public, private, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
envelope := fixedEnvelope(t)
if err := SignRequest(&envelope, private); err != nil {
t.Fatal(err)
}
if err := VerifyRequest(envelope, public); err != nil {
t.Fatalf("VerifyRequest: %v", err)
}
// Every routing field must be covered. If any of these mutations still verified, the relay
// could change it in flight.
mutations := map[string]func(*Envelope){
"node": func(e *Envelope) { e.Node = "nd_attacker" },
"req": func(e *Envelope) { e.Req = "00000000-0000-4000-8000-000000000000" },
"app": func(e *Envelope) { e.App = "ap_other" },
"ts": func(e *Envelope) { e.TS = e.TS + 1 },
"ek": func(e *Envelope) { e.EK = bytes.Repeat([]byte{7}, PublicBytes) },
"p": func(e *Envelope) { e.P = append(append([]byte{}, e.P...), 0) },
"alg": func(e *Envelope) { e.Alg = "other" },
"v": func(e *Envelope) { e.V = 2 },
}
for name, mutate := range mutations {
changed := envelope
changed.EK = append([]byte{}, envelope.EK...)
changed.P = append([]byte{}, envelope.P...)
changed.S = append([]byte{}, envelope.S...)
mutate(&changed)
if err := VerifyRequest(changed, public); err == nil {
t.Errorf("signature still verified after mutating %s", name)
}
}
// A response signature must not satisfy a request verification even over identical bytes.
response := fixedEnvelope(t)
if err := SignResponse(&response, private); err != nil {
t.Fatal(err)
}
if err := VerifyRequest(response, public); err == nil {
t.Fatal("a response signature verified as a request signature")
}
}
func TestVerifyRejectsWrongKey(t *testing.T) {
_, private, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
otherPublic, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
envelope := fixedEnvelope(t)
if err := SignRequest(&envelope, private); err != nil {
t.Fatal(err)
}
if err := VerifyRequest(envelope, otherPublic); !errors.Is(err, ErrSignature) {
t.Fatalf("VerifyRequest with the wrong key = %v, want ErrSignature", err)
}
if err := VerifyRequest(envelope, otherPublic[:16]); err == nil {
t.Fatal("accepted a truncated public key")
}
}
// TestSharedSecretMatchesRFC7748 is the X25519 known-answer test. Without it a silent change to the
// scalar clamping or the endianness would produce a working system that interoperates with nobody.
func TestSharedSecretMatchesRFC7748(t *testing.T) {
// RFC 7748 section 6.1: alice private, bob public, expected shared secret.
alicePrivate := mustHex(t, "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a")
bobPublic := mustHex(t, "de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f")
want := mustHex(t, "4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742")
got, err := SharedSecret(alicePrivate, bobPublic)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, want) {
t.Fatalf("shared secret = %x, want %x", got, want)
}
}
func TestSharedSecretRejectsBadKeys(t *testing.T) {
valid := bytes.Repeat([]byte{7}, PublicBytes)
if _, err := SharedSecret(nil, valid); !errors.Is(err, ErrBadKey) {
t.Errorf("nil private key err = %v, want ErrBadKey", err)
}
if _, err := SharedSecret(bytes.Repeat([]byte{1}, KeyBytes), nil); !errors.Is(err, ErrBadPublicKey) {
t.Errorf("nil peer key err = %v, want ErrBadPublicKey", err)
}
// The all-zero public key is the classic low-order point; it must not yield a shared secret.
if _, err := SharedSecret(bytes.Repeat([]byte{9}, KeyBytes), make([]byte, PublicBytes)); err == nil {
t.Error("accepted the all-zero peer public key")
}
}
func TestSealOpenRoundTrip(t *testing.T) {
key := bytes.Repeat([]byte{0x2a}, KeyBytes)
iv := bytes.Repeat([]byte{0x0b}, IVBytes)
plaintext := []byte(`{"m":"GET","p":"/api/health"}`)
sealed, err := Seal(key, iv, plaintext)
if err != nil {
t.Fatal(err)
}
if len(sealed) != len(plaintext)+TagBytes {
t.Fatalf("sealed length = %d, want %d", len(sealed), len(plaintext)+TagBytes)
}
opened, err := Open(key, iv, sealed)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(opened, plaintext) {
t.Fatalf("opened = %q, want %q", opened, plaintext)
}
// Tampering anywhere, including in the tag, must fail closed.
for index := range sealed {
damaged := append([]byte{}, sealed...)
damaged[index] ^= 0x01
if _, err := Open(key, iv, damaged); !errors.Is(err, ErrOpen) {
t.Fatalf("flipping byte %d still authenticated", index)
}
}
if _, err := Open(bytes.Repeat([]byte{0x2b}, KeyBytes), iv, sealed); !errors.Is(err, ErrOpen) {
t.Fatal("a different key decrypted the payload")
}
if _, err := Open(key, bytes.Repeat([]byte{0x0c}, IVBytes), sealed); !errors.Is(err, ErrOpen) {
t.Fatal("a different iv decrypted the payload")
}
if _, err := Open(key, iv, sealed[:TagBytes-1]); !errors.Is(err, ErrOpen) {
t.Fatal("a truncated payload was accepted")
}
}
// TestDerivedKeysAreDirectionalAndRequestBound checks the properties that make key reuse safe to
// reason about: the two directions differ, and two requests never share an IV.
func TestDerivedKeysAreDirectionalAndRequestBound(t *testing.T) {
shared := bytes.Repeat([]byte{0x5c}, KeyBytes)
requestKey, requestIV, err := DeriveKey(shared, "nd_a", "req-1", false)
if err != nil {
t.Fatal(err)
}
responseKey, responseIV, err := DeriveKey(shared, "nd_a", "req-1", true)
if err != nil {
t.Fatal(err)
}
if bytes.Equal(requestKey, responseKey) {
t.Fatal("both directions derived the same key")
}
if bytes.Equal(requestIV, responseIV) {
t.Fatal("both directions derived the same iv")
}
if len(requestKey) != KeyBytes || len(requestIV) != IVBytes {
t.Fatalf("derived sizes are wrong: key %d iv %d", len(requestKey), len(requestIV))
}
otherKey, otherIV, err := DeriveKey(shared, "nd_a", "req-2", false)
if err != nil {
t.Fatal(err)
}
if bytes.Equal(requestIV, otherIV) {
t.Fatal("two request ids derived the same iv")
}
if bytes.Equal(requestKey, otherKey) {
t.Fatal("two request ids derived the same key")
}
_ = otherIV
otherNodeKey, _, err := DeriveKey(shared, "nd_b", "req-1", false)
if err != nil {
t.Fatal(err)
}
if bytes.Equal(requestKey, otherNodeKey) {
t.Fatal("two nodes derived the same key from the same secret")
}
}
// TestHKDFMatchesRFC5869 pins the KDF itself against the published test case, so an implementation
// slip cannot hide behind the round-trip tests above.
func TestHKDFMatchesRFC5869(t *testing.T) {
ikm := mustHex(t, "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b")
salt := mustHex(t, "000102030405060708090a0b0c")
info := mustHex(t, "f0f1f2f3f4f5f6f7f8f9")
want := mustHex(t, "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865")
got, err := HKDF(ikm, salt, info, 42)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, want) {
t.Fatalf("hkdf = %x, want %x", got, want)
}
if _, err := HKDF(ikm, salt, info, 0); err == nil {
t.Fatal("accepted a zero-length output")
}
if _, err := HKDF(ikm, salt, info, 255*32+1); err == nil {
t.Fatal("accepted an output longer than HKDF allows")
}
}
func TestEnvelopeJSONRoundTrip(t *testing.T) {
original := fixedEnvelope(t)
original.S = bytes.Repeat([]byte{0x11}, ed25519.SignatureSize)
encoded, err := json.Marshal(original)
if err != nil {
t.Fatal(err)
}
var decoded Envelope
if err := json.Unmarshal(encoded, &decoded); err != nil {
t.Fatal(err)
}
if decoded.Node != original.Node || decoded.Req != original.Req || decoded.TS != original.TS {
t.Fatalf("string fields round-tripped wrong: %+v", decoded)
}
if !bytes.Equal(decoded.EK, original.EK) || !bytes.Equal(decoded.P, original.P) || !bytes.Equal(decoded.S, original.S) {
t.Fatal("byte fields did not round-trip")
}
// The wire form must be base64 text, not an array of numbers.
if !bytes.Contains(encoded, []byte(`"ek":"`)) {
t.Fatalf("ek is not encoded as base64: %s", encoded)
}
if bytes.Contains(encoded, []byte(`"ek":[`)) {
t.Fatalf("ek leaked as a byte array: %s", encoded)
}
}
func TestEnvelopeJSONRejectsMalformedBase64(t *testing.T) {
raw := []byte(`{"v":1,"node":"nd_x","req":"r","ts":1,"ek":"!!!!","alg":"` + Suite + `","p":"AA==","s":""}`)
var envelope Envelope
if err := json.Unmarshal(raw, &envelope); err == nil {
t.Fatal("accepted invalid base64 in ek")
}
}
// TestIntentDigestIsKeyOrderInsensitive is the property that lets the phone and the agent agree on
// a digest without agreeing on JSON key order.
func TestIntentDigestIsKeyOrderInsensitive(t *testing.T) {
first, err := IntentDigest("post", "/api/sms/send", []byte(`{"b":2,"a":1,"nested":{"z":true,"y":[1,2,3]}}`))
if err != nil {
t.Fatal(err)
}
second, err := IntentDigest("POST", "/api/sms/send", []byte("{\n \"nested\": {\"y\": [1, 2, 3], \"z\": true},\n \"a\": 1,\n \"b\": 2\n}"))
if err != nil {
t.Fatal(err)
}
if !VerifyIntent(first, second) {
t.Fatalf("intent digest depends on key order or whitespace: %x vs %x", first, second)
}
// Changing any part of the binding must change the digest.
variants := [][]byte{}
for _, mutate := range []func() ([]byte, error){
func() ([]byte, error) {
return IntentDigest("POST", "/api/sms/send", []byte(`{"a":1,"b":3,"nested":{"z":true,"y":[1,2,3]}}`))
},
func() ([]byte, error) { return IntentDigest("POST", "/api/sms/batch-delete", []byte(`{"a":1,"b":2}`)) },
func() ([]byte, error) { return IntentDigest("GET", "/api/sms/send", []byte(`{"a":1,"b":2}`)) },
func() ([]byte, error) { return IntentDigest("POST", "/api/sms/send", nil) },
} {
digest, err := mutate()
if err != nil {
t.Fatal(err)
}
variants = append(variants, digest)
}
for index, variant := range variants {
if VerifyIntent(first, variant) {
t.Errorf("variant %d produced the same intent digest", index)
}
}
// No body and an explicitly empty body are the same absence of a body.
empty, err := IntentDigest("POST", "/api/sms/send", []byte(" "))
if err != nil {
t.Fatal(err)
}
if !VerifyIntent(empty, variants[3]) {
t.Fatal("blank and absent bodies canonicalised differently")
}
}
func TestCanonicalJSONRules(t *testing.T) {
cases := []struct{ in, want string }{
{`{"b":1,"a":2}`, `{"a":2,"b":1}`},
{`[3,1,2]`, `[3,1,2]`},
{` null `, `null`},
{`true`, `true`},
{`"<&>"`, `"<&>"`},
{`{"x":100}`, `{"x":100}`},
{``, ``},
{` `, ``},
{`{"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 {
got, err := CanonicalJSON([]byte(c.in))
if err != nil {
t.Errorf("CanonicalJSON(%q): %v", c.in, err)
continue
}
if string(got) != c.want {
t.Errorf("CanonicalJSON(%q) = %q, want %q", c.in, got, c.want)
}
}
// HTML escaping must be off, otherwise a body containing & or < hashes differently in Go than
// in Node or Swift.
got, err := CanonicalJSON([]byte(`{"text":"a<b&c>d"}`))
if err != nil {
t.Fatal(err)
}
if string(got) != `{"text":"a<b&c>d"}` {
t.Fatalf("HTML characters were escaped: %s", got)
}
}
// 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) {
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 {
t.Errorf("CanonicalJSON(%q) accepted it", raw)
}
}
}
func mustHex(t *testing.T, value string) []byte {
t.Helper()
raw, err := hex.DecodeString(value)
if err != nil {
t.Fatalf("bad hex in test: %v", err)
}
return raw
}