// 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. signingPublic, 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') // The relay registration carries the agent's *public* signing key as well as the bearer token. // Both are required: the token is only an abuse-control handle, and the signature over the relay's // challenge is what proves the caller holds the node's private key. A registration without the // key makes the relay refuse to start rather than start and reject every node, which is the // failure mode that shipped when the key was omitted. registration, err := json.Marshal(map[string]string{ "nodeID": id, "token": token, "signKey": base64.StdEncoding.EncodeToString(signingPublic), }) check(err) 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, "%s\n", registration) 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: %s\n", registration) } 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) } }