From 54d9696a4df3b44073e7a74ee61a4e800b37367e Mon Sep 17 00:00:00 2001 From: chick Date: Wed, 16 Sep 2026 08:41:13 +0800 Subject: [PATCH] =?UTF-8?q?baseline:=20P1=20=E5=A5=91=E7=BA=A6=E3=80=81age?= =?UTF-8?q?nt=20=E6=A0=B8=E5=BF=83=E4=B8=8E=E4=B8=AD=E7=BB=A7=E6=8A=95?= =?UTF-8?q?=E9=80=92=EF=BC=88=E6=97=A2=E6=9C=89=E5=B7=A5=E4=BD=9C=E9=A6=96?= =?UTF-8?q?=E6=AC=A1=E5=85=A5=E5=BA=93=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 42 +++ cmd/msa-agent/main.go | 227 +++++++++++ cmd/msa-vectors/main.go | 265 +++++++++++++ go.mod | 3 + internal/agent/agent.go | 216 +++++++++++ internal/agent/agent_test.go | 335 +++++++++++++++++ internal/config/config.go | 109 ++++++ internal/config/config_test.go | 87 +++++ internal/egress/egress.go | 161 ++++++++ internal/egress/egress_test.go | 195 ++++++++++ internal/policy/agent-policy.json | 41 ++ internal/policy/policy.go | 232 ++++++++++++ internal/policy/policy_test.go | 210 +++++++++++ internal/runtime/audit.go | 89 +++++ internal/runtime/executor.go | 272 ++++++++++++++ internal/runtime/replay.go | 127 +++++++ internal/runtime/runtime_test.go | 471 +++++++++++++++++++++++ internal/transport/transport.go | 273 ++++++++++++++ internal/transport/transport_test.go | 215 +++++++++++ internal/wire/ephemeral.go | 65 ++++ internal/wire/json.go | 145 +++++++ internal/wire/wire.go | 540 +++++++++++++++++++++++++++ internal/wire/wire_test.go | 474 +++++++++++++++++++++++ systemd/msa-agent.service | 64 ++++ 24 files changed, 4858 insertions(+) create mode 100644 Makefile create mode 100644 cmd/msa-agent/main.go create mode 100644 cmd/msa-vectors/main.go create mode 100644 go.mod create mode 100644 internal/agent/agent.go create mode 100644 internal/agent/agent_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/egress/egress.go create mode 100644 internal/egress/egress_test.go create mode 100644 internal/policy/agent-policy.json create mode 100644 internal/policy/policy.go create mode 100644 internal/policy/policy_test.go create mode 100644 internal/runtime/audit.go create mode 100644 internal/runtime/executor.go create mode 100644 internal/runtime/replay.go create mode 100644 internal/runtime/runtime_test.go create mode 100644 internal/transport/transport.go create mode 100644 internal/transport/transport_test.go create mode 100644 internal/wire/ephemeral.go create mode 100644 internal/wire/json.go create mode 100644 internal/wire/wire.go create mode 100644 internal/wire/wire_test.go create mode 100644 systemd/msa-agent.service diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ba14699 --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +POLICY_SRC := ../contracts/agent-policy.json +POLICY_DST := internal/policy/agent-policy.json +VERSION ?= 0.1.0 +LDFLAGS := -s -w -X main.version=$(VERSION) + +.PHONY: all vendor test vet build build-arm64 build-armv7 clean + +all: vet test build + +# The policy is embedded, so the vendored copy is part of the binary. Keep the copy mechanical: +# an operator editing internal/policy/agent-policy.json by hand would be editing a file the build +# overwrites, and the drift test makes that mistake loud rather than quiet. +vendor: $(POLICY_DST) + +$(POLICY_DST): $(POLICY_SRC) + @cp $(POLICY_SRC) $(POLICY_DST) + +test: vendor + @go test ./... + +vet: vendor + @go vet ./... + +fmt: + @gofmt -l -w . + +build: vendor + @mkdir -p dist + @go build -trimpath -ldflags '$(LDFLAGS)' -o dist/msa-agent ./cmd/msa-agent + +# Nodes are ARM Debian boxes that also run SimAdmin, so the static binary is copied in rather than +# installed through a package manager: no runtime deps, no apt state, trivially removable. +build-arm64: vendor + @mkdir -p dist + @CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags '$(LDFLAGS)' -o dist/msa-agent-linux-arm64 ./cmd/msa-agent + +build-armv7: vendor + @mkdir -p dist + @CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -trimpath -ldflags '$(LDFLAGS)' -o dist/msa-agent-linux-armv7 ./cmd/msa-agent + +clean: + @rm -rf dist diff --git a/cmd/msa-agent/main.go b/cmd/msa-agent/main.go new file mode 100644 index 0000000..68a6672 --- /dev/null +++ b/cmd/msa-agent/main.go @@ -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 +} diff --git a/cmd/msa-vectors/main.go b/cmd/msa-vectors/main.go new file mode 100644 index 0000000..ad13556 --- /dev/null +++ b/cmd/msa-vectors/main.go @@ -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) } diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c03b097 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module fun.chick/msa-agent + +go 1.23 diff --git a/internal/agent/agent.go b/internal/agent/agent.go new file mode 100644 index 0000000..a7ceb22 --- /dev/null +++ b/internal/agent/agent.go @@ -0,0 +1,216 @@ +// Package agent is the control loop: pull an envelope, prove it came from a paired phone, decrypt +// it, decide, execute, and answer. +// +// The ordering in Handle is the security property and is asserted by tests rather than described in +// comments alone: nothing that depends on the payload happens before the signature verifies, and +// nothing that touches SimAdmin happens before the policy says yes. +package agent + +import ( + "context" + "crypto/ed25519" + "encoding/json" + "errors" + "fmt" + "time" + + "fun.chick/msa-agent/internal/policy" + "fun.chick/msa-agent/internal/runtime" + "fun.chick/msa-agent/internal/wire" +) + +// Keys is the agent's own key material, loaded once at startup. +type Keys struct { + // Sign signs responses and the handshake challenge. The phone verifies responses against its + // copy of the public half, so a relay cannot forge an answer. + Sign ed25519.PrivateKey + // Kx is the raw 32-byte X25519 private key the phone encrypts requests to. It is raw on + // purpose: clamping is the curve library's job, and a stored key that has already been clamped + // would be a second, non-standard encoding to keep in sync across three languages. + Kx []byte +} + +// PublicKx derives the X25519 public key to publish at pairing time. +// +// Called rather than stored, so the published value cannot disagree with the private key it was +// derived from. A mismatched pair would fail as an unreadable request with no useful cause. +func (k Keys) PublicKx() ([]byte, error) { return wire.PublicFrom(k.Kx) } + +// Devices answers "is this phone allowed to command this node". +// +// It is an interface because the first implementation is a static list from the config file and the +// intended one is a snapshot pulled from the relay, and the loop must not change when that arrives. +type Devices interface { + // SigningKey returns the Ed25519 public key for app, or nil if it is not paired. + SigningKey(app string) ed25519.PublicKey + // Len reports how many devices are trusted. Logged at startup and on reload. + Len() int +} + +// StaticDevices is a paired-device list fixed at startup. +type StaticDevices map[string]ed25519.PublicKey + +func (d StaticDevices) SigningKey(app string) ed25519.PublicKey { return d[app] } +func (d StaticDevices) Len() int { return len(d) } + +// Error classes the agent reports to the phone, inside the encrypted response so the relay cannot +// read or rewrite them. +const ( + OutcomeBadEnvelope = "bad_envelope" + OutcomeBadSig = "bad_signature" + OutcomeNoDevice = "unknown_device" + OutcomeExpired = "expired" + OutcomeReplay = "replay" + OutcomePolicy = "policy_denied" + OutcomeNoConfirm = "confirmation_missing" + OutcomeBadConfirm = "confirmation_mismatch" + OutcomeRateLimited = "rate_limited" + OutcomeTooLarge = "payload_too_large" + OutcomeUpstream = "upstream_error" +) + +// Agent owns the loop over one node. +type Agent struct { + nodeID string + keys Keys + devices Devices + pol *policy.Policy + exec *runtime.Executor + now func() time.Time +} + +// New wires the pieces. It returns an error rather than starting degraded: an agent that runs with +// no policy or no devices would accept nothing and look healthy while doing so. +func New(nodeID string, keys Keys, devices Devices, pol *policy.Policy, exec *runtime.Executor) (*Agent, error) { + if nodeID == "" { + return nil, errors.New("agent: node id is empty") + } + if len(keys.Sign) != ed25519.PrivateKeySize { + return nil, errors.New("agent: signing key is the wrong size") + } + if len(keys.Kx) != wire.KeyBytes { + return nil, fmt.Errorf("agent: x25519 key must be %d bytes", wire.KeyBytes) + } + if devices == nil || devices.Len() == 0 { + return nil, errors.New("agent: no paired devices, so nothing could ever be authorised") + } + if pol == nil || exec == nil { + return nil, errors.New("agent: policy and executor are required") + } + return &Agent{nodeID: nodeID, keys: keys, devices: devices, pol: pol, exec: exec, now: time.Now}, nil +} + +// Handle processes one request envelope and returns the response envelope ready to post back. +// +// It never returns an error for a bad request. A request that fails any check is answered with a +// refusal inside the encrypted response, for two reasons: the relay must not be able to read why a +// command failed, and the phone must be able to tell the user what to do about it. A transport-level +// error would put that explanation in the relay's hands instead. +func (a *Agent) Handle(ctx context.Context, raw []byte) (json.RawMessage, error) { + var envelope wire.Envelope + if err := json.Unmarshal(raw, &envelope); err != nil { + return nil, fmt.Errorf("agent: envelope: %w", err) + } + // The node is checked before the signature, and this is the one exception to "verify first": an + // envelope addressed to another node is not for us at all, and answering it would leak that this + // agent is alive and knows the difference. + if envelope.Node != a.nodeID { + return nil, fmt.Errorf("agent: envelope is addressed to %q, not %q", envelope.Node, a.nodeID) + } + + device := a.devices.SigningKey(envelope.App) + if device == nil { + return a.answer(envelope, OutcomeNoDevice, 403, nil) + } + if err := wire.VerifyRequest(envelope, device); err != nil { + return a.answer(envelope, OutcomeBadSig, 401, nil) + } + // Only now is the payload touched. Decrypting before verifying would let an unauthenticated + // caller choose the bytes the AEAD processes, which is exactly what an unauthenticated AEAD + // oracle is. + shared, err := wire.SharedSecret(a.keys.Kx, envelope.EK) + if err != nil { + return a.answer(envelope, OutcomeBadEnvelope, 400, nil) + } + key, iv, err := wire.DeriveKey(shared, envelope.Node, envelope.Req, false) + if err != nil { + return a.answer(envelope, OutcomeBadEnvelope, 400, nil) + } + opened, err := wire.Open(key, iv, envelope.P) + if err != nil { + return a.answer(envelope, OutcomeBadEnvelope, 400, nil) + } + var inner wire.InnerRequest + if err := json.Unmarshal(opened, &inner); err != nil { + return a.answer(envelope, OutcomeBadEnvelope, 400, nil) + } + + result, refusal := a.exec.Execute(ctx, envelope, inner) + if refusal != nil { + return a.answer(envelope, refusal.Class, statusFor(refusal.Class), nil) + } + return a.answer(envelope, "ok", result.Status, result.Body) +} + +// answer encrypts, signs and serialises the response. +// +// The ephemeral key pair is generated per response. The request direction is ephemeral-static, so +// generating a fresh one here is what keeps the two directions from sharing a key. +func (a *Agent) answer(request wire.Envelope, outcome string, status int, body []byte) (json.RawMessage, error) { + ephemeral, err := wire.NewEphemeral() + if err != nil { + return nil, err + } + // The response is encrypted to the phone's per-request ephemeral public key, the one that + // arrived in ek and is covered by the signature just verified. That keeps this direction + // ephemeral-ephemeral rather than falling back to a key the phone published once at pairing + // time, and it needs no extra round trip: ek is right there in the request. + shared, err := ephemeral.Exchange(request.EK) + if err != nil { + return nil, err + } + key, iv, err := wire.DeriveKey(shared, request.Node, request.Req, true) + if err != nil { + return nil, err + } + inner, err := wire.MarshalInner(wire.InnerResponse{Status: status, Body: body, Outcome: outcome}) + if err != nil { + return nil, err + } + sealed, err := wire.Seal(key, iv, inner) + if err != nil { + return nil, err + } + response := wire.Envelope{ + V: wire.Version, + Node: request.Node, + Req: request.Req, + TS: uint64(a.now().Unix()), + EK: ephemeral.Public(), + Alg: wire.Suite, + P: sealed, + } + if err := wire.SignResponse(&response, a.keys.Sign); err != nil { + return nil, err + } + encoded, err := json.Marshal(response) + if err != nil { + return nil, err + } + return json.RawMessage(encoded), nil +} + +func statusFor(class string) int { + switch class { + case runtime.ClassExpired, runtime.ClassReplay: + return 408 + case runtime.ClassRateLimited: + return 429 + case runtime.ClassTooLarge: + return 413 + case runtime.ClassUpstream: + return 502 + default: + return 403 + } +} diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go new file mode 100644 index 0000000..7085846 --- /dev/null +++ b/internal/agent/agent_test.go @@ -0,0 +1,335 @@ +package agent + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + "time" + + "fun.chick/msa-agent/internal/policy" + "fun.chick/msa-agent/internal/runtime" + "fun.chick/msa-agent/internal/wire" +) + +// These tests are the executable version of the ordering claim: they build real envelopes with real +// keys and check what the agent does with each one, rather than calling Handle with a hand-made +// struct that skips the parts under test. + +type pair struct { + appSign ed25519.PrivateKey + appKx []byte +} + +func newPair(t *testing.T) pair { + t.Helper() + _, sign, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + kx := make([]byte, wire.KeyBytes) + if _, err := rand.Read(kx); err != nil { + t.Fatal(err) + } + return pair{appSign: sign, appKx: kx} +} + +type harness struct { + agent *Agent + app pair + appID string + agentKx []byte + now time.Time + // The phone must keep the private half of the per-request ephemeral key until the response + // arrives, because the response is encrypted to it. A test that forgot this would have to + // weaken the protocol to pass, which is the failure this catches. + sentEphemeral map[string][]byte +} + +func newHarness(t *testing.T, handler http.Handler) *harness { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + parsed, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + port, err := strconv.Atoi(parsed.Port()) + if err != nil { + t.Fatal(err) + } + pol, err := policy.Load() + if err != nil { + t.Fatal(err) + } + app := newPair(t) + agentKx := make([]byte, wire.KeyBytes) + if _, err := rand.Read(agentKx); err != nil { + t.Fatal(err) + } + // The phone is given the public half derived from that private key, never the raw bytes. A test + // that fed the raw private bytes in as a public key would derive a different shared secret on + // each side and fail as an authentication error with no obvious cause. + agentKxPublic, err := wire.PublicFrom(agentKx) + if err != nil { + t.Fatal(err) + } + _, agentSign, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + now := time.Unix(1_800_000_000, 0).UTC() + exec, err := runtime.NewExecutor(pol, runtime.Config{ + Port: port, + Audit: runtime.NullAudit(), + Clock: func() time.Time { return now }, + }) + if err != nil { + t.Fatal(err) + } + agent, err := New("nd_test0001", Keys{Sign: agentSign, Kx: agentKx}, + StaticDevices{"ap_test0000": app.appSign.Public().(ed25519.PublicKey)}, pol, exec) + if err != nil { + t.Fatal(err) + } + agent.now = func() time.Time { return now } + return &harness{ + agent: agent, app: app, appID: "ap_test0000", agentKx: agentKxPublic, now: now, + sentEphemeral: map[string][]byte{}, + } +} + +// seal builds a complete request envelope the way the phone does. +func (h *harness) seal(t *testing.T, reqID string, inner wire.InnerRequest) []byte { + t.Helper() + body, err := json.Marshal(inner) + if err != nil { + t.Fatal(err) + } + // The phone's side of the exchange, built from raw primitives rather than wire.NewEphemeral: + // this direction needs the private half after the request is sent, which Ephemeral deliberately + // does not hand out. + phonePrivate := make([]byte, wire.KeyBytes) + if _, err := rand.Read(phonePrivate); err != nil { + t.Fatal(err) + } + phonePublic, err := wire.PublicFrom(phonePrivate) + if err != nil { + t.Fatal(err) + } + shared, err := wire.SharedSecret(phonePrivate, h.agentKx) + if err != nil { + t.Fatal(err) + } + key, iv, err := wire.DeriveKey(shared, "nd_test0001", reqID, false) + if err != nil { + t.Fatal(err) + } + sealed, err := wire.Seal(key, iv, body) + if err != nil { + t.Fatal(err) + } + envelope := wire.Envelope{ + V: wire.Version, Node: "nd_test0001", Req: reqID, App: h.appID, + TS: uint64(h.now.Unix()), EK: phonePublic, Alg: wire.Suite, P: sealed, + } + if err := wire.SignRequest(&envelope, h.app.appSign); err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(envelope) + if err != nil { + t.Fatal(err) + } + h.sentEphemeral[reqID] = phonePrivate + return raw +} + +// open decrypts a response the way the phone does, and verifies it. +func (h *harness) open(t *testing.T, raw json.RawMessage, reqID string) wire.InnerResponse { + t.Helper() + var envelope wire.Envelope + if err := json.Unmarshal(raw, &envelope); err != nil { + t.Fatal(err) + } + if err := wire.VerifyResponse(envelope, h.agent.keys.Sign.Public().(ed25519.PublicKey)); err != nil { + t.Fatalf("response signature: %v", err) + } + // The phone holds the private half of the ephemeral key it sent, so it can reproduce the + // response key from the agent's ek. + private, ok := h.sentEphemeral[reqID] + if !ok { + t.Fatalf("test never sent a request with id %s", reqID) + } + shared, err := wire.SharedSecret(private, envelope.EK) + if err != nil { + t.Fatal(err) + } + key, iv, err := wire.DeriveKey(shared, envelope.Node, reqID, true) + if err != nil { + t.Fatal(err) + } + opened, err := wire.Open(key, iv, envelope.P) + if err != nil { + t.Fatalf("open response: %v", err) + } + var inner wire.InnerResponse + if err := json.Unmarshal(opened, &inner); err != nil { + t.Fatal(err) + } + return inner +} + +func okHandler(body string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(body)) + }) +} + +func TestARoundTripFromAPairedPhoneSucceeds(t *testing.T) { + h := newHarness(t, okHandler(`{"status":"up"}`)) + raw, err := h.agent.Handle(context.Background(), + h.seal(t, "11111111-1111-4111-8111-111111111111", + wire.InnerRequest{Method: "GET", Path: "/api/health"})) + if err != nil { + t.Fatalf("Handle: %v", err) + } + inner := h.open(t, raw, "11111111-1111-4111-8111-111111111111") + if inner.Status != 200 || string(inner.Body) != `{"status":"up"}` { + t.Fatalf("got %d %q", inner.Status, inner.Body) + } + if inner.Outcome != "ok" { + t.Fatalf("outcome is %q", inner.Outcome) + } +} + +// The relay cannot read why a command failed, so every refusal has to come back inside the +// ciphertext. If a refusal were returned as a transport error instead, this test would fail. +func TestRefusalsComeBackEncryptedNotAsTransportErrors(t *testing.T) { + h := newHarness(t, okHandler(`{}`)) + _, refusal := h.agent.exec.Execute(context.Background(), + wire.Envelope{TS: uint64(h.now.Unix())}, wire.InnerRequest{Method: "GET", Path: "/api/secrets"}) + if refusal == nil { + t.Fatal("executor allowed a path outside the policy") + } + raw, err := h.agent.Handle(context.Background(), + h.seal(t, "22222222-2222-4222-8222-222222222222", + wire.InnerRequest{Method: "GET", Path: "/api/secrets"})) + if err != nil { + t.Fatalf("a policy refusal must not become a transport error: %v", err) + } + inner := h.open(t, raw, "22222222-2222-4222-8222-222222222222") + if inner.Outcome != OutcomePolicy { + t.Fatalf("outcome is %q, want %q", inner.Outcome, OutcomePolicy) + } +} + +func TestAnUnpairedDeviceIsRefusedBeforeThePayloadIsRead(t *testing.T) { + h := newHarness(t, okHandler(`{}`)) + stranger := newPair(t) + h.app.appSign = stranger.appSign + raw, err := h.agent.Handle(context.Background(), + h.seal(t, "33333333-3333-4333-8333-333333333333", + wire.InnerRequest{Method: "GET", Path: "/api/health"})) + if err != nil { + t.Fatal(err) + } + // The signature check runs before the device lookup's counterpart, so an unpaired phone gets a + // signature failure rather than a "you are not paired" oracle. + inner := h.open(t, raw, "33333333-3333-4333-8333-333333333333") + if inner.Outcome != OutcomeBadSig { + t.Fatalf("outcome is %q, want %q", inner.Outcome, OutcomeBadSig) + } +} + +func TestAnEnvelopeForAnotherNodeIsNotAnswered(t *testing.T) { + h := newHarness(t, okHandler(`{}`)) + raw := h.seal(t, "44444444-4444-4444-8444-444444444444", + wire.InnerRequest{Method: "GET", Path: "/api/health"}) + var envelope map[string]any + if err := json.Unmarshal(raw, &envelope); err != nil { + t.Fatal(err) + } + envelope["node"] = "nd_other9999" + // Re-marshalling breaks the signature, which is the point: the node check must fire first so the + // agent does not confirm to a caller that it holds this node's keys. + retargeted, err := json.Marshal(envelope) + if err != nil { + t.Fatal(err) + } + if _, err := h.agent.Handle(context.Background(), retargeted); err == nil { + t.Fatal("an envelope for another node was answered") + } +} + +func TestADangerousWriteStillNeedsItsIntentEndToEnd(t *testing.T) { + h := newHarness(t, okHandler(`{"sent":true}`)) + body := []byte(`{"phone_number":"+8613800000000","content":"hello"}`) + noIntent := h.seal(t, "55555555-5555-4555-8555-555555555555", + wire.InnerRequest{Method: "POST", Path: "/api/sms/send", Body: body}) + raw, err := h.agent.Handle(context.Background(), noIntent) + if err != nil { + t.Fatal(err) + } + if inner := h.open(t, raw, "55555555-5555-4555-8555-555555555555"); inner.Outcome != OutcomeNoConfirm { + t.Fatalf("outcome is %q, want %q", inner.Outcome, OutcomeNoConfirm) + } + + intent, err := wire.IntentDigest("POST", "/api/sms/send", body) + if err != nil { + t.Fatal(err) + } + withIntent := h.seal(t, "66666666-6666-4666-8666-666666666666", + wire.InnerRequest{Method: "POST", Path: "/api/sms/send", Body: body, Intent: intent}) + raw, err = h.agent.Handle(context.Background(), withIntent) + if err != nil { + t.Fatal(err) + } + inner := h.open(t, raw, "66666666-6666-4666-8666-666666666666") + if inner.Outcome != "ok" || inner.Status != 200 { + t.Fatalf("got %q %d", inner.Outcome, inner.Status) + } +} + +func TestNewRefusesToStartDegraded(t *testing.T) { + h := newHarness(t, okHandler(`{}`)) + pol, err := policy.Load() + if err != nil { + t.Fatal(err) + } + if _, err := New("", h.agent.keys, StaticDevices{"a": nil}, pol, h.agent.exec); err == nil { + t.Fatal("an empty node id was accepted") + } + if _, err := New("nd_x", Keys{Sign: h.agent.keys.Sign}, StaticDevices{"a": nil}, pol, h.agent.exec); err == nil { + t.Fatal("a missing x25519 key was accepted") + } + if _, err := New("nd_x", h.agent.keys, nil, pol, h.agent.exec); err == nil { + t.Fatal("a nil device list was accepted") + } + if _, err := New("nd_x", h.agent.keys, StaticDevices{}, pol, h.agent.exec); err == nil { + t.Fatal("an empty device list was accepted; nothing could ever be authorised") + } +} + +// Status mapping exists so the phone can distinguish "try again later" from "this will never work" +// without parsing a message string. +func TestStatusMappingSeparatesRetryableFromTerminal(t *testing.T) { + cases := map[string]int{ + runtime.ClassRateLimited: 429, + runtime.ClassExpired: 408, + runtime.ClassReplay: 408, + runtime.ClassTooLarge: 413, + runtime.ClassUpstream: 502, + runtime.ClassPolicy: 403, + } + for class, want := range cases { + if got := statusFor(class); got != want { + t.Errorf("statusFor(%q) = %d, want %d", class, got, want) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..dcc8b85 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,109 @@ +// Package config loads the agent's startup file. +// +// The shape is deliberately small and has no host field. The local SimAdmin is reached by port +// number alone: a config entry naming a host would be the one thing standing between a mistyped +// value and an agent that proxies commands to somebody else's machine. +package config + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "regexp" +) + +var nodePattern = regexp.MustCompile(`^nd_[A-Za-z0-9_-]{8,64}$`) +var appPattern = regexp.MustCompile(`^ap_[A-Za-z0-9_-]{8,64}$`) + +// Device is a paired phone. +type Device struct { + ID string `json:"id"` + // SignKey is the phone's Ed25519 public key, base64. The agent verifies every request against + // it and holds no corresponding secret, so a phone cannot be impersonated from the relay side. + SignKey string `json:"signKey"` + Name string `json:"name,omitempty"` +} + +// File is the on-disk shape. +type File struct { + NodeID string `json:"nodeId"` + // RelayURL is https and is the only outbound destination. + RelayURL string `json:"relayUrl"` + // AgentToken is the relay bearer credential. It authorises nothing on its own; the challenge + // signature does the identifying. + AgentToken string `json:"agentToken"` + // LocalPort is the SimAdmin port on 127.0.0.1. There is deliberately no host field. + LocalPort int `json:"localPort"` + // SignKey is the agent's Ed25519 private key, base64. + SignKey string `json:"signKey"` + // KxKey is the agent's X25519 private key, base64. + KxKey string `json:"kxKey"` + // AuditPath is where the audit log is appended. + AuditPath string `json:"auditPath"` + Devices []Device `json:"devices"` +} + +// Load reads and validates a config file. +// +// Validation is exhaustive rather than partial on purpose: an agent that starts with a missing +// device or a bad port would sit there healthy and uncommandable, which is harder to notice than a +// crash at boot. +func Load(path string) (File, error) { + var cfg File + raw, err := os.ReadFile(path) + if err != nil { + return cfg, fmt.Errorf("config: %w", err) + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + if err := decoder.Decode(&cfg); err != nil { + return cfg, fmt.Errorf("config: decode %s: %w", path, err) + } + if err := cfg.Validate(); err != nil { + return cfg, fmt.Errorf("config: %s: %w", path, err) + } + return cfg, nil +} + +// Validate checks every field the agent depends on. +func (c File) Validate() error { + if !nodePattern.MatchString(c.NodeID) { + return errors.New("nodeId must look like nd_xxxxxxxx") + } + if c.RelayURL == "" { + return errors.New("relayUrl is required") + } + if c.AgentToken == "" { + return errors.New("agentToken is required") + } + if c.LocalPort <= 0 || c.LocalPort > 65535 { + return fmt.Errorf("localPort %d is out of range", c.LocalPort) + } + if c.SignKey == "" { + return errors.New("signKey is required") + } + if c.KxKey == "" { + return errors.New("kxKey is required") + } + if c.AuditPath == "" { + return errors.New("auditPath is required") + } + if len(c.Devices) == 0 { + return errors.New("devices is empty, so no phone could ever be authorised") + } + seen := map[string]bool{} + for _, device := range c.Devices { + if !appPattern.MatchString(device.ID) { + return fmt.Errorf("device id %q must look like ap_xxxxxxxx", device.ID) + } + if device.SignKey == "" { + return fmt.Errorf("device %q has no signKey", device.ID) + } + if seen[device.ID] { + return fmt.Errorf("device %q is listed twice", device.ID) + } + seen[device.ID] = true + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..8bd0ff4 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,87 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +// The interesting property is not that a good file parses, it is that a bad one is refused at boot. +// An agent that starts half-configured sits there answering nothing, and "why does nothing work" is +// much harder to diagnose than a service that will not start. +func valid() File { + return File{ + NodeID: "nd_7f3a9c2e", + RelayURL: "https://relay.example", + AgentToken: "tok", + LocalPort: 8788, + SignKey: "AAAA", + KxKey: "AAAA", + AuditPath: "/var/log/msa-agent/audit.log", + Devices: []Device{{ID: "ap_5b1d0000", SignKey: "AAAA"}}, + } +} + +func TestValidateAcceptsACompleteFile(t *testing.T) { + if err := valid().Validate(); err != nil { + t.Fatalf("a complete file was refused: %v", err) + } +} + +func TestValidateNamesEveryMissingField(t *testing.T) { + cases := map[string]func(*File){ + "nodeId": func(c *File) { c.NodeID = "" }, + "relayUrl": func(c *File) { c.RelayURL = "" }, + "token": func(c *File) { c.AgentToken = "" }, + "signKey": func(c *File) { c.SignKey = "" }, + "kxKey": func(c *File) { c.KxKey = "" }, + "auditPath": func(c *File) { c.AuditPath = "" }, + "devices": func(c *File) { c.Devices = nil }, + "badNode": func(c *File) { c.NodeID = "node-1" }, + "badApp": func(c *File) { c.Devices[0].ID = "phone" }, + "noSignKey": func(c *File) { c.Devices[0].SignKey = "" }, + "port0": func(c *File) { c.LocalPort = 0 }, + "portHigh": func(c *File) { c.LocalPort = 70000 }, + "duplicate": func(c *File) { c.Devices = append(c.Devices, c.Devices[0]) }, + } + for name, mutate := range cases { + cfg := valid() + mutate(&cfg) + if err := cfg.Validate(); err == nil { + t.Errorf("%s: a broken config was accepted", name) + } + } +} + +// There is no host field, and this test is what keeps it that way: a "localHost" entry would be the +// one setting able to turn the agent into a LAN pivot. +func TestThereIsNoHostField(t *testing.T) { + raw := []byte(`{"nodeId":"nd_7f3a9c2e","relayUrl":"https://r","agentToken":"t","localHost":"10.0.0.5", + "localPort":8788,"signKey":"AAAA","kxKey":"AAAA","auditPath":"/tmp/a.log", + "devices":[{"id":"ap_5b1d0000","signKey":"AAAA"}]}`) + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + // The unknown field is ignored, and the port is all the agent knows about the local service. + if cfg.LocalPort != 8788 { + t.Fatalf("localPort is %d", cfg.LocalPort) + } +} + +func TestLoadReportsMissingAndMalformedFiles(t *testing.T) { + if _, err := Load(filepath.Join(t.TempDir(), "absent.json")); err == nil { + t.Fatal("a missing file was accepted") + } + path := filepath.Join(t.TempDir(), "broken.json") + if err := os.WriteFile(path, []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatal("a malformed file was accepted") + } +} diff --git a/internal/egress/egress.go b/internal/egress/egress.go new file mode 100644 index 0000000..e7a7c89 --- /dev/null +++ b/internal/egress/egress.go @@ -0,0 +1,161 @@ +// Package egress is the only place in the agent that opens a socket toward SimAdmin. +// +// Everything else hands requests to NewClient. That is the point: the agent holds a long-lived +// credential and an outbound connection to a relay it does not trust, so the one capability that +// would turn a compromised request path into a LAN pivot is the ability to dial an arbitrary +// address. Here it does not exist. The host component of every dial target is discarded and +// replaced with a loopback literal before the resolver is consulted, which means /etc/hosts, DNS, +// and any future config field labelled "host" cannot move the destination. +package egress + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// Loopback is the only address the agent will ever dial. +const Loopback = "127.0.0.1" + +// Loopback6 is the IPv6 form, for a local service that is bound to ::1 only. +const Loopback6 = "[::1]" + +var ( + // ErrNoPort is returned when no local SimAdmin port is configured. There is deliberately no + // default: guessing a port would make a misconfigured node fail quietly against some other + // service on the same box. + ErrNoPort = errors.New("egress: no local port configured") + // ErrNotLoopback is returned when an origin names anything but a loopback literal. + ErrNotLoopback = errors.New("egress: origin host is not a loopback literal") + // ErrHTTPS is returned for https targets. A loopback TLS certificate cannot be verified + // against a hostname, and disabling verification to work around that would be worse than + // requiring plain HTTP on a socket that never leaves the machine. + ErrHTTPS = errors.New("egress: loopback target must be plain http") +) + +// Target is a validated local origin: a loopback literal plus a port, and nothing else. It has no +// host field, so there is nothing for a caller to fill in incorrectly. +type Target struct { + Port int + IPv6 bool +} + +// Origin renders the base URL used for requests. The host is always a literal. +func (t Target) Origin() string { + host := Loopback + if t.IPv6 { + host = Loopback6 + } + return "http://" + host + ":" + strconv.Itoa(t.Port) +} + +// NewTarget validates a port number. It takes no host parameter at all; that is the API, not an +// omission. The result is always the IPv4 loopback literal. +func NewTarget(port int) (Target, error) { + if port <= 0 || port > 65535 { + return Target{}, ErrNoPort + } + return Target{Port: port}, nil +} + +// NewTarget6 is NewTarget for a local service bound to ::1 only. Both addresses are equally +// unreachable from outside the machine, so this is a compatibility switch, not a trust boundary. +func NewTarget6(port int) (Target, error) { + target, err := NewTarget(port) + if err != nil { + return Target{}, err + } + target.IPv6 = true + return target, nil +} + +// Dial discards whatever address the caller believed it was using and dials the loopback literal +// on the same port. Both arguments are treated as untrusted. +func Dial(ctx context.Context, network, address string) (net.Conn, error) { + switch network { + case "tcp", "tcp4", "tcp6": + default: + return nil, fmt.Errorf("egress: refusing network %q", network) + } + _, port, err := net.SplitHostPort(address) + if err != nil { + return nil, fmt.Errorf("egress: malformed dial address: %w", err) + } + num, err := strconv.Atoi(port) + if err != nil || num <= 0 || num > 65535 { + return nil, errors.New("egress: invalid port in dial address") + } + d := &net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second} + return d.DialContext(ctx, "tcp4", net.JoinHostPort(Loopback, strconv.Itoa(num))) +} + +// NewClient returns an HTTP client that cannot leave the machine. Redirects are not followed: a +// local service answering with a Location header pointing elsewhere would otherwise be an exit the +// policy never listed. +func NewClient(target Target, timeout time.Duration) (*http.Client, error) { + if target.Port <= 0 { + return nil, ErrNoPort + } + transport := &http.Transport{ + DialContext: Dial, + MaxIdleConns: 8, + MaxConnsPerHost: 16, + IdleConnTimeout: 60 * time.Second, + // No ProxyFromEnvironment on purpose. Honoring HTTP_PROXY would let the node's shell + // environment decide where "loopback" traffic really goes. + ForceAttemptHTTP2: false, + } + return &http.Client{ + Transport: transport, + Timeout: timeout, + // Stop at the 3xx and hand it back rather than returning an error from here: the caller + // classifies "the local service answered with a redirect" as an upstream problem, and the + // off-loopback address the Location names is still never dialed. + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + }, nil +} + +// AssertLoopback checks an origin string that came from somewhere outside this package, so a +// config path that grows a host field later fails here rather than at a dial. It is deliberately +// stricter than the dialer: a base origin carrying a path, query, or userinfo is a mistake, and +// silently ignoring those parts is how a client ends up talking to the wrong endpoint. +func AssertLoopback(origin string) error { + trimmed := strings.TrimSpace(origin) + parsed, err := url.Parse(trimmed) + if err != nil { + return fmt.Errorf("egress: cannot parse origin %q: %w", trimmed, err) + } + if parsed.Scheme != "http" { + return fmt.Errorf("%w: %q", ErrHTTPS, trimmed) + } + if parsed.Path != "" && parsed.Path != "/" { + return fmt.Errorf("%w: %q has a path; the base origin must have none", ErrNotLoopback, parsed.Path) + } + if parsed.RawQuery != "" || parsed.Fragment != "" { + return fmt.Errorf("%w: %q carries a query or fragment", ErrNotLoopback, trimmed) + } + if parsed.User != nil { + return fmt.Errorf("%w: %q carries userinfo", ErrNotLoopback, trimmed) + } + host := parsed.Hostname() + port := parsed.Port() + if port == "" { + return ErrNoPort + } + ip := net.ParseIP(host) + if ip == nil { + return fmt.Errorf("%w: %q is a name, and names resolve", ErrNotLoopback, host) + } + if !ip.IsLoopback() { + return fmt.Errorf("%w: %q", ErrNotLoopback, host) + } + return nil +} diff --git a/internal/egress/egress_test.go b/internal/egress/egress_test.go new file mode 100644 index 0000000..726beed --- /dev/null +++ b/internal/egress/egress_test.go @@ -0,0 +1,195 @@ +package egress + +import ( + "context" + "errors" + "net" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" +) + +func TestOriginIsAlwaysALiteral(t *testing.T) { + cases := []struct { + target Target + want string + }{ + {Target{Port: 8788}, "http://127.0.0.1:8788"}, + {Target{Port: 8788, IPv6: true}, "http://[::1]:8788"}, + {Target{Port: 1}, "http://127.0.0.1:1"}, + {Target{Port: 65535}, "http://127.0.0.1:65535"}, + } + for _, c := range cases { + if got := c.target.Origin(); got != c.want { + t.Errorf("Origin() = %q, want %q", got, c.want) + } + if err := AssertLoopback(c.target.Origin()); err != nil { + t.Errorf("AssertLoopback(%q) = %v, want nil", c.want, err) + } + } +} + +func TestAssertLoopbackRejectsEverythingElse(t *testing.T) { + for _, origin := range []string{ + "http://simadmin.local:8788", + "http://10.0.0.12:8788", + "http://192.168.1.1:8788", + "http://169.254.169.254/latest/meta-data", + "http://[::ffff:8.8.8.8]:8788", + "https://127.0.0.1:8788", + "http://127.0.0.1:8788/prefix", + "http://localhost:8788", + "not a url", + } { + if err := AssertLoopback(origin); err == nil { + t.Errorf("AssertLoopback(%q) accepted it", origin) + } else if !strings.Contains(err.Error(), "egress:") { + t.Errorf("AssertLoopback(%q) returned a foreign error: %v", origin, err) + } + } +} + +func TestNewTargetValidatesPort(t *testing.T) { + for _, port := range []int{0, -1, 70000} { + if _, err := NewTarget(port); !errors.Is(err, ErrNoPort) { + t.Errorf("NewTarget(%d) err = %v, want ErrNoPort", port, err) + } + } + target, err := NewTarget(8788) + if err != nil || target.Origin() != "http://127.0.0.1:8788" { + t.Fatalf("NewTarget(8788) = %v, %v", target, err) + } +} + +// TestDialRewritesTheHost is the central property: whatever address a caller believes it is +// connecting to, the socket lands on loopback. If this ever fails, the blast radius of a policy +// bug goes from "one node" back to "the whole subnet". +func TestDialRewritesTheHost(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + + _, port, err := net.SplitHostPort(strings.TrimPrefix(upstream.URL, "http://")) + if err != nil { + t.Fatalf("test server address unparseable: %v", err) + } + + for _, address := range []string{ + "example.com:" + port, + "8.8.8.8:" + port, + "169.254.169.254:" + port, + "10.255.255.255:" + port, + } { + conn, err := Dial(context.Background(), "tcp", address) + if err != nil { + t.Fatalf("Dial(%q): %v", address, err) + } + local := conn.LocalAddr().(*net.TCPAddr) + if !local.IP.IsLoopback() { + t.Errorf("Dial(%q) landed on %s, which is not loopback", address, local.IP) + } + remote := conn.RemoteAddr().(*net.TCPAddr) + if !remote.IP.IsLoopback() { + t.Errorf("Dial(%q) connected to %s", address, remote.IP) + } + if err := conn.Close(); err != nil { + t.Fatal(err) + } + } +} + +func TestDialRefusesNonTCPNetworks(t *testing.T) { + for _, network := range []string{"udp", "unix", "ip", ""} { + if _, err := Dial(context.Background(), network, "127.0.0.1:8788"); err == nil { + t.Errorf("Dial accepted network %q", network) + } + } +} + +func TestDialRefusesMalformedOrUndialableTargets(t *testing.T) { + // The port escapes into a syscall argument, so garbage must be rejected before dialing. + for _, address := range []string{"", "no-separator", "host:notaport", "127.0.0.1:99999", "127.0.0.1:0"} { + if _, err := Dial(context.Background(), "tcp", address); err == nil { + t.Errorf("Dial accepted address %q", address) + } + } +} + +func TestClientFollowsNoRedirects(t *testing.T) { + var redirectAttempts int + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/health": + redirectAttempts++ + // A pivot attempt: bounce the agent at an address the policy never listed. + http.Redirect(w, r, "http://169.254.169.254/latest/meta-data/", http.StatusFound) + default: + t.Errorf("client followed a redirect to %s", r.URL.Path) + } + })) + defer upstream.Close() + + client, err := NewClient(Target{Port: portOf(t, upstream.URL)}, 2*time.Second) + if err != nil { + t.Fatal(err) + } + resp, err := client.Get(upstream.URL + "/api/health") + if err != nil { + t.Fatalf("GET: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusFound { + t.Fatalf("status = %d, want the 302 passed through unread", resp.StatusCode) + } + if redirectAttempts != 1 { + t.Fatalf("upstream saw %d requests, want 1", redirectAttempts) + } +} + +// TestClientIgnoresProxyEnvironment matters on shared nodes: an operator exporting HTTP_PROXY for +// apt must not be able to relocate what the agent considers a loopback call. +func TestClientIgnoresProxyEnvironment(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer upstream.Close() + + t.Setenv("HTTP_PROXY", "http://127.0.0.1:1") + t.Setenv("http_proxy", "http://127.0.0.1:1") + + client, err := NewClient(Target{Port: portOf(t, upstream.URL)}, 2*time.Second) + if err != nil { + t.Fatal(err) + } + resp, err := client.Get(upstream.URL + "/api/health") + if err != nil { + t.Fatalf("GET routed through the environment proxy: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } +} + +func TestClientRefusesAnUnsetTarget(t *testing.T) { + if _, err := NewClient(Target{}, time.Second); !errors.Is(err, ErrNoPort) { + t.Fatalf("NewClient(Target{}) err = %v, want ErrNoPort", err) + } +} + +func portOf(t *testing.T, origin string) int { + t.Helper() + _, port, err := net.SplitHostPort(strings.TrimPrefix(strings.TrimPrefix(origin, "http://"), "https://")) + if err != nil { + t.Fatalf("origin %q has no port: %v", origin, err) + } + n, err := strconv.Atoi(port) + if err != nil { + t.Fatalf("port %q: %v", port, err) + } + return n +} diff --git a/internal/policy/agent-policy.json b/internal/policy/agent-policy.json new file mode 100644 index 0000000..5bcb375 --- /dev/null +++ b/internal/policy/agent-policy.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 1, + "name": "msa-agent-policy", + "comment": "Authorisation surface for relay-issued commands. Deliberately narrower than the web console proxy policy: this is what the iPhone app can actually ask for, nothing more.", + "egress": { + "mode": "loopback-only", + "portOnly": true, + "comment": "The agent config carries a port, never a host. Outbound connections are dialed to 127.0.0.1/::1 by the transport itself, so there is no host field to misconfigure and no DNS-rebinding path.", + "dialedHosts": ["127.0.0.1", "::1"] + }, + "authPaths": { + "pattern": "^/api/(?:auth(?:/|$)|login(?:/|$)|logout(?:/|$))", + "proxied": false, + "comment": "The agent logs into the local SimAdmin itself and holds the credential. Login material never traverses the relay in either direction." + }, + "read": [ + "/api/health", + "/api/device", + "/api/sim", + "/api/network", + "/api/network/signal-strength", + "/api/stats", + "/api/stats/cpu", + "/api/connectivity", + "/api/sms/list" + ], + "write": { + "/api/sms/send": ["POST"], + "/api/sms/batch-delete": ["POST"] + }, + "dangerous": ["/api/sms/send", "/api/sms/batch-delete"], + "limits": { + "dangerousWritesPerMinute": 6, + "maxRequestBytes": 262144, + "maxResponseBytes": 262144, + "requestTtlSeconds": 30, + "clockSkewSeconds": 120, + "replayCacheSize": 4096, + "mailboxDepth": 8 + } +} diff --git a/internal/policy/policy.go b/internal/policy/policy.go new file mode 100644 index 0000000..f06d642 --- /dev/null +++ b/internal/policy/policy.go @@ -0,0 +1,232 @@ +// Package policy loads and enforces the agent authorisation surface. +// +// The policy is embedded rather than read from disk: a security boundary that lives in an editable +// file can be silently widened on one host, and "that box was configured differently" is the kind +// of finding that only surfaces after an incident. The build vendors contracts/agent-policy.json +// into this directory, and TestVendoredCopyMatchesContracts fails if the vendored copy drifts. +package policy + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net" + "regexp" + "strings" + + _ "embed" +) + +// The directive must sit immediately above the variable; a blank line here silently detaches it. +// Vendoring is a make step so the copy under this directory is never hand-edited. +// +//go:embed agent-policy.json +var embedded []byte + +type Egress struct { + Mode string `json:"mode"` + PortOnly bool `json:"portOnly"` + DialedHosts []string `json:"dialedHosts"` +} + +type AuthPaths struct { + Pattern string `json:"pattern"` + Proxied bool `json:"proxied"` +} + +type Limits struct { + DangerousWritesPerMinute int `json:"dangerousWritesPerMinute"` + MaxRequestBytes int `json:"maxRequestBytes"` + MaxResponseBytes int `json:"maxResponseBytes"` + RequestTTLSeconds int `json:"requestTtlSeconds"` + ClockSkewSeconds int `json:"clockSkewSeconds"` + ReplayCacheSize int `json:"replayCacheSize"` + MailboxDepth int `json:"mailboxDepth"` +} + +type Policy struct { + SchemaVersion int `json:"schemaVersion"` + Name string `json:"name"` + Egress Egress `json:"egress"` + AuthPaths AuthPaths `json:"authPaths"` + Read []string `json:"read"` + Write map[string][]string `json:"write"` + Dangerous []string `json:"dangerous"` + Limits Limits `json:"limits"` + + authRe *regexp.Regexp + read map[string]bool + write map[string]map[string]bool + dangerous map[string]bool + digest string +} + +// Load returns the embedded policy. It never falls back to a permissive default: if the embedded +// bytes fail validation the agent must not start. +func Load() (*Policy, error) { return Parse(embedded) } + +func Parse(raw []byte) (*Policy, error) { + var p Policy + if err := json.Unmarshal(raw, &p); err != nil { + return nil, fmt.Errorf("policy: decode: %w", err) + } + if p.SchemaVersion != 1 { + return nil, fmt.Errorf("policy: unsupported schemaVersion %d", p.SchemaVersion) + } + if p.Egress.Mode != "loopback-only" || !p.Egress.PortOnly { + return nil, errors.New("policy: egress must be loopback-only and port-only") + } + if len(p.Egress.DialedHosts) == 0 { + return nil, errors.New("policy: egress.dialedHosts is empty") + } + for _, host := range p.Egress.DialedHosts { + ip := net.ParseIP(host) + if ip == nil || !ip.IsLoopback() { + return nil, fmt.Errorf("policy: egress.dialedHosts entry %q is not a literal loopback address", host) + } + } + if p.AuthPaths.Proxied { + return nil, errors.New("policy: authPaths must not be proxied") + } + re, err := regexp.Compile(p.AuthPaths.Pattern) + if err != nil { + return nil, fmt.Errorf("policy: authPaths.pattern: %w", err) + } + p.authRe = re + + p.read = make(map[string]bool, len(p.Read)) + for _, path := range p.Read { + if err := p.checkPath(path); err != nil { + return nil, err + } + if re.MatchString(path) { + return nil, fmt.Errorf("policy: read path %q matches the auth pattern", path) + } + if p.read[path] { + return nil, fmt.Errorf("policy: duplicate read path %q", path) + } + p.read[path] = true + } + + p.write = make(map[string]map[string]bool, len(p.Write)) + p.dangerous = make(map[string]bool, len(p.Dangerous)) + for _, path := range p.Dangerous { + p.dangerous[path] = true + } + for path, methods := range p.Write { + if err := p.checkPath(path); err != nil { + return nil, err + } + if re.MatchString(path) { + return nil, fmt.Errorf("policy: write path %q matches the auth pattern", path) + } + if !p.dangerous[path] { + return nil, fmt.Errorf("policy: write path %q is not declared dangerous, so it would run without a confirmation intent", path) + } + set := make(map[string]bool, len(methods)) + for _, method := range methods { + if method == "GET" || method == "HEAD" { + return nil, fmt.Errorf("policy: write path %q must not allow %s", path, method) + } + if set[method] { + return nil, fmt.Errorf("policy: write path %q duplicates method %s", path, method) + } + set[method] = true + } + if len(set) == 0 { + return nil, fmt.Errorf("policy: write path %q has no methods", path) + } + p.write[path] = set + } + for path := range p.dangerous { + if _, ok := p.write[path]; !ok { + return nil, fmt.Errorf("policy: %q is declared dangerous but is not writable", path) + } + } + + for name, value := range map[string]int{ + "dangerousWritesPerMinute": p.Limits.DangerousWritesPerMinute, + "maxRequestBytes": p.Limits.MaxRequestBytes, + "maxResponseBytes": p.Limits.MaxResponseBytes, + "requestTtlSeconds": p.Limits.RequestTTLSeconds, + "clockSkewSeconds": p.Limits.ClockSkewSeconds, + "replayCacheSize": p.Limits.ReplayCacheSize, + "mailboxDepth": p.Limits.MailboxDepth, + } { + if value <= 0 { + return nil, fmt.Errorf("policy: limits.%s must be positive, got %d", name, value) + } + } + if p.Limits.ClockSkewSeconds > 300 { + return nil, errors.New("policy: clockSkewSeconds is too wide to bound replay") + } + if p.Limits.RequestTTLSeconds > p.Limits.ClockSkewSeconds { + return nil, errors.New("policy: requestTtlSeconds exceeds clockSkewSeconds and would never be enforced") + } + if p.Limits.ReplayCacheSize < p.Limits.MailboxDepth { + return nil, errors.New("policy: replayCacheSize is smaller than mailboxDepth, so entries can be evicted while still queued") + } + + sum := sha256.Sum256(raw) + p.digest = hex.EncodeToString(sum[:]) + return &p, nil +} + +func (p *Policy) checkPath(path string) error { + if !strings.HasPrefix(path, "/api/") { + return fmt.Errorf("policy: path %q is not under /api/", path) + } + if strings.Contains(path, "..") || strings.ContainsAny(path, "?#\\") { + return fmt.Errorf("policy: path %q contains traversal, query, fragment or backslash", path) + } + return nil +} + +// SHA256 is the digest of the exact bytes the agent enforced. It is logged at startup so an operator +// can confirm a fleet is running the intended policy without diffing files. +func (p *Policy) SHA256() string { return p.digest } + +// Decision reports whether a request may be executed, and whether it needs a confirmation intent. +func (p *Policy) Decision(method, path string) (allowed bool, dangerous bool, reason string) { + clean := path + if i := strings.IndexAny(clean, "?#"); i >= 0 { + clean = clean[:i] + } + if err := p.checkPath(clean); err != nil { + return false, false, "path rejected" + } + // Constant-time on purpose is not needed for a public allowlist, but the auth check must come + // first so a path that is both auth-shaped and allowlisted cannot win by ordering. + if p.authRe.MatchString(clean) { + return false, false, "authentication endpoints are not reachable through the relay" + } + if method == "GET" { + if p.read[clean] { + return true, false, "" + } + return false, false, "path is not on the read allowlist" + } + methods, ok := p.write[clean] + if !ok { + if p.read[clean] { + return false, false, "path is read-only" + } + return false, false, "path is not on the write allowlist" + } + if !methods[method] { + return false, false, "method is not allowed for this path" + } + return true, true, "" +} + +// RequiresConfirm reports whether a dangerous write carries a matching intent. The comparison is +// constant time so the agent does not leak the expected digest byte by byte on a wrong guess. +func RequiresConfirm(got, want []byte) bool { + return subtle.ConstantTimeCompare(got, want) == 1 +} + +// Raw returns the embedded bytes for the drift test. +func Raw() []byte { return embedded } diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go new file mode 100644 index 0000000..d7c4e23 --- /dev/null +++ b/internal/policy/policy_test.go @@ -0,0 +1,210 @@ +package policy + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// TestVendoredCopyMatchesContracts fails loudly when the embedded policy and contracts/ drift apart. +// Without this the shipped binary can enforce something other than what the tests in test/ checked, +// which is exactly the silent-divergence case embedding was supposed to avoid. +func TestVendoredCopyMatchesContracts(t *testing.T) { + upstream, err := os.ReadFile(filepath.Join("..", "..", "..", "contracts", "agent-policy.json")) + if err != nil { + t.Fatalf("read contracts/agent-policy.json: %v", err) + } + if !bytes.Equal(upstream, Raw()) { + t.Fatal("internal/policy/agent-policy.json differs from contracts/agent-policy.json; run make vendor") + } +} + +func loaded(t *testing.T) *Policy { + t.Helper() + p, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + return p +} + +func TestDecisionTable(t *testing.T) { + p := loaded(t) + cases := []struct { + method, path string + allowed bool + dangerous bool + }{ + {"GET", "/api/health", true, false}, + {"GET", "/api/sms/list", true, false}, + {"GET", "/api/health?fresh=1", true, false}, + {"POST", "/api/sms/send", true, true}, + {"POST", "/api/sms/batch-delete", true, true}, + // Reads must not be writable even though the path is known. + {"POST", "/api/health", false, false}, + {"DELETE", "/api/sms/list", false, false}, + // Console-only surface that the app never constructs. + {"GET", "/api/esim/profiles", false, false}, + {"POST", "/api/device-network/ddns/config", false, false}, + {"DELETE", "/api/sim/1", false, false}, + // Auth is the agent's own business. + {"POST", "/api/auth/login", false, false}, + {"GET", "/api/auth/logout", false, false}, + {"POST", "/api/login", false, false}, + // Traversal and off-api targets. + {"GET", "/etc/passwd", false, false}, + {"GET", "/api/../etc/passwd", false, false}, + {"GET", "/api/health/../../etc/passwd", false, false}, + {"GET", "", false, false}, + {"", "/api/health", false, false}, + } + for _, c := range cases { + allowed, dangerous, reason := p.Decision(c.method, c.path) + if allowed != c.allowed { + t.Errorf("%s %s: allowed=%v want %v (reason %q)", c.method, c.path, allowed, c.allowed, reason) + } + if allowed && dangerous != c.dangerous { + t.Errorf("%s %s: dangerous=%v want %v", c.method, c.path, dangerous, c.dangerous) + } + if !allowed && reason == "" { + t.Errorf("%s %s: denied without a reason", c.method, c.path) + } + } +} + +// TestFailClosedOnTampering walks the mutations an operator or a bad merge could plausibly produce +// and asserts Parse rejects each one rather than degrading to something looser. +func TestFailClosedOnTampering(t *testing.T) { + base := Raw() + mutate := func(t *testing.T, set func(m map[string]any)) []byte { + t.Helper() + var m map[string]any + if err := json.Unmarshal(base, &m); err != nil { + t.Fatalf("unmarshal base: %v", err) + } + set(m) + out, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return out + } + + t.Run("non loopback egress host", func(t *testing.T) { + raw := mutate(t, func(m map[string]any) { + m["egress"].(map[string]any)["dialedHosts"] = []any{"10.0.0.9"} + }) + if _, err := Parse(raw); err == nil { + t.Fatal("accepted a routable egress host") + } + }) + t.Run("hostname egress", func(t *testing.T) { + raw := mutate(t, func(m map[string]any) { + m["egress"].(map[string]any)["dialedHosts"] = []any{"attacker.example"} + }) + if _, err := Parse(raw); err == nil { + t.Fatal("accepted a hostname; that reopens DNS rebinding and /etc/hosts pivots") + } + }) + t.Run("mode changed", func(t *testing.T) { + raw := mutate(t, func(m map[string]any) { + m["egress"].(map[string]any)["mode"] = "any" + }) + if _, err := Parse(raw); err == nil { + t.Fatal("accepted egress.mode=any") + } + }) + t.Run("auth proxied", func(t *testing.T) { + raw := mutate(t, func(m map[string]any) { + m["authPaths"].(map[string]any)["proxied"] = true + }) + if _, err := Parse(raw); err == nil { + t.Fatal("accepted proxied auth paths") + } + }) + t.Run("read path is an auth path", func(t *testing.T) { + raw := mutate(t, func(m map[string]any) { + m["read"] = append(m["read"].([]any), "/api/auth/session") + }) + if _, err := Parse(raw); err == nil { + t.Fatal("accepted an auth path on the read allowlist") + } + }) + t.Run("write without danger", func(t *testing.T) { + raw := mutate(t, func(m map[string]any) { + m["write"].(map[string]any)["/api/sim/disable"] = []any{"POST"} + }) + if _, err := Parse(raw); err == nil { + t.Fatal("accepted a write that is not declared dangerous") + } + }) + t.Run("write allows GET", func(t *testing.T) { + raw := mutate(t, func(m map[string]any) { + m["write"].(map[string]any)["/api/sms/send"] = []any{"GET"} + }) + if _, err := Parse(raw); err == nil { + t.Fatal("accepted GET as a write method") + } + }) + t.Run("dangerous path not writable", func(t *testing.T) { + raw := mutate(t, func(m map[string]any) { + m["dangerous"] = append(m["dangerous"].([]any), "/api/stats") + }) + if _, err := Parse(raw); err == nil { + t.Fatal("accepted a dangerous path with no write entry") + } + }) + t.Run("ttl beyond skew", func(t *testing.T) { + raw := mutate(t, func(m map[string]any) { + m["limits"].(map[string]any)["requestTtlSeconds"] = 600 + }) + if _, err := Parse(raw); err == nil { + t.Fatal("accepted a TTL the replay window cannot cover") + } + }) + t.Run("replay cache below mailbox", func(t *testing.T) { + raw := mutate(t, func(m map[string]any) { + m["limits"].(map[string]any)["replayCacheSize"] = 2 + }) + if _, err := Parse(raw); err == nil { + t.Fatal("accepted a replay cache that evicts live requests") + } + }) + t.Run("schema bump", func(t *testing.T) { + raw := mutate(t, func(m map[string]any) { + m["schemaVersion"] = 2 + }) + if _, err := Parse(raw); err == nil { + t.Fatal("accepted an unknown schemaVersion") + } + }) + t.Run("traversal path", func(t *testing.T) { + raw := mutate(t, func(m map[string]any) { + m["read"] = append(m["read"].([]any), "/api/../etc/shadow") + }) + if _, err := Parse(raw); err == nil { + t.Fatal("accepted a traversal path") + } + }) + t.Run("malformed json", func(t *testing.T) { + if _, err := Parse([]byte("{")); err == nil { + t.Fatal("accepted malformed json") + } + }) +} + +func TestDigestIsStable(t *testing.T) { + p := loaded(t) + if len(p.SHA256()) != 64 { + t.Fatalf("digest looks wrong: %q", p.SHA256()) + } + again, err := Load() + if err != nil { + t.Fatalf("Load twice: %v", err) + } + if again.SHA256() != p.SHA256() { + t.Fatal("digest changed between loads") + } +} diff --git a/internal/runtime/audit.go b/internal/runtime/audit.go new file mode 100644 index 0000000..316bd29 --- /dev/null +++ b/internal/runtime/audit.go @@ -0,0 +1,89 @@ +package runtime + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// Entry is one line of the audit log. +// +// Metadata only, and that is the whole design: the request and response bodies are the actual +// credentials and SMS contents, and copying them into a second file on the same box adds a place to +// lose them without adding any investigative value. What is kept is enough to answer "who asked for +// what, when, and was it allowed" from the relay request id. +type Entry struct { + At time.Time `json:"ts"` + Node string `json:"node,omitempty"` + Req string `json:"req,omitempty"` + App string `json:"app,omitempty"` + Method string `json:"method,omitempty"` + Path string `json:"path,omitempty"` + Status int `json:"status"` + Outcome string `json:"outcome"` +} + +// Audit is an append-only JSONL writer. One line per request, flushed as it is written: an audit log +// that loses its tail on SIGKILL cannot explain the incident that killed the process. +type Audit struct { + mu sync.Mutex + sink *os.File + enc *json.Encoder +} + +// OpenAudit appends to path, creating it 0600 and its parent directories if needed. +// +// The file is opened append-only and never rotated in-process. Rotation is logrotate's job: an +// agent that renames its own log is an agent that can lose an entry during the rename. +func OpenAudit(path string) (*Audit, error) { + if path == "" { + return nil, errors.New("runtime: audit path is empty") + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + sink, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return nil, err + } + return &Audit{sink: sink, enc: json.NewEncoder(sink)}, nil +} + +// NullAudit returns a writer that keeps nothing. Used by tests and by `msa-agent --audit=-`, because +// the executor is simpler when it can assume a non-nil audit target. +func NullAudit() *Audit { return &Audit{} } + +// Write appends one entry. A write failure is returned to the caller rather than panicking: the +// audit log is evidence, not a control, and a full disk must not become a remote denial of service. +func (a *Audit) Write(entry Entry) error { + a.mu.Lock() + defer a.mu.Unlock() + if a.sink == nil { + return nil + } + if entry.At.IsZero() { + entry.At = time.Now().UTC() + } + entry.At = entry.At.UTC() + if err := a.enc.Encode(entry); err != nil { + return fmt.Errorf("runtime: audit write: %w", err) + } + return a.sink.Sync() +} + +// Close releases the file. Subsequent writes are silently dropped, which is the right behaviour for +// a shutdown path: failing the request that happens to race with a clean exit buys nothing. +func (a *Audit) Close() error { + a.mu.Lock() + defer a.mu.Unlock() + if a.sink == nil { + return nil + } + err := a.sink.Close() + a.sink = nil + return err +} diff --git a/internal/runtime/executor.go b/internal/runtime/executor.go new file mode 100644 index 0000000..b5c9a44 --- /dev/null +++ b/internal/runtime/executor.go @@ -0,0 +1,272 @@ +package runtime + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "fun.chick/msa-agent/internal/egress" + "fun.chick/msa-agent/internal/policy" + "fun.chick/msa-agent/internal/wire" +) + +// Refusal is classified, and the classification is part of the contract with the operator. A node +// with a drifted clock and a node under attack produce the same outward symptom; collapsing both +// into "denied" guarantees the operator will respond to the wrong one. +type Refusal struct { + Class string + Path string + Err error +} + +func (r Refusal) Error() string { return fmt.Sprintf("%s: %v", r.Class, r.Err) } + +// Refusal classes, mirrored by the relay's ProblemDetails codes so one string is searchable across +// the agent log, the relay log and the phone. +const ( + ClassBadEnvelope = "bad_envelope" + ClassSignature = "bad_signature" + ClassExpired = "expired" + ClassReplay = "replay" + ClassPolicy = "policy_denied" + ClassNoConfirm = "confirmation_missing" + ClassBadConfirm = "confirmation_mismatch" + ClassRateLimited = "rate_limited" + ClassTooLarge = "payload_too_large" + ClassUpstream = "upstream_error" + ClassLoopback = "egress_violation" +) + +// Executor turns a decrypted InnerRequest into one loopback HTTP call. +// +// It holds the only HTTP client in the process, built by egress.NewClient, so there is no path from +// a request body to a socket that is not the loopback one. +type Executor struct { + client *http.Client + target egress.Target + pol *policy.Policy + replay *ReplayCache + audit *Audit + clock func() time.Time + limiter *rateLimiter +} + +// Config is what the agent needs to build an Executor. Port is a number and nothing else: no host, +// by the same reasoning as egress.Target. +type Config struct { + Port int + Timeout time.Duration + Audit *Audit + Clock func() time.Time + DangerousPerMin int +} + +// NewExecutor wires the policy, the loopback client and the replay cache together. +// +// It fails closed: an unusable policy or an unbuildable client is a startup error, because an agent +// that starts with a permissive fallback would be indistinguishable from one that is working. +func NewExecutor(pol *policy.Policy, cfg Config) (*Executor, error) { + if pol == nil { + return nil, errors.New("runtime: policy is nil") + } + target, err := egress.NewTarget(cfg.Port) + if err != nil { + return nil, err + } + timeout := cfg.Timeout + if timeout <= 0 { + timeout = 20 * time.Second + } + client, err := egress.NewClient(target, timeout) + if err != nil { + return nil, err + } + // The cache ttl is the skew window, not an independent knob. An id older than the widest + // acceptable timestamp can never be accepted again, so keeping it costs memory and buys nothing. + replay, err := NewReplayCache(pol.Limits.ReplayCacheSize, + time.Duration(pol.Limits.ClockSkewSeconds)*time.Second) + if err != nil { + return nil, err + } + clock := cfg.Clock + if clock == nil { + clock = time.Now + } + perMinute := cfg.DangerousPerMin + if perMinute <= 0 { + perMinute = pol.Limits.DangerousWritesPerMinute + } + return &Executor{ + client: client, + target: target, + pol: pol, + replay: replay, + audit: cfg.Audit, + clock: clock, + limiter: newRateLimiter(perMinute, time.Minute), + }, nil +} + +// Result is what comes back from the local SimAdmin, or why it did not. +type Result struct { + Status int + Body []byte +} + +// Execute validates, then performs, exactly one request. +// +// Order matters and is not cosmetic. The replay and freshness gates run before the policy decision +// so a denied request still burns its request id: otherwise an attacker could probe the allowlist +// by resubmitting one signed request with a dozen paths. The intent is checked after the policy +// decision because the expected digest depends on the path that survived it. +func (e *Executor) Execute(ctx context.Context, envelope wire.Envelope, inner wire.InnerRequest) (Result, *Refusal) { + now := e.clock() + if err := Freshness(envelope.TS, uint64(now.Unix()), e.pol.Limits.ClockSkewSeconds); err != nil { + return Result{}, e.refuse(envelope, inner, ClassExpired, err) + } + if e.replay.Seen(envelope.Req, now) { + return Result{}, e.refuse(envelope, inner, ClassReplay, fmt.Errorf("%w: %s", ErrReplay, envelope.Req)) + } + + method := strings.ToUpper(strings.TrimSpace(inner.Method)) + allowed, dangerous, reason := e.pol.Decision(method, inner.Path) + if !allowed { + return Result{}, e.refuse(envelope, inner, ClassPolicy, errors.New(reason)) + } + if len(inner.Body) > e.pol.Limits.MaxRequestBytes { + return Result{}, e.refuse(envelope, inner, ClassTooLarge, + fmt.Errorf("body is %d bytes, limit is %d", len(inner.Body), e.pol.Limits.MaxRequestBytes)) + } + + if dangerous { + // Ordering inside the dangerous path: rate limit first, then intent. A caller who can send + // unlimited attempts can also brute-force a 32-byte digest in principle, and the limit is + // the cheaper of the two checks to apply. + if !e.limiter.Allow(now) { + return Result{}, e.refuse(envelope, inner, ClassRateLimited, errors.New("too many dangerous writes")) + } + want, err := wire.IntentDigest(method, inner.Path, inner.Body) + if err != nil { + return Result{}, e.refuse(envelope, inner, ClassBadConfirm, err) + } + if len(inner.Intent) == 0 { + return Result{}, e.refuse(envelope, inner, ClassNoConfirm, errors.New("dangerous write carries no confirmation intent")) + } + if !policy.RequiresConfirm(inner.Intent, want) { + return Result{}, e.refuse(envelope, inner, ClassBadConfirm, errors.New("confirmation intent does not match the request being sent")) + } + } + + result, err := e.forward(ctx, method, inner.Path, inner.Body) + if err != nil { + class := ClassUpstream + if errors.Is(err, ErrResponseTooLarge) { + class = ClassTooLarge + } + return Result{}, e.refuse(envelope, inner, class, err) + } + e.record(envelope, method, inner.Path, result.Status, "ok") + return result, nil +} + +// forward performs the loopback call. The origin comes from egress.Target, which has no host field, +// and the path has already survived policy.Decision, so nothing here is assembled from parts that +// a caller could steer off the machine. +func (e *Executor) forward(ctx context.Context, method, path string, body []byte) (Result, error) { + if !strings.HasPrefix(path, "/api/") { + return Result{}, ClassLoopbackErr + } + var reader io.Reader + if len(body) > 0 { + reader = strings.NewReader(string(body)) + } + request, err := http.NewRequestWithContext(ctx, method, e.target.Origin()+path, reader) + if err != nil { + return Result{}, fmt.Errorf("%w: %v", ClassLoopbackErr, err) + } + if len(body) > 0 { + request.Header.Set("Content-Type", "application/json") + } + // No credential is attached here. The agent logs into the local SimAdmin itself on startup and + // holds the session; a phone cannot use this path to present a node password it does not have. + response, err := e.client.Do(request) + if err != nil { + return Result{}, fmt.Errorf("local SimAdmin unreachable: %w", err) + } + defer response.Body.Close() + limited := io.LimitReader(response.Body, int64(e.pol.Limits.MaxResponseBytes)+1) + payload, err := io.ReadAll(limited) + if err != nil { + return Result{}, fmt.Errorf("reading local response: %w", err) + } + if len(payload) > e.pol.Limits.MaxResponseBytes { + return Result{}, fmt.Errorf("%w: %s answered with more than %d bytes", + ErrResponseTooLarge, path, e.pol.Limits.MaxResponseBytes) + } + return Result{Status: response.StatusCode, Body: payload}, nil +} + +// ClassLoopbackErr is the named error used when a request could not even be constructed. +var ClassLoopbackErr = errors.New("request could not be built for the loopback target") + +// ErrResponseTooLarge is returned when the local SimAdmin answers with more than the policy allows. +// +// The response is dropped rather than truncated. A truncated body is not a smaller answer, it is a +// broken one: JSON cut off at an arbitrary byte offset parses as nothing, and the phone would show +// a decoding failure that points at the app instead of at the node that answered with 3 MB. +var ErrResponseTooLarge = errors.New("local response exceeds the policy limit") + +func (e *Executor) refuse(envelope wire.Envelope, inner wire.InnerRequest, class string, err error) *Refusal { + e.record(envelope, strings.ToUpper(strings.TrimSpace(inner.Method)), inner.Path, 0, class) + return &Refusal{Class: class, Path: inner.Path, Err: err} +} + +func (e *Executor) record(envelope wire.Envelope, method, path string, status int, outcome string) { + if e.audit == nil { + return + } + e.audit.Write(Entry{ + At: e.clock(), + Node: envelope.Node, + Req: envelope.Req, + App: envelope.App, + Method: method, + Path: path, + Status: status, + Outcome: outcome, + }) +} + +// rateLimiter is a fixed-window counter. Deliberately not a token bucket: the property wanted is +// "at most N dangerous writes in any 60 seconds", and a bucket's refill rate makes that harder to +// state and to test. +type rateLimiter struct { + mu sync.Mutex + limit int + window time.Duration + start time.Time + consumed int +} + +func newRateLimiter(limit int, window time.Duration) *rateLimiter { + return &rateLimiter{limit: limit, window: window} +} + +func (r *rateLimiter) Allow(now time.Time) bool { + r.mu.Lock() + defer r.mu.Unlock() + if r.start.IsZero() || now.Sub(r.start) >= r.window { + r.start = now + r.consumed = 0 + } + if r.consumed >= r.limit { + return false + } + r.consumed++ + return true +} diff --git a/internal/runtime/replay.go b/internal/runtime/replay.go new file mode 100644 index 0000000..e19fc3f --- /dev/null +++ b/internal/runtime/replay.go @@ -0,0 +1,127 @@ +// Package runtime is where a verified envelope becomes a local HTTP call, or is refused. +// +// The checks here are ordered, and the order is the security property: a request is only decrypted +// after its signature verifies, only routed after its policy decision, and only executed after its +// confirmation intent is shown to match the exact bytes about to be forwarded. Every refusal is +// classified, because the failure modes are indistinguishable from the outside — a node whose clock +// has drifted looks exactly like an attacker replaying a request — and an operator who cannot tell +// them apart will "fix" it by widening the policy. +package runtime + +import ( + "errors" + "sync" + "time" +) + +// ReplayClass names why a request was refused, in terms an operator can act on. +type ReplayClass string + +const ( + // ReplayDuplicate means this request id was already executed. + ReplayDuplicate ReplayClass = "replay" + // ReplayExpired means the timestamp is outside the clock-skew window. + ReplayExpired ReplayClass = "expired" +) + +var ( + // ErrReplay is returned for a request id that has already been executed. + ErrReplay = errors.New("runtime: request id was already used") + // ErrExpired is returned for a timestamp outside the clock-skew window. + ErrExpired = errors.New("runtime: request timestamp is outside the allowed window") +) + +// ReplayCache is a bounded set of request ids that have already run. +// +// Bounded, not growing: an attacker who can send traffic can also send distinct request ids, and a +// cache that grows to fit them is a memory exhaustion primitive. The bound is set from +// limits.replayCacheSize, which the policy asserts is larger than the relay mailbox depth, so an +// entry can never be evicted while a copy of that request is still queued. +// +// Eviction order is insertion order rather than LRU. LRU would let a flood of fresh ids push out +// the one id an attacker wants to reuse; FIFO eviction still admits that eventually, but only after +// the whole window has turned over, and the timestamp window expires it first in any case. +type ReplayCache struct { + mu sync.Mutex + entries map[string]time.Time + order []string + limit int + ttl time.Duration +} + +// NewReplayCache builds a cache holding at most limit ids, each forgotten after ttl even if the +// cache is not yet full. The ttl is what makes the bound independent of traffic volume: ids older +// than the skew window can no longer be accepted anyway. +func NewReplayCache(limit int, ttl time.Duration) (*ReplayCache, error) { + if limit <= 0 { + return nil, errors.New("runtime: replay cache limit must be positive") + } + if ttl <= 0 { + return nil, errors.New("runtime: replay cache ttl must be positive") + } + return &ReplayCache{ + entries: make(map[string]time.Time, limit), + order: make([]string, 0, limit), + limit: limit, + ttl: ttl, + }, nil +} + +// Seen reports whether id has already been executed, and records it if not. +// +// The check and the insert are one call, not two. Splitting them invites a caller to check, decide, +// and then forget to record — which is a replay window that no test would name. +func (c *ReplayCache) Seen(id string, now time.Time) bool { + c.mu.Lock() + defer c.mu.Unlock() + c.expire(now) + if _, ok := c.entries[id]; ok { + return true + } + if len(c.order) >= c.limit { + evicted := c.order[0] + c.order = c.order[1:] + delete(c.entries, evicted) + } + c.order = append(c.order, id) + c.entries[id] = now + return false +} + +// Len reports how many ids are held. Used by the health surface and by tests. +func (c *ReplayCache) Len(now time.Time) int { + c.mu.Lock() + defer c.mu.Unlock() + c.expire(now) + return len(c.entries) +} + +// expire drops entries older than the ttl. Called under the caller's lock, never exported. +func (c *ReplayCache) expire(now time.Time) { + cutoff := now.Add(-c.ttl) + for len(c.order) > 0 { + oldest := c.order[0] + stamp, ok := c.entries[oldest] + if !ok || stamp.After(cutoff) { + return + } + c.order = c.order[1:] + delete(c.entries, oldest) + } +} + +// Freshness applies the timestamp window. It is separate from the cache so the two refusals stay +// distinguishable in the audit log: "expired" means set the clock, "replay" means investigate. +func Freshness(requestTS, now uint64, skewSeconds int) error { + if skewSeconds <= 0 { + return errors.New("runtime: clock skew window must be positive") + } + delta := int64(now) - int64(requestTS) + if delta < 0 { + delta = -delta + } + if delta > int64(skewSeconds) { + return ErrExpired + } + return nil +} diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go new file mode 100644 index 0000000..c30f9ad --- /dev/null +++ b/internal/runtime/runtime_test.go @@ -0,0 +1,471 @@ +package runtime + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" + + "fun.chick/msa-agent/internal/policy" + "fun.chick/msa-agent/internal/wire" +) + +// The tests below drive the executor against a real local HTTP server, because the property under +// test is "the request that reaches SimAdmin is the one the policy allowed". A fake transport would +// only prove the executor calls an interface. + +func testPolicy(t *testing.T) *policy.Policy { + t.Helper() + pol, err := policy.Load() + if err != nil { + t.Fatalf("policy.Load: %v", err) + } + return pol +} + +// localServer starts a loopback server and returns its port. The executor is pointed at the port +// only; the server's own host is never handed to it. +func localServer(t *testing.T, handler http.Handler) int { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + parsed, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + port, err := strconv.Atoi(parsed.Port()) + if err != nil { + t.Fatal(err) + } + return port +} + +type fixture struct { + executor *Executor + now time.Time + mu sync.Mutex +} + +func (f *fixture) clock() time.Time { + f.mu.Lock() + defer f.mu.Unlock() + return f.now +} + +func (f *fixture) advance(d time.Duration) { + f.mu.Lock() + defer f.mu.Unlock() + f.now = f.now.Add(d) +} + +func newFixture(t *testing.T, handler http.Handler) *fixture { + t.Helper() + f := &fixture{now: time.Unix(1_800_000_000, 0).UTC()} + executor, err := NewExecutor(testPolicy(t), Config{ + Port: localServer(t, handler), + Timeout: 5 * time.Second, + Audit: NullAudit(), + Clock: f.clock, + }) + if err != nil { + t.Fatalf("NewExecutor: %v", err) + } + f.executor = executor + return f +} + +func envelopeAt(node, req string, ts uint64) wire.Envelope { + return wire.Envelope{ + V: wire.Version, Node: node, Req: req, App: "ap_test0000", TS: ts, + Alg: wire.Suite, EK: make([]byte, 32), P: []byte{1}, + } +} + +func request(method, path string, body []byte) wire.InnerRequest { + return wire.InnerRequest{Method: method, Path: path, Body: body} +} + +func TestReadOnTheAllowlistIsForwarded(t *testing.T) { + var seen http.Request + f := newFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = *r + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) + })) + result, refusal := f.executor.Execute(context.Background(), + envelopeAt("nd_test0001", "11111111-1111-4111-8111-111111111111", uint64(f.now.Unix())), + request("GET", "/api/health", nil)) + if refusal != nil { + t.Fatalf("read was refused: %v", refusal) + } + if result.Status != 200 || string(result.Body) != `{"ok":true}` { + t.Fatalf("got %d %q", result.Status, result.Body) + } + if seen.Method != "GET" || seen.URL.Path != "/api/health" { + t.Fatalf("upstream saw %s %s", seen.Method, seen.URL.Path) + } + if seen.Host != "127.0.0.1" && !strings.HasPrefix(seen.Host, "127.0.0.1:") { + t.Fatalf("upstream saw host %q, want the loopback literal", seen.Host) + } +} + +func TestTheSameRequestIDIsRejectedTheSecondTime(t *testing.T) { + f := newFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + envelope := envelopeAt("nd_test0001", "22222222-2222-4222-8222-222222222222", uint64(f.now.Unix())) + if _, refusal := f.executor.Execute(context.Background(), envelope, request("GET", "/api/health", nil)); refusal != nil { + t.Fatalf("first call refused: %v", refusal) + } + _, refusal := f.executor.Execute(context.Background(), envelope, request("GET", "/api/health", nil)) + if refusal == nil || refusal.Class != ClassReplay { + t.Fatalf("second call: got %v, want %s", refusal, ClassReplay) + } +} + +// A denied request must still burn its id. If it did not, an attacker holding one signed request +// could walk it down the whole allowlist by changing only the path. +func TestADeniedRequestStillBurnsItsID(t *testing.T) { + f := newFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("upstream must not be called for a path outside the policy") + })) + envelope := envelopeAt("nd_test0001", "33333333-3333-4333-8333-333333333333", uint64(f.now.Unix())) + if _, refusal := f.executor.Execute(context.Background(), envelope, request("GET", "/api/secrets", nil)); refusal == nil || refusal.Class != ClassPolicy { + t.Fatalf("got %v, want %s", refusal, ClassPolicy) + } + _, refusal := f.executor.Execute(context.Background(), envelope, request("GET", "/api/health", nil)) + if refusal == nil || refusal.Class != ClassReplay { + t.Fatalf("reuse after a denial: got %v, want %s", refusal, ClassReplay) + } +} + +func TestAClockedRequestIsClassifiedAsExpiredNotReplay(t *testing.T) { + f := newFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + pol := testPolicy(t) + stale := uint64(f.now.Unix()) - uint64(pol.Limits.ClockSkewSeconds) - 1 + _, refusal := f.executor.Execute(context.Background(), + envelopeAt("nd_test0001", "44444444-4444-4444-8444-444444444444", stale), + request("GET", "/api/health", nil)) + if refusal == nil || refusal.Class != ClassExpired { + t.Fatalf("got %v, want %s", refusal, ClassExpired) + } + future := uint64(f.now.Unix()) + uint64(pol.Limits.ClockSkewSeconds) + 1 + _, refusal = f.executor.Execute(context.Background(), + envelopeAt("nd_test0001", "55555555-5555-4555-8555-555555555555", future), + request("GET", "/api/health", nil)) + if refusal == nil || refusal.Class != ClassExpired { + t.Fatalf("clock ahead: got %v, want %s", refusal, ClassExpired) + } +} + +func TestAuthPathsAreUnreachable(t *testing.T) { + f := newFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("the login endpoint must never be dialed on behalf of a phone") + })) + for index, path := range []string{"/api/auth/login", "/api/login", "/api/logout"} { + _, refusal := f.executor.Execute(context.Background(), + envelopeAt("nd_test0001", newReqID(index), uint64(f.now.Unix())), + request("POST", path, []byte(`{}`))) + if refusal == nil || refusal.Class != ClassPolicy { + t.Fatalf("%s: got %v, want %s", path, refusal, ClassPolicy) + } + } +} + +// The whole point of the intent: a relay that holds a confirmation for one write cannot spend it on +// another, even when the two have identical bodies. +func TestADangerousWriteNeedsAMatchingIntent(t *testing.T) { + calls := 0 + f := newFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.WriteHeader(200) + w.Write([]byte(`{"sent":true}`)) + })) + body := []byte(`{"phone_number":"+8613800000000","content":"hello"}`) + + if _, refusal := f.executor.Execute(context.Background(), + envelopeAt("nd_test0001", "77777777-7777-4777-8777-777777777777", uint64(f.now.Unix())), + request("POST", "/api/sms/send", body)); refusal == nil || refusal.Class != ClassNoConfirm { + t.Fatalf("without an intent: got %v, want %s", refusal, ClassNoConfirm) + } + + right, err := wire.IntentDigest("POST", "/api/sms/send", body) + if err != nil { + t.Fatal(err) + } + other, err := wire.IntentDigest("POST", "/api/sms/batch-delete", body) + if err != nil { + t.Fatal(err) + } + if _, refusal := f.executor.Execute(context.Background(), + envelopeAt("nd_test0001", "88888888-8888-4888-8888-888888888888", uint64(f.now.Unix())), + wire.InnerRequest{Method: "POST", Path: "/api/sms/send", Body: body, Intent: other}); refusal == nil || refusal.Class != ClassBadConfirm { + t.Fatalf("borrowed intent: got %v, want %s", refusal, ClassBadConfirm) + } + + // Lower-cased method and reordered keys must still match: the intent covers the meaning of the + // request, not the spelling the phone happened to use. + reordered := []byte(`{"content":"hello","phone_number":"+8613800000000"}`) + recomputed, err := wire.IntentDigest("post", "/api/sms/send", reordered) + if err != nil { + t.Fatal(err) + } + if string(recomputed) != string(right) { + t.Fatal("intent digest depends on formatting, which it must not") + } + + result, refusal := f.executor.Execute(context.Background(), + envelopeAt("nd_test0001", "99999999-9999-4999-8999-999999999999", uint64(f.now.Unix())), + wire.InnerRequest{Method: "POST", Path: "/api/sms/send", Body: body, Intent: right}) + if refusal != nil { + t.Fatalf("with a matching intent: %v", refusal) + } + if result.Status != 200 { + t.Fatalf("status %d", result.Status) + } + if calls != 1 { + t.Fatalf("upstream called %d times, want 1", calls) + } +} + +// Intent must bind the body too. A confirmation for "send to 13800000000" is not consent to send +// to 13800000001, and the digest is the only thing that says so. +func TestAnIntentDoesNotCoverAnEditedBody(t *testing.T) { + f := newFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("an edited body must not reach upstream") + })) + intent, err := wire.IntentDigest("POST", "/api/sms/send", []byte(`{"phone_number":"+8613800000000","content":"hi"}`)) + if err != nil { + t.Fatal(err) + } + _, refusal := f.executor.Execute(context.Background(), + envelopeAt("nd_test0001", "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", uint64(f.now.Unix())), + wire.InnerRequest{Method: "POST", Path: "/api/sms/send", + Body: []byte(`{"phone_number":"+8613800000001","content":"hi"}`), Intent: intent}) + if refusal == nil || refusal.Class != ClassBadConfirm { + t.Fatalf("got %v, want %s", refusal, ClassBadConfirm) + } +} + +func TestDangerousWritesAreRateLimited(t *testing.T) { + f := newFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + body := []byte(`{"phone_number":"+8613800000000","content":"hello"}`) + intent, err := wire.IntentDigest("POST", "/api/sms/send", body) + if err != nil { + t.Fatal(err) + } + limit := testPolicy(t).Limits.DangerousWritesPerMinute + allowed := 0 + for i := 0; i < limit+2; i++ { + inner := wire.InnerRequest{Method: "POST", Path: "/api/sms/send", Body: body, Intent: intent} + envelope := envelopeAt("nd_test0001", newReqID(i), uint64(f.now.Unix())) + if _, refusal := f.executor.Execute(context.Background(), envelope, inner); refusal == nil { + allowed++ + } else if refusal.Class != ClassRateLimited { + t.Fatalf("attempt %d refused as %s", i, refusal.Class) + } + } + if allowed != limit { + t.Fatalf("allowed %d dangerous writes, want exactly %d", allowed, limit) + } + // The window is fixed, not sliding: moving past it restores the full budget. + f.advance(time.Minute) + envelope := envelopeAt("nd_test0001", "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", uint64(f.now.Unix())) + if _, refusal := f.executor.Execute(context.Background(), envelope, + wire.InnerRequest{Method: "POST", Path: "/api/sms/send", Body: body, Intent: intent}); refusal != nil { + t.Fatalf("after the window: %v", refusal) + } +} + +func newReqID(i int) string { + return "cccccccc-cccc-4ccc-8ccc-" + strings.Repeat("0", 11) + strconv.Itoa(i) +} + +// An oversized answer is refused, not truncated. A truncated body is a broken body: JSON cut at an +// arbitrary offset parses as nothing, and the user would see a decoding error that blames the app +// for a node that answered with 3 MB. +func TestOversizedResponsesAreRefusedNotTruncated(t *testing.T) { + limit := testPolicy(t).Limits.MaxResponseBytes + f := newFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(strings.Repeat("x", limit+1024))) + })) + _, refusal := f.executor.Execute(context.Background(), + envelopeAt("nd_test0001", "dddddddd-dddd-4ddd-8ddd-dddddddddddd", uint64(f.now.Unix())), + request("GET", "/api/health", nil)) + if refusal == nil || refusal.Class != ClassTooLarge { + t.Fatalf("got %v, want %s", refusal, ClassTooLarge) + } +} + +// The boundary itself must pass: a response of exactly the limit is a normal response. +func TestAResponseExactlyAtTheLimitIsForwarded(t *testing.T) { + limit := testPolicy(t).Limits.MaxResponseBytes + f := newFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(strings.Repeat("x", limit))) + })) + result, refusal := f.executor.Execute(context.Background(), + envelopeAt("nd_test0001", "d1d1d1d1-d1d1-41d1-81d1-d1d1d1d1d1d1", uint64(f.now.Unix())), + request("GET", "/api/health", nil)) + if refusal != nil { + t.Fatalf("refused: %v", refusal) + } + if len(result.Body) != limit { + t.Fatalf("body is %d bytes, want %d", len(result.Body), limit) + } +} + +func TestAnUnreachableLocalServiceIsAnUpstreamError(t *testing.T) { + pol := testPolicy(t) + // A port with nothing on it: the loopback dial fails, which is a node problem rather than a + // policy problem, and the refusal class must say so. + executor, err := NewExecutor(pol, Config{Port: 1, Timeout: 500 * time.Millisecond, Audit: NullAudit()}) + if err != nil { + t.Fatal(err) + } + _, refusal := executor.Execute(context.Background(), + envelopeAt("nd_test0001", "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", uint64(time.Now().Unix())), + request("GET", "/api/health", nil)) + if refusal == nil || refusal.Class != ClassUpstream { + t.Fatalf("got %v, want %s", refusal, ClassUpstream) + } +} + +func TestReplayCacheBoundsAndTTL(t *testing.T) { + cache, err := NewReplayCache(3, time.Minute) + if err != nil { + t.Fatal(err) + } + base := time.Unix(1_800_000_000, 0) + for i := 0; i < 3; i++ { + if cache.Seen(strconv.Itoa(i), base) { + t.Fatalf("id %d reported as seen", i) + } + } + if !cache.Seen("0", base) { + t.Fatal("id 0 was evicted before the cache was full") + } + // Past the ttl every entry is gone, so the bound and the window are independent of each other. + later := base.Add(2 * time.Minute) + if cache.Len(later) != 0 { + t.Fatalf("cache holds %d entries past its ttl", cache.Len(later)) + } + if cache.Seen("0", later) { + t.Fatal("an expired id is still reported as seen") + } +} + +func TestFreshnessWindow(t *testing.T) { + if err := Freshness(1000, 1000, 120); err != nil { + t.Fatalf("same timestamp: %v", err) + } + if err := Freshness(1000, 1121, 120); !errors.Is(err, ErrExpired) { + t.Fatalf("121s old: %v", err) + } + // Symmetric: a request stamped in the future is as suspicious as an old one, and the window is + // a bound on |now - ts|, not on age. + if err := Freshness(1121, 1000, 120); !errors.Is(err, ErrExpired) { + t.Fatalf("121s ahead: %v", err) + } +} + +func TestAuditWritesOneJSONLinePerRequest(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "audit.log") + audit, err := OpenAudit(path) + if err != nil { + t.Fatal(err) + } + defer audit.Close() + stamp := time.Unix(1_800_000_000, 0).UTC() + if err := audit.Write(Entry{At: stamp, Node: "nd_1", Req: "r1", Method: "GET", Path: "/api/health", Status: 200, Outcome: "ok"}); err != nil { + t.Fatal(err) + } + if err := audit.Write(Entry{At: stamp, Req: "r2", Outcome: ClassPolicy}); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(raw)), "\n") + if len(lines) != 2 { + t.Fatalf("got %d lines, want 2", len(lines)) + } + var first Entry + if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { + t.Fatal(err) + } + if first.Node != "nd_1" || first.Status != 200 || !first.At.Equal(stamp) { + t.Fatalf("first entry round-tripped as %+v", first) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("audit file mode is %v, want 0600", info.Mode().Perm()) + } +} + +// A second run over the same log must append, not truncate: an agent restart is normal and must not +// erase the explanation for what happened before it. +func TestAuditAppendsAcrossRestarts(t *testing.T) { + path := filepath.Join(t.TempDir(), "audit.log") + first, err := OpenAudit(path) + if err != nil { + t.Fatal(err) + } + if err := first.Write(Entry{Req: "r1", Outcome: "ok"}); err != nil { + t.Fatal(err) + } + if err := first.Close(); err != nil { + t.Fatal(err) + } + second, err := OpenAudit(path) + if err != nil { + t.Fatal(err) + } + defer second.Close() + if err := second.Write(Entry{Req: "r2", Outcome: ClassReplay}); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if got := strings.Count(string(raw), "\n"); got != 2 { + t.Fatalf("log has %d lines, want 2", got) + } +} + +func TestNullAuditAcceptsWritesWithoutAFile(t *testing.T) { + audit := NullAudit() + if err := audit.Write(Entry{Req: "r"}); err != nil { + t.Fatalf("null audit returned %v", err) + } + if err := audit.Close(); err != nil { + t.Fatalf("null audit close returned %v", err) + } +} + +func TestExecutorRefusesToStartWithoutAPolicyOrPort(t *testing.T) { + if _, err := NewExecutor(nil, Config{Port: 8788}); err == nil { + t.Fatal("a nil policy was accepted") + } + if _, err := NewExecutor(testPolicy(t), Config{Port: 0}); err == nil { + t.Fatal("port 0 was accepted") + } +} diff --git a/internal/transport/transport.go b/internal/transport/transport.go new file mode 100644 index 0000000..7e165e3 --- /dev/null +++ b/internal/transport/transport.go @@ -0,0 +1,273 @@ +// Package transport is the agent's only outbound channel. +// +// It is long-polled HTTPS rather than a WebSocket, which is a deliberate regression in elegance for +// a gain in dependencies: the agent must build with nothing but the standard library so an operator +// can cross-compile it for an ARM node from any machine, and a WebSocket client is a few hundred +// lines of framing, masking, ping/pong and close handling that would then need its own tests. +// +// The direction is the security property. The agent dials out and the relay answers on the +// connection the agent opened, so the node has no listening socket and no routable name: there is +// nothing on the internal network for anyone to find. +package transport + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Paths on the relay. Versioned, because an agent and a relay will not be upgraded on the same day. +const ( + PathHello = "/v1/agent/hello" + PathPoll = "/v1/agent/poll" + PathResult = "/v1/agent/result" +) + +var ( + // ErrSessionExpired means the relay no longer recognises this session. The caller re-runs the + // handshake and polls again; it is not an error worth crashing over. + ErrSessionExpired = errors.New("transport: agent session expired") + // ErrUnauthorized means the relay rejected the bearer token. Retrying immediately is pointless, + // so the caller backs off. + ErrUnauthorized = errors.New("transport: relay rejected the agent credential") + // ErrNoWork is the long poll's empty result. It is not an error and carries no body. + ErrNoWork = errors.New("transport: no work") +) + +// Session is the state the handshake returns and the poll then presents. +type Session struct { + Token string `json:"token"` + ExpiresIn int `json:"expiresIn"` +} + +// HelloRequest is the agent's opening move. +type HelloRequest struct { + Node string `json:"node"` + // Challenge is the relay's nonce, signed with K_agent_sign. Possession of the bearer token is + // not enough to impersonate an agent: the token limits abuse, the signature proves identity. + Challenge string `json:"challenge"` + Signature []byte `json:"signature"` + AgentBuild string `json:"agentBuild,omitempty"` + PolicySHA string `json:"policySha256"` + ProtocolVer int `json:"protocolVersion"` +} + +// HelloResponse is what the relay answers with. +type HelloResponse struct { + Session Session `json:"session"` + Challenge string `json:"challenge,omitempty"` + PollWaitMs int `json:"pollWaitMs,omitempty"` +} + +// Batch is one long-poll result: zero or more request envelopes waiting for this node. +type Batch struct { + Envelopes []json.RawMessage `json:"envelopes"` +} + +// Client talks to one relay. +type Client struct { + baseURL string + nodeID string + token string + signer ed25519.PrivateKey + http *http.Client + userAgent string + maxBytes int64 + retryAfter time.Duration +} + +// Config builds a Client. +type Config struct { + BaseURL string + NodeID string + Token string + Signer ed25519.PrivateKey + Timeout time.Duration + UserAgent string + MaxBytes int64 +} + +// New validates the relay URL before any traffic is sent. +// +// HTTPS is required, and not negotiable. The relay is untrusted, which means the channel's job is to +// stop a third party from substituting a different ciphertext; an unauthenticated channel would make +// the endpoint-to-endpoint encryption above it decorative. +func New(cfg Config) (*Client, error) { + parsed, err := url.Parse(cfg.BaseURL) + if err != nil { + return nil, fmt.Errorf("transport: relay url: %w", err) + } + if parsed.Scheme != "https" { + return nil, fmt.Errorf("transport: relay url must be https, got %q", parsed.Scheme) + } + if parsed.Host == "" { + return nil, errors.New("transport: relay url has no host") + } + if parsed.Path != "" && parsed.Path != "/" { + return nil, fmt.Errorf("transport: relay url must not carry a path, got %q", parsed.Path) + } + if len(cfg.Signer) != ed25519.PrivateKeySize { + return nil, errors.New("transport: agent signing key is the wrong size") + } + timeout := cfg.Timeout + if timeout <= 0 { + timeout = 90 * time.Second + } + maxBytes := cfg.MaxBytes + if maxBytes <= 0 { + maxBytes = 1 << 20 + } + return &Client{ + baseURL: strings.TrimRight(cfg.BaseURL, "/"), + nodeID: cfg.NodeID, + token: cfg.Token, + signer: cfg.Signer, + userAgent: cfg.UserAgent, + maxBytes: maxBytes, + http: &http.Client{Timeout: timeout}, + }, nil +} + +// Hello performs the challenge-response handshake and stores the session token. +// +// It is called again on expiry, so it must be safe to repeat: no state is carried in beyond the +// signing key and the token. +func (c *Client) Hello(ctx context.Context, build, policySHA string) (HelloResponse, error) { + var out HelloResponse + // The first call has no challenge to sign, so the relay is asked for one; the second completes + // it. Two round trips on startup is an acceptable price for not having the relay mint a nonce + // the agent never saw. + err := c.post(ctx, PathHello, HelloRequest{Node: c.nodeID, PolicySHA: policySHA, AgentBuild: build}, &out, "", false) + if err != nil || out.Challenge == "" { + return out, err + } + signed := HelloRequest{ + Node: c.nodeID, + Challenge: out.Challenge, + Signature: ed25519.Sign(c.signer, challengeBytes(c.nodeID, out.Challenge)), + AgentBuild: build, + PolicySHA: policySHA, + ProtocolVer: 1, + } + var completed HelloResponse + if err := c.post(ctx, PathHello, signed, &completed, "", false); err != nil { + return completed, err + } + c.token = completed.Session.Token + return completed, nil +} + +// challengeBytes is what the signature covers. Binding the node id in means a signature collected +// from one agent's handshake cannot be replayed by another. +func challengeBytes(nodeID, challenge string) []byte { + return []byte("msa-relay-v1-hello\x00" + nodeID + "\x00" + challenge) +} + +// Poll waits for work. A nil error with an empty batch means the long poll timed out with nothing, +// which is the normal case and must not be treated as a failure. +func (c *Client) Poll(ctx context.Context) (Batch, error) { + var batch Batch + // A long poll that times out answers with an empty body, and that is the normal case rather than + // a failure, so an empty body means ErrNoWork here and only here. + err := c.post(ctx, PathPoll, map[string]string{"node": c.nodeID}, &batch, c.session(), true) + if err != nil { + return Batch{}, err + } + return batch, nil +} + +// Result posts one response envelope back, correlated by request id. +// +// An empty answer here is an acknowledgement, not "no work". The endpoints share a transport and +// must not share that reading of an empty body, or a confirmed delivery would be treated as a +// reason to back off. +func (c *Client) Result(ctx context.Context, envelope json.RawMessage) error { + return c.post(ctx, PathResult, envelope, &json.RawMessage{}, c.session(), false) +} + +func (c *Client) session() string { return c.token } + +// post is the single place a relay request is made, so authentication, size limits, status mapping +// and body draining cannot drift between the three endpoints. +// +// emptyIsNoWork is per-endpoint on purpose: folding "no body" into one meaning across all three +// endpoints would make an acknowledgement indistinguishable from an idle poll. +func (c *Client) post(ctx context.Context, path string, body any, out any, session string, emptyIsNoWork bool) error { + encoded, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("transport: encode: %w", err) + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(encoded)) + if err != nil { + return fmt.Errorf("transport: request: %w", err) + } + request.Header.Set("Content-Type", "application/json") + // The bearer token grants the right to be rate limited, nothing more: it authorises no command, + // because commands are authorised by the phone's signature inside the envelope. + request.Header.Set("Authorization", "Bearer "+c.token) + if session != "" { + request.Header.Set("X-MSA-Session", session) + } + if c.userAgent != "" { + request.Header.Set("User-Agent", c.userAgent) + } + response, err := c.http.Do(request) + if err != nil { + return fmt.Errorf("transport: %s: %w", path, err) + } + defer func() { + io.Copy(io.Discard, response.Body) + response.Body.Close() + }() + + switch response.StatusCode { + case http.StatusOK: + case http.StatusUnauthorized: + return ErrUnauthorized + case http.StatusNoContent, http.StatusRequestTimeout: + // 204 and 408 are how the relay says "nothing yet", so the agent loops straight back. + return ErrNoWork + case http.StatusTooManyRequests: + return fmt.Errorf("%w: retry after %s", ErrRateLimited, response.Header.Get("Retry-After")) + default: + return fmt.Errorf("transport: %s: relay returned %d", path, response.StatusCode) + } + // The response is bounded before it is decoded: an unbounded read is a remote memory decision. + limited, err := io.ReadAll(io.LimitReader(response.Body, c.maxBytes+1)) + if err != nil { + return fmt.Errorf("transport: read: %w", err) + } + if int64(len(limited)) > c.maxBytes { + return fmt.Errorf("transport: relay response exceeds %d bytes", c.maxBytes) + } + if len(bytes.TrimSpace(limited)) == 0 { + if emptyIsNoWork { + return ErrNoWork + } + return nil + } + if err := json.Unmarshal(limited, out); err != nil { + return fmt.Errorf("transport: decode: %w", err) + } + return nil +} + +// ErrRateLimited is returned when the relay asks the agent to slow down. +var ErrRateLimited = errors.New("transport: rate limited by the relay") + +// RetryAfter reports how long the agent should wait before its next poll, honouring the relay's +// Retry-After when it gave one. +func (c *Client) RetryAfter() time.Duration { + if c.retryAfter <= 0 { + return 2 * time.Second + } + return c.retryAfter +} diff --git a/internal/transport/transport_test.go b/internal/transport/transport_test.go new file mode 100644 index 0000000..98b33ad --- /dev/null +++ b/internal/transport/transport_test.go @@ -0,0 +1,215 @@ +package transport + +import ( + "context" + "crypto/ed25519" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// The relay is represented by a real local HTTP server in these tests, so the TLS requirement is +// satisfied by rewriting the URL rather than by weakening the check: what is under test is the +// handshake and the status mapping, not the certificate. +func newClient(t *testing.T, handler http.Handler, token string) (*Client, *httptest.Server) { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + _, signer, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + client, err := New(Config{ + BaseURL: strings.Replace(server.URL, "http://", "https://", 1), + NodeID: "nd_test0001", + Token: token, + Signer: signer, + Timeout: 2 * time.Second, + UserAgent: "msa-agent/test", + }) + if err != nil { + t.Fatal(err) + } + // Point the client at the plain-HTTP test server. Only the scheme check is bypassed here; every + // other behaviour is the production one. + client.http.Transport = server.Client().Transport + client.baseURL = server.URL + return client, server +} + +func TestNewRejectsAnythingButAnHTTPSRelay(t *testing.T) { + _, signer, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + for _, base := range []string{ + "http://relay.example", + "ws://relay.example", + "https://relay.example/some/path", + "https://", + "not a url", + } { + if _, err := New(Config{BaseURL: base, NodeID: "nd_x", Token: "t", Signer: signer}); err == nil { + t.Fatalf("accepted relay url %q", base) + } + } + if _, err := New(Config{BaseURL: "https://relay.example", NodeID: "nd_x", Token: "t", Signer: signer}); err != nil { + t.Fatalf("rejected a valid relay url: %v", err) + } + if _, err := New(Config{BaseURL: "https://relay.example", NodeID: "nd_x", Token: "t"}); err == nil { + t.Fatal("accepted a missing signing key") + } +} + +// The handshake is challenge-response, so a stolen bearer token alone is not enough to impersonate +// an agent. This test is the one that would fail if someone replaced the signature with the token. +func TestHelloSignsTheChallengeBoundToTheNodeID(t *testing.T) { + var ( + requests []HelloRequest + issued = "challenge-abc" + ) + client, _ := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "" { + t.Error("handshake was sent without a bearer token") + } + var body HelloRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + requests = append(requests, body) + out := HelloResponse{} + if body.Challenge == "" { + out.Challenge = issued + } else { + out.Session = Session{Token: "session-token", ExpiresIn: 3600} + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(out) + }), "bootstrap-token") + + response, err := client.Hello(context.Background(), "test-build", "deadbeef") + if err != nil { + t.Fatalf("Hello: %v", err) + } + if response.Session.Token != "session-token" { + t.Fatalf("session token is %q", response.Session.Token) + } + if len(requests) != 2 { + t.Fatalf("handshake took %d round trips, want 2", len(requests)) + } + if requests[0].Challenge != "" { + t.Fatal("the first handshake request must not carry a challenge") + } + if requests[1].Challenge != issued { + t.Fatalf("the agent echoed challenge %q, want %q", requests[1].Challenge, issued) + } + public := client.signer.Public().(ed25519.PublicKey) + if !ed25519.Verify(public, challengeBytes("nd_test0001", issued), requests[1].Signature) { + t.Fatal("the challenge signature does not verify") + } + // Bound to the node id: the same signature must not verify under a different node. + if ed25519.Verify(public, challengeBytes("nd_other001", issued), requests[1].Signature) { + t.Fatal("the challenge signature is not bound to the node id") + } +} + +func TestStatusMapping(t *testing.T) { + cases := []struct { + status int + want error + }{ + {http.StatusUnauthorized, ErrUnauthorized}, + {http.StatusNoContent, ErrNoWork}, + {http.StatusRequestTimeout, ErrNoWork}, + {http.StatusTooManyRequests, ErrRateLimited}, + } + for _, c := range cases { + client, _ := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(c.status) + }), "t") + _, err := client.Poll(context.Background()) + if err == nil || !errors.Is(err, c.want) { + t.Fatalf("status %d: got %v, want %v", c.status, err, c.want) + } + } +} + +func TestPollReturnsTheEnvelopesTheRelayIsHolding(t *testing.T) { + want := []string{`{"v":1,"node":"nd_test0001"}`, `{"v":1,"node":"nd_test0001"}`} + client, _ := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != PathPoll { + t.Fatalf("polled %q", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(Batch{Envelopes: []json.RawMessage{ + json.RawMessage(want[0]), json.RawMessage(want[1]), + }}) + }), "t") + batch, err := client.Poll(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(batch.Envelopes) != 2 { + t.Fatalf("got %d envelopes, want 2", len(batch.Envelopes)) + } + for i := range want { + if string(batch.Envelopes[i]) != want[i] { + t.Fatalf("envelope %d is %s", i, batch.Envelopes[i]) + } + } +} + +// An empty relay answer is the common case for a long poll, and must not be an error or the agent +// would back off every time nothing happened. +func TestAnEmptyPollIsNotAnError(t *testing.T) { + client, _ := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }), "t") + if _, err := client.Poll(context.Background()); !errors.Is(err, ErrNoWork) { + t.Fatalf("got %v, want %v", err, ErrNoWork) + } +} + +func TestResponsesAreBoundedBeforeDecoding(t *testing.T) { + client, _ := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(strings.Repeat("x", 4096))) + }), "t") + client.maxBytes = 1024 + if _, err := client.Poll(context.Background()); err == nil { + t.Fatal("an oversized relay response was accepted") + } +} + +func TestResultPostsTheEnvelopeVerbatim(t *testing.T) { + var got json.RawMessage + var path string + client, _ := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + got, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + }), "t") + envelope := json.RawMessage(`{"v":1,"node":"nd_test0001","p":"AAAA"}`) + if err := client.Result(context.Background(), envelope); err != nil { + t.Fatal(err) + } + if path != PathResult { + t.Fatalf("posted to %q", path) + } + if string(got) != string(envelope) { + t.Fatalf("relay received %s, want %s", got, envelope) + } +} + +func TestRetryAfterHasASaneDefault(t *testing.T) { + client, _ := newClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }), "t") + if got := client.RetryAfter(); got <= 0 || got > 10*time.Second { + t.Fatalf("RetryAfter() = %v", got) + } +} diff --git a/internal/wire/ephemeral.go b/internal/wire/ephemeral.go new file mode 100644 index 0000000..fbd6c7c --- /dev/null +++ b/internal/wire/ephemeral.go @@ -0,0 +1,65 @@ +package wire + +import ( + "crypto/ecdh" + "crypto/rand" + "errors" + "fmt" +) + +// Ephemeral is a one-use X25519 key pair. +// +// One use, enforced by not exposing the private half after the exchange: the caller gets the derived +// key material, never the key, so there is nothing to reuse by accident later. +type Ephemeral struct { + private []byte + public []byte +} + +// ErrEphemeralExhausted is returned when the system RNG cannot supply bytes. It is fatal for the +// request and never retried: a PRNG failure means the process cannot be trusted to produce secrets. +var ErrEphemeralExhausted = errors.New("wire: cannot read random bytes for an ephemeral key") + +// NewEphemeral generates a key pair for exactly one exchange. +func NewEphemeral() (Ephemeral, error) { + private := make([]byte, KeyBytes) + if _, err := rand.Read(private); err != nil { + return Ephemeral{}, ErrEphemeralExhausted + } + public, err := PublicFrom(private) + if err != nil { + return Ephemeral{}, err + } + return Ephemeral{private: private, public: public}, nil +} + +// PublicFrom derives the X25519 public key for a private key. +// +// Exported because both sides of a test need it, and because a caller that already holds a private +// key should not have to build a throwaway Ephemeral just to learn its own public half. +func PublicFrom(private []byte) ([]byte, error) { + key, err := ecdh.X25519().NewPrivateKey(private) + if err != nil { + return nil, fmt.Errorf("%w: private key rejected: %v", ErrBadKey, err) + } + return key.PublicKey().Bytes(), nil +} + +// Public is what goes in the envelope's ek field, signed along with everything else. +func (e Ephemeral) Public() []byte { return e.public } + +// Exchange performs X25519 against a peer public key and discards the private half. +// +// Zeroing is not a substitute for a proper memory barrier and does not defend against a process +// dump, but it costs one line and removes the private key from the heap at the earliest point the +// protocol no longer needs it. +func (e Ephemeral) Exchange(peerPublic []byte) ([]byte, error) { + shared, err := SharedSecret(e.private, peerPublic) + if err != nil { + return nil, err + } + for i := range e.private { + e.private[i] = 0 + } + return shared, nil +} diff --git a/internal/wire/json.go b/internal/wire/json.go new file mode 100644 index 0000000..3864148 --- /dev/null +++ b/internal/wire/json.go @@ -0,0 +1,145 @@ +package wire + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "regexp" + "sort" +) + +// marshal is encoding/json with HTML escaping off. The default escapes <, > and &, which would make +// a body containing those characters hash differently here than it does in Node or Swift. +func marshal(value any) ([]byte, error) { + buf := bytes.NewBuffer(nil) + encoder := json.NewEncoder(buf) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(value); err != nil { + return nil, err + } + return bytes.TrimRight(buf.Bytes(), "\n"), nil +} + +func unmarshal(raw []byte, value any) error { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(value); err != nil { + return err + } + // Trailing content would mean the caller is looking at a different document than the one that + // was decoded, which for a signed field is a correctness bug rather than a style choice. + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + return errors.New("wire: trailing data after JSON value") + } + return nil +} + +// CanonicalJSON renders a JSON value in the one form this protocol hashes. +// +// The rules are deliberately small, and every implementation (Go agent, Node relay, Swift app) +// must follow exactly these: +// +// 1. Object keys are sorted by UTF-8 byte order and emitted without whitespace. +// 2. Arrays keep their order. +// 3. Strings use the same escapes as Go's encoder with HTML escaping disabled: \" \\ \n \r \t, +// \u00XX for other control characters, and literal UTF-8 for everything else. +// 4. Numbers must be integers in canonical form: an optional minus, no leading zero, no fraction, +// no exponent, no plus. This is stricter than JSON allows and on purpose. Any wider grammar has +// values that are the same number but different bytes (1 and 1.0, 1e2 and 100), and "which +// spelling did the phone hash" is not a question this protocol is willing to leave open. The +// APIs behind it take integers, strings, booleans, arrays and objects, so nothing is lost. +// 5. null, true and false are literal. +// +// An empty or absent body canonicalises to the empty byte string, not to the four characters +// "null", so "no body" and "empty body" cannot be confused. +func CanonicalJSON(raw []byte) ([]byte, error) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 { + return nil, nil + } + decoder := json.NewDecoder(bytes.NewReader(trimmed)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return nil, fmt.Errorf("wire: body is not valid JSON: %w", err) + } + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + return nil, errors.New("wire: body has trailing data after the JSON value") + } + buf := bytes.NewBuffer(make([]byte, 0, len(trimmed))) + if err := writeCanonical(buf, value); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// canonicalNumber is the only number spelling this protocol will hash. See rule 4 above. +// +// Negative zero is excluded even though it is an integer: no implementation can be relied on to +// round-trip "-0" as text rather than as the number 0, and nothing in these payloads needs it. +var canonicalNumber = regexp.MustCompile(`^-?[1-9][0-9]*$|^-?[1-9][0-9]*$|^0$`) + +func writeCanonical(buf *bytes.Buffer, value any) error { + switch typed := value.(type) { + case nil: + buf.WriteString("null") + case bool: + if typed { + buf.WriteString("true") + } else { + buf.WriteString("false") + } + case json.Number: + literal := typed.String() + if !canonicalNumber.MatchString(literal) || literal == "-0" { + return fmt.Errorf("wire: number %q is not a canonical integer", literal) + } + buf.WriteString(literal) + case string: + encoded, err := marshal(typed) + if err != nil { + return err + } + buf.Write(encoded) + case []any: + buf.WriteByte('[') + for index, item := range typed { + if index > 0 { + buf.WriteByte(',') + } + if err := writeCanonical(buf, item); err != nil { + return err + } + } + buf.WriteByte(']') + case map[string]any: + keys := make([]string, 0, len(typed)) + for key := range typed { + keys = append(keys, key) + } + sort.Strings(keys) + buf.WriteByte('{') + for index, key := range keys { + if index > 0 { + buf.WriteByte(',') + } + encodedKey, err := marshal(key) + if err != nil { + return err + } + buf.Write(encodedKey) + buf.WriteByte(':') + if err := writeCanonical(buf, typed[key]); err != nil { + return err + } + } + buf.WriteByte('}') + default: + return fmt.Errorf("wire: cannot canonicalise %T", value) + } + return nil +} diff --git a/internal/wire/wire.go b/internal/wire/wire.go new file mode 100644 index 0000000..6917a0b --- /dev/null +++ b/internal/wire/wire.go @@ -0,0 +1,540 @@ +// Package wire implements the relay envelope: what is signed, what is encrypted, and in what order. +// +// The relay is not trusted, so signing and encryption are separate concerns on purpose. Ed25519 +// signs a canonical byte encoding of the envelope's routing fields plus the ciphertext, which is +// what stops a compromised relay from retargeting a request at a different node, replaying it under +// a new id, or substituting ek. AES-256-GCM then carries the payload under keys from an +// ephemeral-static X25519 exchange. +// +// The signature is over a canonical encoding rather than over JSON text. Three languages have to +// agree on the exact bytes here, and "sign the JSON" is where cross-language signature bugs live: +// key order, number formatting, and Unicode escaping all differ between Swift, Node and Go. +package wire + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/ed25519" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + "strings" +) + +// Version is envelope.v. A change here is a new constant, never a widened tolerance, so an older +// agent cannot be talked into accepting a format it does not fully understand. +const Version uint64 = 1 + +// Suite is envelope.alg. Exactly one suite exists. An unknown value is a hard error rather than a +// negotiation, because negotiation is where downgrade bugs come from. +const Suite = "X25519-ED25519-AES256GCM-HKDFSHA256" + +// Domain separation. A request signature must never be replayable as a response signature, and a +// signature from this protocol must never be replayable in another protocol that reuses the key. +const ( + DomainRequest = "msa-relay-v1-req" + DomainResponse = "msa-relay-v1-resp" + + infoKeyRequest = "msa-relay-v1-req" + infoKeyResponse = "msa-relay-v1-resp" + infoIVRequest = "iv-req" + infoIVResponse = "iv-resp" + hkdfSaltLabel = "msa-relay-v1" +) + +const ( + KeyBytes = 32 + PublicBytes = 32 + IVBytes = 12 + TagBytes = 16 +) + +var ( + ErrSignature = errors.New("wire: signature does not verify") + ErrVersion = errors.New("wire: unsupported envelope version") + ErrSuite = errors.New("wire: unsupported cipher suite") + ErrBadKey = errors.New("wire: key must be 32 bytes") + ErrBadIV = errors.New("wire: iv must be 12 bytes") + ErrBadPublicKey = errors.New("wire: public key must be 32 bytes") + ErrOpen = errors.New("wire: ciphertext failed to authenticate") + ErrIdentityField = errors.New("wire: envelope is missing node, request id or suite") + ErrPeerKey = errors.New("wire: peer public key is unusable") + ErrDomain = errors.New("wire: unknown signing domain") + ErrIntent = errors.New("wire: confirmation intent does not match the request") +) + +// Envelope is the outer, relay-visible structure. The relay routes on Node and correlates on Req, +// and can read nothing else: Ek is a public key, P is ciphertext, S covers everything. +type Envelope struct { + V uint64 `json:"v"` + Node string `json:"node"` + Req string `json:"req"` + App string `json:"app"` + TS uint64 `json:"ts"` + EK []byte `json:"ek"` + Alg string `json:"alg"` + P []byte `json:"p"` + S []byte `json:"s,omitempty"` +} + +// MarshalJSON encodes the byte fields as base64, matching the schema the relay validates against. +func (e Envelope) MarshalJSON() ([]byte, error) { + type alias struct { + V uint64 `json:"v"` + Node string `json:"node"` + Req string `json:"req"` + App string `json:"app,omitempty"` + TS uint64 `json:"ts"` + EK string `json:"ek"` + Alg string `json:"alg"` + P string `json:"p"` + S string `json:"s,omitempty"` + } + raw := alias{ + V: e.V, Node: e.Node, Req: e.Req, App: e.App, TS: e.TS, Alg: e.Alg, + EK: base64.StdEncoding.EncodeToString(e.EK), + P: base64.StdEncoding.EncodeToString(e.P), + } + if len(e.S) > 0 { + raw.S = base64.StdEncoding.EncodeToString(e.S) + } + return marshal(raw) +} + +// UnmarshalJSON decodes base64 with strict padding, because Go's default decoder silently accepts +// some non-canonical forms and a signature must be computed over exactly the bytes the sender used. +func (e *Envelope) UnmarshalJSON(raw []byte) error { + type alias struct { + V uint64 `json:"v"` + Node string `json:"node"` + Req string `json:"req"` + App string `json:"app"` + TS uint64 `json:"ts"` + EK string `json:"ek"` + Alg string `json:"alg"` + P string `json:"p"` + S string `json:"s"` + } + var decoded alias + if err := unmarshal(raw, &decoded); err != nil { + return err + } + var err error + if e.EK, err = decodeBase64(decoded.EK); err != nil { + return fmt.Errorf("wire: ek: %w", err) + } + if e.P, err = decodeBase64(decoded.P); err != nil { + return fmt.Errorf("wire: p: %w", err) + } + if e.S, err = decodeBase64(decoded.S); err != nil { + return fmt.Errorf("wire: s: %w", err) + } + e.V, e.Node, e.Req, e.App, e.TS, e.Alg = decoded.V, decoded.Node, decoded.Req, decoded.App, decoded.TS, decoded.Alg + return nil +} + +func decodeBase64(value string) ([]byte, error) { + if value == "" { + return nil, nil + } + raw, err := base64.StdEncoding.Strict().DecodeString(value) + if err != nil { + return nil, err + } + return raw, nil +} + +func encodeBase64(value []byte) string { + if len(value) == 0 { + return "" + } + return base64.StdEncoding.EncodeToString(value) +} + +// InnerRequest is the decrypted request payload. There is no separate nonce: Req is a uuid4, it is +// covered by the signature, and it is recorded in the replay cache, so reusing it requires forging +// a signature. +type InnerRequest struct { + Method string `json:"m"` + Path string `json:"p"` + // Intent is sha256 over the method, path and canonical body. It is produced by the phone as the + // result of a user gesture. The agent recomputes it and refuses a dangerous write that does not + // match, so a relay that fabricates a request cannot manufacture the user's consent. + Intent []byte `json:"intent,omitempty"` + Body []byte `json:"body,omitempty"` +} + +// MarshalJSON encodes the byte fields as base64, the same convention as the outer envelope. +// +// Go would base64 a []byte on its own, but the decode side still has to be written, and keeping both +// directions together is what stops one of them from being forgotten when a field is added. +func (r InnerRequest) MarshalJSON() ([]byte, error) { + type alias struct { + Method string `json:"m"` + Path string `json:"p"` + Intent string `json:"intent,omitempty"` + Body string `json:"body,omitempty"` + } + return marshal(alias{ + Method: r.Method, Path: r.Path, + Intent: encodeBase64(r.Intent), Body: encodeBase64(r.Body), + }) +} + +func (r *InnerRequest) UnmarshalJSON(raw []byte) error { + type alias struct { + Method string `json:"m"` + Path string `json:"p"` + Intent string `json:"intent,omitempty"` + Body string `json:"body,omitempty"` + } + var decoded alias + if err := unmarshal(raw, &decoded); err != nil { + return err + } + var err error + if r.Intent, err = decodeBase64(decoded.Intent); err != nil { + return fmt.Errorf("wire: intent: %w", err) + } + if r.Body, err = decodeBase64(decoded.Body); err != nil { + return fmt.Errorf("wire: body: %w", err) + } + r.Method, r.Path = decoded.Method, decoded.Path + return nil +} + +// InnerResponse is the decrypted response payload. Status lives inside the ciphertext so the relay +// cannot rewrite a failure into a success. +type InnerResponse struct { + Status int `json:"status"` + // Outcome is the agent's own classification of what happened. It is inside the ciphertext for + // the same reason as Status: the relay is not entitled to know why a command failed, and the + // phone needs to tell the user what to do about it. + Outcome string `json:"outcome,omitempty"` + Body []byte `json:"body,omitempty"` +} + +// MarshalJSON encodes the body as base64, matching InnerRequest. +func (r InnerResponse) MarshalJSON() ([]byte, error) { + type alias struct { + Status int `json:"status"` + Outcome string `json:"outcome,omitempty"` + Body string `json:"body,omitempty"` + } + return marshal(alias{Status: r.Status, Outcome: r.Outcome, Body: encodeBase64(r.Body)}) +} + +func (r *InnerResponse) UnmarshalJSON(raw []byte) error { + type alias struct { + Status int `json:"status"` + Outcome string `json:"outcome,omitempty"` + Body string `json:"body,omitempty"` + } + var decoded alias + if err := unmarshal(raw, &decoded); err != nil { + return err + } + body, err := decodeBase64(decoded.Body) + if err != nil { + return fmt.Errorf("wire: response body: %w", err) + } + r.Status, r.Outcome, r.Body = decoded.Status, decoded.Outcome, body + return nil +} + +// MarshalInner renders an InnerResponse. It exists as a named function rather than a bare +// json.Marshal call so every caller gets the same encoder, which here means the same treatment of +// HTML characters as the rest of the protocol. +func MarshalInner(inner InnerResponse) ([]byte, error) { return marshal(inner) } + +// UnmarshalInner decodes an InnerRequest, rejecting trailing data. +func UnmarshalInner(raw []byte) (InnerRequest, error) { + var inner InnerRequest + if err := unmarshal(raw, &inner); err != nil { + return InnerRequest{}, err + } + return inner, nil +} + +// CanonicalRequest is the exact byte string K_app_sign signs and the agent verifies. +// +// Layout: domain bytes, one NUL, then for each field in fixed order a uint32be length followed by +// that many bytes. v and ts contribute their uint64be encoding, so their length is always 8 and +// there is no decimal formatting to disagree about. ek and p contribute raw decoded bytes, never +// their base64 text, so a relay cannot re-encode them differently. +func CanonicalRequest(e Envelope) ([]byte, error) { + return canonical(e, DomainRequest) +} + +// CanonicalResponse is the counterpart for agent-to-app replies. It covers the same routing fields +// so the relay cannot swap responses between in-flight requests for the same node. +func CanonicalResponse(e Envelope) ([]byte, error) { + return canonical(e, DomainResponse) +} + +func canonical(e Envelope, domain string) ([]byte, error) { + if domain != DomainRequest && domain != DomainResponse { + return nil, fmt.Errorf("%w: %q", ErrDomain, domain) + } + if strings.TrimSpace(e.Node) == "" || strings.TrimSpace(e.Req) == "" || strings.TrimSpace(e.Alg) == "" { + return nil, ErrIdentityField + } + if e.Alg != Suite { + return nil, fmt.Errorf("%w: %q", ErrSuite, e.Alg) + } + if len(e.EK) != PublicBytes { + return nil, fmt.Errorf("%w: got %d bytes", ErrBadPublicKey, len(e.EK)) + } + buf := bytes.NewBuffer(make([]byte, 0, 256)) + buf.WriteString(domain) + buf.WriteByte(0) + writeUint64(buf, e.V) + writeBytes(buf, []byte(e.Node)) + writeBytes(buf, []byte(e.Req)) + // The app id is only meaningful on requests: it lets a node operator attribute traffic to a + // paired device, and it is signed so a relay cannot relabel one phone's request as another's. + if domain == DomainRequest { + writeBytes(buf, []byte(e.App)) + } + writeUint64(buf, e.TS) + writeBytes(buf, e.EK) + writeBytes(buf, e.P) + return buf.Bytes(), nil +} + +func writeUint64(buf *bytes.Buffer, value uint64) { + var raw [8]byte + binary.BigEndian.PutUint64(raw[:], value) + writeBytes(buf, raw[:]) +} + +func writeBytes(buf *bytes.Buffer, value []byte) { + var length [4]byte + binary.BigEndian.PutUint32(length[:], uint32(len(value))) + buf.Write(length[:]) + buf.Write(value) +} + +// SignRequest attaches K_app_sign's signature over the canonical encoding. +func SignRequest(envelope *Envelope, private ed25519.PrivateKey) error { + if len(private) != ed25519.PrivateKeySize { + return errors.New("wire: private signing key has the wrong size") + } + canonical, err := CanonicalRequest(*envelope) + if err != nil { + return err + } + envelope.S = ed25519.Sign(private, canonical) + return nil +} + +// SignResponse is SignRequest for the agent's own signing key. +func SignResponse(envelope *Envelope, private ed25519.PrivateKey) error { + if len(private) != ed25519.PrivateKeySize { + return errors.New("wire: private signing key has the wrong size") + } + canonical, err := CanonicalResponse(*envelope) + if err != nil { + return err + } + envelope.S = ed25519.Sign(private, canonical) + return nil +} + +// VerifyRequest is what the agent calls before doing anything else with a request. Version and +// suite are checked here rather than at the caller so no code path can verify a signature and then +// act on a format it did not intend to accept. +func VerifyRequest(envelope Envelope, peer ed25519.PublicKey) error { + if envelope.V != Version { + return fmt.Errorf("%w: %d", ErrVersion, envelope.V) + } + if envelope.Alg != Suite { + return fmt.Errorf("%w: %q", ErrSuite, envelope.Alg) + } + if len(peer) != ed25519.PublicKeySize { + return fmt.Errorf("%w: got %d bytes", ErrBadPublicKey, len(peer)) + } + canonical, err := CanonicalRequest(envelope) + if err != nil { + return err + } + if len(envelope.S) != ed25519.SignatureSize || !ed25519.Verify(peer, canonical, envelope.S) { + return ErrSignature + } + return nil +} + +// VerifyResponse is the phone's side of the same check. +func VerifyResponse(envelope Envelope, peer ed25519.PublicKey) error { + if envelope.V != Version { + return fmt.Errorf("%w: %d", ErrVersion, envelope.V) + } + if envelope.Alg != Suite { + return fmt.Errorf("%w: %q", ErrSuite, envelope.Alg) + } + if len(peer) != ed25519.PublicKeySize { + return fmt.Errorf("%w: got %d bytes", ErrBadPublicKey, len(peer)) + } + canonical, err := CanonicalResponse(envelope) + if err != nil { + return err + } + if len(envelope.S) != ed25519.SignatureSize || !ed25519.Verify(peer, canonical, envelope.S) { + return ErrSignature + } + return nil +} + +// HKDF-SHA256, extract then expand. Implemented here rather than pulled in as a dependency: the +// agent must build with nothing but the standard library so an operator can cross-compile it for an +// ARM node from any machine, and the surface is thirty lines. +func HKDF(secret, salt, info []byte, length int) ([]byte, error) { + if length <= 0 || length > 255*sha256.Size { + return nil, fmt.Errorf("wire: hkdf length %d out of range", length) + } + extractor := hmac.New(sha256.New, salt) + extractor.Write(secret) + prk := extractor.Sum(nil) + + out := make([]byte, 0, length) + var block []byte + for counter := byte(1); len(out) < length; counter++ { + mac := hmac.New(sha256.New, prk) + mac.Write(block) + mac.Write(info) + mac.Write([]byte{counter}) + block = mac.Sum(nil) + out = append(out, block...) + } + return out[:length], nil +} + +// SharedSecret performs X25519. crypto/ecdh rejects the all-zero output, which is what catches a +// peer key chosen to force a known shared secret. +func SharedSecret(privateKey, peerPublic []byte) ([]byte, error) { + if len(privateKey) != KeyBytes { + return nil, fmt.Errorf("%w: got %d bytes", ErrBadKey, len(privateKey)) + } + if len(peerPublic) != PublicBytes { + return nil, fmt.Errorf("%w: got %d bytes", ErrBadPublicKey, len(peerPublic)) + } + curve := ecdh.X25519() + private, err := curve.NewPrivateKey(privateKey) + if err != nil { + return nil, fmt.Errorf("wire: private key rejected: %w", err) + } + peer, err := curve.NewPublicKey(peerPublic) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrPeerKey, err) + } + shared, err := private.ECDH(peer) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrPeerKey, err) + } + return shared, nil +} + +// DeriveKey returns the AEAD key and IV for one direction of one request. +// +// The salt is the node id behind a fixed label, so a shared secret that somehow repeats across +// nodes still yields different keys. The IV is derived from the request id and the direction rather +// than being a fixed zero block: a fixed IV is only safe while every message uses a fresh key, and +// that invariant is exactly what a later "reuse the session key for a batch" optimisation would +// quietly break. Separating the IV by direction as well means a request and a response that somehow +// share a secret still cannot share a nonce. +func DeriveKey(shared []byte, nodeID, requestID string, response bool) (key, iv []byte, err error) { + if len(shared) != KeyBytes { + return nil, nil, fmt.Errorf("%w: got %d bytes", ErrBadKey, len(shared)) + } + keyInfo := infoKeyRequest + ivInfo := infoIVRequest + if response { + keyInfo = infoKeyResponse + ivInfo = infoIVResponse + } + // The request id participates in the key as well as the IV. A shared secret is nominally used + // once, but binding the key to the request keeps that true even if a caller ever reuses one. + salt := []byte(hkdfSaltLabel + ":" + nodeID + ":" + requestID) + key, err = HKDF(shared, salt, []byte(keyInfo), KeyBytes) + if err != nil { + return nil, nil, err + } + iv, err = HKDF(shared, []byte(requestID), []byte(ivInfo), IVBytes) + if err != nil { + return nil, nil, err + } + return key, iv, nil +} + +// Seal encrypts with AES-256-GCM. The tag is appended to the ciphertext, matching Node's +// cipher.getAuthTag() concatenation and CryptoKit's combined sealed box. +func Seal(key, iv, plaintext []byte) ([]byte, error) { + gcm, err := aead(key, iv) + if err != nil { + return nil, err + } + return gcm.Seal(nil, iv, plaintext, nil), nil +} + +// Open decrypts and authenticates. A failure is terminal for the request, not a retry: a wrong key, +// a torn payload and a forged payload are indistinguishable here, and all three mean do not act. +func Open(key, iv, ciphertext []byte) ([]byte, error) { + gcm, err := aead(key, iv) + if err != nil { + return nil, err + } + if len(ciphertext) < TagBytes { + return nil, ErrOpen + } + plain, err := gcm.Open(nil, iv, ciphertext, nil) + if err != nil { + return nil, ErrOpen + } + return plain, nil +} + +func aead(key, iv []byte) (cipher.AEAD, error) { + if len(key) != KeyBytes { + return nil, fmt.Errorf("%w: got %d bytes", ErrBadKey, len(key)) + } + if len(iv) != IVBytes { + return nil, fmt.Errorf("%w: got %d bytes", ErrBadIV, len(iv)) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} + +// IntentDigest is sha256(method | path | canonical body), the value a phone sends to prove that a +// human confirmed this exact write. +// +// The body is canonicalised the same way the console does it in server/proxy/confirmations.js: +// object keys sorted, no insignificant whitespace. A dangerous write whose intent does not match +// the bytes the agent is about to forward is refused, so a relay cannot edit the recipient of an +// SMS in flight and keep the confirmation valid. +func IntentDigest(method, path string, body []byte) ([]byte, error) { + canonicalBody, err := CanonicalJSON(body) + if err != nil { + return nil, err + } + hash := sha256.New() + hash.Write([]byte(strings.ToUpper(strings.TrimSpace(method)))) + hash.Write([]byte{'|'}) + hash.Write([]byte(path)) + hash.Write([]byte{'|'}) + hash.Write(canonicalBody) + return hash.Sum(nil), nil +} + +// VerifyIntent compares in constant time. A byte-by-byte comparison would leak how much of a +// guessed digest is correct, which turns a 256-bit search into a byte-at-a-time one. +func VerifyIntent(expected, actual []byte) bool { + return hmac.Equal(expected, actual) +} diff --git a/internal/wire/wire_test.go b/internal/wire/wire_test.go new file mode 100644 index 0000000..446965a --- /dev/null +++ b/internal/wire/wire_test.go @@ -0,0 +1,474 @@ +package wire + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "testing" +) + +func fixedEnvelope(t *testing.T) Envelope { + t.Helper() + ek, err := hex.DecodeString("8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a") + if err != nil { + t.Fatal(err) + } + return Envelope{ + V: Version, + Node: "nd_7f3a9c2e", + Req: "3f2504e0-4f89-41d3-9a0c-0305e82c3301", + App: "ap_5b1d", + TS: 1727289600, + EK: ek, + Alg: Suite, + P: []byte{0x00, 0x01, 0x02, 0xfe, 0xff}, + } +} + +// TestCanonicalRequestVector pins the exact bytes. This is the file every implementation must agree +// with: if the Node relay or the Swift app produces anything else here, signatures will fail in +// production with an error that says nothing about why. Keeping the vector in the repo turns that +// into a failing test instead. +func TestCanonicalRequestVector(t *testing.T) { + canonical, err := CanonicalRequest(fixedEnvelope(t)) + if err != nil { + t.Fatal(err) + } + got := hex.EncodeToString(canonical) + const want = "" + + "6d73612d72656c61792d76312d72657100" + // "msa-relay-v1-req" NUL + "000000080000000000000001" + // v = 1 as uint64be, length-prefixed + "0000000b6e645f3766336139633265" + // node + "0000002433663235303465302d346638392d343164332d396130632d303330356538326333333031" + // req + "0000000661705f35623164" + // app + "000000080000000000000000" + // ts placeholder, replaced below + "000000208520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a" + // ek + "00000005000102feff" // payload + _ = want + + // The literal above is easier to get wrong than to derive, so assert the structural properties + // that actually matter, then pin the whole thing against a recomputed expectation. + if !bytes.HasPrefix(canonical, []byte("msa-relay-v1-req\x00")) { + t.Fatalf("canonical does not start with the domain: %x", canonical[:20]) + } + rawEK := fixedEnvelope(t).EK + if !bytes.Contains(canonical, rawEK) { + t.Fatal("canonical is missing the raw ek bytes") + } + if bytes.Contains(canonical, []byte(base64.StdEncoding.EncodeToString(rawEK))) { + t.Fatal("canonical contains base64 ek text; the signature must cover raw bytes") + } + + // Field order is part of the contract, so check each marker's offset is increasing. + positions := []int{ + bytes.Index(canonical, []byte("msa-relay-v1-req")), + bytes.Index(canonical, []byte("nd_7f3a9c2e")), + bytes.Index(canonical, []byte("3f2504e0-4f89-41d3-9a0c-0305e82c3301")), + bytes.Index(canonical, []byte("ap_5b1d")), + } + for index := 1; index < len(positions); index++ { + if positions[index] <= positions[index-1] { + t.Fatalf("field order is wrong: %v", positions) + } + } + if got != expectCanonicalRequest() { + t.Fatalf("canonical request changed:\n got %s\nwant %s", got, expectCanonicalRequest()) + } +} + +// expectCanonicalRequest is the frozen vector. It is spelled out rather than computed so that a +// change to the encoding shows up as a diff here instead of silently invalidating every paired +// device in the field. +func expectCanonicalRequest() string { + parts := []string{ + hex.EncodeToString([]byte("msa-relay-v1-req")), + "00", + "00000008", "0000000000000001", + "0000000b", hex.EncodeToString([]byte("nd_7f3a9c2e")), + "00000024", hex.EncodeToString([]byte("3f2504e0-4f89-41d3-9a0c-0305e82c3301")), + "00000007", hex.EncodeToString([]byte("ap_5b1d")), + "00000008", "0000000066f45900", + "00000020", "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a", + "00000005", "000102feff", + } + out := "" + for _, part := range parts { + out += part + } + return out +} + +func TestCanonicalResponseOmitsAppID(t *testing.T) { + envelope := fixedEnvelope(t) + request, err := CanonicalRequest(envelope) + if err != nil { + t.Fatal(err) + } + response, err := CanonicalResponse(envelope) + if err != nil { + t.Fatal(err) + } + if bytes.Equal(request, response) { + t.Fatal("request and response canonical forms are identical; a signature could be replayed across directions") + } + if bytes.Contains(response, []byte("ap_5b1d")) { + t.Fatal("response canonical form includes the app id") + } + if !bytes.HasPrefix(response, []byte("msa-relay-v1-resp\x00")) { + t.Fatalf("response canonical form has the wrong domain: %x", response[:20]) + } +} + +func TestCanonicalRejectsMalformedEnvelopes(t *testing.T) { + cases := map[string]func(*Envelope){ + "missing node": func(e *Envelope) { e.Node = "" }, + "missing req": func(e *Envelope) { e.Req = " " }, + "missing suite": func(e *Envelope) { e.Alg = "" }, + "unknown suite": func(e *Envelope) { e.Alg = "AES128" }, + "short ek": func(e *Envelope) { e.EK = e.EK[:16] }, + "empty ek": func(e *Envelope) { e.EK = nil }, + } + for name, mutate := range cases { + envelope := fixedEnvelope(t) + mutate(&envelope) + if _, err := CanonicalRequest(envelope); err == nil { + t.Errorf("%s: canonical form accepted it", name) + } + } +} + +func TestSignAndVerifyRoundTrip(t *testing.T) { + public, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + envelope := fixedEnvelope(t) + if err := SignRequest(&envelope, private); err != nil { + t.Fatal(err) + } + if err := VerifyRequest(envelope, public); err != nil { + t.Fatalf("VerifyRequest: %v", err) + } + + // Every routing field must be covered. If any of these mutations still verified, the relay + // could change it in flight. + mutations := map[string]func(*Envelope){ + "node": func(e *Envelope) { e.Node = "nd_attacker" }, + "req": func(e *Envelope) { e.Req = "00000000-0000-4000-8000-000000000000" }, + "app": func(e *Envelope) { e.App = "ap_other" }, + "ts": func(e *Envelope) { e.TS = e.TS + 1 }, + "ek": func(e *Envelope) { e.EK = bytes.Repeat([]byte{7}, PublicBytes) }, + "p": func(e *Envelope) { e.P = append(append([]byte{}, e.P...), 0) }, + "alg": func(e *Envelope) { e.Alg = "other" }, + "v": func(e *Envelope) { e.V = 2 }, + } + for name, mutate := range mutations { + changed := envelope + changed.EK = append([]byte{}, envelope.EK...) + changed.P = append([]byte{}, envelope.P...) + changed.S = append([]byte{}, envelope.S...) + mutate(&changed) + if err := VerifyRequest(changed, public); err == nil { + t.Errorf("signature still verified after mutating %s", name) + } + } + + // A response signature must not satisfy a request verification even over identical bytes. + response := fixedEnvelope(t) + if err := SignResponse(&response, private); err != nil { + t.Fatal(err) + } + if err := VerifyRequest(response, public); err == nil { + t.Fatal("a response signature verified as a request signature") + } +} + +func TestVerifyRejectsWrongKey(t *testing.T) { + _, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + otherPublic, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + envelope := fixedEnvelope(t) + if err := SignRequest(&envelope, private); err != nil { + t.Fatal(err) + } + if err := VerifyRequest(envelope, otherPublic); !errors.Is(err, ErrSignature) { + t.Fatalf("VerifyRequest with the wrong key = %v, want ErrSignature", err) + } + if err := VerifyRequest(envelope, otherPublic[:16]); err == nil { + t.Fatal("accepted a truncated public key") + } +} + +// TestSharedSecretMatchesRFC7748 is the X25519 known-answer test. Without it a silent change to the +// scalar clamping or the endianness would produce a working system that interoperates with nobody. +func TestSharedSecretMatchesRFC7748(t *testing.T) { + // RFC 7748 section 6.1: alice private, bob public, expected shared secret. + alicePrivate := mustHex(t, "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a") + bobPublic := mustHex(t, "de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f") + want := mustHex(t, "4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742") + got, err := SharedSecret(alicePrivate, bobPublic) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, want) { + t.Fatalf("shared secret = %x, want %x", got, want) + } +} + +func TestSharedSecretRejectsBadKeys(t *testing.T) { + valid := bytes.Repeat([]byte{7}, PublicBytes) + if _, err := SharedSecret(nil, valid); !errors.Is(err, ErrBadKey) { + t.Errorf("nil private key err = %v, want ErrBadKey", err) + } + if _, err := SharedSecret(bytes.Repeat([]byte{1}, KeyBytes), nil); !errors.Is(err, ErrBadPublicKey) { + t.Errorf("nil peer key err = %v, want ErrBadPublicKey", err) + } + // The all-zero public key is the classic low-order point; it must not yield a shared secret. + if _, err := SharedSecret(bytes.Repeat([]byte{9}, KeyBytes), make([]byte, PublicBytes)); err == nil { + t.Error("accepted the all-zero peer public key") + } +} + +func TestSealOpenRoundTrip(t *testing.T) { + key := bytes.Repeat([]byte{0x2a}, KeyBytes) + iv := bytes.Repeat([]byte{0x0b}, IVBytes) + plaintext := []byte(`{"m":"GET","p":"/api/health"}`) + sealed, err := Seal(key, iv, plaintext) + if err != nil { + t.Fatal(err) + } + if len(sealed) != len(plaintext)+TagBytes { + t.Fatalf("sealed length = %d, want %d", len(sealed), len(plaintext)+TagBytes) + } + opened, err := Open(key, iv, sealed) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(opened, plaintext) { + t.Fatalf("opened = %q, want %q", opened, plaintext) + } + + // Tampering anywhere, including in the tag, must fail closed. + for index := range sealed { + damaged := append([]byte{}, sealed...) + damaged[index] ^= 0x01 + if _, err := Open(key, iv, damaged); !errors.Is(err, ErrOpen) { + t.Fatalf("flipping byte %d still authenticated", index) + } + } + if _, err := Open(bytes.Repeat([]byte{0x2b}, KeyBytes), iv, sealed); !errors.Is(err, ErrOpen) { + t.Fatal("a different key decrypted the payload") + } + if _, err := Open(key, bytes.Repeat([]byte{0x0c}, IVBytes), sealed); !errors.Is(err, ErrOpen) { + t.Fatal("a different iv decrypted the payload") + } + if _, err := Open(key, iv, sealed[:TagBytes-1]); !errors.Is(err, ErrOpen) { + t.Fatal("a truncated payload was accepted") + } +} + +// TestDerivedKeysAreDirectionalAndRequestBound checks the properties that make key reuse safe to +// reason about: the two directions differ, and two requests never share an IV. +func TestDerivedKeysAreDirectionalAndRequestBound(t *testing.T) { + shared := bytes.Repeat([]byte{0x5c}, KeyBytes) + requestKey, requestIV, err := DeriveKey(shared, "nd_a", "req-1", false) + if err != nil { + t.Fatal(err) + } + responseKey, responseIV, err := DeriveKey(shared, "nd_a", "req-1", true) + if err != nil { + t.Fatal(err) + } + if bytes.Equal(requestKey, responseKey) { + t.Fatal("both directions derived the same key") + } + if bytes.Equal(requestIV, responseIV) { + t.Fatal("both directions derived the same iv") + } + if len(requestKey) != KeyBytes || len(requestIV) != IVBytes { + t.Fatalf("derived sizes are wrong: key %d iv %d", len(requestKey), len(requestIV)) + } + + otherKey, otherIV, err := DeriveKey(shared, "nd_a", "req-2", false) + if err != nil { + t.Fatal(err) + } + if bytes.Equal(requestIV, otherIV) { + t.Fatal("two request ids derived the same iv") + } + if bytes.Equal(requestKey, otherKey) { + t.Fatal("two request ids derived the same key") + } + _ = otherIV + + otherNodeKey, _, err := DeriveKey(shared, "nd_b", "req-1", false) + if err != nil { + t.Fatal(err) + } + if bytes.Equal(requestKey, otherNodeKey) { + t.Fatal("two nodes derived the same key from the same secret") + } +} + +// TestHKDFMatchesRFC5869 pins the KDF itself against the published test case, so an implementation +// slip cannot hide behind the round-trip tests above. +func TestHKDFMatchesRFC5869(t *testing.T) { + ikm := mustHex(t, "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b") + salt := mustHex(t, "000102030405060708090a0b0c") + info := mustHex(t, "f0f1f2f3f4f5f6f7f8f9") + want := mustHex(t, "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865") + got, err := HKDF(ikm, salt, info, 42) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, want) { + t.Fatalf("hkdf = %x, want %x", got, want) + } + if _, err := HKDF(ikm, salt, info, 0); err == nil { + t.Fatal("accepted a zero-length output") + } + if _, err := HKDF(ikm, salt, info, 255*32+1); err == nil { + t.Fatal("accepted an output longer than HKDF allows") + } +} + +func TestEnvelopeJSONRoundTrip(t *testing.T) { + original := fixedEnvelope(t) + original.S = bytes.Repeat([]byte{0x11}, ed25519.SignatureSize) + encoded, err := json.Marshal(original) + if err != nil { + t.Fatal(err) + } + var decoded Envelope + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatal(err) + } + if decoded.Node != original.Node || decoded.Req != original.Req || decoded.TS != original.TS { + t.Fatalf("string fields round-tripped wrong: %+v", decoded) + } + if !bytes.Equal(decoded.EK, original.EK) || !bytes.Equal(decoded.P, original.P) || !bytes.Equal(decoded.S, original.S) { + t.Fatal("byte fields did not round-trip") + } + // The wire form must be base64 text, not an array of numbers. + if !bytes.Contains(encoded, []byte(`"ek":"`)) { + t.Fatalf("ek is not encoded as base64: %s", encoded) + } + if bytes.Contains(encoded, []byte(`"ek":[`)) { + t.Fatalf("ek leaked as a byte array: %s", encoded) + } +} + +func TestEnvelopeJSONRejectsMalformedBase64(t *testing.T) { + raw := []byte(`{"v":1,"node":"nd_x","req":"r","ts":1,"ek":"!!!!","alg":"` + Suite + `","p":"AA==","s":""}`) + var envelope Envelope + if err := json.Unmarshal(raw, &envelope); err == nil { + t.Fatal("accepted invalid base64 in ek") + } +} + +// TestIntentDigestIsKeyOrderInsensitive is the property that lets the phone and the agent agree on +// a digest without agreeing on JSON key order. +func TestIntentDigestIsKeyOrderInsensitive(t *testing.T) { + first, err := IntentDigest("post", "/api/sms/send", []byte(`{"b":2,"a":1,"nested":{"z":true,"y":[1,2,3]}}`)) + if err != nil { + t.Fatal(err) + } + second, err := IntentDigest("POST", "/api/sms/send", []byte("{\n \"nested\": {\"y\": [1, 2, 3], \"z\": true},\n \"a\": 1,\n \"b\": 2\n}")) + if err != nil { + t.Fatal(err) + } + if !VerifyIntent(first, second) { + t.Fatalf("intent digest depends on key order or whitespace: %x vs %x", first, second) + } + + // Changing any part of the binding must change the digest. + variants := [][]byte{} + for _, mutate := range []func() ([]byte, error){ + func() ([]byte, error) { + return IntentDigest("POST", "/api/sms/send", []byte(`{"a":1,"b":3,"nested":{"z":true,"y":[1,2,3]}}`)) + }, + func() ([]byte, error) { return IntentDigest("POST", "/api/sms/batch-delete", []byte(`{"a":1,"b":2}`)) }, + func() ([]byte, error) { return IntentDigest("GET", "/api/sms/send", []byte(`{"a":1,"b":2}`)) }, + func() ([]byte, error) { return IntentDigest("POST", "/api/sms/send", nil) }, + } { + digest, err := mutate() + if err != nil { + t.Fatal(err) + } + variants = append(variants, digest) + } + for index, variant := range variants { + if VerifyIntent(first, variant) { + t.Errorf("variant %d produced the same intent digest", index) + } + } + + // No body and an explicitly empty body are the same absence of a body. + empty, err := IntentDigest("POST", "/api/sms/send", []byte(" ")) + if err != nil { + t.Fatal(err) + } + if !VerifyIntent(empty, variants[3]) { + t.Fatal("blank and absent bodies canonicalised differently") + } +} + +func TestCanonicalJSONRules(t *testing.T) { + cases := []struct{ in, want string }{ + {`{"b":1,"a":2}`, `{"a":2,"b":1}`}, + {`[3,1,2]`, `[3,1,2]`}, + {` null `, `null`}, + {`true`, `true`}, + {`"<&>"`, `"<&>"`}, + {`{"x":100}`, `{"x":100}`}, + {``, ``}, + {` `, ``}, + {`{"a":{"c":[1,{"b":2,"a":1}],"b":0},"A":9}`, `{"A":9,"a":{"b":0,"c":[1,{"a":1,"b":2}]}}`}, + } + for _, c := range cases { + got, err := CanonicalJSON([]byte(c.in)) + if err != nil { + t.Errorf("CanonicalJSON(%q): %v", c.in, err) + continue + } + if string(got) != c.want { + t.Errorf("CanonicalJSON(%q) = %q, want %q", c.in, got, c.want) + } + } + + // HTML escaping must be off, otherwise a body containing & or < hashes differently in Go than + // in Node or Swift. + got, err := CanonicalJSON([]byte(`{"text":"ad"}`)) + if err != nil { + t.Fatal(err) + } + if string(got) != `{"text":"ad"}` { + t.Fatalf("HTML characters were escaped: %s", got) + } +} + +func TestCanonicalJSONRejectsGarbage(t *testing.T) { + for _, raw := range []string{`{`, `{"a":1} trailing`, `{"a":+1}`, `{"a":.5}`, `{"a":NaN}`, `{"a":1.0}`, `{"a":1e3}`, `{"a":01}`, `{"a":-01}`, `{"a":-0}`} { + if _, err := CanonicalJSON([]byte(raw)); err == nil { + t.Errorf("CanonicalJSON(%q) accepted it", raw) + } + } +} + +func mustHex(t *testing.T, value string) []byte { + t.Helper() + raw, err := hex.DecodeString(value) + if err != nil { + t.Fatalf("bad hex in test: %v", err) + } + return raw +} diff --git a/systemd/msa-agent.service b/systemd/msa-agent.service new file mode 100644 index 0000000..550bfc3 --- /dev/null +++ b/systemd/msa-agent.service @@ -0,0 +1,64 @@ +[Unit] +Description=MultiSimAdmin relay agent +Documentation=https://gitea.chickliu.fun/Hermes/simadmin-relay +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/opt/msa-agent/msa-agent --config /etc/msa-agent/config.json +Restart=always +# Backing off on our own is not enough: a relay that is down for an hour should not be hammered by +# every node in the fleet the moment it comes back. +RestartSec=5 +TimeoutStopSec=20 +KillSignal=SIGTERM + +User=msa-agent +Group=msa-agent + +# The agent needs almost nothing from the host. Everything below is a capability the process never +# uses, removed so that a compromise of the agent is not automatically a compromise of the box. +NoNewPrivileges=yes +PrivateDevices=yes +PrivateTmp=yes +PrivateUsers=yes +ProtectSystem=strict +ProtectHome=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectClock=yes +ProtectControlGroups=yes +ProtectHostname=yes +ProtectKernelLogs=yes +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +RemoveIPC=yes +UMask=0077 + +# Configuration and the audit log are the only writable paths. ReadWritePaths is what keeps +# ProtectSystem=strict from making the audit log unwritable. +ReadWritePaths=/var/log/msa-agent +ConfigurationDirectory=msa-agent +LogsDirectory=msa-agent + +# AF_INET and AF_INET6 only: the agent dials out to the relay and to 127.0.0.1, so it has no reason +# to reach a Unix socket, a netlink family or a packet socket. +# +# No IPAddressDeny / IPAddressAllow pair here, deliberately. It reads like the obvious way to pin the +# agent to loopback, and it is wrong: those directives filter both directions, so IPAddressDeny=any +# would also cut the outbound connection to the relay the agent exists to maintain. The loopback +# invariant is enforced in code by internal/egress, which is testable, rather than by a directive +# that silently breaks the only path out. +RestrictAddressFamilies=AF_INET AF_INET6 +# No capability at all, so the process cannot even ask to bind a privileged port. +CapabilityBoundingSet= +AmbientCapabilities= +SystemCallFilter=@system-service +SystemCallErrorNumber=EPERM + +[Install] +WantedBy=multi-user.target