mesh-vault: mint each account the grants part wants and the vault holds no value or adopted hash for, in the controller's shape, hashed before account-issued and secret-changed (issue 774, ADR 0561 §1, §2, §4)

This commit is contained in:
2026-10-27 22:33:44 +01:00
parent bf94af0a82
commit d0bba2d364
8 changed files with 462 additions and 8 deletions
@@ -0,0 +1,16 @@
package main
import "git.novox.be/novox/mesh-sdk/go/buscompose"
// LeaseHeard is the watch of the controller's lease (ADR 0561 §3): minting is on only while it is handed to a holder that
// is no controller.
func (v *Vault) LeaseHeard(raw []byte, found bool, readErr error) {
on, why := buscompose.LeaseHandedAway(raw, found, readErr)
m := &v.minting
m.mu.Lock()
defer m.mu.Unlock()
if on != m.on || m.why == "" {
m.since = v.Now().UTC()
}
m.on, m.why = on, why
}
+21
View File
@@ -95,6 +95,23 @@ func watchHandings(env *Env) bool {
return true
}
// watchWants mints from the grants part's wanted accounts as they change (mint.go, ADR 0561 §2): a watch, never a poll.
// It answers whether the watch began; one that did not is begun again at the next minute pass.
func watchWants(env *Env) bool {
if env.Vault == nil {
return true
}
err := stdio.State(WantedRead).Watch("", func(c stdio.StateChange) error {
env.Vault.WantHeard(c.Key, c.Value, c.Op == "delete" || c.Op == "purge" || len(c.Value) == 0)
return nil
})
if err != nil {
say("the grants part's wanted accounts are not watched, so nothing is minted; begun again at the next pass: %v", err)
return false
}
return true
}
// adoptOperatorKey takes the operator key from the export the mesh writes to the vault's root (what
// `operator key set` recorded; the controller keeps only a public half there), and seals again any
// operator copy not yet sealed to the current key. Run every minute, so a failure partway is finished. It
@@ -149,10 +166,14 @@ func main() {
say("hashed %d bus accounts at start; %d could not be, tried again every minute: %v", len(written), len(failed), failed)
}
watched := watchHandings(env)
wants := watchWants(env)
for {
if !watched {
watched = watchHandings(env)
}
if !wants {
wants = watchWants(env)
}
env.pass()
if env.Vault != nil {
env.Vault.HashOwed()
+287
View File
@@ -0,0 +1,287 @@
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
// minted are the accounts this vault minted since it started, by user (vault_status).
minted map[string]Minted
// 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)
}
// 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) {
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
}
if _, held, err := v.Store.Read(secret); err != nil {
return Minted{}, err
} else if held {
// Its value is held (the controller's, handed in, or one the vault minted): never minted anew (§4).
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)"}
}
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}
}
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
}
c, err := v.keepLocked(secret, string(value), "the vault", "", OriginMade, keepOptions{neverReplace: true, quiet: true})
if err != nil {
v.holdBack(w.User, "its value could not be kept: "+err.Error())
return Minted{}, err
}
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
}
// 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
}
// 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.")
}
@@ -0,0 +1,107 @@
package main
import (
"encoding/json"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/seal"
)
// aWant is a wanted account of a module on ace, as the grants part writes it.
func aWant(module string) buscompose.WantedAccount {
return buscompose.WantedAccount{User: "ace." + module, Kind: buscompose.KindModule, Node: "ace", Module: module,
URL: "nats://novox.mesh:4222", Fingerprint: "ab:cd", Claims: []buscompose.ClaimedSeat{{Seat: "s", Scope: "node"}}}
}
func wantRaw(t *testing.T, w buscompose.WantedAccount) []byte {
t.Helper()
raw, err := json.Marshal(w)
if err != nil {
t.Fatal(err)
}
return raw
}
// mintingVault is a vault with a state of bus accounts, ace's sealing key stated, ace's modules assigned, and minting
// turned on or off as on says.
func mintingVault(t *testing.T, on bool) (testVault, *fakeAccounts, string) {
t.Helper()
plans := assignments{"ace.mod-x": `{"node":"ace","module":"mod-x"}`, "ace.mod-y": `{"node":"ace","module":"mod-y"}`}
v := newVault(t, nil)
v.Askers = Declared{Read: plans.read, Store: v.Store}
accounts := &fakeAccounts{}
v.Accounts = accounts
pub, priv := aSealingKey(t)
if err := v.recordNodeKey("ace", pub, "node.ace"); err != nil {
t.Fatal(err)
}
if on {
v.LeaseHeard([]byte(`{"handed-to":"the operator's hand-over"}`), true, nil)
} else {
v.LeaseHeard([]byte(`{"instance":"novox/1/2026-10-27","epoch":57}`), true, nil)
}
return v, accounts, priv
}
// 774/AC7 (novox/hq ADR 0561 §1, §2, §4): a want with no value, minting on, is minted once: kept whole in the controller's
// shape as the module's broker, its hash in bus-accounts, said account-issued before secret-changed, and answered by own
// sealed to the node; a value held or a hash adopted is never minted.
func TestTheVaultMintsAWantedAccountOnceAndNothingItHolds(t *testing.T) {
v, accounts, nodePriv := mintingVault(t, true)
w := aWant("mod-x")
before := len(v.bus.events)
v.WantHeard(w.User, wantRaw(t, w), false)
rec, held, err := v.Store.Read("own/ace/mod-x/broker")
if err != nil || !held || rec.Generation != 1 || rec.Origin != OriginMade {
t.Fatalf("the minted account is kept as %+v (held %v, %v)", rec, held, err)
}
value, err := seal.Open(rec.AtRest, v.Own.PrivateKey)
if err != nil {
t.Fatal(err)
}
var got struct{ Password string }
_ = json.Unmarshal([]byte(value), &got)
shape, _ := buscompose.HeldCredential(w, got.Password)
if len(got.Password) != 40 || value != string(shape) {
t.Errorf("the minted value is %q, not the controller's shape %q", value, shape)
}
if !accounts.hashOf(w.User, got.Password, 1) {
t.Errorf("%s's hash is not the minted password's: %+v", w.User, accounts.keys[w.User])
}
var order []string
for _, e := range v.bus.events[before:] {
order = append(order, e["event"].(string))
}
if strings.Join(order, ",") != AccountHashedEvent+","+AccountIssuedEvent+","+ChangedEvent {
t.Errorf("said %v, want the hash, account-issued, then secret-changed", order)
}
for _, raw := range v.bus.raw[before:] {
if strings.Contains(raw, got.Password) || strings.Contains(raw, "$2a$") {
t.Errorf("a value or a hash was said: %s", raw)
}
}
own, err := v.OwnOfNode("node.ace")
if err != nil || len(own.Own) != 1 {
t.Fatalf("own answered %+v, %v", own, err)
}
if opened, err := seal.Open(own.Own[0].Sealed, nodePriv); err != nil || opened != value {
t.Errorf("own's sealed value opens to %q, %v", opened, err)
}
// Heard again, or for a value held, or for a hash adopted: nothing is minted, nothing is held back.
v.WantHeard(w.User, wantRaw(t, w), false)
if rec, _, _ := v.Store.Read("own/ace/mod-x/broker"); rec.Generation != 1 {
t.Errorf("a want heard again minted generation %d", rec.Generation)
}
accounts.keys["ace.mod-y"] = AccountHash{User: "ace.mod-y", Hash: "$2a$04$adopted", Adopted: true}
y := aWant("mod-y")
v.WantHeard(y.User, wantRaw(t, y), false)
if _, held, _ := v.Store.Read("own/ace/mod-y/broker"); held || accounts.keys["ace.mod-y"].Hash != "$2a$04$adopted" {
t.Errorf("an adopted user was minted")
}
if st := v.MintState(); len(st.HeldBack) != 0 || len(st.Minted) != 1 {
t.Errorf("vault_status says %+v: an adopted user is not held back, one account minted", st)
}
}
@@ -0,0 +1,12 @@
package main
// wantGone is a wanted account's key deleted: forgotten here (its retirement is ADR 0230's, retire's).
func (v *Vault) wantGone(user string) {
v.minting.mu.Lock()
defer v.minting.mu.Unlock()
delete(v.minting.wants, user)
delete(v.minting.heldBack, user)
}
// wantBack is a wanted account's key written again.
func (v *Vault) wantBack(string) {}
+9 -3
View File
@@ -56,6 +56,8 @@ type Vault struct {
hashes hashing
// adoption is the last adoption (adopt.go), under mu.
adoption adoptionKept
// minting is the switch on the lease and what the vault knows of the wanted bus accounts (mint.go).
minting minting
// callsMu guards pending: the keys of the calls open now (intake.go), never written anywhere.
callsMu sync.Mutex
pending map[string]*callKey
@@ -104,8 +106,10 @@ func (v *Vault) keep(name, value, by, via, origin string) (Changed, error) {
// was first made, and where it was moved from.
type keepOptions struct {
neverReplace bool
madeAt time.Time
movedFrom *MovedFrom
// 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
}
// keepWith is keep with the move's options. A covered bus account kept anew has its hash written once it is kept
@@ -186,7 +190,9 @@ func (v *Vault) keepLocked(name, value, by, via, origin string, opt keepOptions)
return Changed{}, err
}
c := Changed{Name: name, Generation: rec.Generation, At: now, By: by}
v.announce(c)
if !opt.quiet {
v.announce(c)
}
return c, nil
}
@@ -147,8 +147,9 @@ func TestNoBucketHoldsASecret(t *testing.T) {
t.Errorf("the manifest declares buckets %s: a secret is in no bucket, sealed or not (ADR 0201 §13), and the one "+
"bucket is the accounts' hashes (ADR 0547 clause 9)", s)
}
if r, _ := json.Marshal(manifest["reads"]); string(r) != `["mesh-controller.assignments","node-engine.have"]` {
t.Errorf("the manifest reads %s: the vault reads the controller's assignments and the haves alone", r)
if r, _ := json.Marshal(manifest["reads"]); string(r) != `["mesh-controller.assignments","node-engine.have","mesh-grants.accounts"]` {
t.Errorf("the manifest reads %s: the vault reads the controller's assignments, the haves and the grants part's "+
"wanted accounts alone", r)
}
sources, _ := filepath.Glob("*.go")
bucket := regexp.MustCompile(`stdio\.State\(|nats-io|jetstream|KeyValue`)
@@ -167,10 +168,12 @@ func TestNoBucketHoldsASecret(t *testing.T) {
code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Put(", "", 1))
code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Keys(", "", 1))
}
// The haves, in main.go alone: one read and one watch, never a write.
// The haves, in main.go alone: one read and one watch, never a write; and one watch of the wanted accounts, the
// grants part's, which hold no secret (ADR 0561 §2).
if f == "main.go" {
code = []byte(strings.Replace(string(code), "stdio.State(HaveRead).Get(", "", 1))
code = []byte(strings.Replace(string(code), "stdio.State(HaveRead).Watch(", "", 1))
code = []byte(strings.Replace(string(code), "stdio.State(WantedRead).Watch(", "", 1))
}
if m := bucket.Find(code); m != nil {
t.Errorf("%s opens a bucket (%s): the vault keeps every secret in its own store only", f, m)
+4 -2
View File
@@ -15,11 +15,13 @@
"provisioned",
"rotated",
"secret-changed",
"account-hashed"
"account-hashed",
"account-issued"
],
"reads": [
"mesh-controller.assignments",
"node-engine.have"
"node-engine.have",
"mesh-grants.accounts"
],
"state": [
{