Files
mesh-host/internal/identity/identity.go
T
jschoubben a488c76b5e A node holds its link open, and applies what the mesh signs
The loop the whole thing exists for: told, apply, report.

`run` holds one outbound connection open and consumes the node's own queue.
Every declaration is verified against the control plane's signing key before a
byte of it is read as an instruction -- not once at connect, every time. The
transport being pinned is a different question from the instruction being
genuine, and pinning only the first would make the second transitive: a
compromised broker could forge declarations, and this host applies whatever the
link delivers.

Malformed and forged are reported differently, because ADR 0004 requires a host
to tell "this is not from the mesh I joined" from "this is broken". One means
somebody is trying and the other means something needs fixing.

A node now keeps what it needs to come back on its own: the broker's address
and fingerprint, the signing key it believes, and its own broker password --
which the mesh issues at enrolment to replace the token's secret, so the
one-time thing stays one-time and the credential it holds for years is not the
one that was pasted into a terminal.

Verified in the lab end to end. The node enrolled, held its link, received a
signed declaration and applied it -- the file is on the machine with the right
contents, and the host's own record lists both resources.

That run also found issue 010, which is recorded in novox/hq: the declaration
removed every container on the machine, including the control plane that sent
it. Correct reconciliation, shared store, and the first thing that happens.
2026-08-29 16:23:27 +02:00

183 lines
6.6 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, 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"`
}
// 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")
}
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)
}