Files
msa-agent/cmd/msa-vectors/main.go
T
chick 3473816167 feat: 独立成仓库,自己拥有契约文件
agent 从 simadmin-relay 拆出来,作为公开仓库:安装命令要由节点执行,而节点没有凭据,所以
二进制必须放在匿名可读的地方,而 relay 源码与 IPA 继续私有。

拆出来之后有一处必须改,不是搬文件:`contracts/agent-policy.json` 原先由 relay 仓库拥有、
agent 侧是 vendored 副本(Makefile 从 `../contracts/` 拷、policy_test.go 断言两份相等)。
现在反过来——**agent 拥有策略**,因为 agent 是执行它的那一方,让它去拷贝一份住在别处的文件
本来就是别扭的。relay 保留 vendored 副本(它的限额从同一组数字推导),而两侧的一致性不再
靠构建期比较(跨仓库的构建期比较谁也做不了),改由运行时保证:agent 在握手时上报策略摘要,
relay 拿自己那份比对,在 `/v1/status` 里逐节点给出 `policyMatches`。这比原来的测试更强,
因为它比的是**实际部署的那两份**,而不是两个 checkout 里的文件。

`contracts/test-vectors.json` 同理:Go 实现生成它(`make vectors`),relay 的 Node 参考实现
与 app 的 Swift 测试从 vendored 副本重算。实测重新生成的结果与 relay 那份逐字节相同。

systemd 单元的 `Documentation=` 指向本仓库。`VERSION` 提到 1.0.0。

20 个提交的历史完整保留(filter-branch 按子目录抽取)。
2026-09-17 23:24:57 +08:00

309 lines
12 KiB
Go

// Command msa-vectors regenerates contracts/test-vectors.json.
//
// Three implementations have to agree on the exact bytes that get signed and derived: the Go
// agent, the Node relay, and the Swift app. They share no language, no JSON encoder and no crypto
// library, and canonical encoding is precisely where they would drift. Signatures are included
// because Ed25519 is deterministic: identical key and message must yield identical bytes in every
// implementation, which is a much stronger check than sign-then-verify inside one language.
//
// The vectors are written to contracts/ and then recomputed independently by test/wire.test.js, so
// a change to the encoding on one side fails a test rather than surfacing as an unexplained
// signature failure on a device that is already deployed.
package main
import (
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"fun.chick/msa-agent/internal/wire"
)
// Fixed inputs, all literals, so the vectors are reproducible and reviewable.
const (
nodeID = "nd_7f3a9c2e"
appID = "ap_5b1d"
reqID = "3f2504e0-4f89-41d3-9a0c-0305e82c3301"
timestamp = uint64(1727289600)
ekHex = "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a"
payloadHex = "000102feff"
// RFC 8032 test vector private key, so the signature below is checkable by hand.
appSignHex = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60" +
"d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"
// RFC 8032 test vector 2, used as the agent's signing key. The two parties must hold different
// keys or the vector file proves nothing about whose signature is whose: domain separation and
// key separation are meant to be independent defences, and checking only one of them hides a
// bug in the other.
agentSignHex = "4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb" +
"3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"
// RFC 7748 section 6.1 keypair, so the derived values trace to a published test case.
appKxHex = "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a"
agentKxHex = "de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f"
plaintext = `{"m":"GET","p":"/api/health"}`
)
func main() {
envelope := wire.Envelope{
V: wire.Version,
Node: nodeID,
Req: reqID,
App: appID,
TS: timestamp,
EK: mustHex(ekHex),
Alg: wire.Suite,
P: mustHex(payloadHex),
}
requestCanonical := must2(wire.CanonicalRequest(envelope))
responseCanonical := must2(wire.CanonicalResponse(envelope))
appSign := ed25519.PrivateKey(mustHex(appSignHex))
signedRequest := envelope
check(wire.SignRequest(&signedRequest, appSign))
agentSign := ed25519.PrivateKey(mustHex(agentSignHex))
signedResponse := envelope
check(wire.SignResponse(&signedResponse, agentSign))
// Both signatures are checked before the file is written. A signature that the recomputing side
// cannot verify is worse than no vector at all: it looks like the other language is broken.
check(wire.VerifyRequest(signedRequest, appSign.Public().(ed25519.PublicKey)))
check(wire.VerifyResponse(signedResponse, agentSign.Public().(ed25519.PublicKey)))
shared := must2(wire.SharedSecret(mustHex(appKxHex), mustHex(agentKxHex)))
requestKey, requestIV := must3(wire.DeriveKey(shared, nodeID, reqID, false))
responseKey, responseIV := must3(wire.DeriveKey(shared, nodeID, reqID, true))
sealed := must2(wire.Seal(requestKey, requestIV, []byte(plaintext)))
intent := must2(wire.IntentDigest("POST", "/api/sms/send",
[]byte(`{"phone_number":"+8613800000000","content":"hello"}`)))
document := vectors{
GeneratedBy: "agent/cmd/msa-vectors",
Note: "Deterministic cross-language vectors. The Node relay (test/wire.test.js) and the " +
"Swift app recompute these from this file; a mismatch means the envelope encoding " +
"drifted, not that one side is broken.",
Suite: wire.Suite,
Version: wire.Version,
Canonical: canonicalVector{
Comment: "Length-prefixed canonical bytes, then the deterministic Ed25519 signature " +
"over them. ek and p are raw decoded bytes, never their base64 text.",
Input: envelope,
Bytes: pair{hexStr(requestCanonical), hexStr(responseCanonical)},
Signature: pair{hexStr(signedRequest.S), hexStr(signedResponse.S)},
PublicKey: hexStr(appSign.Public().(ed25519.PublicKey)),
ResponsePublicKey: hexStr(agentSign.Public().(ed25519.PublicKey)),
},
HKDF: hkdfVector{
Comment: "RFC 5869 test case 1, pinning the KDF itself rather than only its callers.",
Secret: "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
Salt: "000102030405060708090a0b0c",
Info: "f0f1f2f3f4f5f6f7f8f9",
Length: 42,
Output: hexStr(must2(wire.HKDF(
mustHex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b"),
mustHex("000102030405060708090a0b0c"),
mustHex("f0f1f2f3f4f5f6f7f8f9"),
42,
))),
},
X25519: x25519Vector{
Comment: "RFC 7748 section 6.1, then the derived per-direction key and IV for the " +
"envelope above. The IV is bound to the request id and the direction, so a key " +
"that is ever reused still cannot reuse a nonce.",
AppPrivate: appKxHex,
AgentPublic: agentKxHex,
SharedSecret: hexStr(shared),
RequestKey: hexStr(requestKey),
RequestIV: hexStr(requestIV),
ResponseKey: hexStr(responseKey),
ResponseIV: hexStr(responseIV),
},
AEAD: aeadVector{
Comment: "AES-256-GCM under the request key above; the 16-byte tag is appended.",
Key: hexStr(requestKey),
IV: hexStr(requestIV),
Plaintext: hexStr([]byte(plaintext)),
Ciphertext: hexStr(sealed),
},
Intent: intentVector{
Comment: "sha256(method | path | canonical body). The agent recomputes it from the " +
"request it is about to forward, so a relay cannot edit a confirmed write.",
Method: "POST",
Path: "/api/sms/send",
Body: `{"phone_number":"+8613800000000","content":"hello"}`,
Digest: hexStr(intent),
},
CanonicalJSON: jsonPairs(),
CanonicalRejected: rejectedPairs(),
}
encoded, err := json.MarshalIndent(document, "", " ")
check(err)
encoded = append(encoded, '\n')
// This repository owns the vectors: the Go implementation generates them, and the Node and Swift
// implementations recompute them from a vendored copy.
target := filepath.Join("contracts", "test-vectors.json")
if len(os.Args) > 1 {
target = os.Args[1]
}
check(os.WriteFile(target, encoded, 0o644))
fmt.Printf("wrote %s\n", target)
}
type pair struct {
Request string `json:"request"`
Response string `json:"response"`
}
type vectors struct {
GeneratedBy string `json:"generatedBy"`
Note string `json:"note"`
Suite string `json:"suite"`
Version uint64 `json:"version"`
Canonical canonicalVector `json:"canonical"`
HKDF hkdfVector `json:"hkdf"`
X25519 x25519Vector `json:"x25519"`
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 {
Comment string `json:"comment"`
Input wire.Envelope `json:"input"`
Bytes pair `json:"bytes"`
Signature pair `json:"signature"`
PublicKey string `json:"publicKey"`
ResponsePublicKey string `json:"responsePublicKey"`
}
type hkdfVector struct {
Comment string `json:"comment"`
Secret string `json:"secret"`
Salt string `json:"salt"`
Info string `json:"info"`
Length int `json:"length"`
Output string `json:"output"`
}
type x25519Vector struct {
Comment string `json:"comment"`
AppPrivate string `json:"appPrivate"`
AgentPublic string `json:"agentPublic"`
SharedSecret string `json:"sharedSecret"`
RequestKey string `json:"requestKey"`
RequestIV string `json:"requestIV"`
ResponseKey string `json:"responseKey"`
ResponseIV string `json:"responseIV"`
}
type aeadVector struct {
Comment string `json:"comment"`
Key string `json:"key"`
IV string `json:"iv"`
Plaintext string `json:"plaintext"`
Ciphertext string `json:"ciphertext"`
}
type intentVector struct {
Comment string `json:"comment"`
Method string `json:"method"`
Path string `json:"path"`
Body string `json:"body"`
Digest string `json:"digest"`
}
type jsonPairVector struct {
Input string `json:"input"`
Output string `json:"output"`
}
func jsonPairs() []jsonPairVector {
inputs := []string{
`{"b":1,"a":2}`,
`[3,1,2]`,
` null `,
`true`,
`"<&>"`,
`{"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 {
out, err := wire.CanonicalJSON([]byte(input))
check(err)
pairs = append(pairs, jsonPairVector{Input: input, Output: string(out)})
}
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)
return raw
}
func must2(value []byte, err error) []byte {
check(err)
return value
}
func must3(key, iv []byte, err error) ([]byte, []byte) {
check(err)
return key, iv
}
func check(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, "msa-vectors:", err)
os.Exit(1)
}
}
func hexStr(value []byte) string { return hex.EncodeToString(value) }