baseline: P1 契约、agent 核心与中继投递(既有工作首次入库)

This commit is contained in:
chick
2026-09-16 08:41:13 +08:00
commit 54d9696a4d
24 changed files with 4858 additions and 0 deletions
+227
View File
@@ -0,0 +1,227 @@
// Command msa-agent runs on a SimAdmin node and lets a paired iPhone reach it through a public
// relay without any inbound path.
//
// It opens no listening socket. Every network action it takes is either an outbound HTTPS request to
// the relay or a loopback request to the SimAdmin on the same machine, which is the property that
// makes "no port mapping, no VPN" true rather than merely advertised.
package main
import (
"context"
"crypto/ed25519"
"encoding/base64"
"errors"
"flag"
"fmt"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"fun.chick/msa-agent/internal/agent"
"fun.chick/msa-agent/internal/config"
"fun.chick/msa-agent/internal/policy"
"fun.chick/msa-agent/internal/runtime"
"fun.chick/msa-agent/internal/transport"
)
// version is stamped in by the Makefile.
var version = "dev"
// Backoff bounds. The lower bound is what makes a relay outage survivable for the relay's operator,
// and the ceiling is what keeps a long outage from turning into a 30-second-per-retry queue.
const (
minBackoff = time.Second
maxBackoff = 60 * time.Second
)
func main() {
var (
configPath = flag.String("config", "/etc/msa-agent/config.json", "path to the agent config file")
check = flag.Bool("check", false, "validate the config and policy, then exit")
once = flag.Bool("once", false, "run a single poll cycle and exit")
)
flag.Parse()
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)
if err := run(*configPath, *check, *once); err != nil {
// One line, then exit non-zero. systemd restarts it, and the journal keeps the reason.
logger.Error("msa-agent exiting", "error", err)
os.Exit(1)
}
}
func run(configPath string, checkOnly, once bool) error {
cfg, err := config.Load(configPath)
if err != nil {
return err
}
pol, err := policy.Load()
if err != nil {
return err
}
sign, err := decodeKey(cfg.SignKey, ed25519.PrivateKeySize, "signKey")
if err != nil {
return err
}
kx, err := decodeKey(cfg.KxKey, 32, "kxKey")
if err != nil {
return err
}
devices, err := deviceSet(cfg.Devices)
if err != nil {
return err
}
// The policy digest is logged at every startup and sent in the handshake. An operator comparing
// one line across a fleet learns whether every node enforces the same surface; without it the
// only way to know is to log into each box.
slog.Info("msa-agent starting",
"version", version,
"node", cfg.NodeID,
"localPort", cfg.LocalPort,
"policy", pol.SHA256(),
"devices", devices.Len())
if checkOnly {
slog.Info("configuration is valid", "relay", cfg.RelayURL)
return nil
}
audit, err := runtime.OpenAudit(cfg.AuditPath)
if err != nil {
return err
}
defer audit.Close()
exec, err := runtime.NewExecutor(pol, runtime.Config{
Port: cfg.LocalPort,
Audit: audit,
})
if err != nil {
return err
}
node, err := agent.New(cfg.NodeID, agent.Keys{Sign: sign, Kx: kx}, devices, pol, exec)
if err != nil {
return err
}
client, err := transport.New(transport.Config{
BaseURL: cfg.RelayURL,
NodeID: cfg.NodeID,
Token: cfg.AgentToken,
Signer: sign,
UserAgent: "msa-agent/" + version,
})
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if once {
return cycle(ctx, node, client, pol)
}
return loop(ctx, node, client, pol)
}
// loop is the agent's whole life: handshake, then poll forever, re-handshaking whenever the relay
// says the session is gone and backing off when the relay is unreachable.
//
// Errors are classified here rather than handled per call site, because the three situations look
// identical from inside a function and need three different responses: retry now, re-authenticate,
// or wait.
func loop(ctx context.Context, node *agent.Agent, client *transport.Client, pol *policy.Policy) error {
backoff := minBackoff
for {
if ctx.Err() != nil {
return nil
}
err := handshakeAndPoll(ctx, node, client, pol)
switch {
case err == nil:
backoff = minBackoff
case errors.Is(err, transport.ErrNoWork):
backoff = minBackoff
case errors.Is(err, transport.ErrUnauthorized), errors.Is(err, transport.ErrSessionExpired):
// Re-handshake on the next iteration. The wait is what stops a bad token from becoming
// a tight loop against the relay.
slog.Warn("relay rejected the agent session, re-authenticating", "error", err)
backoff = minBackoff * 5
default:
slog.Warn("poll cycle failed", "error", err, "retryIn", backoff)
backoff = next(backoff)
}
select {
case <-ctx.Done():
return nil
case <-time.After(backoff):
}
}
}
func next(current time.Duration) time.Duration {
doubled := current * 2
if doubled > maxBackoff {
return maxBackoff
}
return doubled
}
func handshakeAndPoll(ctx context.Context, node *agent.Agent, client *transport.Client, pol *policy.Policy) error {
if _, err := client.Hello(ctx, version, pol.SHA256()); err != nil {
return err
}
return cycle(ctx, node, client, pol)
}
// cycle drains one batch. Each request is handled and answered before the next is fetched, so a
// slow upstream request delays the queue rather than buffering it in memory.
func cycle(ctx context.Context, node *agent.Agent, client *transport.Client, pol *policy.Policy) error {
batch, err := client.Poll(ctx)
if err != nil {
return err
}
if len(batch.Envelopes) == 0 {
return transport.ErrNoWork
}
for _, raw := range batch.Envelopes {
response, err := node.Handle(ctx, raw)
if err != nil {
// Addressed to another node, or malformed beyond answering. Logged, not fatal: one bad
// envelope must not stop the batch.
slog.Warn("envelope dropped", "error", err)
continue
}
if err := client.Result(ctx, response); err != nil {
return err
}
}
return nil
}
func decodeKey(value string, size int, field string) ([]byte, error) {
raw, err := base64.StdEncoding.Strict().DecodeString(value)
if err != nil {
return nil, fmt.Errorf("config: %s is not valid base64: %w", field, err)
}
if len(raw) != size {
return nil, fmt.Errorf("config: %s must be %d bytes, got %d", field, size, len(raw))
}
return raw, nil
}
func deviceSet(devices []config.Device) (agent.StaticDevices, error) {
set := make(agent.StaticDevices, len(devices))
for _, device := range devices {
public, err := decodeKey(device.SignKey, ed25519.PublicKeySize, fmt.Sprintf("device %s signKey", device.ID))
if err != nil {
return nil, err
}
set[device.ID] = ed25519.PublicKey(public)
}
return set, nil
}
+265
View File
@@ -0,0 +1,265 @@
// 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) }