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
+71 -6
View File
@@ -16,8 +16,10 @@ import (
"syscall"
"time"
"github.com/novox/mesh-control/internal/identity"
"github.com/novox/mesh-control/internal/inventory"
"github.com/novox/mesh-control/internal/store"
"github.com/novox/mesh-control/internal/token"
)
// version is stamped at link time. Unset in a development build, and it says so rather than
@@ -33,6 +35,7 @@ var held = []struct {
migrations func() ([]store.Migration, error)
}{
{inventory.Name, inventory.Migrations},
{identity.Name, identity.Migrations},
}
func main() {
@@ -59,6 +62,8 @@ func run() error {
return nodeCommand(ctx, args[1:])
case "token":
return tokenCommand(ctx, args[1:])
case "identity":
return identityCommand(ctx, args[1:])
case "version":
fmt.Println(version)
return nil
@@ -79,6 +84,7 @@ func usage() {
node list the nodes this mesh knows about
token issue --node <name> a one-time right to join, for an existing record
token issue --new <name> create the record and issue for it
identity show this control plane's signing key
version what this binary is
Each context reaches its own store through its own credential (novox/hq ADR 0008), named
@@ -231,12 +237,71 @@ func tokenCommand(ctx context.Context, args []string) error {
return err
}
// Assembled from two contexts by the process that holds both grants. Neither reads the
// other's store (novox/hq ADR 0008) — each is asked for its own part.
ident, err := openIdentity(ctx)
if err != nil {
return err
}
defer ident.Close()
key, err := ident.Establish(ctx)
if err != nil {
return err
}
made := token.Token{Signer: key.Public, Secret: issued.Secret}
encoded, err := made.Encode()
if err != nil {
return err
}
fmt.Printf("token for %s, usable once, until %s\n\n %s\n\n",
issued.Node.Name, issued.Expires.Format(time.RFC3339), issued.Secret)
fmt.Print("This is the only time that secret is shown; what is stored is a hash of it.\n\n")
fmt.Print("INCOMPLETE. novox/hq ADR 0004 requires a token to carry four things, and this\n" +
"carries one. Missing: the broker's address, the fingerprint of its certificate, and\n" +
"the control plane's signing identity. None of the three exists yet, so this secret\n" +
"cannot be used to join anything -- it is the half that could be built without them.\n")
issued.Node.Name, issued.Expires.Format(time.RFC3339), encoded)
fmt.Println("This is the only time it is shown. What is stored is a hash of the secret.")
if missing := made.Missing(); len(missing) > 0 {
fmt.Printf("\nINCOMPLETE — this token cannot be used to join anything yet. Missing:\n")
for _, m := range missing {
fmt.Printf(" - %s\n", m)
}
fmt.Println("\nThe broker and its certificate are step 5 of the substrate bootstrap and " +
"do not exist yet\n(novox/hq 07-the-substrate). The signing key above is real.")
}
return nil
}
func openIdentity(ctx context.Context) (*identity.Identity, error) {
ident, err := identity.Open(ctx)
if err != nil {
return nil, err
}
if err := ident.Ready(ctx, 30*time.Second); err != nil {
ident.Close()
return nil, err
}
return ident, nil
}
func identityCommand(ctx context.Context, args []string) error {
if len(args) == 0 || args[0] != "show" {
return errors.New("identity show")
}
ident, err := openIdentity(ctx)
if err != nil {
return err
}
defer ident.Close()
// Establish rather than read: a control plane asked for its identity before it has one should
// get one, not an error. Generating it is idempotent, so this is safe to run at any time.
key, err := ident.Establish(ctx)
if err != nil {
return err
}
fmt.Printf("signing key %s\n", key.ID)
fmt.Printf("fingerprint %s\n", key.Fingerprint())
fmt.Printf("created %s\n", key.Created.Format(time.RFC3339))
fmt.Printf("\nThe public half of this travels in every enrolment token. A node believes a\n" +
"declaration because it carries a signature this key made (novox/hq ADR 0004).\n")
return nil
}