// Package enroll mints the key material, identifiers and secrets a node needs, and renders the two // artefacts an operator has to place: the agent's config file and the relay's registration entry. // // It exists as a package rather than as a command because two callers need the same thing and must // not drift. `msa-keygen` was the first, and it required a Go toolchain on whichever machine ran it — // fine when that was the operator's laptop, wrong now that enrollment happens on the node itself, // where there is a cross-compiled binary and no toolchain. `msa-agent enroll` is the second caller, // and it is the same code path, so the two can never disagree about what a config looks like. // // What it deliberately does NOT do: invent the node's SimAdmin password. That is the operator's, and // it is the one secret a tool must not make up. An unprotected node needs no value; a protected one // needs the real one, or every command fails with node_auth_failed and the cause is not obvious. package enroll import ( "crypto/ed25519" "crypto/rand" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "fun.chick/msa-agent/internal/wire" ) // Options are what the caller knows and cannot derive. type Options struct { // RelayURL is the relay this node will connect to. https; the agent refuses anything else. RelayURL string // LocalPort is the SimAdmin port on 127.0.0.1. There is deliberately no host. LocalPort int // AuditPath is where the audit log is appended. AuditPath string // NodeID, AgentToken and ProvisionSecret are generated when empty. They are settable so an // operator re-enrolling a node can keep the identifiers the relay already knows. NodeID string AgentToken string ProvisionSecret string // RelayCAFile pins the relay's CA instead of the system trust store. Optional. RelayCAFile string // NodePassword is written into the config when non-empty. Never generated. NodePassword string // SelfEnrolled records that this node registered itself with a one-time code, so `uninstall` can // tell the operator which of the relay's two records mentions it. SelfEnrolled bool } // Result is everything the caller has to place or show. type Result struct { // Config is the agent config file, ready to write 0600. Config []byte // Registration is the relay's agents.json entry, as compact JSON. Registration []byte // NodeID and ProvisionSecret are echoed because both have to be shown to the operator. NodeID string ProvisionSecret string } // Registration is the relay's view of this node: an id, a bearer token, and the public key that // verifies its handshake signatures. // // The signing key is the part that cannot be omitted, and the reason is not tidiness. The relay // refuses to start when an entry has no usable key — it did ship once with an optional verifier that // nobody supplied, and the result was a relay that reported healthy and answered every node with 500. type Registration struct { NodeID string `json:"nodeID"` Token string `json:"token"` SignKey string `json:"signKey"` } // Generate mints the material and renders both artefacts. func Generate(opts Options) (Result, error) { if opts.RelayURL == "" { return Result{}, fmt.Errorf("enroll: relay URL is required") } if opts.LocalPort <= 0 || opts.LocalPort > 65535 { return Result{}, fmt.Errorf("enroll: local port %d is out of range", opts.LocalPort) } if opts.AuditPath == "" { return Result{}, fmt.Errorf("enroll: audit path is required") } // The signing key is Ed25519: it answers the relay's handshake challenge and signs responses. signingPublic, signingKey, err := ed25519.GenerateKey(rand.Reader) if err != nil { return Result{}, fmt.Errorf("enroll: generating the signing key: %w", 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) if _, err := rand.Read(kxKey); err != nil { return Result{}, fmt.Errorf("enroll: generating the key-agreement key: %w", err) } nodeID := opts.NodeID if nodeID == "" { nodeID = "nd_" + randomHex(5) } token := opts.AgentToken if token == "" { token = randomHex(32) } // The pairing secret is minted even though an unprotected node is the only thing that needs it: 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 and this value is unused. pairingSecret := opts.ProvisionSecret if pairingSecret == "" { pairingSecret = randomHex(16) } config := map[string]any{ "nodeId": nodeID, "relayUrl": opts.RelayURL, "agentToken": token, "localPort": opts.LocalPort, "signKey": base64.StdEncoding.EncodeToString(signingKey), "kxKey": base64.StdEncoding.EncodeToString(kxKey), "auditPath": opts.AuditPath, // Only used to authorise the pairing window on an unprotected node. Deleting it after pairing // is fine; 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{}, } // Absent rather than empty when unset, so a config that has not been told about a password does not // claim the node has none. if opts.NodePassword != "" { config["nodePassword"] = opts.NodePassword } if opts.RelayCAFile != "" { config["relayCAFile"] = opts.RelayCAFile } if opts.SelfEnrolled { config["selfEnrolled"] = true } encoded, err := json.MarshalIndent(config, "", " ") if err != nil { return Result{}, fmt.Errorf("enroll: encoding the config: %w", err) } encoded = append(encoded, '\n') registration, err := json.Marshal(Registration{ NodeID: nodeID, Token: token, SignKey: base64.StdEncoding.EncodeToString(signingPublic), }) if err != nil { return Result{}, fmt.Errorf("enroll: encoding the registration: %w", err) } return Result{ Config: encoded, Registration: registration, NodeID: nodeID, ProvisionSecret: pairingSecret, }, nil } func randomHex(bytes int) string { raw := make([]byte, bytes) if _, err := rand.Read(raw); err != nil { // rand.Read only fails if the system entropy source is gone, in which case nothing here can run // safely: a key minted from a broken source is worse than no key. panic(fmt.Sprintf("enroll: entropy source failed: %v", err)) } return hex.EncodeToString(raw) }