// 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') 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) }