Files
msa-agent/cmd/msa-vectors/main.go
T

266 lines
9.1 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(),
}
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"`
}
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"}`,
}
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
}
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) }