baseline: P1 契约、agent 核心与中继投递(既有工作首次入库)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
Reference in New Issue
Block a user