The host side of enrolment. It parses a token the control plane issued, dials the broker, refuses anything but the pinned certificate, and generates an Ed25519 keypair whose private half never leaves the machine. Verified against a real LavinMQ serving a real certificate: the pin matched and the node proceeded. Then against a second broker with a different certificate on another port, which was refused -- with an error that says retrying will not help, because it does not mean the network is down, it means the mesh was substituted. InsecureSkipVerify is set and that is the point rather than a weakening. At bootstrap the broker is self-signed and reached at an address, so there is no authority to trace and no name to match. Chain and hostname checks are replaced with something stricter: this exact certificate or nothing, checked in VerifyPeerCertificate, which runs before the handshake completes -- so nothing is sent to the wrong broker. There is a test that counts the bytes an impostor receives, and it is zero. The token format is defined separately here and in the control plane, because this binary requires nothing present and does not import it. They are held together by a test on each side asserting the exact field names, so a rename breaks both immediately rather than at enrolment on a real machine. Two distinctions the identity file has to keep. A machine that never joined has no identity, which is an ordinary state and not a fault. A machine whose identity cannot be read is a different thing entirely, and must not take the same path -- re-enrolling would discard the identity the mesh still believes and need a person with a new token. Fault injection found the second case untested: the corrupt-file test was passing on the parse check, so the read-error path had nothing defending it. It does now. An already-enrolled machine refuses to enrol again rather than quietly acquiring a second identity. What is not built is the link. Enrolment stops after verifying the broker and generating the identity, having saved nothing, so it can be run again unchanged. 132 tests, plus 32 launcher and 9 rollback.
139 lines
4.5 KiB
Go
139 lines
4.5 KiB
Go
// 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)
|
|
}
|