diff --git a/cmd/msa-keygen/main.go b/cmd/msa-keygen/main.go new file mode 100644 index 0000000..caed954 --- /dev/null +++ b/cmd/msa-keygen/main.go @@ -0,0 +1,125 @@ +// Command msa-keygen mints the key material and identifiers a node needs, and prints a config file +// skeleton to stdout. +// +// It exists because the agent's config cannot be written by hand: the two private keys are raw +// 32-byte values that must be base64, and a node id, an agent token and a device id all have to +// match patterns the agent and the relay both enforce. An operator generating those with openssl and +// a text editor will eventually produce a file that fails at startup with a validation error whose +// cause is a character in the wrong place. +// +// It generates the *agent's* keys only. The phone's keys are generated on the phone, at pairing +// time, and never leave it — a tool that could mint a device key would be a tool that can impersonate +// a phone. +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + + "fun.chick/msa-agent/internal/wire" +) + +func main() { + var ( + nodeID = flag.String("node", "", "node id (nd_...); generated when empty") + relayURL = flag.String("relay", "https://relay.example", "relay base URL, https") + localPort = flag.Int("port", 3000, "the local SimAdmin port on 127.0.0.1") + auditPath = flag.String("audit", "/var/log/msa-agent/audit.log", "audit log path") + agentToken = flag.String("token", "", "relay bearer for this agent; generated when empty") + secret = flag.String("provision-secret", "", "pairing secret for an unprotected node; generated when empty") + write = flag.String("write", "", "write the config here instead of stdout (mode 0600)") + ) + flag.Parse() + + // The signing key is Ed25519 for challenge-response at the relay and for signing responses. + _, signingKey, err := ed25519.GenerateKey(rand.Reader) + check(err) + // The key-agreement key is raw X25519: clamping is the curve library's job, and a stored key + // that had already been clamped would be a second, non-standard encoding to keep in sync across + // three languages. + kxKey := make([]byte, wire.KeyBytes) + _, err = rand.Read(kxKey) + check(err) + + id := *nodeID + if id == "" { + id = "nd_" + randomHex(5) + } + token := *agentToken + if token == "" { + token = randomHex(32) + } + // The pairing secret is generated even though it is only needed for an unprotected node: a + // config without one cannot pair at all, and discovering that at pairing time means another trip + // to the node. When the node *is* protected, `nodePassword` takes precedence over this value, so + // carrying it is harmless. + pairingSecret := *secret + if pairingSecret == "" { + pairingSecret = randomHex(16) + } + + config := map[string]any{ + "nodeId": id, + "relayUrl": *relayURL, + "agentToken": token, + "localPort": *localPort, + "signKey": base64.StdEncoding.EncodeToString(signingKey), + "kxKey": base64.StdEncoding.EncodeToString(kxKey), + // The node password is intentionally absent: it is the operator's, and it is the one secret + // this tool must not invent. An unprotected node needs no value; a protected one needs the + // real password or every command fails with node_auth_failed. + "auditPath": *auditPath, + // Only used to authorise the pairing window on an unprotected node. Delete it after pairing + // if you like; nothing else reads it. + "provisionSecret": pairingSecret, + // Empty on purpose: devices are added by pairing (`msa-agent --pair`), which is the only + // path that should ever be able to introduce a trusted phone. + "devices": []any{}, + } + + encoded, err := json.MarshalIndent(config, "", " ") + check(err) + encoded = append(encoded, '\n') + + if *write == "" { + // The relay's own registration is printed alongside, because the two files have to agree on + // the node id and token and copying them by hand is where that agreement breaks. + fmt.Fprintf(os.Stderr, "# agent config (paste into %s, then add \"nodePassword\" if the node is protected)\n", "/etc/msa-agent/config.json") + os.Stdout.Write(encoded) + fmt.Fprintf(os.Stderr, "\n# relay registration: add this entry to MSA_AGENTS\n") + fmt.Fprintf(os.Stderr, "{\"nodeID\":%q,\"token\":%q}\n", id, token) + fmt.Fprintf(os.Stderr, "\n# pairing secret (unprotected nodes only; protected ones use nodePassword): %s\n", pairingSecret) + fmt.Fprintf(os.Stderr, "# next: run `msa-agent --pair` on the node once, then pair the phone from the app\n") + return + } + if err := os.MkdirAll(filepath.Dir(*write), 0o755); err != nil { + check(err) + } + // 0600: this file holds the agent's private keys and the node's password. + if err := os.WriteFile(*write, encoded, 0o600); err != nil { + check(err) + } + fmt.Printf("wrote %s (mode 0600)\n", *write) + fmt.Printf("relay registration: {\"nodeID\":%q,\"token\":%q}\n", id, token) +} + +func randomHex(bytes int) string { + raw := make([]byte, bytes) + _, err := rand.Read(raw) + check(err) + return hex.EncodeToString(raw) +} + +func check(err error) { + if err != nil { + fmt.Fprintln(os.Stderr, "msa-keygen:", err) + os.Exit(1) + } +}