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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user