diff --git a/README.md b/README.md index 9d53866..a3ec31e 100644 --- a/README.md +++ b/README.md @@ -25,18 +25,42 @@ argument that is not settled there. | | | |---|---| -| `inventory` | the node records — **the schema exists** | +| `inventory` | node records and enrolment tokens — **built, as far as identity** | | `config`, `connectivity`, `provisioning`, `delivery`, `observability`, `identity` | not built | | the interface every surface speaks to | not built; its shape is not decided | ``` -mesh-control migrate bring each context's schema up to date -mesh-control version what this binary is +mesh-control migrate bring each context's schema up to date +mesh-control node add create a node record +mesh-control node list the nodes this mesh knows about +mesh-control token issue --node a one-time right to join, for an existing record +mesh-control token issue --new create the record and issue for it +mesh-control version what this binary is ``` `migrate` is **step 3 of the substrate bootstrap** — the step the first node cannot get past, run against a database raised moments earlier from the bundle the host carries. +### Tokens, and what they are missing + +A token is **a one-time right to join, issued for a node record** — which is where re-enrolment is +decided, since what an identity binds to is settled when the token is made rather than when it is +presented. + +What is built: the secret is 256 bits from the system's random source, shown once, and **stored +only as a hash**, so a copy of this database is not a set of working credentials. It is usable +exactly once and only before it expires, and both are read from the row rather than from a status +something would have had to write. Issuing again for the same node invalidates the outstanding +one — two live tokens are two machines able to join as the same node. + +Redemption is a single statement that both finds a live token and spends it, so eight concurrent +attempts on one secret produce exactly one winner. There is a test that runs them. + +**What a token is missing is three of its four parts.** ADR 0004 requires the broker's address, +the fingerprint of its certificate, and the control plane's signing identity. None of the three +exists yet, so `token issue` prints the secret **and says so**, rather than producing something +that looks complete and cannot be used. + ### Where this stops, and why there At **identity**. A node's own identity is the next thing needed and its cryptographic form is not diff --git a/cmd/mesh-control/main.go b/cmd/mesh-control/main.go index 97b059c..a791f8a 100644 --- a/cmd/mesh-control/main.go +++ b/cmd/mesh-control/main.go @@ -8,6 +8,8 @@ package main import ( "context" + "errors" + "flag" "fmt" "os" "os/signal" @@ -53,6 +55,10 @@ func run() error { switch args[0] { case "migrate": return migrate(ctx) + case "node": + return nodeCommand(ctx, args[1:]) + case "token": + return tokenCommand(ctx, args[1:]) case "version": fmt.Println(version) return nil @@ -68,8 +74,12 @@ func run() error { func usage() { fmt.Fprint(os.Stderr, `mesh-control — the control plane - migrate bring each context's schema up to date - version what this binary is + migrate bring each context's schema up to date + node add create a node record + node list the nodes this mesh knows about + token issue --node a one-time right to join, for an existing record + token issue --new create the record and issue for it + version what this binary is Each context reaches its own store through its own credential (novox/hq ADR 0008), named `+store.Variable("")+`. This process holds: @@ -123,3 +133,110 @@ func migrate(ctx context.Context) error { } return nil } + +// openInventory connects and waits, the way every command that touches it needs to. +func openInventory(ctx context.Context) (*inventory.Inventory, error) { + inv, err := inventory.Open(ctx) + if err != nil { + return nil, err + } + if err := inv.Ready(ctx, 30*time.Second); err != nil { + inv.Close() + return nil, err + } + return inv, nil +} + +func nodeCommand(ctx context.Context, args []string) error { + if len(args) == 0 { + return errors.New("node add , or node list") + } + inv, err := openInventory(ctx) + if err != nil { + return err + } + defer inv.Close() + + switch args[0] { + case "add": + if len(args) != 2 { + return errors.New("node add ") + } + node, err := inv.AddNode(ctx, args[1]) + if err != nil { + return err + } + fmt.Printf("added %s (%s)\n", node.Name, node.ID) + return nil + + case "list": + nodes, err := inv.Nodes(ctx) + if err != nil { + return err + } + if len(nodes) == 0 { + // Said rather than printed as nothing: an empty list and a failed read must never + // look the same, and this command answering "none" is only honest because getting + // here means the store answered. + fmt.Println("this mesh has no node records yet") + return nil + } + for _, n := range nodes { + fmt.Printf("%-20s %s added %s\n", n.Name, n.ID, n.Created.Format(time.RFC3339)) + } + return nil + + default: + return fmt.Errorf("node has no %q; it has add and list", args[0]) + } +} + +func tokenCommand(ctx context.Context, args []string) error { + if len(args) == 0 || args[0] != "issue" { + return errors.New("token issue --node , or token issue --new ") + } + + set := flag.NewFlagSet("token issue", flag.ContinueOnError) + existing := set.String("node", "", "issue for a node record that already exists") + fresh := set.String("new", "", "create the node record, then issue for it") + validFor := set.Duration("for", time.Hour, "how long the token may be used") + if err := set.Parse(args[1:]); err != nil { + return err + } + + // Exactly one, because the difference is what the token binds to. A command that guessed + // would sometimes create a second record for a machine that already has one. + if (*existing == "") == (*fresh == "") { + return errors.New("give exactly one of --node or --new : the first is a " + + "machine the mesh already has a record for, the second is one it has never seen") + } + + inv, err := openInventory(ctx) + if err != nil { + return err + } + defer inv.Close() + + name := *existing + if *fresh != "" { + node, err := inv.AddNode(ctx, *fresh) + if err != nil { + return err + } + name = node.Name + } + + issued, err := inv.IssueToken(ctx, name, *validFor) + 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") + return nil +} diff --git a/internal/inventory/inventory_test.go b/internal/inventory/inventory_test.go new file mode 100644 index 0000000..8b03597 --- /dev/null +++ b/internal/inventory/inventory_test.go @@ -0,0 +1,292 @@ +package inventory + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/novox/mesh-control/internal/store" +) + +// Against a real PostgreSQL, for the reason novox/hq ADR 0017 gives: what is being tested here is +// that the database enforces what this code relies on it enforcing — a unique name, a token that +// two racing redemptions cannot both spend, a cascade that leaves no token behind. A fake would +// assert that the fake enforces them. + +func fresh(t *testing.T) *Inventory { + 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("inv_%d_%s", time.Now().UnixNano()%1_000_000, + strings.ToLower(strings.NewReplacer("/", "", "-", "").Replace(t.Name()))) + if len(name) > 60 { + name = name[:60] + } + + 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") + + inv, err := Open(t.Context()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + inv.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 := inv.Ready(t.Context(), 20*time.Second); err != nil { + t.Fatal(err) + } + + migrations, err := Migrations() + if err != nil { + t.Fatal(err) + } + if _, err := inv.store.Migrate(t.Context(), migrations); err != nil { + t.Fatal(err) + } + return inv +} + +func TestANodeRecordRoundTrips(t *testing.T) { + inv := fresh(t) + made, err := inv.AddNode(t.Context(), "workstation") + if err != nil { + t.Fatal(err) + } + + found, err := inv.NodeByName(t.Context(), "workstation") + if err != nil { + t.Fatal(err) + } + if found.ID != made.ID { + t.Errorf("added %s and found %s", made.ID, found.ID) + } +} + +func TestTwoNodesCannotShareAName(t *testing.T) { + // A name is how a token is issued for a node. Two records with one name makes that command + // ambiguous at the moment it grants access to the mesh. + inv := fresh(t) + if _, err := inv.AddNode(t.Context(), "workstation"); err != nil { + t.Fatal(err) + } + _, err := inv.AddNode(t.Context(), "workstation") + if !errors.Is(err, ErrNameTaken) { + t.Fatalf("a duplicate name gave %v; it must be a plain answer a person can act on", err) + } +} + +func TestAnUnknownNodeIsNotAnEmptyRecord(t *testing.T) { + inv := fresh(t) + _, err := inv.NodeByName(t.Context(), "never-existed") + if !errors.Is(err, ErrNoSuchNode) { + t.Fatalf("expected ErrNoSuchNode, got %v", err) + } +} + +func TestATokenIsRedeemableExactlyOnce(t *testing.T) { + // "Useless once used" (novox/hq ADR 0004). Without it a token that leaked after a successful + // join is a second machine's way in, and nothing would have noticed the first. + inv := fresh(t) + if _, err := inv.AddNode(t.Context(), "laptop"); err != nil { + t.Fatal(err) + } + issued, err := inv.IssueToken(t.Context(), "laptop", time.Hour) + if err != nil { + t.Fatal(err) + } + + node, err := inv.Redeem(t.Context(), issued.Secret) + if err != nil { + t.Fatalf("a fresh token was refused: %v", err) + } + if node.Name != "laptop" { + t.Errorf("redeemed a token for %q", node.Name) + } + + if _, err := inv.Redeem(t.Context(), issued.Secret); !errors.Is(err, ErrTokenRefused) { + t.Fatal("the same token was redeemed twice") + } +} + +func TestAnExpiredTokenIsRefused(t *testing.T) { + // "Useless after it expires" — the other half, and the one nothing notices, because a token + // ages out with nobody watching. It has to be read from the row rather than from a status + // something would have had to write. + inv := fresh(t) + if _, err := inv.AddNode(t.Context(), "laptop"); err != nil { + t.Fatal(err) + } + issued, err := inv.IssueToken(t.Context(), "laptop", 40*time.Millisecond) + if err != nil { + t.Fatal(err) + } + time.Sleep(120 * time.Millisecond) + + if _, err := inv.Redeem(t.Context(), issued.Secret); !errors.Is(err, ErrTokenRefused) { + t.Fatal("an expired token was accepted") + } +} + +func TestATokenWithNoLifetimeIsRefused(t *testing.T) { + inv := fresh(t) + if _, err := inv.AddNode(t.Context(), "laptop"); err != nil { + t.Fatal(err) + } + if _, err := inv.IssueToken(t.Context(), "laptop", 0); err == nil { + t.Fatal("a token that never expires was issued") + } +} + +func TestIssuingAgainInvalidatesTheOutstandingToken(t *testing.T) { + // Two live tokens for one node record are two machines able to join as the same node, with + // nothing downstream able to tell which was meant. + inv := fresh(t) + if _, err := inv.AddNode(t.Context(), "laptop"); err != nil { + t.Fatal(err) + } + first, err := inv.IssueToken(t.Context(), "laptop", time.Hour) + if err != nil { + t.Fatal(err) + } + second, err := inv.IssueToken(t.Context(), "laptop", time.Hour) + if err != nil { + t.Fatal(err) + } + + if _, err := inv.Redeem(t.Context(), first.Secret); !errors.Is(err, ErrTokenRefused) { + t.Error("the first token still worked after a second was issued") + } + if _, err := inv.Redeem(t.Context(), second.Secret); err != nil { + t.Errorf("the newest token was refused: %v", err) + } +} + +func TestTheSecretIsNotStored(t *testing.T) { + // A copy of this database must not be a set of working credentials. + inv := fresh(t) + if _, err := inv.AddNode(t.Context(), "laptop"); err != nil { + t.Fatal(err) + } + issued, err := inv.IssueToken(t.Context(), "laptop", time.Hour) + if err != nil { + t.Fatal(err) + } + + var stored string + if err := inv.store.Pool().QueryRow(t.Context(), + `select secret from enrolment_token limit 1`).Scan(&stored); err != nil { + t.Fatal(err) + } + if stored == issued.Secret { + t.Fatal("the token secret is stored verbatim; this table would be a set of live credentials") + } + if strings.Contains(stored, issued.Secret) { + t.Fatal("the stored value contains the secret") + } +} + +func TestTwoRedemptionsOfOneSecretCannotBothWin(t *testing.T) { + // The check and the spend are one statement for this reason. Reading first and writing second + // leaves a window where two machines both pass the check and both join as the same node. + inv := fresh(t) + if _, err := inv.AddNode(t.Context(), "laptop"); err != nil { + t.Fatal(err) + } + issued, err := inv.IssueToken(t.Context(), "laptop", time.Hour) + if err != nil { + t.Fatal(err) + } + + var wg sync.WaitGroup + results := make([]error, 8) + for i := range results { + wg.Add(1) + go func(i int) { + defer wg.Done() + _, results[i] = inv.Redeem(context.Background(), issued.Secret) + }(i) + } + wg.Wait() + + won := 0 + for _, err := range results { + if err == nil { + won++ + } + } + if won != 1 { + t.Errorf("%d of 8 concurrent redemptions succeeded; exactly one may", won) + } +} + +func TestRemovingANodeTakesItsTokensWithIt(t *testing.T) { + // A token outliving the record it was issued for is a right to join as nobody. + inv := fresh(t) + node, err := inv.AddNode(t.Context(), "laptop") + if err != nil { + t.Fatal(err) + } + if _, err := inv.IssueToken(t.Context(), "laptop", time.Hour); err != nil { + t.Fatal(err) + } + if _, err := inv.store.Pool().Exec(t.Context(), `delete from node where id = $1`, node.ID); err != nil { + t.Fatal(err) + } + + var left int + if err := inv.store.Pool().QueryRow(t.Context(), + `select count(*) from enrolment_token`).Scan(&left); err != nil { + t.Fatal(err) + } + if left != 0 { + t.Errorf("%d token(s) outlived the node record they were issued for", left) + } +} + +func TestAnUnknownSecretIsRefusedTheSameWayAsAnExpiredOne(t *testing.T) { + // One error for every reason. Somebody guessing must not learn which of their guesses was a + // real token that had merely expired. + inv := fresh(t) + if _, err := inv.AddNode(t.Context(), "laptop"); err != nil { + t.Fatal(err) + } + expired, err := inv.IssueToken(t.Context(), "laptop", 30*time.Millisecond) + if err != nil { + t.Fatal(err) + } + time.Sleep(100 * time.Millisecond) + + _, unknownErr := inv.Redeem(t.Context(), "not-a-token-at-all") + _, expiredErr := inv.Redeem(t.Context(), expired.Secret) + + if unknownErr == nil || expiredErr == nil { + t.Fatal("one of them was accepted") + } + if unknownErr.Error() != expiredErr.Error() { + t.Errorf("the two are distinguishable:\n unknown: %v\n expired: %v", unknownErr, expiredErr) + } +} diff --git a/internal/inventory/migrations/0002-enrolment-tokens.sql b/internal/inventory/migrations/0002-enrolment-tokens.sql new file mode 100644 index 0000000..f2d18ba --- /dev/null +++ b/internal/inventory/migrations/0002-enrolment-tokens.sql @@ -0,0 +1,38 @@ +-- The right to join, once. +-- +-- novox/hq ADR 0004: a token carries the broker's address, the fingerprint to expect, the control +-- plane's signing identity, and a one-time secret. Only the last of those is stored here -- the +-- other three are facts about the mesh, the same in every token, and belong wherever the mesh's +-- own configuration lives rather than copied into each row. + +create table enrolment_token ( + id uuid primary key default gen_random_uuid(), + + -- A token is issued FOR a node record, and that is where re-enrolment is decided + -- (novox/hq 09-the-node-lifecycle). The host presenting it does not need to know whether it + -- is joining as a new node or returning as an existing one; what the identity binds to was + -- settled when the token was made. + -- + -- Cascading: a node record removed takes its unused tokens with it. A token outliving the + -- record it was issued for is a right to join as nobody. + node uuid not null references node(id) on delete cascade, + + -- The secret is never stored. What is stored is a hash of it, so a copy of this table is not + -- a set of working credentials -- the same reason a password is not kept. + -- + -- Unique because a collision would make two tokens redeem as one, and because it lets the + -- lookup at redemption be by hash rather than a scan. + secret text not null unique, + + issued timestamptz not null default now(), + + -- "Useless once used and useless after it expires" is two conditions, so it is two columns. + -- Neither is a status field: a status has to be written by something noticing, and nothing + -- notices a token quietly ageing out. Both are read from what is already here. + expires timestamptz not null, + redeemed timestamptz +); + +-- Redemption looks a token up by the hash of what was presented, and it is the one query on the +-- path where a node is waiting. +create index enrolment_token_node on enrolment_token (node); diff --git a/internal/inventory/nodes.go b/internal/inventory/nodes.go new file mode 100644 index 0000000..b3c7e0f --- /dev/null +++ b/internal/inventory/nodes.go @@ -0,0 +1,210 @@ +package inventory + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/novox/mesh-control/internal/store" +) + +// Inventory is this context, holding the store it exclusively owns. +type Inventory struct{ store *store.Store } + +// Open connects to the inventory store. +func Open(ctx context.Context) (*Inventory, error) { + s, err := store.Open(ctx, Name) + if err != nil { + return nil, err + } + return &Inventory{store: s}, nil +} + +func (i *Inventory) Close() { i.store.Close() } + +// Ready waits for the database to answer. +func (i *Inventory) Ready(ctx context.Context, within time.Duration) error { + return i.store.Ready(ctx, within) +} + +// Node is a machine the mesh knows about. +type Node struct { + ID string + Name string + Created time.Time +} + +// ErrNoSuchNode is returned when a name matches no record. +var ErrNoSuchNode = errors.New("no node of that name") + +// ErrNameTaken is returned when a node of that name already exists. +// +// Its own error rather than the driver's, because "that name is taken" is an ordinary answer a +// person can act on, and a unique-violation from PostgreSQL is not. +var ErrNameTaken = errors.New("a node of that name already exists") + +// AddNode creates a node record. +// +// The record comes first and the machine second: a token is issued *for* a node record +// (novox/hq 09-the-node-lifecycle), so the record is what a token binds to and must exist before +// there is anything to join. +func (i *Inventory) AddNode(ctx context.Context, name string) (Node, error) { + name = strings.TrimSpace(name) + if name == "" { + return Node{}, errors.New("a node needs a name: it is how a token is issued for it") + } + + var n Node + err := i.store.Pool().QueryRow(ctx, + `insert into node (name) values ($1) returning id, name, created`, + name).Scan(&n.ID, &n.Name, &n.Created) + if err != nil { + if strings.Contains(err.Error(), "node_name_key") { + return Node{}, fmt.Errorf("%w: %s", ErrNameTaken, name) + } + return Node{}, err + } + return n, nil +} + +// Nodes are every node record, oldest first. +func (i *Inventory) Nodes(ctx context.Context) ([]Node, error) { + rows, err := i.store.Pool().Query(ctx, + `select id, name, created from node order by created, name`) + if err != nil { + return nil, err + } + defer rows.Close() + + var nodes []Node + for rows.Next() { + var n Node + if err := rows.Scan(&n.ID, &n.Name, &n.Created); err != nil { + return nil, err + } + nodes = append(nodes, n) + } + return nodes, rows.Err() +} + +// NodeByName finds one node record. +func (i *Inventory) NodeByName(ctx context.Context, name string) (Node, error) { + var n Node + err := i.store.Pool().QueryRow(ctx, + `select id, name, created from node where name = $1`, name).Scan(&n.ID, &n.Name, &n.Created) + if errors.Is(err, pgx.ErrNoRows) { + return Node{}, fmt.Errorf("%w: %s", ErrNoSuchNode, name) + } + return n, err +} + +// Issued is a token that has just been made. The secret is in it exactly once. +type Issued struct { + Node Node + Secret string + Expires time.Time +} + +// hashSecret is what gets stored in place of the secret. +// +// SHA-256 rather than a password hash, and that is deliberate rather than a shortcut. bcrypt and +// its relatives are slow on purpose because a password is low-entropy and guessable; this secret +// is 256 bits from the system's random source, so there is nothing to guess and the slowness would +// buy nothing while making every redemption expensive. +func hashSecret(secret string) string { + sum := sha256.Sum256([]byte(secret)) + return hex.EncodeToString(sum[:]) +} + +// IssueToken mints a one-time right to join, for a node record. +// +// The secret is returned once and never again. What is stored is its hash, so a copy of this +// database is not a set of working credentials. +// +// Any outstanding token for the same node is expired first. Two live tokens for one node record +// are two machines able to join as the same node, and nothing downstream could tell which was +// meant — novox/hq ADR 0004's stolen-laptop case arriving before enrolment rather than after. +func (i *Inventory) IssueToken(ctx context.Context, nodeName string, validFor time.Duration) (Issued, error) { + if validFor <= 0 { + return Issued{}, errors.New("a token needs a lifetime: one that never expires is a " + + "permanent credential, which is the thing this is designed not to be") + } + + node, err := i.NodeByName(ctx, nodeName) + if err != nil { + return Issued{}, err + } + + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return Issued{}, fmt.Errorf("cannot generate a token secret: %w", err) + } + secret := base64.RawURLEncoding.EncodeToString(raw) + expires := time.Now().Add(validFor) + + tx, err := i.store.Pool().Begin(ctx) + if err != nil { + return Issued{}, err + } + defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }() + + // Expired rather than deleted: what was issued and then withdrawn is worth being able to see. + if _, err := tx.Exec(ctx, + `update enrolment_token set expires = now() + where node = $1 and redeemed is null and expires > now()`, node.ID); err != nil { + return Issued{}, err + } + if _, err := tx.Exec(ctx, + `insert into enrolment_token (node, secret, expires) values ($1, $2, $3)`, + node.ID, hashSecret(secret), expires); err != nil { + return Issued{}, err + } + if err := tx.Commit(ctx); err != nil { + return Issued{}, err + } + + return Issued{Node: node, Secret: secret, Expires: expires}, nil +} + +// ErrTokenRefused is what redemption returns for anything that is not a live token. +// +// One error for every reason — unknown, already used, expired — and deliberately so. Whoever is +// presenting a token that does not work is either a machine whose operator can be told out of +// band, or somebody guessing, and the second must not learn which of their guesses was a real +// token that had expired. +var ErrTokenRefused = errors.New("that token cannot be used") + +// Redeem spends a token and reports which node it was for. +// +// It does not issue an identity. What a node presents afterwards to prove it is that node is not +// decided anywhere (novox/hq ADR 0004 names the property, not the mechanism), and guessing at it +// in a migration is the most expensive guess available here. +// +// The update is the check: one statement that both finds a live token and marks it used, so two +// simultaneous redemptions of one secret cannot both succeed. Reading first and writing second +// would leave exactly that gap. +func (i *Inventory) Redeem(ctx context.Context, secret string) (Node, error) { + var id string + err := i.store.Pool().QueryRow(ctx, + `update enrolment_token set redeemed = now() + where secret = $1 and redeemed is null and expires > now() + returning node`, hashSecret(secret)).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return Node{}, ErrTokenRefused + } + if err != nil { + return Node{}, err + } + + var n Node + err = i.store.Pool().QueryRow(ctx, + `select id, name, created from node where id = $1`, id).Scan(&n.ID, &n.Name, &n.Created) + return n, err +}