456 lines
18 KiB
Go
456 lines
18 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.
|
|
//
|
|
// **The vault is the one issuer** (issue 774, research 072's gap "vault mints after the lease"): it mints every wanted
|
|
// account it holds no value for, whoever holds the controller's lease, and the controller mints none once a vault is
|
|
// installed (mesh-controller forwards `module issue` and its assignment's issue to issue-account). No account depends
|
|
// on a node reporting to the controller. The lease still turns retirement and rotation (retire.go, rotation.go), and
|
|
// vault_status names it. **Never two issuers of one value** (§3, §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; a value the controller
|
|
// moves in never replaces one held (move.go), and one it hands with origin issued never replaces an account the vault
|
|
// minted (AcceptTake): during the delivery an older controller may still mint, and the vault's value wins.
|
|
// **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 what the vault knows of the wanted accounts and of the controller's lease, 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. on, why and since are
|
|
// the lease (handed away or not), which turns retirement and rotation, never minting.
|
|
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: always on (the vault is the one issuer, issue 774), and the lease,
|
|
// which turns retirement and rotation.
|
|
type MintState struct {
|
|
On bool `json:"on"`
|
|
Why string `json:"why"`
|
|
// Lease is whether the controller's lease is handed away, LeaseWhy why, and Since when that last turned.
|
|
Lease bool `json:"lease_handed_away"`
|
|
LeaseWhy string `json:"lease_why"`
|
|
Since time.Time `json:"since,omitzero"`
|
|
Wanted int `json:"wanted"`
|
|
HeldBack map[string]string `json:"held_back"`
|
|
Minted []Minted `json:"minted"`
|
|
}
|
|
|
|
// mintingWhy is why the vault mints, whoever holds the lease.
|
|
const mintingWhy = "the vault is the one issuer of bus accounts: it mints every wanted account it holds no value for " +
|
|
"and adopted no hash of, whoever holds the controller's lease (issue 774)"
|
|
|
|
// MintState answers how minting stands. A user held back whose account the vault holds a value for meanwhile (moved in,
|
|
// handed, or minted by another path) is not said held back: it is not (issue 774, 2026-10-31).
|
|
func (v *Vault) MintState() MintState {
|
|
m := &v.minting
|
|
m.mu.Lock()
|
|
out := MintState{On: true, Why: mintingWhy, Lease: m.on, LeaseWhy: m.why, Since: m.since, Wanted: len(m.wants),
|
|
HeldBack: map[string]string{}, Minted: []Minted{}}
|
|
if out.LeaseWhy == "" {
|
|
out.LeaseWhy = leaseUnread
|
|
}
|
|
wants := map[string]buscompose.WantedAccount{}
|
|
for u, why := range m.heldBack {
|
|
out.HeldBack[u] = why
|
|
if w, ok := m.wants[u]; ok {
|
|
wants[u] = w
|
|
}
|
|
}
|
|
for _, x := range m.minted {
|
|
out.Minted = append(out.Minted, x)
|
|
}
|
|
m.mu.Unlock()
|
|
for u, w := range wants {
|
|
if v.holdsAccount(w) {
|
|
delete(out.HeldBack, u)
|
|
}
|
|
}
|
|
sort.Slice(out.Minted, func(i, j int) bool { return out.Minted[i].User < out.Minted[j].User })
|
|
return out
|
|
}
|
|
|
|
// holdsAccount says the vault holds a bus account as w's own secret: a value that is an account.
|
|
func (v *Vault) holdsAccount(w buscompose.WantedAccount) bool {
|
|
secret, err := AccountSecret(w)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
rec, held, err := v.Store.Read(secret)
|
|
if err != nil || !held {
|
|
return false
|
|
}
|
|
value, err := openAtRest(rec, v)
|
|
return err == nil && BusAccountValue(secret, value)
|
|
}
|
|
|
|
// leaseUnread is why nothing is retired or rotated before the lease is read.
|
|
const leaseUnread = "the controller's lease has not been read yet, so nothing is retired or rotated"
|
|
|
|
// leaseHanded says whether the controller's lease is handed away, and why: what retirement and rotation wait for.
|
|
func (v *Vault) leaseHanded() (bool, string) {
|
|
v.minting.mu.Lock()
|
|
defer v.minting.mu.Unlock()
|
|
if v.minting.why == "" {
|
|
return false, leaseUnread
|
|
}
|
|
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}
|
|
}
|
|
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, and what the controller's `module issue` forwards to
|
|
// (issue 774): it mints nothing a want would not, answers "held already" for an account it holds, and is refused 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)
|
|
}
|
|
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.")
|
|
}
|