// 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) } // Identity is this node's own keypair, and the name the mesh knows it by. type Identity struct { // Node is the name in the mesh's records. Learned at enrolment, from the mesh — it is the one // thing here the node does not decide for itself. Node string `json:"node"` Public []byte `json:"public"` Private []byte `json:"private"` } // 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) } 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") } 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) }