683fd547andb7f87822took every `broker-<id>` own secret for a bus account. mesh-controller's broker-address is the bus's address (MESH_BROKER_ADDRESS_FILE, read by broker.FromEnvironment to issue enrolment tokens and credentials): c1 would have refused to keep it, and it was counted among the "owed bus-account hashes" every minute. A name is now a bus account when it is declared: `broker` or `provisioner-broker`, or the own secret of an account the grants part wants, which it composes from the manifests' bus processes (what catalogue.ToolsBusSecret names). A `broker-<id>` holding an account the controller issued before its want was heard is still hashed. Anything else is kept as given, never refused, never minted over, never hashed and never owed. The mint path replaced only wanted names already, so the address was never at risk of replacement there; the test now proves it. Quick fix: except broker-address by name. Thorough fix: the declaration decides, for every name. Chose thorough. Table rows: broker-address kept as given, not replaced, owed nothing; broker-nats-tools replaced by a minted account once wanted and refused a plain value after. They fail on the previous code.
423 lines
16 KiB
Go
423 lines
16 KiB
Go
package main
|
|
|
|
// The vault mints every new bus account (novox/hq ADR 0561 §1 to §4, issue 774).
|
|
//
|
|
// The grants part says which accounts the mesh wants, in its state `accounts` that the vault alone reads (§2): one key
|
|
// per bus user, holding what the controller's credential carries beside the password. The vault watches it, and for a
|
|
// user it holds no value for and whose hash it did not adopt it makes a password, keeps the account whole (built by
|
|
// mesh-sdk's HeldCredential, byte for byte the controller's shape) as the module's own secret on that node
|
|
// (`own/<node>/<module>/<broker|broker-<process>|provisioner-broker>`), sealed, with the operator's copy; writes the
|
|
// bcrypt hash into `bus-accounts`; and says `account-issued`, then `secret-changed`. The node's engine asks `own` again
|
|
// on `secret-changed` and composes.
|
|
//
|
|
// **One issuer at every moment** (§3): minting is on only while the controller's lease is handed to a holder that is no
|
|
// controller (mesh-sdk LeaseHandedAway). Until then the controller mints, the vault keeps hashing what it is handed, and
|
|
// vault_status names every wanted account it holds back and why. **Nothing is minted anew** (§4): an account whose value
|
|
// the vault holds, or whose hash it adopted, is never minted; the node accounts and the controller's own are never wanted.
|
|
// **Never two values for one user** (§7): a user whose hash the vault already wrote from another of its secrets is held
|
|
// back, said, and never replaced but by a rotation.
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
|
)
|
|
|
|
// WantedRead is the grants part's state of wanted accounts, as the vault's manifest reads it.
|
|
const WantedRead = buscompose.GrantsAccounts
|
|
|
|
// LeaseRead is the controller's lease, as the vault's manifest reads it, and LeaseKey its one key.
|
|
const (
|
|
LeaseRead = buscompose.ControllerLease
|
|
LeaseKey = buscompose.ControllerLeaseKey
|
|
)
|
|
|
|
// AccountIssuedEvent is what the vault says when it minted an account (mesh-sdk buscompose.AccountIssued).
|
|
const AccountIssuedEvent = buscompose.AccountIssuedEvent
|
|
|
|
// minting is the switch and what the vault knows of the wanted accounts, kept in memory and read again from the bus at
|
|
// every start: the grants part's state and the lease are the record, never this.
|
|
type minting struct {
|
|
mu sync.Mutex
|
|
on bool
|
|
why string
|
|
since time.Time
|
|
// wants are the wanted accounts, by user, as last heard.
|
|
wants map[string]buscompose.WantedAccount
|
|
// heldBack are the wanted users the vault did not mint, with why (vault_status).
|
|
heldBack map[string]string
|
|
// owed are the wanted users whose mint failed, tried again at the pass (MintOwed).
|
|
owed map[string]bool
|
|
// minted are the accounts this vault minted since it started, by user (vault_status).
|
|
minted map[string]Minted
|
|
// loadedRevision is the highest revision of the user list the bus node said it loaded (rotation.go).
|
|
loadedRevision uint64
|
|
// retiring is what the passes keep of the accounts no longer wanted (retire.go).
|
|
retiring retiring
|
|
// mintMu keeps one mint at a time: a want heard, the switch turning on, and issue-account.
|
|
mintMu sync.Mutex
|
|
}
|
|
|
|
// Minted is what one mint did: the account and its secret, never its value or its hash.
|
|
type Minted struct {
|
|
User string `json:"user"`
|
|
Secret string `json:"secret"`
|
|
Generation int `json:"generation"`
|
|
At time.Time `json:"at"`
|
|
// Hashed says its hash is written; false leaves it owed, written at the next pass.
|
|
Hashed bool `json:"hashed"`
|
|
}
|
|
|
|
// MintState is what vault_status says of minting.
|
|
type MintState struct {
|
|
On bool `json:"on"`
|
|
Why string `json:"why"`
|
|
Since time.Time `json:"since,omitzero"`
|
|
Wanted int `json:"wanted"`
|
|
HeldBack map[string]string `json:"held_back"`
|
|
Minted []Minted `json:"minted"`
|
|
}
|
|
|
|
// MintState answers how minting stands.
|
|
func (v *Vault) MintState() MintState {
|
|
m := &v.minting
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
out := MintState{On: m.on, Why: m.why, Since: m.since, Wanted: len(m.wants), HeldBack: map[string]string{}, Minted: []Minted{}}
|
|
if out.Why == "" {
|
|
out.Why = "the controller's lease has not been read yet, so nothing is minted"
|
|
}
|
|
for u, why := range m.heldBack {
|
|
out.HeldBack[u] = why
|
|
}
|
|
for _, x := range m.minted {
|
|
out.Minted = append(out.Minted, x)
|
|
}
|
|
sort.Slice(out.Minted, func(i, j int) bool { return out.Minted[i].User < out.Minted[j].User })
|
|
return out
|
|
}
|
|
|
|
// mintingOn says whether the vault mints now, and why.
|
|
func (v *Vault) mintingOn() (bool, string) {
|
|
v.minting.mu.Lock()
|
|
defer v.minting.mu.Unlock()
|
|
if v.minting.why == "" {
|
|
return false, "the controller's lease has not been read yet, so nothing is minted"
|
|
}
|
|
return v.minting.on, v.minting.why
|
|
}
|
|
|
|
func (v *Vault) holdBack(user, why string) {
|
|
m := &v.minting
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.heldBack == nil {
|
|
m.heldBack = map[string]string{}
|
|
}
|
|
m.heldBack[user] = why
|
|
}
|
|
|
|
func (v *Vault) noLongerHeld(user string) {
|
|
m := &v.minting
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
delete(m.heldBack, user)
|
|
}
|
|
|
|
// DeclaredAccount says secret is a bus account the mesh declares: one by its name (NamedAccount), or the own secret of
|
|
// an account the grants part wants, which it composes from the manifests' bus processes (catalogue.BusAccounts, as
|
|
// catalogue.ToolsBusSecret names them). Never by a `broker-` prefix alone (the sweep of 2026-10-30, issue 774:
|
|
// mesh-controller's broker-address is the bus's address, read by the controller to issue tokens).
|
|
func (v *Vault) DeclaredAccount(secret string) bool {
|
|
if NamedAccount(secret) {
|
|
return true
|
|
}
|
|
if !Covered(secret) {
|
|
return false
|
|
}
|
|
v.minting.mu.Lock()
|
|
defer v.minting.mu.Unlock()
|
|
for _, w := range v.minting.wants {
|
|
if s, err := AccountSecret(w); err == nil && s == secret {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// busAccount says the held secret is a bus account: declared, or a `broker-<id>` whose value is an account (one the
|
|
// controller issued before its want was heard). Anything else is not hashed and not owed.
|
|
func (v *Vault) busAccount(secret, value string) bool {
|
|
return v.DeclaredAccount(secret) || (Covered(secret) && BusAccountValue(secret, value))
|
|
}
|
|
|
|
// AccountSecret is the vault's name of the own secret a wanted account is held in.
|
|
func AccountSecret(w buscompose.WantedAccount) (string, error) {
|
|
return OwnSecret(w.Node, w.Module, w.OwnSecret())
|
|
}
|
|
|
|
// WantHeard is the watch of the grants part's wanted accounts: a key put is a want, minted when minting is on; a key
|
|
// deleted is a want gone (retire.go).
|
|
func (v *Vault) WantHeard(user string, raw []byte, deleted bool) {
|
|
if deleted {
|
|
v.wantGone(user)
|
|
return
|
|
}
|
|
w, err := buscompose.ReadWantedAccount(user, raw)
|
|
if err != nil {
|
|
v.log("the wanted account %s is not read, so nothing is minted for it: %v", user, err)
|
|
v.holdBack(user, "its wanted key is not readable: "+err.Error())
|
|
return
|
|
}
|
|
v.minting.mu.Lock()
|
|
if v.minting.wants == nil {
|
|
v.minting.wants = map[string]buscompose.WantedAccount{}
|
|
}
|
|
v.minting.wants[user] = w
|
|
v.minting.mu.Unlock()
|
|
v.wantBack(user)
|
|
if _, err := v.mintWanted(w, "the grants part's want of "+user); err != nil {
|
|
v.log("%s is not minted: %v", user, err)
|
|
}
|
|
}
|
|
|
|
// errHeldBack is a want the vault does not mint now, with why: not an error of the vault.
|
|
type errHeldBack struct{ why string }
|
|
|
|
func (e errHeldBack) Error() string { return e.why }
|
|
|
|
// mintWanted mints w when minting is on, the vault holds no value for it and adopted no hash of it; otherwise it says
|
|
// why not and, where that is a hold, keeps it for vault_status.
|
|
func (v *Vault) mintWanted(w buscompose.WantedAccount, by string) (Minted, error) {
|
|
m, err := v.mintOnce(w, by)
|
|
var held errHeldBack
|
|
v.owe(w.User, err != nil && !errors.As(err, &held) && w.Valid() == nil)
|
|
return m, err
|
|
}
|
|
|
|
// mintOnce is one try of mintWanted.
|
|
func (v *Vault) mintOnce(w buscompose.WantedAccount, by string) (Minted, error) {
|
|
v.minting.mintMu.Lock()
|
|
defer v.minting.mintMu.Unlock()
|
|
if err := w.Valid(); err != nil {
|
|
v.holdBack(w.User, err.Error())
|
|
return Minted{}, err
|
|
}
|
|
secret, err := AccountSecret(w)
|
|
if err != nil {
|
|
v.holdBack(w.User, err.Error())
|
|
return Minted{}, err
|
|
}
|
|
replacing := false
|
|
if rec, held, err := v.Store.Read(secret); err != nil {
|
|
return Minted{}, err
|
|
} else if held {
|
|
// A held value that is no account (kept before the vault judged it, the sweep of 2026-10-30) has no hash and
|
|
// logs in nowhere on the bus: it is replaced by a minted account. Any other held value is never minted anew (§4).
|
|
if value, err := openAtRest(rec, v); err != nil || BusAccountValue(secret, value) {
|
|
v.noLongerHeld(w.User)
|
|
return Minted{}, errHeldBack{secret + " is held already: an account the vault holds a value for is never minted anew (ADR 0561 §4)"}
|
|
}
|
|
replacing = true
|
|
}
|
|
if v.Accounts == nil {
|
|
v.holdBack(w.User, "the vault has no state of bus accounts to write the hash to")
|
|
return Minted{}, errors.New("the vault has no state of bus accounts to write the hash to")
|
|
}
|
|
was, err := v.Accounts.Get(w.User)
|
|
if err != nil {
|
|
v.holdBack(w.User, "its key in "+BusAccountsState+" cannot be read: "+err.Error())
|
|
return Minted{}, err
|
|
}
|
|
if was != nil && was.Adopted {
|
|
// The hash the bus loaded is kept (§4): not minted, and not held back either.
|
|
v.noLongerHeld(w.User)
|
|
return Minted{}, errHeldBack{w.User + "'s hash was adopted from the list the bus loaded: it keeps it, and is never minted (ADR 0561 §4)"}
|
|
}
|
|
if was != nil && was.Of != "" && was.Of != secret {
|
|
why := fmt.Sprintf("%s holds the hash of %s for %s, not of %s: a user has one value, so nothing is minted until "+
|
|
"a person sees why (ADR 0561 §7)", BusAccountsState, was.Of, w.User, secret)
|
|
v.holdBack(w.User, why)
|
|
return Minted{}, errHeldBack{why}
|
|
}
|
|
v.leaseAfresh()
|
|
if on, why := v.mintingOn(); !on {
|
|
v.holdBack(w.User, "held back: "+why)
|
|
return Minted{}, errHeldBack{why}
|
|
}
|
|
raw := make([]byte, 30)
|
|
if _, err := rand.Read(raw); err != nil {
|
|
return Minted{}, err
|
|
}
|
|
value, err := buscompose.HeldCredential(w, base64.RawURLEncoding.EncodeToString(raw))
|
|
if err != nil {
|
|
v.holdBack(w.User, err.Error())
|
|
return Minted{}, err
|
|
}
|
|
opt := keepOptions{neverReplace: true, quiet: true}
|
|
if replacing {
|
|
// Only the value that is no account is replaced, judged again under the store's lock; the one replaced is kept.
|
|
opt = keepOptions{quiet: true, keepPrevious: true, refuse: func(before Record) string {
|
|
if was, err := openAtRest(before, v); err != nil || BusAccountValue(secret, was) {
|
|
return secret + " is held as an account meanwhile: it is never minted anew (ADR 0561 §4)"
|
|
}
|
|
return ""
|
|
}}
|
|
}
|
|
c, err := v.keepLocked(secret, string(value), "the vault", "", OriginMade, opt)
|
|
if err != nil {
|
|
v.holdBack(w.User, "its value could not be kept: "+err.Error())
|
|
return Minted{}, err
|
|
}
|
|
if replacing {
|
|
v.log("%s held a value that is no bus account, which no hash was taken of and which logs in nowhere: replaced by a "+
|
|
"minted account (generation %d), the value it replaces kept beside it", secret, c.Generation)
|
|
}
|
|
out := Minted{User: w.User, Secret: secret, Generation: c.Generation, At: c.At}
|
|
// The hash first, then account-issued, then secret-changed: the grants part composes the user before the node asks.
|
|
if _, err := v.HashAccount(secret, false); err != nil {
|
|
v.hashes.owe(secret, err.Error())
|
|
v.log("%s is minted and kept, and ITS HASH IS NOT WRITTEN, written at the next pass: %v", w.User, err)
|
|
} else {
|
|
out.Hashed = true
|
|
if v.Emit != nil {
|
|
e := buscompose.AccountIssued{User: w.User, Kind: w.Kind, Node: w.Node, Module: w.Module, Process: w.Process,
|
|
Generation: c.Generation, At: c.At}
|
|
body := map[string]any{}
|
|
b, _ := json.Marshal(e)
|
|
_ = json.Unmarshal(b, &body)
|
|
if err := v.Emit(AccountIssuedEvent, body); err != nil {
|
|
v.log("%s is minted and hashed, but %s could not be said: %v", w.User, AccountIssuedEvent, err)
|
|
}
|
|
}
|
|
}
|
|
v.announce(c)
|
|
v.minting.mu.Lock()
|
|
if v.minting.minted == nil {
|
|
v.minting.minted = map[string]Minted{}
|
|
}
|
|
v.minting.minted[w.User] = out
|
|
v.minting.mu.Unlock()
|
|
v.noLongerHeld(w.User)
|
|
v.log("minted the bus account %s as %s (generation %d), for %s", w.User, secret, c.Generation, by)
|
|
return out, nil
|
|
}
|
|
|
|
// MintOwed tries again, at the vault's minute pass, every wanted account whose mint failed (its value could not be kept,
|
|
// its key read, its password made): a want heard is never lost to one refusal. A want held back is not owed: the switch
|
|
// or the want changing judges it again.
|
|
func (v *Vault) MintOwed() {
|
|
v.minting.mu.Lock()
|
|
var owed []string
|
|
for u := range v.minting.owed {
|
|
owed = append(owed, u)
|
|
}
|
|
v.minting.mu.Unlock()
|
|
sort.Strings(owed)
|
|
for _, u := range owed {
|
|
if w, ok := v.wanted(u); ok {
|
|
if _, err := v.mintWanted(w, "the pass, after a mint that failed"); err != nil {
|
|
v.log("%s is still not minted: %v", u, err)
|
|
}
|
|
} else {
|
|
v.owe(u, false)
|
|
}
|
|
}
|
|
}
|
|
|
|
// owe marks user's mint as failed (tried again at the pass) or not.
|
|
func (v *Vault) owe(user string, owed bool) {
|
|
v.minting.mu.Lock()
|
|
defer v.minting.mu.Unlock()
|
|
if !owed {
|
|
delete(v.minting.owed, user)
|
|
return
|
|
}
|
|
if v.minting.owed == nil {
|
|
v.minting.owed = map[string]bool{}
|
|
}
|
|
v.minting.owed[user] = true
|
|
}
|
|
|
|
// heldBackOrMinted is a mint's answer as a tool gives it.
|
|
func heldBackOrMinted(m Minted, err error) (any, error) {
|
|
var held errHeldBack
|
|
if errors.As(err, &held) {
|
|
return map[string]any{"minted": false, "why": held.why}, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"minted": true, "account": m}, nil
|
|
}
|
|
|
|
// IssueAccount is the seat's verb issue-account (ADR 0561 §9): one wanted user minted now, its key read afresh from the
|
|
// grants part's state. The recovery when a want was not heard: it mints nothing a want would not, and is refused while
|
|
// minting is off, for a node's account and the controller's (never minted here, §4), and for a user nobody wants.
|
|
func (v *Vault) IssueAccount(caller, user string) (any, error) {
|
|
user = strings.TrimSpace(user)
|
|
if user == "" {
|
|
return nil, errors.New("issue-account names a bus user (`user`), e.g. ace.mesh-build-go")
|
|
}
|
|
if nodeOrController(user) {
|
|
return nil, fmt.Errorf("%s is never minted by the vault: a node's account and the controller's keep the hash they "+
|
|
"have (ADR 0561 §4)", user)
|
|
}
|
|
v.leaseAfresh()
|
|
if on, why := v.mintingOn(); !on {
|
|
return nil, fmt.Errorf("not minted: %s", why)
|
|
}
|
|
raw, found, err := []byte(nil), false, error(nil)
|
|
if v.WantRead != nil {
|
|
raw, found, err = v.WantRead(user)
|
|
} else if w, ok := v.wanted(user); ok {
|
|
raw, err = json.Marshal(w)
|
|
found = err == nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the wanted account %s could not be read: %w", user, err)
|
|
}
|
|
if !found {
|
|
return nil, fmt.Errorf("the grants part holds no wanted account %s: the vault mints only what placement composes "+
|
|
"(grants_accounts lists them)", user)
|
|
}
|
|
w, err := buscompose.ReadWantedAccount(user, raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
v.log("issue-account %s, asked by %s", user, someoneOr("", caller))
|
|
return heldBackOrMinted(v.mintWanted(w, "issue-account by "+someoneOr("", caller)))
|
|
}
|
|
|
|
// wantedUsers is every wanted user heard, sorted.
|
|
func (v *Vault) wantedUsers() []string {
|
|
v.minting.mu.Lock()
|
|
defer v.minting.mu.Unlock()
|
|
out := make([]string, 0, len(v.minting.wants))
|
|
for u := range v.minting.wants {
|
|
out = append(out, u)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func (v *Vault) wanted(user string) (buscompose.WantedAccount, bool) {
|
|
v.minting.mu.Lock()
|
|
defer v.minting.mu.Unlock()
|
|
w, ok := v.minting.wants[user]
|
|
return w, ok
|
|
}
|
|
|
|
// nodeOrController says user is a node's account or the controller's: never minted here (ADR 0561 §4, §9).
|
|
func nodeOrController(user string) bool {
|
|
return user == ControllerCaller || strings.HasPrefix(user, "node.")
|
|
}
|