The control plane's signing key, and a second context to hold it

Everything is blocked on what a node presents to prove which node it is. This
builds the other direction, which is not blocked: what a node believes.

identity is the second of the seven contexts. It holds an Ed25519 signing key
the control plane generates once, whose public half now travels in every
enrolment token. A node believes a declaration because it carries a signature
that key made -- pinning only the broker would make the control plane's
authority transitive, and since the host applies whatever the link delivers, a
compromised broker forging declarations is the whole machine.

Establishing the key is idempotent, and it has to be: a second key generated by
a restart is a mesh where every node holds the wrong public half, so every
declaration is refused by every node with nothing visibly wrong. The guarantee
is a partial unique index plus a read-back, not the check before the insert --
six processes racing to establish all agree on one key, and there is a test
that runs them.

Tokens are now one line of base64 carrying three of their four parts. The
missing two are the broker's address and its certificate fingerprint, both step
5 of the bootstrap. The command prints the token and names what is missing
rather than emitting something that looks usable.

The second context also tests a claim this repository had made and never
checked: that a context reaches only its own store. Two databases, two
credentials, no setting that reaches both. Running migrate with one stops and
names the grant it lacks -- verified, not asserted. Assembling a token needs a
node record from one and a key from the other, and neither reads the other's
store; the process holding both grants asks each for its part.

45 tests, none skipped. Fault injection found one test whose property is
enforced somewhere other than where I injected -- idempotency comes from the
database constraint, not from the early return, which is what the code comment
already said.
This commit is contained in:
2026-08-29 15:05:23 +02:00
parent 66768208d2
commit 7553af6c5a
7 changed files with 712 additions and 11 deletions
+152
View File
@@ -0,0 +1,152 @@
// 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)
}
+205
View File
@@ -0,0 +1,205 @@
package identity
import (
"context"
"errors"
"fmt"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/novox/mesh-control/internal/store"
)
func fresh(t *testing.T) *Identity {
t.Helper()
admin := os.Getenv("MESH_TEST_POSTGRES")
if admin == "" {
t.Skip("no MESH_TEST_POSTGRES; run `make check` to raise one")
}
name := fmt.Sprintf("ident_%d", time.Now().UnixNano()%10_000_000)
conn, err := pgx.Connect(t.Context(), admin)
if err != nil {
t.Fatalf("cannot reach the test PostgreSQL: %v", err)
}
if _, err := conn.Exec(t.Context(), "create database "+name); err != nil {
t.Fatalf("cannot create %s: %v", name, err)
}
conn.Close(t.Context())
cut := strings.LastIndex(admin, "/")
t.Setenv(store.Variable(Name), admin[:cut]+"/"+name+"?sslmode=disable")
ident, err := Open(t.Context())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
ident.Close()
c, err := pgx.Connect(context.Background(), admin)
if err != nil {
return
}
defer c.Close(context.Background())
_, _ = c.Exec(context.Background(), "drop database if exists "+name+" with (force)")
})
if err := ident.Ready(t.Context(), 20*time.Second); err != nil {
t.Fatal(err)
}
migrations, err := Migrations()
if err != nil {
t.Fatal(err)
}
if _, err := ident.store.Migrate(t.Context(), migrations); err != nil {
t.Fatal(err)
}
return ident
}
func TestNoKeyIsAnErrorRatherThanAnEmptyKey(t *testing.T) {
// Signing with nothing, or with a key invented on the spot, produces declarations every
// existing node correctly refuses — and that refusal looks like a compromise rather than a
// control plane that lost its key.
ident := fresh(t)
if _, err := ident.Active(t.Context()); !errors.Is(err, ErrNoSigningKey) {
t.Fatalf("expected ErrNoSigningKey, got %v", err)
}
if _, err := ident.Sign(t.Context(), []byte("anything")); !errors.Is(err, ErrNoSigningKey) {
t.Fatalf("signing without a key gave %v", err)
}
}
func TestEstablishingTwiceKeepsTheFirstKey(t *testing.T) {
// The control plane runs this at every start. A second key generated by a restart is a mesh
// whose nodes all hold the wrong public half — every declaration refused, by every node,
// with nothing visibly having gone wrong.
ident := fresh(t)
first, err := ident.Establish(t.Context())
if err != nil {
t.Fatal(err)
}
second, err := ident.Establish(t.Context())
if err != nil {
t.Fatal(err)
}
if first.ID != second.ID || string(first.Public) != string(second.Public) {
t.Error("a second Establish replaced the signing key; every node would hold the wrong one")
}
}
func TestTwoProcessesStartingTogetherAgreeOnOneKey(t *testing.T) {
// A restart while another copy is coming up. Both find nothing and both generate; only one
// insert may survive, and the loser must read back the winner rather than return the key it
// generated and did not store.
ident := fresh(t)
var wg sync.WaitGroup
keys := make([]SigningKey, 6)
errs := make([]error, 6)
for i := range keys {
wg.Add(1)
go func(i int) {
defer wg.Done()
keys[i], errs[i] = ident.Establish(context.Background())
}(i)
}
wg.Wait()
for i, err := range errs {
if err != nil {
t.Fatalf("establish %d failed: %v", i, err)
}
}
for i, k := range keys {
if k.ID != keys[0].ID {
t.Errorf("establish %d got key %s, establish 0 got %s — they disagree", i, k.ID, keys[0].ID)
}
}
var count int
if err := ident.store.Pool().QueryRow(t.Context(),
`select count(*) from signing_key`).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Errorf("%d signing keys exist; exactly one may be active", count)
}
}
func TestASignatureVerifiesAgainstThePublicHalfThatTravels(t *testing.T) {
// The whole point: a node holds only the public half, from a token it may have received
// months ago, and must be able to tell a real declaration from a forged one.
ident := fresh(t)
key, err := ident.Establish(t.Context())
if err != nil {
t.Fatal(err)
}
declaration := []byte(`{"declaration":1,"resources":[]}`)
signature, err := ident.Sign(t.Context(), declaration)
if err != nil {
t.Fatal(err)
}
if !Verify(key.Public, declaration, signature) {
t.Fatal("a declaration this control plane signed did not verify against the key it hands out")
}
}
func TestATamperedDeclarationDoesNotVerify(t *testing.T) {
// Since the host applies whatever the link delivers, a forged declaration is the whole
// machine. This is the check that stands between those two facts.
ident := fresh(t)
key, err := ident.Establish(t.Context())
if err != nil {
t.Fatal(err)
}
signature, err := ident.Sign(t.Context(), []byte(`{"resources":["harmless"]}`))
if err != nil {
t.Fatal(err)
}
if Verify(key.Public, []byte(`{"resources":["something else entirely"]}`), signature) {
t.Fatal("a signature made over one declaration verified against a different one")
}
}
func TestAnotherControlPlanesSignatureIsRefused(t *testing.T) {
// "This is not from the mesh I joined" — the case ADR 0004 requires a host to tell apart
// from "this is malformed".
mine := fresh(t)
theirs := fresh(t)
myKey, err := mine.Establish(t.Context())
if err != nil {
t.Fatal(err)
}
if _, err := theirs.Establish(t.Context()); err != nil {
t.Fatal(err)
}
declaration := []byte(`{"declaration":1}`)
theirSignature, err := theirs.Sign(t.Context(), declaration)
if err != nil {
t.Fatal(err)
}
if Verify(myKey.Public, declaration, theirSignature) {
t.Fatal("a signature from a different control plane verified against this one's key")
}
}
func TestTheFingerprintIsOfThePublicHalf(t *testing.T) {
ident := fresh(t)
key, err := ident.Establish(t.Context())
if err != nil {
t.Fatal(err)
}
if len(key.Fingerprint()) != 64 {
t.Errorf("fingerprint is %q", key.Fingerprint())
}
// And it must not be derivable from something that is not the key.
if key.Fingerprint() == (SigningKey{Public: make([]byte, 32)}).Fingerprint() {
t.Error("the fingerprint does not depend on the key")
}
}
@@ -0,0 +1,30 @@
-- The control plane's own signing identity.
--
-- novox/hq ADR 0004: a node takes instruction from the control plane behind the broker, and each
-- declaration is verified by its signature, every time. The public half of this key travels in
-- every enrolment token; the private half never leaves this context.
--
-- Why not pin only the broker: that would make the control plane's authority transitive. A
-- compromised broker could then forge declarations, and since the host applies whatever the link
-- delivers, that is the whole machine. The transport is verified once at connect; the instruction
-- is verified on arrival.
create table signing_key (
id uuid primary key default gen_random_uuid(),
-- Ed25519. Fixed rather than a column: a key that carries its own algorithm invites a caller
-- to be told which one to use, and the two sizes below are Ed25519's.
public bytea not null check (octet_length(public) = 32),
private bytea not null check (octet_length(private) = 64),
created timestamptz not null default now(),
-- Retiring a signing key is a fleet-wide operation with an overlapping rollover -- every node
-- holds the public half, delivered in a token it may have received months ago. So keys are
-- retired, never deleted, and more than one may be valid at a time during a rollover.
retired timestamptz
);
-- The rollover is what makes this a partial index rather than a plain unique constraint: exactly
-- one key may be signing at any moment, while any number of retired ones remain verifiable.
create unique index signing_key_one_active on signing_key ((retired is null)) where retired is null;