// Package identity is what this node presents to prove it is this node. // // novox/hq ADR 0004: the node generates a keypair, the private half never leaves the machine, and // the mesh records the public half. The same rule the overlay keys already follow, applied to the // node itself. // // The mesh issues nothing here. A node arrives at enrolment already holding its identity; what it // receives is *being known*. So this package is the whole of a node's identity, and it is made // before anybody is asked for anything. package identity import ( "crypto/ed25519" "encoding/base64" "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" ) // FileName is where a node keeps its identity, beside its state. const FileName = "identity.json" // Path is where the identity lives, given where the state lives. func Path(statePath string) string { return filepath.Join(filepath.Dir(statePath), FileName) } // dirOf is where a node keeps everything it knows about itself. func dirOf(statePath string) string { return filepath.Dir(statePath) } // Identity is this node's own keypair, the name the mesh knows it by, and what it needs to get // back to that mesh without a person. // // The keypair is the node's own and was never anybody else's. Everything under Membership was // learned at enrolment and is the mesh's answer rather than this machine's — kept here because a // node that could not reconnect after a restart without a new token would make disconnection a // crisis instead of an ordinary situation (novox/hq ADR 0004). type Identity struct { // Node is the name in the mesh's records. Learned at enrolment — the one thing here the node // does not decide for itself. Node string `json:"node"` Public []byte `json:"public"` Private []byte `json:"private"` Membership Membership `json:"membership"` // Overlay is this node's key on the private network. Generated here, like the identity above, // and for the same reason: the mesh computes a graph it cannot impersonate. Overlay OverlayKey `json:"overlay"` } // Membership is how this node reaches the mesh it belongs to, and who it believes. type Membership struct { // Broker is an address, not a name: there is no resolution before the link. Broker string `json:"broker"` // Fingerprint is checked before anything is sent, on every connection and not only the first. Fingerprint string `json:"fingerprint"` // Signer is the control plane's public signing key. Kept because **each declaration is // verified by its signature, every time** (novox/hq ADR 0004) — a node that only pinned the // broker would make the control plane's authority transitive, and a compromised broker could // then forge declarations, which is the whole machine. Signer []byte `json:"signer"` // Password is this node's own broker account, issued at enrolment and belonging to it alone. // Not the token's secret: that is spent, and a credential that lives for ever should not be // the same string as one that was meant to be used once. Password string `json:"password"` } // Queue is where this node listens. Its account may read this and nothing else. func (i Identity) Queue() string { return "node." + i.Node } // Joined reports whether this identity can reach its mesh unaided. func (m Membership) Joined() bool { return m.Broker != "" && m.Fingerprint != "" && len(m.Signer) == ed25519.PublicKeySize && m.Password != "" } // ErrNoIdentity means this machine has not enrolled. // // Not a fault: a hosted machine has a host running and no identity, and that is a real state // (novox/hq 09-the-node-lifecycle). It is the difference between "not a node yet" and "a node // whose identity is missing", and only the second is a problem. var ErrNoIdentity = errors.New("this machine has no identity, so it has not joined a mesh") // Generate makes a new identity. The private half exists only here, from this moment. func Generate(node string) (Identity, error) { public, private, err := ed25519.GenerateKey(nil) if err != nil { return Identity{}, fmt.Errorf("cannot generate this node's identity: %w", err) } return Identity{Node: node, Public: public, Private: private}, nil } // Sign proves this node is that node. func (i Identity) Sign(message []byte) []byte { return ed25519.Sign(ed25519.PrivateKey(i.Private), message) } // PublicBase64 is the public half as it travels. func (i Identity) PublicBase64() string { return base64.StdEncoding.EncodeToString(i.Public) } // Load reads this node's identity. func Load(path string) (Identity, error) { raw, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return Identity{}, ErrNoIdentity } if err != nil { // Never a silent absence. A machine that has an identity and cannot read it must not // behave as one that never had one — the second re-enrols, which would discard the // identity the mesh still believes. return Identity{}, fmt.Errorf( "this node has an identity at %s and cannot read it: %w. That is not the same as "+ "having none, so it will not re-enrol on its own", path, err) } var i Identity if err := json.Unmarshal(raw, &i); err != nil { return Identity{}, fmt.Errorf("the identity at %s is not readable: %w", path, err) } if len(i.Private) != ed25519.PrivateKeySize || len(i.Public) != ed25519.PublicKeySize { return Identity{}, fmt.Errorf( "the identity at %s is the wrong shape: %d-byte public and %d-byte private, where an "+ "Ed25519 identity is %d and %d", path, len(i.Public), len(i.Private), ed25519.PublicKeySize, ed25519.PrivateKeySize) } if strings.TrimSpace(i.Node) == "" { return Identity{}, fmt.Errorf("the identity at %s names no node", path) } // Checked here rather than at the moment it is used, which would be while trying to // reconnect on a machine nobody is watching. if !i.Membership.Joined() { return Identity{}, fmt.Errorf( "the identity at %s does not say how to reach its mesh, so this node cannot "+ "reconnect. It needs a new token", path) } return i, nil } // Save writes the identity, readable by nobody else. // // Written to a temporary file and renamed, so a machine losing power mid-write keeps the identity // it had rather than acquiring half of one. A node cannot regenerate its way out of that: the mesh // believes the old public key, and a new one needs a new token from a person. func Save(path string, i Identity) error { if len(i.Private) != ed25519.PrivateKeySize { return errors.New("refusing to save an identity with no usable private key") } // Save refuses exactly what Load refuses. Without this, a caller can write a file that // cannot be read back — and it would be read back on the next start, on a machine nobody is // watching, by which time the token that could have fixed it is spent. if strings.TrimSpace(i.Node) == "" { return errors.New("refusing to save an identity that names no node") } if !i.Membership.Joined() { return errors.New("refusing to save an identity that does not say how to reach its " + "mesh: it could not be used after a restart, and Load will not accept it") } if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } raw, err := json.MarshalIndent(i, "", " ") if err != nil { return err } tmp, err := os.CreateTemp(filepath.Dir(path), ".identity-*") if err != nil { return err } defer os.Remove(tmp.Name()) if err := tmp.Chmod(0o600); err != nil { tmp.Close() return err } if _, err := tmp.Write(raw); err != nil { tmp.Close() return err } if err := tmp.Sync(); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } return os.Rename(tmp.Name(), path) }