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.
153 lines
5.2 KiB
Go
153 lines
5.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)
|
|
}
|