diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8106d45 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Cross-compiled binaries. The build is reproducible from the Makefile, so committing multi-megabyte +# ELF files would only make the repository grow with every rebuild. +dist/ +# The vendored copy is produced by `make vendor` from contracts/agent-policy.json. +internal/policy/agent-policy.json.tmp +internal/install/msa-agent.service.tmp +# A binary left behind by `go build ./...` run in this directory. +msa-agent +*.tmp +.DS_Store diff --git a/Makefile b/Makefile index 535e06c..bbb9054 100644 --- a/Makefile +++ b/Makefile @@ -1,21 +1,31 @@ -POLICY_SRC := ../contracts/agent-policy.json +# This repository owns the authorisation surface: the agent is what enforces it, so it is not a copy of +# a file that lives somewhere else. `contracts/agent-policy.json` is embedded in the binary and is the +# source of truth; the relay vendors a copy and the two are compared at runtime, by the policy digest the +# agent reports in its handshake. +POLICY_SRC := contracts/agent-policy.json POLICY_DST := internal/policy/agent-policy.json # The systemd unit is embedded in the binary so that `msa-agent install` needs no route to anything but -# the relay, and so the unit a node runs is the one its agent was built with. Vendored mechanically for -# the same reason the policy is: an operator editing the copy under internal/ would be editing a file -# the build overwrites, and TestVendoredUnitMatchesSystemd makes that loud. +# the relay, and so the unit a node runs is the one its agent was built with. Vendored mechanically: an +# operator editing the copy under internal/ would be editing a file the build overwrites, and +# TestVendoredUnitMatchesSystemd makes that loud. UNIT_SRC := systemd/msa-agent.service UNIT_DST := internal/install/msa-agent.service # The version stamped into the binary, and the tag scripts/release.sh publishes under. It is the # agent's own version rather than the app's: the two are released together but a node can be running an # older agent than the phone, and the handshake's `agentBuild` field is what makes that visible. -VERSION ?= 0.3.0 +VERSION ?= 1.0.0 LDFLAGS := -s -w -X main.version=$(VERSION) -.PHONY: all vendor test vet build build-arm64 build-armv7 clean +.PHONY: all vendor test vet fmt vectors build build-arm64 build-armv7 clean all: vet test build +# Regenerates contracts/test-vectors.json. It is the Go implementation that generates them, and the +# relay's Node reference implementation and the app's Swift tests recompute them from the result — three +# implementations with no shared code, so agreement is evidence rather than a tautology. +vectors: + @go run ./cmd/msa-vectors contracts/test-vectors.json + # 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1446c9b --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# msa-agent + +Runs on a SimAdmin node and lets a paired iPhone reach it through a public relay, without a VPN and +without any inbound path into the internal network. + +It opens no listening socket in normal operation. Every network action it takes is either an outbound +HTTPS long poll to the relay or a loopback request to the SimAdmin on the same machine, which is what +makes "no port mapping, no VPN" true rather than merely advertised. + +```text +iPhone ──HTTPS(signed, encrypted envelope)──> relay <──outbound long poll── msa-agent ──HTTP──> 127.0.0.1: +``` + +The relay is a dumb pipe: it cannot read a request, cannot forge an answer and cannot send a command. +The phone signs each request with its own private key and encrypts it to this agent's public key; this +agent verifies the signature before touching the payload and re-checks the policy before dialing +anything. + +## Install on a node + +The agent is a static binary with no runtime dependencies and no third-party modules. `enroll` mints +the node's keys and writes its config; `install` puts the systemd service around it. + +```sh +sudo mkdir -p /opt/msa-agent /etc/msa-agent +sudo curl -fsSL https://gitea.chickliu.fun/Hermes/msa-agent/releases/latest/download/msa-agent-linux-arm64 \ + -o /opt/msa-agent/msa-agent +sudo chmod 755 /opt/msa-agent/msa-agent +sudo /opt/msa-agent/msa-agent enroll --relay https://your-relay --port 3000 \ + --code --write /etc/msa-agent/config.json +sudo /opt/msa-agent/msa-agent install +``` + +`armv7` is the same build for 32-bit ARM. `SHA256SUMS` covers both. + +With `--code` the node registers itself with the relay and does not need an entry written into the +relay's `agents.json` by hand. Without it, `enroll` prints the entry to add. + +Removing it again is `sudo msa-agent uninstall`; `--dry-run` shows the plan first, and `--keep-logs` +keeps the audit trail. + +## Subcommands + +| Command | What it does | +|---|---| +| *(none)* | The daemon: handshake once, then poll forever | +| `enroll` | Mints keys and identifiers, writes the config, optionally registers with the relay | +| `install` | Creates the service user, gives it the config and logs, writes the unit, starts the service | +| `uninstall` | Tells the relay first, then removes the service, unit, config, logs and user | +| `version` | Prints the version stamped into the binary | +| `--pair` | Opens the temporary LAN window a phone uses to pair; closes itself | +| `--check` | Validates the config and policy, then exits | +| `--once` | Runs a single poll cycle and exits | + +`install --print-unit` prints the systemd unit before installing it. The unit is embedded in the +binary rather than fetched, so a node cannot end up running a unit from a different release than its +agent. + +## Layout + +| Path | Contents | +|---|---| +| `contracts/agent-policy.json` | The authorisation surface: read/write allowlists, loopback enforcement, limits. Embedded in the binary; this repository owns it | +| `contracts/test-vectors.json` | Deterministic vectors shared with the relay (Node) and the app (Swift), generated by `cmd/msa-vectors` | +| `internal/wire/` | The envelope: what is signed, what is encrypted, and in what order | +| `internal/policy/` | Loads and enforces the policy | +| `internal/runtime/` | Turns a verified envelope into one loopback call, or refuses it | +| `internal/egress/` | The only place a socket is opened toward SimAdmin | +| `internal/agent/` | The control loop: verify, decrypt, decide, execute, answer | +| `internal/pair/` | The temporary pairing window | +| `internal/enroll/` | Key and identifier generation, and self-registration with the relay | +| `internal/install/` | The service lifecycle: user, ownership, unit, and its reverse | +| `internal/transport/` | The outbound long-poll client | +| `systemd/` | The unit file, vendored into the binary by `make vendor` | + +## Tests + +```sh +make test # go test ./... (vendors first) +make vet +go test ./internal/agent -fuzz FuzzHandleDoesNotPanicOnArbitraryEnvelopes +``` + +The agent is the one component whose input comes from a party the design assumes is hostile — the +relay — so `Handle` is fuzzed as well as table-tested. A panic there is not a bad request: the agent +is a single process holding one poll, so an unrecovered panic ends it and the node goes offline until +systemd restarts it. + +Three properties are worth stating because they are what the tests are about, and each one was a real +defect at some point: + +- **Ordering.** Nothing that depends on the payload happens before the signature verifies, and nothing + that touches SimAdmin happens before the policy says yes. Every refusal is classified, because a node + with a drifted clock and a node under attack produce the same outward symptom. +- **The limiters are sliding windows.** "At most N dangerous writes in any 60 seconds" is the property; + a fixed window admits 2N across its boundary, and a token bucket admits a burst after any quiet + period. The relay's per-device budget is the same algorithm, and the two are kept in step because + they bound the same operation from two sides. +- **A request the phone has given up on is not executed.** The relay answers 504 with "it may or may not + have executed it", and that is only honest while it stays true. A batch is processed serially, so the + last envelope of a full mailbox can begin well after its deadline; executing it then turns an honest + unknown into a duplicate SMS. + +## Cross-repository contracts + +This repository owns the two contract files. The relay keeps vendored copies: + +- `agent-policy.json` — the relay derives its envelope ceiling, mailbox depth, request deadline and + long-poll window from these numbers, so the two cannot drift into disagreeing about what a node will + accept. `scripts/sync-contracts.sh` in the relay repository pulls the copy. +- `test-vectors.json` — generated here by `make vectors` (or `go run ./cmd/msa-vectors`), recomputed by + the relay's Node reference implementation and by the app's Swift tests. + +Drift in the policy is visible at runtime rather than only in a test: the agent sends its policy digest +in the handshake, the relay compares it with its own copy and reports `policyMatches` per node in +`/v1/status`. + +## License + +No license file yet: until one is added, the default is all rights reserved. The source is public so +the binaries can be fetched by a node that has no credentials — the install command downloads from +this repository's releases — not as an invitation to redistribute. diff --git a/cmd/msa-vectors/main.go b/cmd/msa-vectors/main.go index d2e2c6d..e12330e 100644 --- a/cmd/msa-vectors/main.go +++ b/cmd/msa-vectors/main.go @@ -144,7 +144,9 @@ func main() { check(err) encoded = append(encoded, '\n') - target := filepath.Join("..", "..", "contracts", "test-vectors.json") + // This repository owns the vectors: the Go implementation generates them, and the Node and Swift + // implementations recompute them from a vendored copy. + target := filepath.Join("contracts", "test-vectors.json") if len(os.Args) > 1 { target = os.Args[1] } diff --git a/contracts/agent-policy.json b/contracts/agent-policy.json new file mode 100644 index 0000000..9f3deac --- /dev/null +++ b/contracts/agent-policy.json @@ -0,0 +1,42 @@ +{ + "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, + "agentUpstreamTimeoutSeconds": 20, + "maxRequestBytes": 262144, + "maxResponseBytes": 262144, + "requestTtlSeconds": 30, + "clockSkewSeconds": 120, + "replayCacheSize": 4096, + "mailboxDepth": 8 + } +} diff --git a/contracts/test-vectors.json b/contracts/test-vectors.json new file mode 100644 index 0000000..6db98f7 --- /dev/null +++ b/contracts/test-vectors.json @@ -0,0 +1,129 @@ +{ + "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": "X25519-ED25519-AES256GCM-HKDFSHA256", + "version": 1, + "canonical": { + "comment": "Length-prefixed canonical bytes, then the deterministic Ed25519 signature over them. ek and p are raw decoded bytes, never their base64 text.", + "input": { + "v": 1, + "node": "nd_7f3a9c2e", + "req": "3f2504e0-4f89-41d3-9a0c-0305e82c3301", + "app": "ap_5b1d", + "ts": 1727289600, + "ek": "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo=", + "alg": "X25519-ED25519-AES256GCM-HKDFSHA256", + "p": "AAEC/v8=" + }, + "bytes": { + "request": "6d73612d72656c61792d76312d726571000000000800000000000000010000000b6e645f37663361396332650000002433663235303465302d346638392d343164332d396130632d3033303565383263333330310000000761705f35623164000000080000000066f45900000000208520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a00000005000102feff", + "response": "6d73612d72656c61792d76312d72657370000000000800000000000000010000000b6e645f37663361396332650000002433663235303465302d346638392d343164332d396130632d303330356538326333333031000000080000000066f45900000000208520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a00000005000102feff" + }, + "signature": { + "request": "f91fc4a40c4cb19643515894dba14a1baae602d0b307feafcf441a2d64690b94ac10e20982f8f3e8d2b48c74db11d250344256af92f7c22fa1b2c2fb6975fb0f", + "response": "3977dc4ad544ed83d9921be9929c49f5b38cd618583d99d10d096d3997237c02110da17ffebd45656453d94cd07044d2b1348e0c45381fa193bf5fb08b30b50e" + }, + "publicKey": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", + "responsePublicKey": "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c" + }, + "hkdf": { + "comment": "RFC 5869 test case 1, pinning the KDF itself rather than only its callers.", + "secret": "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b", + "salt": "000102030405060708090a0b0c", + "info": "f0f1f2f3f4f5f6f7f8f9", + "length": 42, + "output": "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865" + }, + "x25519": { + "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": "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a", + "agentPublic": "de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f", + "sharedSecret": "4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742", + "requestKey": "14f4f1823bd68aabccf326e5a4ee69891428491477bfc6949a25dd69bff1b1e9", + "requestIV": "e5be943a5b8ea89e34bbcb02", + "responseKey": "e350511cf24be4045c770180276146ec647283695394dec608f1695b5f07524e", + "responseIV": "44c5c0499337c5da62b463ad" + }, + "aead": { + "comment": "AES-256-GCM under the request key above; the 16-byte tag is appended.", + "key": "14f4f1823bd68aabccf326e5a4ee69891428491477bfc6949a25dd69bff1b1e9", + "iv": "e5be943a5b8ea89e34bbcb02", + "plaintext": "7b226d223a22474554222c2270223a222f6170692f6865616c7468227d", + "ciphertext": "6601340c304905a8308894ab9d050d5e2ea083ee7ad43df01b9c3e79daead5321099e7203abd5985cf2ef12114" + }, + "intent": { + "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": "d0f584d844cf6e25d5b233a65e86fd80b82a396836cb80522e528b0f6ea5ac00" + }, + "canonicalJson": [ + { + "input": "{\"b\":1,\"a\":2}", + "output": "{\"a\":2,\"b\":1}" + }, + { + "input": "[3,1,2]", + "output": "[3,1,2]" + }, + { + "input": " null ", + "output": "null" + }, + { + "input": "true", + "output": "true" + }, + { + "input": "\"\u003c\u0026\u003e\"", + "output": "\"\u003c\u0026\u003e\"" + }, + { + "input": "{\"x\":100}", + "output": "{\"x\":100}" + }, + { + "input": "{\"a\":{\"c\":[1,{\"b\":2,\"a\":1}],\"b\":0},\"A\":9}", + "output": "{\"A\":9,\"a\":{\"b\":0,\"c\":[1,{\"a\":1,\"b\":2}]}}" + }, + { + "input": "{\"phone_number\":\"+8613800000000\",\"content\":\"hello\"}", + "output": "{\"content\":\"hello\",\"phone_number\":\"+8613800000000\"}" + }, + { + "input": "{\"content\":\"a\u2028b\"}", + "output": "{\"content\":\"a\u2028b\"}" + }, + { + "input": "{\"content\":\"a\u2029b\"}", + "output": "{\"content\":\"a\u2029b\"}" + }, + { + "input": "{\"content\":\"😀\"}", + "output": "{\"content\":\"😀\"}" + }, + { + "input": "{\"content\":\"\\b\\f\\u0001\"}", + "output": "{\"content\":\"\\b\\f\\u0001\"}" + }, + { + "input": "{\"content\":\"\\\\u2028\"}", + "output": "{\"content\":\"\\\\u2028\"}" + }, + { + "input": "{\"é\":1,\"z\":2}", + "output": "{\"z\":2,\"é\":1}" + }, + { + "input": "{\"𐀀\":1,\"�\":2}", + "output": "{\"�\":2,\"𐀀\":1}" + } + ], + "canonicalJsonRejected": [ + "{\"content\":\"\\ud800\"}", + "{\"content\":\"\\udc00\"}", + "{\"content\":\"\\ud800\\ud800\"}", + "{\"content\":\"\\ud800x\"}" + ] +} diff --git a/internal/install/msa-agent.service b/internal/install/msa-agent.service index 550bfc3..f13f226 100644 --- a/internal/install/msa-agent.service +++ b/internal/install/msa-agent.service @@ -1,6 +1,6 @@ [Unit] Description=MultiSimAdmin relay agent -Documentation=https://gitea.chickliu.fun/Hermes/simadmin-relay +Documentation=https://gitea.chickliu.fun/Hermes/msa-agent After=network-online.target Wants=network-online.target diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go index d7c4e23..1ac12a8 100644 --- a/internal/policy/policy_test.go +++ b/internal/policy/policy_test.go @@ -11,8 +11,14 @@ import ( // 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. +// +// The file this reads is the one this repository owns. The relay keeps its own copy — it derives its +// limits from the same numbers — and the two are compared at runtime by the policy digest the agent +// reports in its handshake, which the relay surfaces per node. A build-time comparison across two +// repositories is not something either side can make, so the guard is the digest and the drift is +// visible in /v1/status rather than in a test. func TestVendoredCopyMatchesContracts(t *testing.T) { - upstream, err := os.ReadFile(filepath.Join("..", "..", "..", "contracts", "agent-policy.json")) + upstream, err := os.ReadFile(filepath.Join("..", "..", "contracts", "agent-policy.json")) if err != nil { t.Fatalf("read contracts/agent-policy.json: %v", err) } diff --git a/systemd/msa-agent.service b/systemd/msa-agent.service index 550bfc3..f13f226 100644 --- a/systemd/msa-agent.service +++ b/systemd/msa-agent.service @@ -1,6 +1,6 @@ [Unit] Description=MultiSimAdmin relay agent -Documentation=https://gitea.chickliu.fun/Hermes/simadmin-relay +Documentation=https://gitea.chickliu.fun/Hermes/msa-agent After=network-online.target Wants=network-online.target