Files
mesh-controller/internal/identity/identity.go
T
jschoubben 6d3bb18546 A node is known by a key it generated
The thing I had been calling blocked for weeks, built in an afternoon once it
was pointed out that it was already decided. 08-connectivity says of the
overlay keys: each node generates its own keypair, the private half never
leaves the machine, the public half is published -- and says outright this IS
ADR 0004's "a node holds its own identity". Nobody had applied it to node
identity itself.

identity now holds the public half of each node's key. Only the public half,
which is the property worth having: a copy of this database is a list of who to
believe, not a set of credentials, so compromise of a node really is compromise
of only that node.

Exactly one key is live per node, and re-enrolment revokes the one it replaced
in the same transaction -- two live identities is the stolen-laptop case with
the replaced machine still believed.

Fault injection was worth the time here. Three findings. The unique index was
defended by no test at all: sequential enrolment is already safe because the
code revokes before inserting, so removing the constraint changed nothing. The
constraint only matters when two enrolments race, and there is now a test that
runs six at once and fails without it.

My injection harness also lied to me. One injection matched nothing, changed no
file, and reported NO BITE identically to a real one -- so a test that defends
nothing and an injection that does nothing look the same. The harness now
checksums the files and says NO-OP when they did not change.

And one honest NO BITE left standing: making the key lookup return a zero key
for an unknown node does not fail the test, because the signature check refuses
it a line later. Two independent mechanisms, not a placebo.

61 tests, none skipped.
2026-08-29 15:25:19 +02:00

240 lines
8.2 KiB
Go

// Package identity is the context that holds who anything in the mesh is.
//
// novox/hq ADR 0006 names it as one of the seven. Built second, and only as far as the control
// plane's own signing identity — what a *node* presents to prove it is that node is not decided
// anywhere, and this deliberately stops short of guessing at it.
//
// It owns its store exclusively (novox/hq ADR 0008): a database called `identity`, reached with a
// credential no other context holds — including `inventory`, in the same process.
package identity
import (
"context"
"crypto/ed25519"
"crypto/sha256"
"embed"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/novox/mesh-control/internal/store"
)
// Name is what this context is called: its database and its credential are named after it.
const Name = "identity"
//go:embed migrations/*.sql
var files embed.FS
// Migrations are this context's schema changes, in order.
func Migrations() ([]store.Migration, error) {
return store.LoadMigrations(files, "migrations")
}
// Identity is this context, holding the store it exclusively owns.
type Identity struct{ store *store.Store }
// Open connects to the identity store.
func Open(ctx context.Context) (*Identity, error) {
s, err := store.Open(ctx, Name)
if err != nil {
return nil, err
}
return &Identity{store: s}, nil
}
func (i *Identity) Close() { i.store.Close() }
// Ready waits for the database to answer.
func (i *Identity) Ready(ctx context.Context, within time.Duration) error {
return i.store.Ready(ctx, within)
}
// SigningKey is the control plane's signing identity. Public is what travels in a token.
type SigningKey struct {
ID string
Public ed25519.PublicKey
Created time.Time
}
// Fingerprint is how a person compares two keys without reading 32 bytes.
//
// Of the public half, which is the half anything else ever sees.
func (k SigningKey) Fingerprint() string {
sum := sha256.Sum256(k.Public)
return hex.EncodeToString(sum[:])
}
// ErrNoSigningKey means this control plane has never generated one.
var ErrNoSigningKey = errors.New("this control plane has no signing key")
// Active is the key currently signing.
//
// Absence is an error rather than an empty key. A control plane that cannot find its signing
// identity must say so: signing with nothing, or with a freshly invented key, would produce
// declarations that every existing node correctly refuses — and the refusal would look like a
// compromise rather than a missing file.
func (i *Identity) Active(ctx context.Context) (SigningKey, error) {
var k SigningKey
var public []byte
err := i.store.Pool().QueryRow(ctx,
`select id, public, created from signing_key where retired is null`).
Scan(&k.ID, &public, &k.Created)
if errors.Is(err, pgx.ErrNoRows) {
return SigningKey{}, ErrNoSigningKey
}
if err != nil {
return SigningKey{}, err
}
k.Public = public
return k, nil
}
// Establish generates the signing identity if there is not one already.
//
// Idempotent, and it has to be: the control plane runs this at every start, and a second key
// generated by a restart would be a mesh whose nodes hold the wrong public half — every
// declaration refused, by every node, with nothing having gone wrong that anybody could see.
//
// The insert is what makes it safe rather than the check before it. Two processes starting
// together both find nothing; only one insert survives the partial unique index, and the other
// reads back the winner instead of failing.
func (i *Identity) Establish(ctx context.Context) (SigningKey, error) {
existing, err := i.Active(ctx)
if err == nil {
return existing, nil
}
if !errors.Is(err, ErrNoSigningKey) {
return SigningKey{}, err
}
public, private, err := ed25519.GenerateKey(nil)
if err != nil {
return SigningKey{}, fmt.Errorf("cannot generate a signing key: %w", err)
}
_, err = i.store.Pool().Exec(ctx,
`insert into signing_key (public, private) values ($1, $2)
on conflict do nothing`, []byte(public), []byte(private))
if err != nil {
return SigningKey{}, err
}
// Read back rather than return what was generated: on conflict this process generated a key
// that was not stored, and returning it would hand out a public half nothing will ever sign
// with (novox/hq ADR 0018 — a picture is read from the system).
return i.Active(ctx)
}
// Sign signs a declaration with the active key.
//
// The private half is fetched per call rather than held in memory for the process's lifetime.
// That is not paranoia about memory: it means a key retired while this process runs stops being
// used at the next signature rather than at the next restart.
func (i *Identity) Sign(ctx context.Context, message []byte) ([]byte, error) {
var private []byte
err := i.store.Pool().QueryRow(ctx,
`select private from signing_key where retired is null`).Scan(&private)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNoSigningKey
}
if err != nil {
return nil, err
}
return ed25519.Sign(ed25519.PrivateKey(private), message), nil
}
// Verify checks a signature against a public key. Here because the host does the same thing with
// the same algorithm, and the two must not drift apart.
func Verify(public ed25519.PublicKey, message, signature []byte) bool {
return ed25519.Verify(public, message, signature)
}
// NodeKey is the public half of a node's own keypair, as the mesh holds it.
type NodeKey struct {
ID string
Node string
Public ed25519.PublicKey
Issued time.Time
}
// ErrNotThisNode is what verification returns when a key is not the live one for a node.
//
// One error whether the key is unknown, revoked, or belongs to a different node. Whoever is
// presenting a key that does not work is either a machine whose operator can be told out of band,
// or something probing, and the second must not learn which.
var ErrNotThisNode = errors.New("that key does not identify that node")
// RecordNodeKey writes down the public key the mesh will believe for a node.
//
// Any previous key for the node is revoked in the same transaction. Two live identities for one
// node record is novox/hq ADR 0004's stolen-laptop case — the machine that was replaced going on
// being believed — and the window between two statements is exactly when it would exist.
func (i *Identity) RecordNodeKey(ctx context.Context, node string, public ed25519.PublicKey) (NodeKey, error) {
if len(public) != ed25519.PublicKeySize {
return NodeKey{}, fmt.Errorf(
"a node key is %d bytes and this is %d: a node presents an Ed25519 public key",
ed25519.PublicKeySize, len(public))
}
tx, err := i.store.Pool().Begin(ctx)
if err != nil {
return NodeKey{}, err
}
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
if _, err := tx.Exec(ctx,
`update node_key set revoked = now() where node = $1 and revoked is null`, node); err != nil {
return NodeKey{}, err
}
var k NodeKey
var stored []byte
err = tx.QueryRow(ctx,
`insert into node_key (node, public) values ($1, $2) returning id, node, public, issued`,
node, []byte(public)).Scan(&k.ID, &k.Node, &stored, &k.Issued)
if err != nil {
return NodeKey{}, err
}
k.Public = stored
if err := tx.Commit(ctx); err != nil {
return NodeKey{}, err
}
return k, nil
}
// LiveKey is the key currently identifying a node.
func (i *Identity) LiveKey(ctx context.Context, node string) (NodeKey, error) {
var k NodeKey
var public []byte
err := i.store.Pool().QueryRow(ctx,
`select id, node, public, issued from node_key where node = $1 and revoked is null`,
node).Scan(&k.ID, &k.Node, &public, &k.Issued)
if errors.Is(err, pgx.ErrNoRows) {
return NodeKey{}, ErrNotThisNode
}
if err != nil {
return NodeKey{}, err
}
k.Public = public
return k, nil
}
// VerifyNode checks that something signed a challenge with the live key for a node.
//
// This is the whole of proving a node is that node, and it is the same operation the node performs
// in the other direction on every declaration it receives. Nothing here is stored that could be
// replayed: the mesh holds a public key, so a copy of this database proves nothing to anybody.
func (i *Identity) VerifyNode(ctx context.Context, node string, challenge, signature []byte) error {
key, err := i.LiveKey(ctx, node)
if err != nil {
return err
}
if !ed25519.Verify(key.Public, challenge, signature) {
return ErrNotThisNode
}
return nil
}