baseline: P1 契约、agent 核心与中继投递(既有工作首次入库)
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,227 @@
|
||||
// Command msa-agent runs on a SimAdmin node and lets a paired iPhone reach it through a public
|
||||
// relay without any inbound path.
|
||||
//
|
||||
// It opens no listening socket. Every network action it takes is either an outbound HTTPS request to
|
||||
// the relay or a loopback request to the SimAdmin on the same machine, which is the property that
|
||||
// makes "no port mapping, no VPN" true rather than merely advertised.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"fun.chick/msa-agent/internal/agent"
|
||||
"fun.chick/msa-agent/internal/config"
|
||||
"fun.chick/msa-agent/internal/policy"
|
||||
"fun.chick/msa-agent/internal/runtime"
|
||||
"fun.chick/msa-agent/internal/transport"
|
||||
)
|
||||
|
||||
// version is stamped in by the Makefile.
|
||||
var version = "dev"
|
||||
|
||||
// Backoff bounds. The lower bound is what makes a relay outage survivable for the relay's operator,
|
||||
// and the ceiling is what keeps a long outage from turning into a 30-second-per-retry queue.
|
||||
const (
|
||||
minBackoff = time.Second
|
||||
maxBackoff = 60 * time.Second
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
configPath = flag.String("config", "/etc/msa-agent/config.json", "path to the agent config file")
|
||||
check = flag.Bool("check", false, "validate the config and policy, then exit")
|
||||
once = flag.Bool("once", false, "run a single poll cycle and exit")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
if err := run(*configPath, *check, *once); err != nil {
|
||||
// One line, then exit non-zero. systemd restarts it, and the journal keeps the reason.
|
||||
logger.Error("msa-agent exiting", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(configPath string, checkOnly, once bool) error {
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pol, err := policy.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sign, err := decodeKey(cfg.SignKey, ed25519.PrivateKeySize, "signKey")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kx, err := decodeKey(cfg.KxKey, 32, "kxKey")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
devices, err := deviceSet(cfg.Devices)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The policy digest is logged at every startup and sent in the handshake. An operator comparing
|
||||
// one line across a fleet learns whether every node enforces the same surface; without it the
|
||||
// only way to know is to log into each box.
|
||||
slog.Info("msa-agent starting",
|
||||
"version", version,
|
||||
"node", cfg.NodeID,
|
||||
"localPort", cfg.LocalPort,
|
||||
"policy", pol.SHA256(),
|
||||
"devices", devices.Len())
|
||||
|
||||
if checkOnly {
|
||||
slog.Info("configuration is valid", "relay", cfg.RelayURL)
|
||||
return nil
|
||||
}
|
||||
|
||||
audit, err := runtime.OpenAudit(cfg.AuditPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer audit.Close()
|
||||
|
||||
exec, err := runtime.NewExecutor(pol, runtime.Config{
|
||||
Port: cfg.LocalPort,
|
||||
Audit: audit,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
node, err := agent.New(cfg.NodeID, agent.Keys{Sign: sign, Kx: kx}, devices, pol, exec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := transport.New(transport.Config{
|
||||
BaseURL: cfg.RelayURL,
|
||||
NodeID: cfg.NodeID,
|
||||
Token: cfg.AgentToken,
|
||||
Signer: sign,
|
||||
UserAgent: "msa-agent/" + version,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if once {
|
||||
return cycle(ctx, node, client, pol)
|
||||
}
|
||||
return loop(ctx, node, client, pol)
|
||||
}
|
||||
|
||||
// loop is the agent's whole life: handshake, then poll forever, re-handshaking whenever the relay
|
||||
// says the session is gone and backing off when the relay is unreachable.
|
||||
//
|
||||
// Errors are classified here rather than handled per call site, because the three situations look
|
||||
// identical from inside a function and need three different responses: retry now, re-authenticate,
|
||||
// or wait.
|
||||
func loop(ctx context.Context, node *agent.Agent, client *transport.Client, pol *policy.Policy) error {
|
||||
backoff := minBackoff
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
err := handshakeAndPoll(ctx, node, client, pol)
|
||||
switch {
|
||||
case err == nil:
|
||||
backoff = minBackoff
|
||||
case errors.Is(err, transport.ErrNoWork):
|
||||
backoff = minBackoff
|
||||
case errors.Is(err, transport.ErrUnauthorized), errors.Is(err, transport.ErrSessionExpired):
|
||||
// Re-handshake on the next iteration. The wait is what stops a bad token from becoming
|
||||
// a tight loop against the relay.
|
||||
slog.Warn("relay rejected the agent session, re-authenticating", "error", err)
|
||||
backoff = minBackoff * 5
|
||||
default:
|
||||
slog.Warn("poll cycle failed", "error", err, "retryIn", backoff)
|
||||
backoff = next(backoff)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func next(current time.Duration) time.Duration {
|
||||
doubled := current * 2
|
||||
if doubled > maxBackoff {
|
||||
return maxBackoff
|
||||
}
|
||||
return doubled
|
||||
}
|
||||
|
||||
func handshakeAndPoll(ctx context.Context, node *agent.Agent, client *transport.Client, pol *policy.Policy) error {
|
||||
if _, err := client.Hello(ctx, version, pol.SHA256()); err != nil {
|
||||
return err
|
||||
}
|
||||
return cycle(ctx, node, client, pol)
|
||||
}
|
||||
|
||||
// cycle drains one batch. Each request is handled and answered before the next is fetched, so a
|
||||
// slow upstream request delays the queue rather than buffering it in memory.
|
||||
func cycle(ctx context.Context, node *agent.Agent, client *transport.Client, pol *policy.Policy) error {
|
||||
batch, err := client.Poll(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(batch.Envelopes) == 0 {
|
||||
return transport.ErrNoWork
|
||||
}
|
||||
for _, raw := range batch.Envelopes {
|
||||
response, err := node.Handle(ctx, raw)
|
||||
if err != nil {
|
||||
// Addressed to another node, or malformed beyond answering. Logged, not fatal: one bad
|
||||
// envelope must not stop the batch.
|
||||
slog.Warn("envelope dropped", "error", err)
|
||||
continue
|
||||
}
|
||||
if err := client.Result(ctx, response); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeKey(value string, size int, field string) ([]byte, error) {
|
||||
raw, err := base64.StdEncoding.Strict().DecodeString(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config: %s is not valid base64: %w", field, err)
|
||||
}
|
||||
if len(raw) != size {
|
||||
return nil, fmt.Errorf("config: %s must be %d bytes, got %d", field, size, len(raw))
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func deviceSet(devices []config.Device) (agent.StaticDevices, error) {
|
||||
set := make(agent.StaticDevices, len(devices))
|
||||
for _, device := range devices {
|
||||
public, err := decodeKey(device.SignKey, ed25519.PublicKeySize, fmt.Sprintf("device %s signKey", device.ID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
set[device.ID] = ed25519.PublicKey(public)
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// Command msa-vectors regenerates contracts/test-vectors.json.
|
||||
//
|
||||
// Three implementations have to agree on the exact bytes that get signed and derived: the Go
|
||||
// agent, the Node relay, and the Swift app. They share no language, no JSON encoder and no crypto
|
||||
// library, and canonical encoding is precisely where they would drift. Signatures are included
|
||||
// because Ed25519 is deterministic: identical key and message must yield identical bytes in every
|
||||
// implementation, which is a much stronger check than sign-then-verify inside one language.
|
||||
//
|
||||
// The vectors are written to contracts/ and then recomputed independently by test/wire.test.js, so
|
||||
// a change to the encoding on one side fails a test rather than surfacing as an unexplained
|
||||
// signature failure on a device that is already deployed.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"fun.chick/msa-agent/internal/wire"
|
||||
)
|
||||
|
||||
// Fixed inputs, all literals, so the vectors are reproducible and reviewable.
|
||||
const (
|
||||
nodeID = "nd_7f3a9c2e"
|
||||
appID = "ap_5b1d"
|
||||
reqID = "3f2504e0-4f89-41d3-9a0c-0305e82c3301"
|
||||
timestamp = uint64(1727289600)
|
||||
ekHex = "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a"
|
||||
payloadHex = "000102feff"
|
||||
// RFC 8032 test vector private key, so the signature below is checkable by hand.
|
||||
appSignHex = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60" +
|
||||
"d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"
|
||||
// RFC 8032 test vector 2, used as the agent's signing key. The two parties must hold different
|
||||
// keys or the vector file proves nothing about whose signature is whose: domain separation and
|
||||
// key separation are meant to be independent defences, and checking only one of them hides a
|
||||
// bug in the other.
|
||||
agentSignHex = "4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb" +
|
||||
"3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"
|
||||
// RFC 7748 section 6.1 keypair, so the derived values trace to a published test case.
|
||||
appKxHex = "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a"
|
||||
agentKxHex = "de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f"
|
||||
|
||||
plaintext = `{"m":"GET","p":"/api/health"}`
|
||||
)
|
||||
|
||||
func main() {
|
||||
envelope := wire.Envelope{
|
||||
V: wire.Version,
|
||||
Node: nodeID,
|
||||
Req: reqID,
|
||||
App: appID,
|
||||
TS: timestamp,
|
||||
EK: mustHex(ekHex),
|
||||
Alg: wire.Suite,
|
||||
P: mustHex(payloadHex),
|
||||
}
|
||||
|
||||
requestCanonical := must2(wire.CanonicalRequest(envelope))
|
||||
responseCanonical := must2(wire.CanonicalResponse(envelope))
|
||||
|
||||
appSign := ed25519.PrivateKey(mustHex(appSignHex))
|
||||
signedRequest := envelope
|
||||
check(wire.SignRequest(&signedRequest, appSign))
|
||||
|
||||
agentSign := ed25519.PrivateKey(mustHex(agentSignHex))
|
||||
signedResponse := envelope
|
||||
check(wire.SignResponse(&signedResponse, agentSign))
|
||||
// Both signatures are checked before the file is written. A signature that the recomputing side
|
||||
// cannot verify is worse than no vector at all: it looks like the other language is broken.
|
||||
check(wire.VerifyRequest(signedRequest, appSign.Public().(ed25519.PublicKey)))
|
||||
check(wire.VerifyResponse(signedResponse, agentSign.Public().(ed25519.PublicKey)))
|
||||
|
||||
shared := must2(wire.SharedSecret(mustHex(appKxHex), mustHex(agentKxHex)))
|
||||
requestKey, requestIV := must3(wire.DeriveKey(shared, nodeID, reqID, false))
|
||||
responseKey, responseIV := must3(wire.DeriveKey(shared, nodeID, reqID, true))
|
||||
sealed := must2(wire.Seal(requestKey, requestIV, []byte(plaintext)))
|
||||
intent := must2(wire.IntentDigest("POST", "/api/sms/send",
|
||||
[]byte(`{"phone_number":"+8613800000000","content":"hello"}`)))
|
||||
|
||||
document := vectors{
|
||||
GeneratedBy: "agent/cmd/msa-vectors",
|
||||
Note: "Deterministic cross-language vectors. The Node relay (test/wire.test.js) and the " +
|
||||
"Swift app recompute these from this file; a mismatch means the envelope encoding " +
|
||||
"drifted, not that one side is broken.",
|
||||
Suite: wire.Suite,
|
||||
Version: wire.Version,
|
||||
Canonical: canonicalVector{
|
||||
Comment: "Length-prefixed canonical bytes, then the deterministic Ed25519 signature " +
|
||||
"over them. ek and p are raw decoded bytes, never their base64 text.",
|
||||
Input: envelope,
|
||||
Bytes: pair{hexStr(requestCanonical), hexStr(responseCanonical)},
|
||||
Signature: pair{hexStr(signedRequest.S), hexStr(signedResponse.S)},
|
||||
PublicKey: hexStr(appSign.Public().(ed25519.PublicKey)),
|
||||
ResponsePublicKey: hexStr(agentSign.Public().(ed25519.PublicKey)),
|
||||
},
|
||||
HKDF: hkdfVector{
|
||||
Comment: "RFC 5869 test case 1, pinning the KDF itself rather than only its callers.",
|
||||
Secret: "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
|
||||
Salt: "000102030405060708090a0b0c",
|
||||
Info: "f0f1f2f3f4f5f6f7f8f9",
|
||||
Length: 42,
|
||||
Output: hexStr(must2(wire.HKDF(
|
||||
mustHex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b"),
|
||||
mustHex("000102030405060708090a0b0c"),
|
||||
mustHex("f0f1f2f3f4f5f6f7f8f9"),
|
||||
42,
|
||||
))),
|
||||
},
|
||||
X25519: x25519Vector{
|
||||
Comment: "RFC 7748 section 6.1, then the derived per-direction key and IV for the " +
|
||||
"envelope above. The IV is bound to the request id and the direction, so a key " +
|
||||
"that is ever reused still cannot reuse a nonce.",
|
||||
AppPrivate: appKxHex,
|
||||
AgentPublic: agentKxHex,
|
||||
SharedSecret: hexStr(shared),
|
||||
RequestKey: hexStr(requestKey),
|
||||
RequestIV: hexStr(requestIV),
|
||||
ResponseKey: hexStr(responseKey),
|
||||
ResponseIV: hexStr(responseIV),
|
||||
},
|
||||
AEAD: aeadVector{
|
||||
Comment: "AES-256-GCM under the request key above; the 16-byte tag is appended.",
|
||||
Key: hexStr(requestKey),
|
||||
IV: hexStr(requestIV),
|
||||
Plaintext: hexStr([]byte(plaintext)),
|
||||
Ciphertext: hexStr(sealed),
|
||||
},
|
||||
Intent: intentVector{
|
||||
Comment: "sha256(method | path | canonical body). The agent recomputes it from the " +
|
||||
"request it is about to forward, so a relay cannot edit a confirmed write.",
|
||||
Method: "POST",
|
||||
Path: "/api/sms/send",
|
||||
Body: `{"phone_number":"+8613800000000","content":"hello"}`,
|
||||
Digest: hexStr(intent),
|
||||
},
|
||||
CanonicalJSON: jsonPairs(),
|
||||
}
|
||||
|
||||
encoded, err := json.MarshalIndent(document, "", " ")
|
||||
check(err)
|
||||
encoded = append(encoded, '\n')
|
||||
|
||||
target := filepath.Join("..", "..", "contracts", "test-vectors.json")
|
||||
if len(os.Args) > 1 {
|
||||
target = os.Args[1]
|
||||
}
|
||||
check(os.WriteFile(target, encoded, 0o644))
|
||||
fmt.Printf("wrote %s\n", target)
|
||||
}
|
||||
|
||||
type pair struct {
|
||||
Request string `json:"request"`
|
||||
Response string `json:"response"`
|
||||
}
|
||||
|
||||
type vectors struct {
|
||||
GeneratedBy string `json:"generatedBy"`
|
||||
Note string `json:"note"`
|
||||
Suite string `json:"suite"`
|
||||
Version uint64 `json:"version"`
|
||||
Canonical canonicalVector `json:"canonical"`
|
||||
HKDF hkdfVector `json:"hkdf"`
|
||||
X25519 x25519Vector `json:"x25519"`
|
||||
AEAD aeadVector `json:"aead"`
|
||||
Intent intentVector `json:"intent"`
|
||||
CanonicalJSON []jsonPairVector `json:"canonicalJson"`
|
||||
}
|
||||
|
||||
type canonicalVector struct {
|
||||
Comment string `json:"comment"`
|
||||
Input wire.Envelope `json:"input"`
|
||||
Bytes pair `json:"bytes"`
|
||||
Signature pair `json:"signature"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
ResponsePublicKey string `json:"responsePublicKey"`
|
||||
}
|
||||
|
||||
type hkdfVector struct {
|
||||
Comment string `json:"comment"`
|
||||
Secret string `json:"secret"`
|
||||
Salt string `json:"salt"`
|
||||
Info string `json:"info"`
|
||||
Length int `json:"length"`
|
||||
Output string `json:"output"`
|
||||
}
|
||||
|
||||
type x25519Vector struct {
|
||||
Comment string `json:"comment"`
|
||||
AppPrivate string `json:"appPrivate"`
|
||||
AgentPublic string `json:"agentPublic"`
|
||||
SharedSecret string `json:"sharedSecret"`
|
||||
RequestKey string `json:"requestKey"`
|
||||
RequestIV string `json:"requestIV"`
|
||||
ResponseKey string `json:"responseKey"`
|
||||
ResponseIV string `json:"responseIV"`
|
||||
}
|
||||
|
||||
type aeadVector struct {
|
||||
Comment string `json:"comment"`
|
||||
Key string `json:"key"`
|
||||
IV string `json:"iv"`
|
||||
Plaintext string `json:"plaintext"`
|
||||
Ciphertext string `json:"ciphertext"`
|
||||
}
|
||||
|
||||
type intentVector struct {
|
||||
Comment string `json:"comment"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Body string `json:"body"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
type jsonPairVector struct {
|
||||
Input string `json:"input"`
|
||||
Output string `json:"output"`
|
||||
}
|
||||
|
||||
func jsonPairs() []jsonPairVector {
|
||||
inputs := []string{
|
||||
`{"b":1,"a":2}`,
|
||||
`[3,1,2]`,
|
||||
` null `,
|
||||
`true`,
|
||||
`"<&>"`,
|
||||
`{"x":100}`,
|
||||
`{"a":{"c":[1,{"b":2,"a":1}],"b":0},"A":9}`,
|
||||
`{"phone_number":"+8613800000000","content":"hello"}`,
|
||||
}
|
||||
pairs := make([]jsonPairVector, 0, len(inputs))
|
||||
for _, input := range inputs {
|
||||
out, err := wire.CanonicalJSON([]byte(input))
|
||||
check(err)
|
||||
pairs = append(pairs, jsonPairVector{Input: input, Output: string(out)})
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
func mustHex(value string) []byte {
|
||||
raw, err := hex.DecodeString(value)
|
||||
check(err)
|
||||
return raw
|
||||
}
|
||||
|
||||
func must2(value []byte, err error) []byte {
|
||||
check(err)
|
||||
return value
|
||||
}
|
||||
|
||||
func must3(key, iv []byte, err error) ([]byte, []byte) {
|
||||
check(err)
|
||||
return key, iv
|
||||
}
|
||||
|
||||
func check(err error) {
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "msa-vectors:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func hexStr(value []byte) string { return hex.EncodeToString(value) }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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":"a<b&c>d"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != `{"text":"a<b&c>d"}` {
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user