A token is an account on the bus, and genesis can place the list
novox/hq 04-ISSUES/146. The composed user list names an enrolment user for every machine with a live token and nothing minted a credential for it, so the composer left it out as a user with no password — and every enrolment since the mesh moved to this bus was refused before the mesh heard of it. The comment above the issuing code already said the account is created before the token is handed over; now it is. Recorded rather than minted, because the token's secret is the password. And 'broker accounts', which composes the same list the declaration carries and writes it to standard output. For genesis, where no declaration can reach the machine running the bus because that machine is not yet a node. It says what it composed; whoever is raising the machine places it. A control plane that wrote the file itself would have to learn where the bus keeps its configuration and how to make it reload, which is the module's knowledge.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
@@ -12,7 +13,10 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
)
|
||||
|
||||
// The bus's own certificate, made by the mesh rather than borrowed from an image.
|
||||
@@ -169,3 +173,86 @@ func writeBusCertificate(crt, key string) error {
|
||||
busCertificateNames, template.NotAfter.Format(time.RFC3339), crt, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// busAccounts writes the mesh's composed user list to a file.
|
||||
//
|
||||
// **For genesis, where no declaration can deliver it** (novox/hq 04-ISSUES/146). Everywhere else
|
||||
// the list reaches the machine running the bus as a resource of the module that holds it — which
|
||||
// requires that machine to be an enrolled node, and at genesis it is not: the first node cannot
|
||||
// enrol because the account it would enrol with cannot be composed onto a bus it has no declaration
|
||||
// for. The installer breaks that circle by placing the file itself, once, and the module takes the
|
||||
// file over from its first push.
|
||||
//
|
||||
// The same composition, not a second one: this asks the store for the same records and renders them
|
||||
// with the same composer the declaration uses. A genesis that hand-wrote an account would be a
|
||||
// second statement of who may say what, able to disagree with the first.
|
||||
//
|
||||
// **It writes to standard output unless told a file**, and that is the point: the control plane
|
||||
// composes and says what it composed, and whoever is raising the machine puts it where that
|
||||
// machine's bus reads it. A control plane that wrote into the bus's own directory would have to
|
||||
// know where that is and how to make the server re-read it — which is the module's knowledge, and
|
||||
// the module is what takes this over on the first push.
|
||||
//
|
||||
// broker accounts > /var/lib/mesh-bus-conf/accounts.conf
|
||||
func busAccounts(ctx context.Context, args []string) error {
|
||||
into := ""
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--into":
|
||||
if i+1 >= len(args) {
|
||||
return errors.New("--into needs a file")
|
||||
}
|
||||
into = args[i+1]
|
||||
i++
|
||||
default:
|
||||
return fmt.Errorf("broker accounts --into <file>: %q", args[i])
|
||||
}
|
||||
}
|
||||
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer open.Close()
|
||||
|
||||
records, err := open.inventory.BusRecords(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users, err := broker.Users(records)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kept, err := open.inventory.BusUsers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hashes := make(map[string]string, len(kept))
|
||||
for name, u := range kept {
|
||||
hashes[name] = u.PasswordHash
|
||||
}
|
||||
filled, missing := broker.WithPasswords(users, hashes)
|
||||
if len(missing) > 0 {
|
||||
// To standard error, always: the composed file may be going to standard output, and a
|
||||
// remark in the middle of it is a configuration the server refuses to parse.
|
||||
fmt.Fprintf(os.Stderr, "leaving out %d user(s) the mesh has minted no credential for: %s\n",
|
||||
len(missing), strings.Join(missing, ", "))
|
||||
}
|
||||
if len(filled) == 0 {
|
||||
return errors.New("not one user has a credential, so this list would refuse every " +
|
||||
"connection in the mesh")
|
||||
}
|
||||
accounts, err := broker.ComposeAccounts(filled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if into == "" {
|
||||
fmt.Print(accounts)
|
||||
return nil
|
||||
}
|
||||
if err := os.WriteFile(into, []byte(accounts), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("wrote %d user(s) to %s\n", len(filled), into)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ func run() error {
|
||||
case "identity":
|
||||
return identityCommand(ctx, args[1:])
|
||||
case "broker":
|
||||
return brokerCommand(args[1:])
|
||||
return brokerCommand(ctx, args[1:])
|
||||
case "serve":
|
||||
return serve(ctx)
|
||||
case "upgrade":
|
||||
|
||||
@@ -363,12 +363,15 @@ func identityCommand(ctx context.Context, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func brokerCommand(args []string) error {
|
||||
func brokerCommand(ctx context.Context, args []string) error {
|
||||
if len(args) > 0 && args[0] == "certificate" {
|
||||
return busCertificate(args[1:])
|
||||
}
|
||||
if len(args) > 0 && args[0] == "accounts" {
|
||||
return busAccounts(ctx, args[1:])
|
||||
}
|
||||
if len(args) == 0 || args[0] != "show" {
|
||||
return errors.New("broker show | broker certificate [--check] --into <directory>")
|
||||
return errors.New("broker show | broker certificate [--check] --into <directory> | broker accounts --into <file>")
|
||||
}
|
||||
known, err := broker.FromEnvironment()
|
||||
if errors.Is(err, broker.ErrNotConfigured) {
|
||||
|
||||
@@ -62,6 +62,22 @@ func TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose(t *testing.T)
|
||||
|
||||
// theCarriedAccounts is the accounts file the installer's template writes at genesis.
|
||||
func theCarriedAccounts(t *testing.T) string {
|
||||
t.Helper()
|
||||
for _, r := range theTemplate(t) {
|
||||
if r["id"] == "bus-accounts" {
|
||||
content, _ := r["content"].(string)
|
||||
if content == "" {
|
||||
t.Fatal("the template's accounts file is empty, so the bus would refuse every connection")
|
||||
}
|
||||
return content
|
||||
}
|
||||
}
|
||||
t.Fatal("the template carries no accounts file, so a mesh raised from it has a bus nobody may use")
|
||||
return ""
|
||||
}
|
||||
|
||||
// theTemplate is the installer's bundle, as resources.
|
||||
func theTemplate(t *testing.T) []map[string]any {
|
||||
t.Helper()
|
||||
path := filepath.Join("..", "..", "..", "mesh-host", "examples", "foundation-first-node-nats.lock")
|
||||
raw, err := os.ReadFile(path)
|
||||
@@ -81,17 +97,7 @@ func theCarriedAccounts(t *testing.T) string {
|
||||
if err := json.Unmarshal([]byte(strings.Join(lines, "\n")), &bundle); err != nil {
|
||||
t.Fatalf("the template is not readable: %v", err)
|
||||
}
|
||||
for _, r := range bundle.Resources {
|
||||
if r["id"] == "bus-accounts" {
|
||||
content, _ := r["content"].(string)
|
||||
if content == "" {
|
||||
t.Fatal("the template's accounts file is empty, so the bus would refuse every connection")
|
||||
}
|
||||
return content
|
||||
}
|
||||
}
|
||||
t.Fatal("the template carries no accounts file, so a mesh raised from it has a bus nobody may use")
|
||||
return ""
|
||||
return bundle.Resources
|
||||
}
|
||||
|
||||
// subjectsIn reads one allow-list out of a composed accounts file.
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Reading the bus's user list out of the mesh's records, against a real store.
|
||||
@@ -164,3 +165,63 @@ func granted(all []string, one string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// **A token is an account on the bus, or it is a string nothing accepts** (novox/hq 04-ISSUES/146).
|
||||
//
|
||||
// The composed list names an enrolment user for every machine with a live token, and nothing minted
|
||||
// a credential for it — so the composer left it out as a user with no password, and every enrolment
|
||||
// since the mesh moved to this bus was refused by the server before the mesh heard of it. Nothing
|
||||
// caught it because nothing had enrolled since.
|
||||
//
|
||||
// The password cannot be minted, because it is the token's own secret: the machine will present
|
||||
// exactly that string. So this checks the two halves that make the account usable — that a row
|
||||
// exists under the name the composer asks for, and that the secret handed out is what that row
|
||||
// accepts.
|
||||
func TestIssuingATokenRecordsTheAccountItIsThePasswordOf(t *testing.T) {
|
||||
inv, ctx := aMeshWith(t)
|
||||
if _, err := inv.AddNode(ctx, "joiner"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
issued, err := inv.IssueToken(ctx, "joiner", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
name := broker.Principal{Kind: broker.KindEnrolment, Node: "joiner"}.Username()
|
||||
users, err := inv.BusUsers(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, has := users[name]
|
||||
if !has {
|
||||
t.Fatalf("no bus account for %q; the composer would leave the enrolment out and the "+
|
||||
"machine would be refused before the mesh heard of it: %v", name, users)
|
||||
}
|
||||
if user.Kind != BusEnrolment || user.Node != "joiner" {
|
||||
t.Errorf("the account is %+v, not this node's enrolment", user)
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(issued.Secret)); err != nil {
|
||||
t.Error("the account does not accept the secret the token carries, so presenting the " +
|
||||
"token would be refused by the server")
|
||||
}
|
||||
|
||||
// And the composition contains it, which is the thing the server reads.
|
||||
records, err := inv.BusRecords(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
derived, err := broker.Users(records)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hashes := map[string]string{}
|
||||
for n, u := range users {
|
||||
hashes[n] = u.PasswordHash
|
||||
}
|
||||
_, missing := broker.WithPasswords(derived, hashes)
|
||||
for _, m := range missing {
|
||||
if m == name {
|
||||
t.Fatal("the enrolment user is composed without a password, which is a user nobody can be")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,40 @@ const (
|
||||
// reply, into a module's sealed environment — and the mesh keeps only the hash, so a credential is
|
||||
// never recoverable from the store. A caller that loses it must mint again, which is a rotation and
|
||||
// is meant to feel like one.
|
||||
// RecordBusPassword records a hash for a password the caller already holds.
|
||||
//
|
||||
// **For the one credential the mesh does not choose**: an enrolment token's secret is the password
|
||||
// of the user that presents it (novox/hq ADR 0004, design 25 §6), so the token cannot be given a
|
||||
// minted password — it already has one, and the machine will connect with exactly that string.
|
||||
// Everything else goes through Mint, which chooses and returns the plaintext once.
|
||||
func (i *Inventory) RecordBusPassword(ctx context.Context, u BusUser, password string) error {
|
||||
if u.Username == "" || u.Kind == "" {
|
||||
return errors.New("a bus user needs a username and a kind")
|
||||
}
|
||||
if password == "" {
|
||||
return errors.New("a bus user needs a password")
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot hash a bus password: %w", err)
|
||||
}
|
||||
return i.writeBusUser(ctx, u, string(hash))
|
||||
}
|
||||
|
||||
// writeBusUser is the row, whoever chose the password.
|
||||
func (i *Inventory) writeBusUser(ctx context.Context, u BusUser, hash string) error {
|
||||
if _, err := i.store.Pool().Exec(ctx,
|
||||
`insert into bus_user (username, kind, node, module, password_hash)
|
||||
values ($1, $2, $3, $4, $5)
|
||||
on conflict (username) do update
|
||||
set kind = excluded.kind, node = excluded.node, module = excluded.module,
|
||||
password_hash = excluded.password_hash, minted_at = now()`,
|
||||
u.Username, u.Kind, u.Node, u.Module, hash); err != nil {
|
||||
return fmt.Errorf("cannot record the bus user %s: %w", u.Username, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Inventory) MintBusPassword(ctx context.Context, u BusUser) (string, error) {
|
||||
if u.Username == "" || u.Kind == "" {
|
||||
return "", errors.New("a bus user needs a username and a kind")
|
||||
@@ -69,14 +103,8 @@ func (i *Inventory) MintBusPassword(ctx context.Context, u BusUser) (string, err
|
||||
return "", fmt.Errorf("cannot hash a bus password: %w", err)
|
||||
}
|
||||
|
||||
if _, err := i.store.Pool().Exec(ctx,
|
||||
`insert into bus_user (username, kind, node, module, password_hash)
|
||||
values ($1, $2, $3, $4, $5)
|
||||
on conflict (username) do update
|
||||
set kind = excluded.kind, node = excluded.node, module = excluded.module,
|
||||
password_hash = excluded.password_hash, minted_at = now()`,
|
||||
u.Username, u.Kind, u.Node, u.Module, string(hash)); err != nil {
|
||||
return "", fmt.Errorf("cannot record the bus user %s: %w", u.Username, err)
|
||||
if err := i.writeBusUser(ctx, u, string(hash)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/store"
|
||||
)
|
||||
|
||||
@@ -263,6 +264,29 @@ func (i *Inventory) IssueToken(ctx context.Context, nodeName string, validFor ti
|
||||
return Issued{}, err
|
||||
}
|
||||
|
||||
// **And the account that secret is the password of** (novox/hq 04-ISSUES/146). The composed
|
||||
// user list names an enrolment user for every node with a live token, and nothing minted a
|
||||
// credential for it — so the composer left it out as a user with no password and every
|
||||
// enrolment was refused by the server before the mesh heard of it.
|
||||
//
|
||||
// Recorded rather than minted: the token's secret IS the password, which is what lets a
|
||||
// machine's first connection be authenticated by the thing it is enrolling with. It cannot be
|
||||
// chosen here, because it has already been handed to whoever will present it.
|
||||
//
|
||||
// Outside the transaction on purpose. The token is what the mesh promised; a credential that
|
||||
// the next composition rewrites anyway is not worth failing an issue over, and a token with no
|
||||
// account is recoverable by issuing another, while an account with no token is a user nobody
|
||||
// can be.
|
||||
if err := i.RecordBusPassword(ctx, BusUser{
|
||||
Username: broker.Principal{Kind: broker.KindEnrolment, Node: node.Name}.Username(),
|
||||
Kind: BusEnrolment,
|
||||
Node: node.Name,
|
||||
}, secret); err != nil {
|
||||
return Issued{}, fmt.Errorf(
|
||||
"the token for %s was issued and the bus account it is the password of was not "+
|
||||
"recorded, so this token cannot connect: %w", node.Name, err)
|
||||
}
|
||||
|
||||
return Issued{Node: node, Secret: secret, Expires: expires}, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user