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…
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
326 lines
11 KiB
Go
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)
|
|
}
|