Vault: hash each bus account it is handed into bus-accounts.<user>, once per generation, so the grants part can compose the user list without the controller

The controller still mints each covered account and hands it to the vault (ADR 0374); ADR 0547 clause 9 has the vault
write the bcrypt hash of what it holds into its own state, readable by the grants part, and say account-hashed. It
hashes on keep, at start every held account with no key of its generation, and again by hand (account_hash); a refused
write is owed and written at the next pass. secret_own says per bus account whether this generation's hash is written.

Issue: 1945
This commit is contained in:
2026-10-26 02:28:28 +01:00
parent e572937d97
commit 32b912cfe5
8 changed files with 567 additions and 12 deletions
@@ -0,0 +1,317 @@
package main
// The vault hashes the bus accounts it is handed (novox/hq ADR 0547 clause 9, first milestone; issue 1945).
//
// Until the vault mints bus accounts (ADR 0113 §6, issue 774), the controller mints each covered account and hands its
// value here (ADR 0374 §1). The grants part composes the bus's user list from bcrypt hashes, and the one party that holds
// the value beside the controller is the vault, so the vault writes the hash of each covered account it holds into its own
// state `bus-accounts`, keyed by the bus user, once per generation, and says `account-hashed`. The value is never written
// anywhere but the hash: the key carries the user, the hash, the generation, the fingerprint of the value as the vault's
// other tools give it, and when.
//
// It hashes when a covered account is kept (handed, moved in, or replaced in place by a re-issue), at start every covered
// account it holds with no key for its current generation, and again by hand with the tool `account_hash`. A write the
// bus refused is kept as owed and tried again at the next minute pass, never lost silently.
//
// The hashes of accounts the vault holds no value for (enrolment users, persons, the controller's own) are adopted once,
// before take-over, from the list the bus loaded (clause 9's adoption): such a key is marked adopted and is replaced by
// the vault's own hash the moment the vault holds the account's value, never the other way round.
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"golang.org/x/crypto/bcrypt"
)
// BusAccountsState is the vault's state of bus-account hashes, by bus user (`mesh-vault.bus-accounts`).
const BusAccountsState = "bus-accounts"
// AccountHashedEvent is what the vault says when it writes an account's hash: the user, the secret, its generation and
// fingerprint, never the hash or the value.
const AccountHashedEvent = "account-hashed"
// AccountHash is one key of BusAccountsState.
type AccountHash struct {
User string `json:"user"`
// Hash is the bcrypt hash of the account's password, at the composer's cost.
Hash string `json:"hash"`
// Secret, Generation and Fingerprint are the vault's own secret the hash is of: absent for an adopted one.
Secret string `json:"secret,omitempty"`
Generation int `json:"generation,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
At time.Time `json:"at"`
// Adopted marks a hash taken from the list the bus loaded, for an account the vault holds no value for.
Adopted bool `json:"adopted,omitempty"`
}
// AccountStore is the vault's state of bus-account hashes: read and written by the vault's instances alone.
type AccountStore interface {
Get(user string) (*AccountHash, error)
Put(a AccountHash) error
}
// busAccounts is AccountStore over the vault's own state through its runtime.
type busAccounts struct{}
func (busAccounts) Get(user string) (*AccountHash, error) {
e, err := stdio.State(BusAccountsState).Get(user)
if err != nil || e == nil {
return nil, err
}
var a AccountHash
if err := json.Unmarshal(e.Value, &a); err != nil {
return nil, fmt.Errorf("%s %s is not an account's hash: %w", BusAccountsState, user, err)
}
return &a, nil
}
func (busAccounts) Put(a AccountHash) error {
_, err := stdio.State(BusAccountsState).Put(a.User, a)
return err
}
// hashing is what the vault keeps of its hashing beside its store: the accounts whose hash is owed, with why.
type hashing struct {
mu sync.Mutex
owed map[string]string
}
func (h *hashing) owe(secret, why string) {
h.mu.Lock()
defer h.mu.Unlock()
if h.owed == nil {
h.owed = map[string]string{}
}
h.owed[secret] = why
}
func (h *hashing) paid(secret string) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.owed, secret)
}
func (h *hashing) list() map[string]string {
h.mu.Lock()
defer h.mu.Unlock()
out := map[string]string{}
for k, v := range h.owed {
out[k] = v
}
return out
}
// hashCost is the bcrypt cost of a hash: the controller's (inventory.hashBusPassword, bcrypt's default) unless a test
// set the vault's own.
func (v *Vault) hashCost() int {
if v.HashCost != 0 {
return v.HashCost
}
return bcrypt.DefaultCost
}
// accountOf reads a covered account's value, `{url, fingerprint, node, [module,] user, password[, claims]}` as the
// controller hands it, for its bus user and password. A value that names no user is read by its secret's name, as the
// controller names the user it minted for it (a module's `<node>.<module>`, a provisioner client's
// `provisioner.<node>`); one with no password is not an account.
func accountOf(secret, value string) (user, password string, err error) {
var v struct {
User string `json:"user"`
Password string `json:"password"`
}
if err := json.Unmarshal([]byte(value), &v); err != nil {
return "", "", fmt.Errorf("%s holds no account the controller hands (not JSON)", secret)
}
if v.Password == "" {
return "", "", fmt.Errorf("%s holds no password", secret)
}
user = v.User
if user == "" {
parts := strings.Split(secret, "/")
switch {
case len(parts) == 4 && parts[3] == "broker":
user = parts[1] + "." + parts[2]
case len(parts) == 4 && parts[3] == "provisioner-broker":
user = "provisioner." + parts[1]
default:
return "", "", fmt.Errorf("%s names no user", secret)
}
}
return user, v.Password, nil
}
// Hashed is what one hashing did: the key written or found, never the hash.
type Hashed struct {
Secret string `json:"secret"`
User string `json:"user"`
Generation int `json:"generation"`
Fingerprint string `json:"fingerprint"`
At time.Time `json:"at"`
Written bool `json:"written"`
}
// HashAccount writes the hash of the covered account secret into BusAccountsState, when the key for its user is not
// already of this generation and value (or force), and says `account-hashed`. An adopted key is replaced: the vault
// holds the value now.
func (v *Vault) HashAccount(secret string, force bool) (Hashed, error) {
if v.Accounts == nil {
return Hashed{}, errors.New("the vault has no state of bus accounts to write to")
}
if !Covered(secret) {
return Hashed{}, fmt.Errorf("%s is not a bus account the vault is handed (%s)", secret, strings.Join(CoveredSecrets, ", "))
}
v.hashMu.Lock()
defer v.hashMu.Unlock()
value, rec, err := v.openHeld(secret)
if err != nil {
return Hashed{}, err
}
user, password, err := accountOf(secret, value)
if err != nil {
return Hashed{}, err
}
fp := Fingerprint(value)
h := Hashed{Secret: secret, User: user, Generation: rec.Generation, Fingerprint: fp}
was, err := v.Accounts.Get(user)
if err != nil {
v.hashes.owe(secret, "its key could not be read: "+err.Error())
return Hashed{}, fmt.Errorf("%s %s could not be read: %w", BusAccountsState, user, err)
}
if !force && was != nil && !was.Adopted && was.Secret == secret && was.Generation == rec.Generation && was.Fingerprint == fp {
v.hashes.paid(secret)
h.At = was.At
return h, nil
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), v.hashCost())
if err != nil {
return Hashed{}, err
}
h.At = v.Now().UTC()
if err := v.Accounts.Put(AccountHash{User: user, Hash: string(hash), Secret: secret, Generation: rec.Generation,
Fingerprint: fp, At: h.At}); err != nil {
v.hashes.owe(secret, "its key could not be written: "+err.Error())
return Hashed{}, fmt.Errorf("the hash of %s (generation %d) could not be written to %s %s, tried again at the "+
"next pass: %w", secret, rec.Generation, BusAccountsState, user, err)
}
v.hashes.paid(secret)
h.Written = true
if v.Emit != nil {
if err := v.Emit(AccountHashedEvent, map[string]any{"user": user, "secret": secret, "generation": rec.Generation,
"fingerprint": fp, "at": h.At.Format(time.RFC3339Nano)}); err != nil {
v.log("the hash of %s is written, but %s could not be said: %v", secret, AccountHashedEvent, err)
}
}
return h, nil
}
// openHeld is a secret's record and its value, opened with the vault's key.
func (v *Vault) openHeld(secret string) (string, Record, error) {
rec, held, err := v.Store.Read(secret)
if err != nil {
return "", Record{}, err
}
if !held {
return "", Record{}, fmt.Errorf("the vault holds no secret named %s", secret)
}
value, err := openAtRest(rec, v)
if err != nil {
return "", Record{}, fmt.Errorf("the vault could not open its copy of %s: %w", secret, err)
}
return value, rec, nil
}
// hashKept is what keeping a covered account does after it is kept: its hash written, or the failure said and owed.
func (v *Vault) hashKept(secret string) {
if v.Accounts == nil || !Covered(secret) {
return
}
if _, err := v.HashAccount(secret, false); err != nil {
v.hashes.owe(secret, err.Error())
v.log("THE HASH OF %s IS NOT WRITTEN: %v", secret, err)
}
}
// HashHeld hashes every covered account the vault holds whose key is not of its current generation: at start. It
// answers what it wrote and what it could not.
func (v *Vault) HashHeld() (written []Hashed, failed map[string]string) {
failed = map[string]string{}
if v.Accounts == nil {
return nil, failed
}
records, err := v.Store.List()
if err != nil {
failed["the store"] = err.Error()
return nil, failed
}
for _, r := range records {
if !Covered(r.Name) {
continue
}
h, err := v.HashAccount(r.Name, false)
if err != nil {
v.hashes.owe(r.Name, err.Error())
failed[r.Name] = err.Error()
continue
}
if h.Written {
written = append(written, h)
}
}
return written, failed
}
// HashOwed tries again every hash owed: at each minute pass. Nothing owed reads nothing.
func (v *Vault) HashOwed() {
owed := v.hashes.list()
names := make([]string, 0, len(owed))
for n := range owed {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
if _, held, err := v.Store.Read(n); err == nil && !held {
v.hashes.paid(n)
continue
}
if _, err := v.HashAccount(n, false); err != nil {
v.log("the hash of %s is still owed: %v", n, err)
}
}
}
// HashUser is the tool account_hash: the covered account whose value names user, hashed again now.
func (v *Vault) HashUser(user, caller string) (Hashed, error) {
user = strings.TrimSpace(user)
if user == "" {
return Hashed{}, errors.New("account_hash names a bus user (`user`), e.g. ace.mesh-build-go")
}
records, err := v.Store.List()
if err != nil {
return Hashed{}, err
}
for _, r := range records {
if !Covered(r.Name) {
continue
}
value, err := openAtRest(r, v)
if err != nil {
continue
}
if u, _, err := accountOf(r.Name, value); err == nil && u == user {
h, err := v.HashAccount(r.Name, true)
if err == nil {
v.log("hashed %s again (%s, generation %d), asked by %s", user, r.Name, h.Generation, someoneOr(caller, "a tool"))
}
return h, err
}
}
return Hashed{}, fmt.Errorf("the vault holds no bus account of %s: an account it holds no value for is adopted, "+
"never hashed here", user)
}
@@ -1,10 +1,64 @@
package main package main
import ( import (
"errors"
"strings" "strings"
"sync"
"testing" "testing"
"golang.org/x/crypto/bcrypt"
) )
// fakeAccounts is the vault's state bus-accounts: every key written, and a refusal when told.
type fakeAccounts struct {
mu sync.Mutex
keys map[string]AccountHash
puts int
refuse bool
}
func (f *fakeAccounts) Get(user string) (*AccountHash, error) {
f.mu.Lock()
defer f.mu.Unlock()
a, ok := f.keys[user]
if !ok {
return nil, nil
}
return &a, nil
}
func (f *fakeAccounts) Put(a AccountHash) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.refuse {
return errors.New("Permissions Violation for Publish to $KV.mesh-vault_bus-accounts")
}
if f.keys == nil {
f.keys = map[string]AccountHash{}
}
f.keys[a.User] = a
f.puts++
return nil
}
// hashOf says whether the key of user is the hash of password at generation, of the vault's own value.
func (f *fakeAccounts) hashOf(user, password string, generation int) bool {
f.mu.Lock()
defer f.mu.Unlock()
a, ok := f.keys[user]
return ok && !a.Adopted && a.Generation == generation && bcrypt.CompareHashAndPassword([]byte(a.Hash), []byte(password)) == nil
}
func eventsNamed(v testVault, from int, name string) int {
n := 0
for _, e := range v.bus.events[from:] {
if e["event"] == name {
n++
}
}
return n
}
// A bus account the controller minted for a core or moved module is handed to the vault with origin issued (novox/hq // A bus account the controller minted for a core or moved module is handed to the vault with origin issued (novox/hq
// ADR 0374, issue 787): kept as that module's own secret on that machine, served to the controller's core get and to // ADR 0374, issue 787): kept as that module's own secret on that machine, served to the controller's core get and to
// the moved module's client, replaced in place on a re-issue with `secret-changed`, and never made anew by the vault. // the moved module's client, replaced in place on a re-issue with `secret-changed`, and never made anew by the vault.
@@ -82,6 +136,79 @@ func TestAHandedBusAccountIsKeptServedReplacedAndNeverMadeByTheVault(t *testing.
} }
} }
// The vault hashes what it is handed (ADR 0547 clause 9): once per generation into bus-accounts.<user>, saying
// account-hashed; a refused write is owed and written at the next pass; at start every held account without a key of its
// generation; an adopted key is replaced by the vault's own; never the value, and never the hash in what it says.
func TestAHandedBusAccountIsHashedOncePerGeneration(t *testing.T) {
const secret, user = "own/ace/mesh-build-go/broker", "ace.mesh-build-go"
account := func(pw string) string {
return `{"url":"nats://x","node":"ace","module":"mesh-build-go","user":"` + user + `","password":"` + pw + `"}`
}
v := newVault(t, nil)
acc := &fakeAccounts{}
v.Accounts = acc
for _, step := range []struct {
name string
do func() error
password string
generation int
puts int
said int
}{
{"handed: hashed once, said", func() error { _, err := handIssued(t, v, ControllerCaller, secret, account("pw-1")); return err }, "pw-1", 1, 1, 1},
{"the same value again: nothing", func() error { _, err := handIssued(t, v, ControllerCaller, secret, account("pw-1")); return err }, "pw-1", 1, 1, 0},
{"start with a key of this generation: nothing", func() error { _, f := v.HashHeld(); return mapErr(f) }, "pw-1", 1, 1, 0},
{"a re-issue: the new generation hashed", func() error { _, err := handIssued(t, v, ControllerCaller, secret, account("pw-2")); return err }, "pw-2", 2, 2, 1},
{"a refused write is owed, not lost", func() error {
acc.refuse = true
_, err := handIssued(t, v, ControllerCaller, secret, account("pw-3"))
if _, owed := v.hashes.list()[secret]; !owed {
return errors.New("the refused hash is not owed")
}
return err
}, "pw-2", 2, 2, 0},
{"the next pass writes what is owed", func() error { acc.refuse = false; v.HashOwed(); return nil }, "pw-3", 3, 3, 1},
{"an adopted key is replaced at start", func() error {
acc.keys[user] = AccountHash{User: user, Hash: "$2a$10$adopted", Adopted: true}
_, f := v.HashHeld()
return mapErr(f)
}, "pw-3", 3, 4, 1},
{"by hand, again", func() error { _, err := v.HashUser(user, "person.operator"); return err }, "pw-3", 3, 5, 1},
{"a value with no user is read by its secret's name", func() error {
_, err := handIssued(t, v, ControllerCaller, "own/g14/dunst/provisioner-broker", `{"password":"pw-p"}`)
if !acc.hashOf("provisioner.g14", "pw-p", 1) {
return errors.New("provisioner.g14 is not hashed")
}
return err
}, "pw-3", 3, 6, 1},
} {
before := len(v.bus.events)
if err := step.do(); err != nil {
t.Fatalf("%s: %v", step.name, err)
}
if !acc.hashOf(user, step.password, step.generation) || acc.puts != step.puts || eventsNamed(v, before, AccountHashedEvent) != step.said {
t.Errorf("%s: key %+v, %d writes (want %d), %d said (want %d)", step.name, acc.keys[user], acc.puts, step.puts,
eventsNamed(v, before, AccountHashedEvent), step.said)
}
}
if _, err := v.HashUser("nobody.here", "person.operator"); err == nil {
t.Error("an account the vault holds no value for was hashed by hand")
}
said := everything(t, v)
for _, never := range []string{"pw-1", "pw-2", "pw-3", acc.keys[user].Hash} {
if strings.Contains(said, never) {
t.Fatalf("%q is in something the vault said or wrote", never)
}
}
}
func mapErr(failed map[string]string) error {
for k, v := range failed {
return errors.New(k + ": " + v)
}
return nil
}
// A core module's handed account is the one the controller's core get is answered, sealed to its machine's key. // A core module's handed account is the one the controller's core get is answered, sealed to its machine's key.
func TestAHandedCoreAccountIsKeptAsTheModulesOwnSecret(t *testing.T) { func TestAHandedCoreAccountIsKeptAsTheModulesOwnSecret(t *testing.T) {
const natsBroker = "own/anchor/nats/broker" const natsBroker = "own/anchor/nats/broker"
+9 -1
View File
@@ -50,7 +50,7 @@ func build() *Env {
// Where it sees a consumer bind a provider (pair.go): the same mirror, every key. // Where it sees a consumer bind a provider (pair.go): the same mirror, every key.
env.Vault = &Vault{Store: store, Own: own, Askers: Declared{Read: readAssignment, Store: store}, env.Vault = &Vault{Store: store, Own: own, Askers: Declared{Read: readAssignment, Store: store},
Assignments: Mirror{Keys: assignmentKeys, Read: readAssignment}, Emit: emit, Ask: stdio.Ask, Assignments: Mirror{Keys: assignmentKeys, Read: readAssignment}, Emit: emit, Ask: stdio.Ask,
Now: time.Now, Log: say} Now: time.Now, Log: say, Accounts: busAccounts{}}
return env return env
} }
@@ -102,8 +102,16 @@ func main() {
go func() { go func() {
tick := time.NewTicker(time.Minute) tick := time.NewTicker(time.Minute)
defer tick.Stop() defer tick.Stop()
// At start, every covered account it holds whose hash is not of its current generation (ADR 0547 clause 9).
if env.Vault != nil {
written, failed := env.Vault.HashHeld()
say("hashed %d bus accounts at start; %d could not be, tried again every minute: %v", len(written), len(failed), failed)
}
for { for {
env.pass() env.pass()
if env.Vault != nil {
env.Vault.HashOwed()
}
env.pairPass() env.pairPass()
if env.Custody != nil { if env.Custody != nil {
env.Custody.Pass() env.Custody.Pass()
+52 -2
View File
@@ -345,7 +345,9 @@ func definitions(env *Env) []toolDef {
Description: "The secrets the vault keeps for modules themselves (`own/<node>/<module>/<name>`, e.g. a module's " + Description: "The secrets the vault keeps for modules themselves (`own/<node>/<module>/<name>`, e.g. a module's " +
"bus account `broker`): for each its name, generation, the fingerprint of the value it holds now " + "bus account `broker`): for each its name, generation, the fingerprint of the value it holds now " +
"(sha256:<hex>), when it was made and last changed, by whom, where the value came from, and whether it is a " + "(sha256:<hex>), when it was made and last changed, by whom, where the value came from, and whether it is a " +
"covered bus account. Narrow by node, module or name prefix. At most 1000 rows; says when cut. Never a value.", "covered bus account; for a bus account, its user and whether the hash of this generation is written in " +
"`bus-accounts` (hashed, hashedAt, or why not). Narrow by node, module or name prefix. At most 1000 rows; " +
"says when cut. Never a value or a hash.",
Input: map[string]any{"node": str("only this machine's secrets"), "module": str("only this module's secrets"), Input: map[string]any{"node": str("only this machine's secrets"), "module": str("only this module's secrets"),
"name": str("only secrets whose name (the last part) starts with this, e.g. broker")}, "name": str("only secrets whose name (the last part) starts with this, e.g. broker")},
run: func(_ string, a map[string]any) (any, error) { run: func(_ string, a map[string]any) (any, error) {
@@ -354,6 +356,17 @@ func definitions(env *Env) []toolDef {
} }
return ownAnswer(env.Vault, arg(a, "node"), arg(a, "module"), arg(a, "name")) return ownAnswer(env.Vault, arg(a, "node"), arg(a, "module"), arg(a, "name"))
}}, }},
{Name: "account_hash",
Description: "Hash one covered bus account again now (ADR 0547 clause 9): the vault writes the bcrypt hash of " +
"the account it holds for the bus user into its state `bus-accounts` and says `account-hashed`, as it does " +
"when the account is handed. Answers the generation and fingerprint hashed, never the hash or the value.",
Input: map[string]any{"user": str("the bus user, e.g. ace.mesh-build-go")},
run: func(caller string, a map[string]any) (any, error) {
if env.Vault == nil {
return nil, errors.New("the vault is not ready")
}
return env.Vault.HashUser(arg(a, "user"), caller)
}},
{Name: "secret_vault_export", {Name: "secret_vault_export",
Description: "Every secret the vault itself holds, as its copy sealed to the operator key, in the shape of " + Description: "Every secret the vault itself holds, as its copy sealed to the operator key, in the shape of " +
"`secret export`: saved to a file, `nox secret recover vault mesh-vault <name> --key <operator key file> " + "`secret export`: saved to a file, `nox secret recover vault mesh-vault <name> --key <operator key file> " +
@@ -715,8 +728,45 @@ func ownAnswer(v *Vault, node, module, prefix string) (map[string]any, error) {
if note != nil { if note != nil {
row["error"] = note row["error"] = note
} }
if Covered(r.Name) && fp != "" {
hashedRow(v, r, fp, row)
}
rows = append(rows, row) rows = append(rows, row)
} }
return map[string]any{"secrets": rows, "count": len(rows), "matched": matched, "cut": matched > len(rows), return map[string]any{"secrets": rows, "count": len(rows), "matched": matched, "cut": matched > len(rows),
"note": "the store records no hash of a bus account's password; fingerprint is sha256 of the value the vault holds"}, nil "note": "fingerprint is sha256 of the value the vault holds; a bus account's hash is in the state bus-accounts, never here"}, nil
}
// hashedRow says of a covered account's row whether the hash of its current generation is written in bus-accounts.
func hashedRow(v *Vault, r Record, fp string, row map[string]any) {
value, err := openAtRest(r, v)
if err != nil {
return
}
user, _, err := accountOf(r.Name, value)
if err != nil {
row["hashed"], row["hashWhy"] = false, err.Error()
return
}
row["user"] = user
if v.Accounts == nil {
row["hashed"], row["hashWhy"] = false, "the vault has no state of bus accounts"
return
}
h, err := v.Accounts.Get(user)
switch {
case err != nil:
row["hashed"], row["hashWhy"] = false, "its key could not be read: "+err.Error()
case h == nil:
row["hashed"], row["hashWhy"] = false, "no key"
case h.Adopted:
row["hashed"], row["hashedAt"], row["hashWhy"] = false, h.At, "the key is adopted from the loaded list, not of this value"
case h.Secret != r.Name || h.Generation != r.Generation || h.Fingerprint != fp:
row["hashed"], row["hashedAt"], row["hashWhy"] = false, h.At, fmt.Sprintf("the key is of %s generation %d", h.Secret, h.Generation)
default:
row["hashed"], row["hashedAt"] = true, h.At
}
if why, owed := v.hashes.list()[r.Name]; owed {
row["hashOwed"] = why
}
} }
@@ -153,8 +153,10 @@ func contains(list []string, s string) bool {
func TestSecretOwnListsFingerprintsOfOwnSecretsOnly(t *testing.T) { func TestSecretOwnListsFingerprintsOfOwnSecretsOnly(t *testing.T) {
v := newVault(t, AllowList{}) v := newVault(t, AllowList{})
acc := &fakeAccounts{}
v.Accounts = acc
for name, value := range map[string]string{"own/ace/mesh-build-go/broker": "pw-one", "own/ace/mesh-build-go/token": "tok-secret", for name, value := range map[string]string{"own/ace/mesh-build-go/broker": "pw-one", "own/ace/mesh-build-go/token": "tok-secret",
"own/g14/dunst/broker": "pw-two", "pair/postgres/gitea": "pair-value"} { "own/g14/dunst/broker": `{"user":"g14.dunst","password":"pw-two"}`, "pair/postgres/gitea": "pair-value"} {
if _, err := v.Put(name, value, "the test"); err != nil { if _, err := v.Put(name, value, "the test"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -195,9 +197,25 @@ func TestSecretOwnListsFingerprintsOfOwnSecretsOnly(t *testing.T) {
} }
if len(rows) > 0 && rows[0]["name"] == "own/ace/mesh-build-go/broker" { if len(rows) > 0 && rows[0]["name"] == "own/ace/mesh-build-go/broker" {
if rows[0]["generation"] != 2 || rows[0]["fingerprint"] != Fingerprint("pw-one-b") || rows[0]["busAccount"] != true || if rows[0]["generation"] != 2 || rows[0]["fingerprint"] != Fingerprint("pw-one-b") || rows[0]["busAccount"] != true ||
rows[0]["changedBy"] != "the test" { rows[0]["changedBy"] != "the test" || rows[0]["hashed"] != false || rows[0]["hashWhy"] == nil {
t.Errorf("%v: broker row said %v", c.args, rows[0]) t.Errorf("%v: broker row said %v", c.args, rows[0])
} }
} }
for _, r := range rows {
if r["name"] == "own/g14/dunst/broker" && (r["hashed"] != true || r["user"] != "g14.dunst" || r["hashedAt"] == nil) {
t.Errorf("%v: a hashed account's row said %v", c.args, r)
}
if r["name"] == "own/ace/mesh-build-go/token" && r["hashed"] != nil {
t.Errorf("%v: a secret that is no bus account says hashed: %v", c.args, r)
}
}
}
hash := toolNamed(t, definitions(&Env{Vault: v.Vault}), "account_hash")
got, err := hash.Run(map[string]any{"user": "g14.dunst"})
if h, _ := got.(Hashed); err != nil || !h.Written || h.Generation != 1 || acc.puts != 2 {
t.Errorf("account_hash = %+v, %v (%d writes)", got, err, acc.puts)
}
if _, err := hash.Run(map[string]any{"user": "ace.mesh-build-go"}); err == nil {
t.Error("account_hash hashed a value that is no account")
} }
} }
+20 -1
View File
@@ -42,8 +42,15 @@ type Vault struct {
Ask func(key string, body any) (json.RawMessage, error) Ask func(key string, body any) (json.RawMessage, error)
Now func() time.Time Now func() time.Time
Log func(format string, args ...any) 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
mu sync.Mutex mu sync.Mutex
// hashMu keeps one hashing at a time, and hashes what is owed (accounts.go).
hashMu sync.Mutex
hashes hashing
// callsMu guards pending: the keys of the calls open now (intake.go), never written anywhere. // callsMu guards pending: the keys of the calls open now (intake.go), never written anywhere.
callsMu sync.Mutex callsMu sync.Mutex
pending map[string]*callKey pending map[string]*callKey
@@ -96,8 +103,20 @@ type keepOptions struct {
movedFrom *MovedFrom movedFrom *MovedFrom
} }
// keepWith is keep with the move's options. // 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) { 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
}
// 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 { if err := validName(name); err != nil {
return Changed{}, err return Changed{}, err
} }
@@ -23,6 +23,7 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go" stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/seal" "git.novox.be/novox/mesh-sdk/go/seal"
"golang.org/x/crypto/bcrypt"
"golang.org/x/crypto/nacl/box" "golang.org/x/crypto/nacl/box"
) )
@@ -104,7 +105,7 @@ func newVault(t *testing.T, askers Askers) testVault {
bus := &published{} bus := &published{}
clock := time.Date(2026, 10, 14, 9, 0, 0, 0, time.UTC) clock := time.Date(2026, 10, 14, 9, 0, 0, 0, time.UTC)
v := &Vault{Store: store, Own: own, Askers: askers, Emit: bus.emit, v := &Vault{Store: store, Own: own, Askers: askers, Emit: bus.emit,
Now: func() time.Time { clock = clock.Add(time.Second); return clock }, Log: t.Logf} Now: func() time.Time { clock = clock.Add(time.Second); return clock }, Log: t.Logf, HashCost: bcrypt.MinCost}
pub, priv := operatorKey(t) pub, priv := operatorKey(t)
if _, err := v.SetOperatorKey(asPEM(t, pub), "the test"); err != nil { if _, err := v.SetOperatorKey(asPEM(t, pub), "the test"); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -128,8 +129,9 @@ func filesUnder(t *testing.T, dir string) map[string]string {
// ---- no bucket holds a secret --------------------------------------------------------------------------- // ---- no bucket holds a secret ---------------------------------------------------------------------------
// The manifest declares no bucket, and the code opens none: the vault's only place for a secret is its own // The manifest declares one bucket, and no secret is in it: the vault's only place for a secret is its own store on its
// store on its own disk (ADR 0349 §7, ADR 0201 §13). It reads one bucket of another's, and only reads it: the // own disk (ADR 0349 §7, ADR 0201 §13). The one bucket is bus-accounts, the bcrypt hashes of the bus accounts it holds
// (ADR 0547 clause 9), opened in accounts.go alone. It reads one bucket of another's, and only reads it: the
// controller's assignments, for what each node's plan names (askers.go, design 57 V4). // controller's assignments, for what each node's plan names (askers.go, design 57 V4).
func TestNoBucketHoldsASecret(t *testing.T) { func TestNoBucketHoldsASecret(t *testing.T) {
raw, err := os.ReadFile("../../module.json") raw, err := os.ReadFile("../../module.json")
@@ -140,8 +142,9 @@ func TestNoBucketHoldsASecret(t *testing.T) {
if err := json.Unmarshal(raw, &manifest); err != nil { if err := json.Unmarshal(raw, &manifest); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if s, ok := manifest["state"]; ok { if s, _ := json.Marshal(manifest["state"]); string(s) != `[{"history":2,"name":"bus-accounts"}]` {
t.Errorf("the manifest declares buckets %v: a secret is in no bucket, sealed or not (ADR 0201 §13)", s) 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"]` { if r, _ := json.Marshal(manifest["reads"]); string(r) != `["mesh-controller.assignments"]` {
t.Errorf("the manifest reads %s: the vault reads the controller's assignments alone", r) t.Errorf("the manifest reads %s: the vault reads the controller's assignments alone", r)
@@ -157,6 +160,11 @@ func TestNoBucketHoldsASecret(t *testing.T) {
// binds it remakes a provider's credential on, pair.go). // binds it remakes a provider's credential on, pair.go).
code = []byte(strings.Replace(string(code), "stdio.State(AssignmentsRead).Get(", "", 1)) code = []byte(strings.Replace(string(code), "stdio.State(AssignmentsRead).Get(", "", 1))
code = []byte(strings.Replace(string(code), "stdio.State(AssignmentsRead).Keys(", "", 1)) code = []byte(strings.Replace(string(code), "stdio.State(AssignmentsRead).Keys(", "", 1))
// The accounts' hashes, in accounts.go alone: one read and one write of its own bucket.
if f == "accounts.go" {
code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Get(", "", 1))
code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Put(", "", 1))
}
if m := bucket.Find(code); m != nil { 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) t.Errorf("%s opens a bucket (%s): the vault keeps every secret in its own store only", f, m)
} }
+9 -1
View File
@@ -14,11 +14,18 @@
"emits": [ "emits": [
"provisioned", "provisioned",
"rotated", "rotated",
"secret-changed" "secret-changed",
"account-hashed"
], ],
"reads": [ "reads": [
"mesh-controller.assignments" "mesh-controller.assignments"
], ],
"state": [
{
"name": "bus-accounts",
"history": 2
}
],
"receives": { "receives": {
"secret": "${dir:grants}/mesh.json" "secret": "${dir:grants}/mesh.json"
}, },
@@ -146,6 +153,7 @@
"secret_verify", "secret_verify",
"secret_handed", "secret_handed",
"secret_own", "secret_own",
"account_hash",
"secret_asks", "secret_asks",
"secret_vault_export", "secret_vault_export",
"provisioner_retirement", "provisioner_retirement",