Curve25519, which is what WireGuard uses. The private half never leaves the machine and is written to a file of its own, so the interface configuration the mesh composes can point at it without ever carrying it. Separate from the identity keypair on purpose. One signs messages to the mesh and the other encrypts traffic between nodes -- different things verified by different parties at different times, and a key used for two purposes is one rotation away from breaking the other.
200 lines
7.5 KiB
Go
200 lines
7.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)
|
|
}
|
|
|
|
// 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)
|
|
}
|