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 }