diff --git a/modules/mesh-vault/cmd/mesh-vault/accounts.go b/modules/mesh-vault/cmd/mesh-vault/accounts.go new file mode 100644 index 00000000..1accbc27 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/accounts.go @@ -0,0 +1,324 @@ +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 `.`, a provisioner client's +// `provisioner.`); 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) + still := map[string]string{} + 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 { + still[n] = err.Error() + } + } + if len(still) > 0 { + v.log("%d of %d owed bus-account hashes are still owed, tried again at the next pass: %v", len(still), len(names), still) + } +} + +// 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")) + } else { + v.log("did not hash %s again (%s), asked by %s: %v", user, r.Name, someoneOr(caller, "a tool"), err) + } + return h, err + } + } + v.log("did not hash %s again, asked by %s: the vault holds no bus account of it", user, someoneOr(caller, "a tool")) + 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) +} diff --git a/modules/mesh-vault/cmd/mesh-vault/intake.go b/modules/mesh-vault/cmd/mesh-vault/intake.go index 329b2c8e..33e91639 100644 --- a/modules/mesh-vault/cmd/mesh-vault/intake.go +++ b/modules/mesh-vault/cmd/mesh-vault/intake.go @@ -32,7 +32,6 @@ import ( "errors" "fmt" "regexp" - "slices" "strings" "time" @@ -197,15 +196,27 @@ func zero(b []byte) { // CoveredSecrets are the names, in a module's own secrets, of the bus accounts the controller mints for a core module // or a module marked moved and hands to the vault (novox/hq ADR 0374, issue 787): the module's account on the bus -// (`broker`) and, for the machine's provisioner client, its own (`provisioner-broker`). -var CoveredSecrets = []string{"broker", "provisioner-broker"} +// (`broker`), for the machine's provisioner client its own (`provisioner-broker`), and for each process of its bus +// declarations its own (`broker-`, ADR 0436 §2), named here by ProcessSecretPrefix. +var CoveredSecrets = []string{"broker", "provisioner-broker", ProcessSecretPrefix + ""} -// Covered says whether secret is a covered account: a module's own secret named one of CoveredSecrets. The vault -// cannot tell core or moved from a module's manifest; the controller, which can, is the only caller that may hand -// one in, and the name keeps what it hands to these. +// ProcessSecretPrefix starts a process's own bus account's secret, `broker-` (the controller's +// processSecretName). +const ProcessSecretPrefix = "broker-" + +// Covered says whether secret is a covered account: a module's own secret named `broker`, `provisioner-broker` or +// `broker-`. The vault cannot tell core or moved from a module's manifest; the controller, which can, is the +// only caller that may hand one in, and the name keeps what it hands to these. func Covered(secret string) bool { parts := strings.Split(secret, "/") - return len(parts) == 4 && parts[0] == "own" && slices.Contains(CoveredSecrets, parts[3]) + if len(parts) != 4 || parts[0] != "own" { + return false + } + name := parts[3] + if id, process := strings.CutPrefix(name, ProcessSecretPrefix); process { + return id != "" + } + return name == "broker" || name == "provisioner-broker" } // AcceptOpen opens a call to take a value given at the controller's terminal for secret, which the terminal diff --git a/modules/mesh-vault/cmd/mesh-vault/issued_account_test.go b/modules/mesh-vault/cmd/mesh-vault/issued_account_test.go index 01e99e8f..ef6a1de5 100644 --- a/modules/mesh-vault/cmd/mesh-vault/issued_account_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/issued_account_test.go @@ -1,10 +1,64 @@ package main import ( + "errors" "strings" + "sync" "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 // 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. @@ -82,6 +136,93 @@ func TestAHandedBusAccountIsKeptServedReplacedAndNeverMadeByTheVault(t *testing. } } +// The vault hashes what it is handed (ADR 0547 clause 9): once per generation into bus-accounts., 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}, + {"a process's own account (ADR 0436 §2) is covered and hashed", func() error { + _, err := handIssued(t, v, ControllerCaller, "own/ace/mesh-build-go/broker-worker", + `{"node":"ace","module":"mesh-build-go","user":"ace.mesh-build-go.worker","password":"pw-w"}`) + if !acc.hashOf("ace.mesh-build-go.worker", "pw-w", 1) { + return errors.New("ace.mesh-build-go.worker is not hashed") + } + if _, rerr := v.Rotate(ControllerCaller, "own/ace/mesh-build-go/broker-worker", "jochen", "x"); rerr == nil { + return errors.New("the vault rotated a process's covered account") + } + if Covered("own/ace/mesh-build-go/broker-") || Covered("own/ace/mesh-build-go/brokers") { + return errors.New("a name that is no process account is covered") + } + return err + }, "pw-3", 3, 7, 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. func TestAHandedCoreAccountIsKeptAsTheModulesOwnSecret(t *testing.T) { const natsBroker = "own/anchor/nats/broker" diff --git a/modules/mesh-vault/cmd/mesh-vault/main.go b/modules/mesh-vault/cmd/mesh-vault/main.go index d8f09abd..6e344591 100644 --- a/modules/mesh-vault/cmd/mesh-vault/main.go +++ b/modules/mesh-vault/cmd/mesh-vault/main.go @@ -50,7 +50,7 @@ func build() *Env { // 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}, 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 } @@ -102,8 +102,16 @@ func main() { go func() { tick := time.NewTicker(time.Minute) 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 { env.pass() + if env.Vault != nil { + env.Vault.HashOwed() + } env.pairPass() if env.Custody != nil { env.Custody.Pass() diff --git a/modules/mesh-vault/cmd/mesh-vault/tools.go b/modules/mesh-vault/cmd/mesh-vault/tools.go index a737cfdf..0d1732b0 100644 --- a/modules/mesh-vault/cmd/mesh-vault/tools.go +++ b/modules/mesh-vault/cmd/mesh-vault/tools.go @@ -12,6 +12,7 @@ import ( "encoding/json" "errors" "fmt" + "git.novox.be/novox/mesh-sdk/go/seal" "os" "path/filepath" "strings" @@ -340,6 +341,32 @@ func definitions(env *Env) []toolDef { } return env.Vault.Store.Handed(limitOf(a)) }}, + {Name: "secret_own", + Description: "The secrets the vault keeps for modules themselves (`own///`, e.g. a module's " + + "bus account `broker`): for each its name, generation, the fingerprint of the value it holds now " + + "(sha256:), when it was made and last changed, by whom, where the value came from, and whether it is a " + + "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"), + "name": str("only secrets whose name (the last part) starts with this, e.g. broker")}, + run: func(_ string, a map[string]any) (any, error) { + if env.Vault == nil { + return nil, errors.New("the vault is not ready") + } + 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", 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 --key " + @@ -661,3 +688,85 @@ func tools(env *Env) []stdio.Tool { } return out } + +// ownRowsMax bounds what secret_own answers. +const ownRowsMax = 1000 + +// ownAnswer lists the own secrets the store holds, narrowed by node, module and name prefix: fingerprints only. +func ownAnswer(v *Vault, node, module, prefix string) (map[string]any, error) { + records, err := v.Store.List() + if err != nil { + return nil, err + } + rows := []map[string]any{} + matched := 0 + for _, r := range records { + parts := strings.Split(r.Name, "/") + if len(parts) != 4 || parts[0] != "own" { + continue + } + if (node != "" && parts[1] != node) || (module != "" && parts[2] != module) || !strings.HasPrefix(parts[3], prefix) { + continue + } + matched++ + if len(rows) >= ownRowsMax { + continue + } + fp := "" + var note any + if value, err := seal.Open(r.AtRest, v.Own.PrivateKey); err != nil { + note = "the vault could not open its copy: " + err.Error() + } else { + fp = Fingerprint(value) + } + row := map[string]any{"name": r.Name, "node": parts[1], "module": parts[2], "secret": parts[3], + "generation": r.Generation, "fingerprint": fp, "made": r.MadeAt, "changed": r.ChangedAt, + "changedBy": r.ChangedBy, "origin": r.Origin, "busAccount": Covered(r.Name)} + if r.ChangedVia != "" { + row["changedVia"] = r.ChangedVia + } + if note != nil { + row["error"] = note + } + if Covered(r.Name) && fp != "" { + hashedRow(v, r, fp, row) + } + rows = append(rows, row) + } + return map[string]any{"secrets": rows, "count": len(rows), "matched": matched, "cut": matched > len(rows), + "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 + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/tools_test.go b/modules/mesh-vault/cmd/mesh-vault/tools_test.go index dee060cb..4201ac97 100644 --- a/modules/mesh-vault/cmd/mesh-vault/tools_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/tools_test.go @@ -150,3 +150,72 @@ func contains(list []string, s string) bool { } return false } + +func TestSecretOwnListsFingerprintsOfOwnSecretsOnly(t *testing.T) { + 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", + "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 { + t.Fatal(err) + } + } + if _, err := v.Put("own/ace/mesh-build-go/broker", "pw-one-b", "the test"); err != nil { + t.Fatal(err) + } + own := toolNamed(t, definitions(&Env{Vault: v.Vault}), "secret_own") + for _, c := range []struct { + args map[string]any + names []string + }{ + {map[string]any{}, []string{"own/ace/mesh-build-go/broker", "own/ace/mesh-build-go/token", "own/g14/dunst/broker"}}, + {map[string]any{"node": "ace"}, []string{"own/ace/mesh-build-go/broker", "own/ace/mesh-build-go/token"}}, + {map[string]any{"module": "dunst"}, []string{"own/g14/dunst/broker"}}, + {map[string]any{"name": "bro"}, []string{"own/ace/mesh-build-go/broker", "own/g14/dunst/broker"}}, + {map[string]any{"node": "ace", "name": "broker"}, []string{"own/ace/mesh-build-go/broker"}}, + {map[string]any{"node": "nowhere"}, nil}, + } { + got, err := own.Run(c.args) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(got) + for _, value := range []string{"pw-one", "pw-two", "tok-secret", "pair-value"} { + if strings.Contains(string(raw), value) { + t.Errorf("%v returned a value: %s", c.args, raw) + } + } + rows := got.(map[string]any)["secrets"].([]map[string]any) + if len(rows) != len(c.names) || got.(map[string]any)["cut"] != false { + t.Fatalf("%v said %s", c.args, raw) + } + for i, n := range c.names { + if rows[i]["name"] != n { + t.Errorf("%v row %d is %v, want %s", c.args, i, rows[i]["name"], n) + } + } + 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 || + rows[0]["changedBy"] != "the test" || rows[0]["hashed"] != false || rows[0]["hashWhy"] == nil { + 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") + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/vault.go b/modules/mesh-vault/cmd/mesh-vault/vault.go index 0fffa275..fa3f7129 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault.go @@ -42,8 +42,15 @@ type Vault struct { Ask func(key string, body any) (json.RawMessage, error) 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 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 sync.Mutex pending map[string]*callKey @@ -96,8 +103,20 @@ type keepOptions struct { 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) { + 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 { return Changed{}, err } diff --git a/modules/mesh-vault/cmd/mesh-vault/vault_test.go b/modules/mesh-vault/cmd/mesh-vault/vault_test.go index db21a44a..c44c270a 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault_test.go @@ -23,6 +23,7 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/seal" + "golang.org/x/crypto/bcrypt" "golang.org/x/crypto/nacl/box" ) @@ -104,7 +105,7 @@ func newVault(t *testing.T, askers Askers) testVault { bus := &published{} clock := time.Date(2026, 10, 14, 9, 0, 0, 0, time.UTC) 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) if _, err := v.SetOperatorKey(asPEM(t, pub), "the test"); err != nil { t.Fatal(err) @@ -128,8 +129,9 @@ func filesUnder(t *testing.T, dir string) map[string]string { // ---- 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 -// store on its own disk (ADR 0349 §7, ADR 0201 §13). It reads one bucket of another's, and only reads it: the +// 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 +// 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). func TestNoBucketHoldsASecret(t *testing.T) { raw, err := os.ReadFile("../../module.json") @@ -140,8 +142,9 @@ func TestNoBucketHoldsASecret(t *testing.T) { if err := json.Unmarshal(raw, &manifest); err != nil { t.Fatal(err) } - if s, ok := manifest["state"]; ok { - t.Errorf("the manifest declares buckets %v: a secret is in no bucket, sealed or not (ADR 0201 §13)", s) + if s, _ := json.Marshal(manifest["state"]); string(s) != `[{"history":2,"name":"bus-accounts"}]` { + 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"]` { 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). code = []byte(strings.Replace(string(code), "stdio.State(AssignmentsRead).Get(", "", 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 { t.Errorf("%s opens a bucket (%s): the vault keeps every secret in its own store only", f, m) } diff --git a/modules/mesh-vault/module.json b/modules/mesh-vault/module.json index 44944c95..8c4f2e04 100644 --- a/modules/mesh-vault/module.json +++ b/modules/mesh-vault/module.json @@ -14,11 +14,18 @@ "emits": [ "provisioned", "rotated", - "secret-changed" + "secret-changed", + "account-hashed" ], "reads": [ "mesh-controller.assignments" ], + "state": [ + { + "name": "bus-accounts", + "history": 2 + } + ], "receives": { "secret": "${dir:grants}/mesh.json" }, @@ -145,6 +152,8 @@ "secret_holders", "secret_verify", "secret_handed", + "secret_own", + "account_hash", "secret_asks", "secret_vault_export", "provisioner_retirement",