266 lines
9.1 KiB
Go
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) }
|