Files
mesh-catalog/modules/mesh-vault/cmd/mesh-vault/accounts.go
T
jschoubben e7aaa8420e
mesh/delivery merged: source.main moved to novox/mesh-catalog@3ded9e780043
mesh/check pass e7aaa8420eac on 3ded9e780043 | unit 2018 run, 2325 kept, 5 skipped | integration 2040 run, 2344 kept, 9 skipped | over bound: unit, in…
Vault: name a hash record's secret in a field called of, so the runtime accepts it
The runtime refuses a state field whose name ends in secret, so no bus-account hash could be written on novox. The test state now applies the same check.

Issue: 1945
2026-10-26 09:25:35 +01:00

326 lines
11 KiB
Go

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"`
// Of, Generation and Fingerprint name the vault's own secret the hash is of: absent for an adopted one. The field
// is not called "secret": the runtime refuses a state field whose name says it is a credential (ADR 0201).
Of string `json:"of,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 {
Of string `json:"of"`
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{Of: 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.Of == 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), Of: 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, "of": 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)
}