Files
mesh-catalog/modules/mesh-vault/cmd/mesh-vault/vault.go
T
jschoubben 049bd176c3 Vault: enrolment token and node account, signing for enrolment, a node's two logins and their rotation, own hand-out of a login
The vault serves the verbs enrolment-token, node-account, rotate-node, node-account-confirm and abandon, keeps the state
node-logins, and says rotation-begun, node-account-ready and rotation-done (ADR 0587 §3, §5, §8; ADR 0583 §1-§7 and
insights; ADR 0561 §4, §5). Only mesh-enrolment asks the first two and sign for an enrolment; a rotation needs a hand act
read back from mesh-hand-acts by its id, runs one at a time with the bus node last, and is ready only when the loaded list
holds the new login and done when it lacks the old one. A node's login is handed to that node's engine alone, sealed to
the key it stated. The key of a node that leaves the placement is deleted. Accounts kept for an enrolment or a node are
marked and never retired as unwanted.

Subtasks 774/S43 to S50 in one commit: they share enrolment.go, noderotation.go, tools.go and the manifest, and could not
be cut apart without a commit that does not build. New tests: 3 unit functions (table-style flows) and the one
integration test of S50; mesh-vault stays at 134 unit and 2 integration tests, within the module budget of 500 and 50, so
none was merged or deleted. The integration test's player gained a mesh/ask answer.

(issue 774, 774/S43, 774/S44, 774/S45, 774/S46, 774/S47, 774/S48, 774/S49, 774/S50)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:42:19 +01:00

523 lines
21 KiB
Go

package main
// The vault's rules (novox/hq ADR 0349 §7 and §11; ADR 0113 §1, §2, §22): it holds every secret sealed at
// rest, with a copy sealed to the operator key; it hands one out only on request and reply, sealed to the
// asker's key, and only to an asker it allows; it says a secret changed — who, when, never a value.
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/seal"
)
// Seat is the role this module holds.
const Seat = "mesh-vault"
// ChangedEvent is what the vault raises when a secret it holds changes: who, when, which, its generation.
const ChangedEvent = "secret-changed"
// Vault holds the secrets.
type Vault struct {
Store *Store
// Own is the vault's own key; the private half never leaves its state directory.
Own seal.KeyPair
// Askers decides who may ask `get` for what. Nil allows nobody.
Askers Askers
// Assignments is the controller's assignments mirror, where the vault sees a consumer bind a provider and
// unbind (pair.go). Empty sees nothing, and makes no provision's credential.
Assignments Mirror
// Bound is the vault's own state of outcome entries, one per credential it provides, and its read of the seat's
// provisions (outcomes.go, novox/hq ADR 0570 clauses 3, 6).
Bound Bound
Emit func(event string, body map[string]any) error
// Ask calls a tool on the mesh as the vault, through the runtime that launched it, under the vault's own bus
// account (ADR 0259 §8's runs-as, issue 723): the desks' secret prompt and each issuing provider's tool, which
// the bus lets the vault's account alone call in its own name. Nil asks nobody.
Ask func(key string, body any) (json.RawMessage, error)
// AskCallers are the callers besides the controller that may ask secret-ask themselves, as the vault's manifest names
// them (MESH_VAULT_SECRET_ASK_CALLERS; novox/hq ADR 0563 §8): each is named by the runtime's proof. Empty: the
// controller alone.
AskCallers stdio.Callers
Now func() time.Time
Log func(format string, args ...any)
// Accounts is the vault's state of bus-account hashes (accounts.go, ADR 0547 clause 9): nil writes none.
Accounts AccountStore
// HashCost is the bcrypt cost of those hashes: zero is the controller's, bcrypt's default.
HashCost int
// Haves reads the node-engines' have bucket, where the bus node hands the loaded list's hashes (adopt.go): nil
// adopts nothing.
Haves HaveReader
// HaveKeys lists the keys of the node-engines' have bucket, for own_drift (owntake.go): nil lists none.
HaveKeys func() ([]string, error)
mu sync.Mutex
// hashMu keeps one hashing at a time, and hashes what is owed (accounts.go).
hashMu sync.Mutex
hashes hashing
// adoption is the last adoption (adopt.go), under mu.
adoption adoptionKept
// WantRead reads one wanted account afresh (issue-account): nil reads the wants as the watch handed them.
WantRead func(user string) ([]byte, bool, error)
// Placement reads the seat's placement (placed.go): where modules stand once the assignments are gone.
Placement PlacementReader
// LeaseRead reads the controller's lease afresh before a mint (lease.go): nil reads it only as the watch hands it.
LeaseRead func() ([]byte, bool, error)
// minting is the switch on the lease and what the vault knows of the wanted bus accounts (mint.go).
minting minting
// Signers is the vault's state `signers`, where it publishes its signing halves (signing.go, ADR 0550 §2): nil writes
// none.
Signers SignersStore
// EnrolCallers are the callers of the enrolment verbs `enrolment-token` and `node-account` and of `sign` for an
// enrolment: the seat mesh-enrolment (enrolment.go). Nil: that module alone.
EnrolCallers stdio.Callers
// TokenBase says where the bus is and its certificate's fingerprint, for the token shown (enrolment.go): nil shows none.
TokenBase func() (TokenBase, error)
// Logins is the vault's state node-logins (noderotation.go): nil writes none.
Logins NodeLoginStore
// ReadAct reads a hand act back from mesh-hand-acts by its id, which a rotation carries (noderotation.go): nil answers none.
ReadAct ActReader
// nr is what the vault keeps in memory of its node rotations (noderotation.go).
nr nodeRotation
// signingWhy is why the last signing pass did not finish, under mu (signing.go).
signingWhy string
// callsMu guards pending: the keys of the calls open now (intake.go), never written anywhere.
callsMu sync.Mutex
pending map[string]*callKey
}
// Changed is what one change to a secret was. Never its value.
type Changed struct {
Name string `json:"name"`
Generation int `json:"generation"`
At time.Time `json:"at"`
By string `json:"by"`
Unchanged bool `json:"unchanged,omitempty"`
// Each is, for a rotation of a provider whose consumers keep logins of their own, each one's own value remade.
Each []Changed `json:"each,omitempty"`
}
// Answer is `get`'s reply: the secret sealed to the asker's key, and which generation it is.
type Answer struct {
Name string `json:"name"`
Generation int `json:"generation"`
ChangedAt time.Time `json:"changed-at"`
Sealed seal.Box `json:"sealed"`
}
func (v *Vault) log(format string, args ...any) {
if v.Log != nil {
v.Log(format, args...)
}
}
// Put keeps value as the secret name: sealed to the vault's key and to the operator key, a new generation
// when it differs from the one held, and `secret-changed` said. The same value again changes nothing.
// Refused: a private key (it stays where it was made), and any value while the vault has no operator key —
// a secret only the vault could open is what ADR 0113 §22 rules out.
func (v *Vault) Put(name, value, by string) (Changed, error) {
return v.keep(name, value, by, "", OriginMade)
}
// keep is Put with where the value came from (origin) and the caller the runtime proved when it is not who
// by names (via).
func (v *Vault) keep(name, value, by, via, origin string) (Changed, error) {
return v.keepWith(name, value, by, via, origin, keepOptions{})
}
// keepOptions are what the move keeps beside a value (move.go): that it never replaces another, when the value
// was first made, and where it was moved from.
type keepOptions struct {
neverReplace bool
// quiet keeps secret-changed for the caller to say once the rest is done (a minted account: mint.go).
quiet bool
madeAt time.Time
movedFrom *MovedFrom
// replaceOnly, keepPrevious and refuse are what move-in with replace_running keeps (owntake.go): only a value held is replaced,
// keeping its origin and the value it replaces, and refuse, under the store's lock, says why a held value is not.
replaceOnly bool
keepPrevious bool
refuse func(before Record) string
}
// keepWith is keep with the move's options. A covered bus account kept anew has its hash written once it is kept
// (accounts.go), outside the store's lock.
func (v *Vault) keepWith(name, value, by, via, origin string, opt keepOptions) (Changed, error) {
c, err := v.keepLocked(name, value, by, via, origin, opt)
if err == nil && !c.Unchanged {
v.hashKept(name)
}
return c, err
}
// HeldAnother is the refusal of a value the vault never replaces: it holds another value for the name. Its fields are the
// held value's record, never the value, so a caller reads them rather than the words.
type HeldAnother struct {
Name string `json:"name"`
Generation int `json:"generation"`
ChangedBy string `json:"changed_by"`
ChangedAt time.Time `json:"changed_at"`
Origin string `json:"origin,omitempty"`
}
func (h *HeldAnother) Error() string {
return fmt.Sprintf("the vault already holds another value for %s (generation %d, changed by %s at %s), and the move "+
"never replaces a value the vault holds: nothing was taken", h.Name, h.Generation, h.ChangedBy,
h.ChangedAt.Format(time.RFC3339))
}
// BusAccountValue says value is a bus account as secret names it: the controller's or the vault's JSON, with its user
// (or one its name gives) and a password (accountOf).
func BusAccountValue(secret, value string) bool {
_, _, err := accountOf(secret, value)
return err == nil
}
// openAtRest opens a record's value with the vault's own key.
func openAtRest(r Record, v *Vault) (string, error) { return seal.Open(r.AtRest, v.Own.PrivateKey) }
func (v *Vault) keepLocked(name, value, by, via, origin string, opt keepOptions) (Changed, error) {
if err := validName(name); err != nil {
return Changed{}, err
}
if value == "" {
return Changed{}, fmt.Errorf("%s: a secret is not empty", name)
}
if privateKeyIn(value) {
return Changed{}, fmt.Errorf("%s: that is a private key, and a private key stays where it was made and never reaches the vault (ADR 0113 §2)", name)
}
// A bus account is kept only as one (the sweep of 2026-10-30, issue 774): the controller had moved in random values
// for broker-nats-tools, broker-address and broker-management, which no hash could be taken of, so the grants part
// left those users out of the bus. Such a name is never kept with a value that is no account: not issued.
if v.DeclaredAccount(name) && !BusAccountValue(name, value) {
return Changed{}, fmt.Errorf("%s is not issued: it names a bus account, and the value handed in is no account "+
"(the JSON the controller hands or the vault mints, with a user and a password); the vault keeps a bus "+
"account only as one, so nothing was kept", name)
}
if by == "" {
by = "the vault"
}
v.mu.Lock()
defer v.mu.Unlock()
operator, err := v.Store.OperatorKey()
if err != nil {
return Changed{}, err
}
if operator == "" {
return Changed{}, fmt.Errorf("%s is not kept: the vault has no operator key to seal its copy to (ADR 0113 §22) — the mesh writes it with the export once `operator key set` has been run", name)
}
before, held, err := v.Store.Read(name)
if err != nil {
return Changed{}, err
}
if !held && opt.replaceOnly {
return Changed{}, fmt.Errorf("the vault holds no secret named %s, so there is nothing to replace; nothing was taken", name)
}
if held {
if old, err := seal.Open(before.AtRest, v.Own.PrivateKey); err == nil && old == value {
return Changed{Name: name, Generation: before.Generation, At: before.ChangedAt, By: before.ChangedBy, Unchanged: true}, nil
}
if opt.refuse != nil {
if why := opt.refuse(before); why != "" {
return Changed{}, errors.New(why)
}
}
if opt.neverReplace {
return Changed{}, &HeldAnother{Name: name, Generation: before.Generation, ChangedBy: before.ChangedBy,
ChangedAt: before.ChangedAt, Origin: before.Origin}
}
}
atRest, err := seal.Seal(value, v.Own.PublicKey)
if err != nil {
return Changed{}, err
}
forOperator, err := operatorSeal(operator, []byte(value))
if err != nil {
return Changed{}, err
}
now := v.Now().UTC()
if via == by {
via = ""
}
rec := Record{Name: name, Generation: 1, AtRest: atRest,
Operator: OperatorCopy{Box: OperatorBox, Key: operator, Sealed: forOperator},
MadeAt: now, ChangedAt: now, ChangedBy: by, ChangedVia: via, Origin: origin}
if held {
rec.Generation, rec.MadeAt = before.Generation+1, before.MadeAt
if opt.replaceOnly {
rec.Origin, rec.MovedFrom = before.Origin, before.MovedFrom
}
if opt.keepPrevious {
rec.Previous = &PreviousValue{Generation: before.Generation, AtRest: before.AtRest, Operator: before.Operator,
ChangedAt: before.ChangedAt, ChangedBy: before.ChangedBy, Origin: before.Origin}
}
} else if !opt.madeAt.IsZero() {
rec.MadeAt = opt.madeAt.UTC()
}
if opt.movedFrom != nil {
moved := *opt.movedFrom
moved.At = now
rec.MovedFrom = &moved
}
if err := v.Store.Write(rec); err != nil {
return Changed{}, err
}
c := Changed{Name: name, Generation: rec.Generation, At: now, By: by}
if !opt.quiet {
v.announce(c)
}
return c, nil
}
// Mint makes a fresh value for name and keeps it: 30 random bytes, base64url without padding — 40
// characters, the controller's shape, which fits the tightest backend a minted password reaches.
func (v *Vault) Mint(name, by string) (Changed, error) {
raw := make([]byte, 30)
if _, err := rand.Read(raw); err != nil {
return Changed{}, err
}
return v.Put(name, base64.RawURLEncoding.EncodeToString(raw), by)
}
// announce raises `secret-changed`: who, when, which and its generation — never a value or a sealed copy.
func (v *Vault) announce(c Changed) {
if v.Emit == nil {
return
}
body := map[string]any{"name": c.Name, "by": c.By, "at": c.At.Format(time.RFC3339Nano), "generation": c.Generation}
if err := v.Emit(ChangedEvent, body); err != nil {
v.log("%s is kept at generation %d, but %s could not be said: %v", c.Name, c.Generation, ChangedEvent, err)
}
}
// recordHanding keeps who asked for what, and what they got, in the vault's own store, and says loudly
// when the record had to be rolled to make room.
func (v *Vault) recordHanding(h Handing) error { return v.recordHandings([]Handing{h}) }
// recordHandings keeps every handing of one answer in one synced write, as recordHanding keeps one.
func (v *Vault) recordHandings(hs []Handing) error {
if len(hs) == 0 {
return nil
}
at := v.Now().UTC()
for i := range hs {
hs[i].At = at
}
rolled, err := v.Store.RecordHandings(hs)
if err != nil {
names := make([]string, len(hs))
for i, h := range hs {
names[i] = h.Name
}
v.log("the record of %s asking for %s could not be written: %v", hs[0].To, strings.Join(names, ", "), err)
return err
}
if rolled {
v.log("THE RECORD OF HAND-OUTS WAS ROLLED (%d times since the vault started): it reached its bound of %d bytes; "+
"the oldest of %d kept files is dropped. Many asks in little time is worth a look: secret_handed and vault_status",
v.Store.Rolled(), v.Store.HandedBound(), HandedKept)
}
return nil
}
// busUser is the shape of a caller the runtime proves (the SDK's): the one shape a refusal is recorded for.
var busUser = regexp.MustCompile(`^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$`)
// Get answers caller with the secret name sealed to publicKey, if caller may ask for it. The caller is the
// bus user the runtime proved, never an argument; an unproven call is refused.
func (v *Vault) Get(caller, name, publicKey string) (Answer, error) {
if caller == "" || caller == stdio.Unproven {
return Answer{}, errors.New("the vault answers only a caller the runtime proved")
}
askers := v.Askers
if askers == nil {
askers = NoAsker{}
}
if !busUser.MatchString(caller) || len(caller) > 200 {
return Answer{}, errors.New("the vault answers only a caller the runtime proved")
}
// A name is judged before anything is recorded, so an arbitrary argument fills no record.
if err := validName(name); err != nil {
return Answer{}, err
}
// The vault's signing secrets are handed to nobody: it signs with them itself (ADR 0550 §2).
if signingSecret(name) {
return Answer{}, fmt.Errorf("%s is the vault's own signing secret and is handed to nobody: ask the verb sign", name)
}
// An enrolment token is shown to the operator sealed to the operator key, and handed to nobody (ADR 0587 §8).
if enrolSecretName(name) {
return Answer{}, fmt.Errorf("%s is an enrolment token and is handed to nobody: the operator opens it sealed to the operator key", name)
}
// A node's login is its node-engine's alone, sealed to the key that machine stated (ADR 0583 §3 insight).
isLogin, why := v.loginRefusal(caller, name)
if isLogin {
if why == "" {
why = v.loginKeyRefusal(caller, publicKey)
}
if why != "" {
v.log("refused %s to %s: %s", name, caller, why)
_ = v.recordHanding(Handing{To: caller, Name: name, Outcome: "refused", Why: why})
return Answer{}, fmt.Errorf("%s may not ask for %s: %s", caller, name, why)
}
} else if why := notTheHolder(caller, name); why != "" {
v.log("refused %s to %s: %s", name, caller, why)
_ = v.recordHanding(Handing{To: caller, Name: name, Outcome: "refused", Why: why})
return Answer{}, fmt.Errorf("%s may not ask for %s: %s", caller, name, why)
}
// The core's own credentials, to the controller alone, sealed only to their machine's key (ADR 0349 §16).
if isLogin {
// Judged above: the node's own engine, sealed to its own key.
} else if caller == ControllerCaller {
if why := v.coreRefusal(name, publicKey); why != "" {
v.log("refused %s to %s: %s", name, caller, why)
_ = v.recordHanding(Handing{To: caller, Name: name, Outcome: "refused", Why: why})
return Answer{}, fmt.Errorf("%s may not ask for %s: %s", caller, name, why)
}
} else if ok, why := askers.May(caller, name); !ok {
if reason, notYet := strings.CutPrefix(why, notYetPrefix); notYet {
// Not a refusal (issue 928): the client waits and asks again, and nothing is recorded against it.
v.log("not yet %s to %s: %s", name, caller, reason)
return Answer{}, fmt.Errorf("the vault holds no secret named %s yet: %s", name, reason)
}
v.log("refused %s to %s: %s", name, caller, why)
_ = v.recordHanding(Handing{To: caller, Name: name, Outcome: "refused", Why: why})
return Answer{}, fmt.Errorf("%s may not ask for %s: %s", caller, name, why)
}
key, err := askerKey(publicKey)
if err != nil {
return Answer{}, err
}
rec, held, err := v.Store.Read(name)
if err != nil {
return Answer{}, err
}
if !held {
return Answer{}, fmt.Errorf("the vault holds no secret named %s", name)
}
value, err := seal.Open(rec.AtRest, v.Own.PrivateKey)
if err != nil {
return Answer{}, fmt.Errorf("%s does not open with the vault's key: %w", name, err)
}
sealed, err := seal.Seal(value, key)
if err != nil {
return Answer{}, err
}
if err := v.recordHanding(Handing{To: caller, Name: name, Generation: rec.Generation, Outcome: "handed"}); err != nil {
return Answer{}, fmt.Errorf("%s is not handed out: who was handed it could not be recorded: %w", name, err)
}
v.log("handed %s generation %d to %s, sealed to its key", name, rec.Generation, caller)
return Answer{Name: name, Generation: rec.Generation, ChangedAt: rec.ChangedAt, Sealed: sealed}, nil
}
// SetOperatorKey makes key (SPKI PEM) the operator key, then seals again to it every operator copy that is
// not yet — see Reseal. It returns how many copies it sealed again.
//
// The key is recorded first and each record says which key its copy is sealed to, so that record is the
// progress: a failure partway leaves the rest for the next call, and the same key given again (every minute
// from the mesh's export) finishes them. Nothing returns early while a copy is sealed to another key.
func (v *Vault) SetOperatorKey(key, by string) (int, error) {
raw, err := checkOperatorKey(key)
if err != nil {
return 0, err
}
v.mu.Lock()
defer v.mu.Unlock()
current, err := v.Store.OperatorKey()
if err != nil {
return 0, err
}
if current != raw {
if err := v.Store.setOperatorKey(raw, by, v.Now().UTC()); err != nil {
return 0, err
}
v.log("the operator key is now %s (by %s); every operator copy is sealed again to it", raw, by)
}
return v.reseal(raw)
}
// Reseal seals again to the current operator key every copy sealed to another one, and says how many it
// did. Called every minute, so a copy a failure left behind is not left for ever.
func (v *Vault) Reseal() (int, error) {
v.mu.Lock()
defer v.mu.Unlock()
key, err := v.Store.OperatorKey()
if err != nil || key == "" {
return 0, err
}
return v.reseal(key)
}
func (v *Vault) reseal(key string) (int, error) {
all, err := v.Store.List()
if err != nil {
return 0, err
}
n := 0
var failed []error
for _, rec := range all {
if rec.Operator.Key == key && rec.Operator.Box == OperatorBox &&
(rec.Previous == nil || rec.Previous.Operator.Key == key && rec.Previous.Operator.Box == OperatorBox) {
continue
}
value, err := seal.Open(rec.AtRest, v.Own.PrivateKey)
if err != nil {
failed = append(failed, fmt.Errorf("%s does not open with the vault's key: %w", rec.Name, err))
continue
}
sealed, err := operatorSeal(key, []byte(value))
if err == nil && rec.Previous != nil {
// And the value it replaced (owntake.go): a copy of every value the vault keeps is the operator's.
var old string
if old, err = seal.Open(rec.Previous.AtRest, v.Own.PrivateKey); err == nil {
var prev string
if prev, err = operatorSeal(key, []byte(old)); err == nil {
rec.Previous.Operator = OperatorCopy{Box: OperatorBox, Key: key, Sealed: prev}
}
}
}
if err == nil {
rec.Operator = OperatorCopy{Box: OperatorBox, Key: key, Sealed: sealed}
err = v.Store.Write(rec)
}
if err != nil {
failed = append(failed, fmt.Errorf("%s: %w", rec.Name, err))
continue
}
n++
}
if len(failed) > 0 {
err := fmt.Errorf("%d operator copies are still sealed to an earlier key, tried again on the next pass: %w",
len(failed), errors.Join(failed...))
v.log("%v", err)
return n, err
}
return n, nil
}
// StillSealedToAnother is how many operator copies are not yet sealed to the current operator key.
func (v *Vault) StillSealedToAnother() (int, error) {
key, err := v.Store.OperatorKey()
if err != nil {
return 0, err
}
all, err := v.Store.List()
if err != nil {
return 0, err
}
n := 0
for _, rec := range all {
if rec.Operator.Key != key {
n++
}
}
return n, nil
}