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
+113
View File
@@ -0,0 +1,113 @@
// Package token is the thing a person carries to a machine that is joining.
//
// novox/hq ADR 0004: it carries four things, and it is the only thing a joining node needs —
// where the broker is, what certificate to expect there, whose signature to believe afterwards,
// and a one-time right to join.
//
// Its authenticity comes from the channel it travelled, not from anything the node can check
// afterwards: trust on first use, with the first use moved out of band. Which makes the token
// security-critical, because it carries the pin. Tampering with it substitutes the mesh — still
// better than the alternative, where there is nothing to tamper with and a node trusts the first
// answer it gets.
package token
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
)
// Token is the four things, and it is assembled by whatever holds all four.
//
// Two contexts contribute: `inventory` owns the node record and mints the secret, `identity` owns
// the signing key. Neither reads the other's store — the process holding both grants asks each
// for its part (novox/hq ADR 0008).
type Token struct {
Version int `json:"v"`
// Broker is an address and not a name. There is no resolution before joining, which is why
// this is the one place in the mesh where an address is carried deliberately.
Broker string `json:"broker,omitempty"`
// Fingerprint is the broker certificate's, checked before anything is sent.
Fingerprint string `json:"fingerprint,omitempty"`
// Signer is the control plane's public signing key: whose declarations to believe.
Signer []byte `json:"signer,omitempty"`
// Secret is the one-time right to join. Useless once used, useless after it expires.
Secret string `json:"secret"`
}
// Missing names the parts that are not filled in.
//
// Returned as a list rather than a bool, because "this token cannot be used" is not an answer
// anybody can act on and "it has no broker address" is. Everything here is required: a token
// missing the fingerprint cannot verify what it connects to, and one missing the signer makes
// the control plane's authority transitive through the broker — which ADR 0004 rejects, because
// a compromised broker could then forge declarations, and that is the whole machine.
func (t Token) Missing() []string {
var missing []string
if strings.TrimSpace(t.Broker) == "" {
missing = append(missing, "the broker's address — there is nowhere to connect to")
}
if strings.TrimSpace(t.Fingerprint) == "" {
missing = append(missing,
"the broker certificate's fingerprint — nothing to check the connection against")
}
if len(t.Signer) != ed25519.PublicKeySize {
missing = append(missing,
"the control plane's signing key — declarations could not be told from forgeries")
}
if strings.TrimSpace(t.Secret) == "" {
missing = append(missing, "the one-time secret — nothing to present")
}
return missing
}
// Complete reports whether this token could actually be used to join.
func (t Token) Complete() bool { return len(t.Missing()) == 0 }
// Encode renders the token as one line a person can carry.
//
// Base64 of JSON: self-describing, so a token from an older control plane says what it is rather
// than being misread by a newer one; and one line, because it is copied by hand between a
// terminal and a machine.
func (t Token) Encode() (string, error) {
t.Version = 1
raw, err := json.Marshal(t)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
// Decode reads a token a person pasted.
func Decode(encoded string) (Token, error) {
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(encoded))
if err != nil {
return Token{}, fmt.Errorf("this is not a token: %w", err)
}
var t Token
if err := json.Unmarshal(raw, &t); err != nil {
return Token{}, fmt.Errorf("this is not a token: %w", err)
}
if t.Version != 1 {
return Token{}, fmt.Errorf(
"this token says it is version %d, and this host understands version 1. It was made "+
"by a different control plane than the one this was built against", t.Version)
}
return t, nil
}
// base64Decode and encodeBase64 exist for the tests, which construct a token by hand to prove a
// version this build does not understand is refused. Kept beside the encoding they mirror.
func base64Decode(s string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(strings.TrimSpace(s))
}
func encodeBase64(s string) string {
return base64.RawURLEncoding.EncodeToString([]byte(s))
}
+118
View File
@@ -0,0 +1,118 @@
package token
import (
"crypto/ed25519"
"strings"
"testing"
)
func complete(t *testing.T) Token {
t.Helper()
public, _, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
return Token{
Broker: "192.0.2.10:5671",
Fingerprint: "sha256:" + strings.Repeat("ab", 32),
Signer: public,
Secret: "a-one-time-secret",
}
}
func TestATokenSurvivesBeingCarried(t *testing.T) {
// It is copied by hand out of a terminal and into a machine. Whatever comes back must be
// exactly what went in, including the key — a signing key that changed in transit is a node
// that refuses every declaration it is later sent.
original := complete(t)
encoded, err := original.Encode()
if err != nil {
t.Fatal(err)
}
if strings.ContainsAny(encoded, " \n\t") {
t.Error("the encoded token contains whitespace; it is copied by hand as one line")
}
back, err := Decode(encoded)
if err != nil {
t.Fatal(err)
}
if back.Broker != original.Broker || back.Fingerprint != original.Fingerprint ||
back.Secret != original.Secret || string(back.Signer) != string(original.Signer) {
t.Errorf("the token changed in transit:\n sent %+v\n got %+v", original, back)
}
}
func TestSurroundingWhitespaceIsTolerated(t *testing.T) {
// It arrives pasted. A trailing newline is not a corrupted token, and refusing one would
// send somebody hunting for a fault that is not there.
encoded, err := complete(t).Encode()
if err != nil {
t.Fatal(err)
}
if _, err := Decode(" " + encoded + "\n"); err != nil {
t.Errorf("a pasted token was refused: %v", err)
}
}
func TestGarbageIsRefusedAsNotBeingAToken(t *testing.T) {
for _, bad := range []string{"", "not-base64-!!!", "aGVsbG8"} {
if _, err := Decode(bad); err == nil {
t.Errorf("%q was accepted as a token", bad)
}
}
}
func TestATokenFromAnotherVersionIsRefusedClearly(t *testing.T) {
// The host must tell "this is not from the mesh I joined" apart from "this is malformed"
// (novox/hq ADR 0004). A version it does not understand is the first case.
future := complete(t)
encoded, err := future.Encode()
if err != nil {
t.Fatal(err)
}
// Re-encode by hand at a version this build does not know.
raw := strings.Replace(string(mustDecodeBase64(t, encoded)), `"v":1`, `"v":99`, 1)
if _, err := Decode(encodeBase64(raw)); err == nil {
t.Fatal("a token from an unknown version was accepted")
}
}
func TestEveryMissingPartIsNamed(t *testing.T) {
// "This token cannot be used" is not something anybody can act on. "It has no broker
// address" is. And all of them at once, not the first: fixing one at a time turns a single
// decision into four.
empty := Token{}
missing := empty.Missing()
if len(missing) != 4 {
t.Fatalf("an empty token named %d missing parts, expected 4: %v", len(missing), missing)
}
if empty.Complete() {
t.Error("an empty token reported itself complete")
}
}
func TestAShortSigningKeyIsNotASigningKey(t *testing.T) {
// The one that would pass a nil check and fail at the moment a declaration is verified —
// which is on a node, in production, long after this.
t1 := complete(t)
t1.Signer = []byte("too short")
if t1.Complete() {
t.Error("a truncated signing key was accepted as present")
}
}
func TestACompleteTokenIsComplete(t *testing.T) {
if got := complete(t); !got.Complete() {
t.Errorf("a token with all four parts reported missing: %v", got.Missing())
}
}
func mustDecodeBase64(t *testing.T, s string) []byte {
t.Helper()
raw, err := base64Decode(s)
if err != nil {
t.Fatal(err)
}
return raw
}