Files
mesh-catalog/modules/mesh-vault/cmd/mesh-vault/store.go
T
jschoubben 049bd176c3 Vault: enrolment token and node account, signing for enrolment, a node's two logins and their rotation, own hand-out of a login
The vault serves the verbs enrolment-token, node-account, rotate-node, node-account-confirm and abandon, keeps the state
node-logins, and says rotation-begun, node-account-ready and rotation-done (ADR 0587 §3, §5, §8; ADR 0583 §1-§7 and
insights; ADR 0561 §4, §5). Only mesh-enrolment asks the first two and sign for an enrolment; a rotation needs a hand act
read back from mesh-hand-acts by its id, runs one at a time with the bus node last, and is ready only when the loaded list
holds the new login and done when it lacks the old one. A node's login is handed to that node's engine alone, sealed to
the key it stated. The key of a node that leaves the placement is deleted. Accounts kept for an enrolment or a node are
marked and never retired as unwanted.

Subtasks 774/S43 to S50 in one commit: they share enrolment.go, noderotation.go, tools.go and the manifest, and could not
be cut apart without a commit that does not build. New tests: 3 unit functions (table-style flows) and the one
integration test of S50; mesh-vault stays at 134 unit and 2 integration tests, within the module budget of 500 and 50, so
none was merged or deleted. The integration test's player gained a mesh/ask answer.

(issue 774, 774/S43, 774/S44, 774/S45, 774/S46, 774/S47, 774/S48, 774/S49, 774/S50)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:42:19 +01:00

493 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// The vault's own store (novox/hq ADR 0349 §7, ADR 0113 §22): one file per secret on the vault's own disk,
// every value in it sealed — once to the vault's own key, the copy the vault opens to answer `get`, and once
// to the operator key, the copy the operator opens when the vault is lost (ADR 0113 §21). No database and no
// bucket: the vault is installed before any other module (ADR 0113 §14), a database login would be a
// secret only the vault makes, and a bucket holds no secret, sealed or not (ADR 0201 §13).
//
// Each file is written whole and renamed into place, so a reader never sees half a record.
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/seal"
)
// OperatorBox names the box an operator copy is sealed in: the NaCl anonymous box the operator key is made
// for today (`operator key make`) and `secret recover --from-export` opens.
const OperatorBox = "nacl-anonymous-box"
// Record is one secret as the store keeps it: never its value.
type Record struct {
Name string `json:"name"`
Generation int `json:"generation"`
// AtRest is the value sealed to the vault's own key (the ADR 0206 box).
AtRest seal.Box `json:"at-rest"`
// Operator is the value sealed to the operator key.
Operator OperatorCopy `json:"operator"`
MadeAt time.Time `json:"made-at"`
ChangedAt time.Time `json:"changed-at"`
ChangedBy string `json:"changed-by"`
// ChangedVia is the caller the runtime proved, when it is not who ChangedBy names: the controller
// carrying a person's act (design 57's V2). Empty when they are the same.
ChangedVia string `json:"changed-via,omitempty"`
// Origin is where the value came from: made by the vault, accepted at the controller's terminal, typed at
// a desk, or issued by a provider's software (ADR 0349 clauses 8 to 10). Empty is made, as V1 wrote it.
Origin string `json:"origin,omitempty"`
// MovedFrom is, for a value the move took from the controller's store (design 57's V3), the node-engine that
// handed it in and the controller's generation for it, kept as it was.
MovedFrom *MovedFrom `json:"moved-from,omitempty"`
// Previous is the value this one replaced when a machine's node-engine handed the vault what runs there
// (move-in with replace_running, owntake.go): kept sealed as it was, so the replacement can be read and undone. Any later
// change of the secret drops it.
Previous *PreviousValue `json:"previous,omitempty"`
// Retired marks a bus account no longer wanted (retire.go, ADR 0561 §2): its value kept, its hash taken out.
Retired *RetiredMark `json:"retired,omitempty"`
}
// PreviousValue is a replaced value as the store kept it: its generation, both its seals, and who changed it when.
type PreviousValue struct {
Generation int `json:"generation"`
AtRest seal.Box `json:"at-rest"`
Operator OperatorCopy `json:"operator"`
ChangedAt time.Time `json:"changed-at"`
ChangedBy string `json:"changed-by"`
Origin string `json:"origin,omitempty"`
}
// OperatorCopy is a value sealed to the operator key named in Key.
type OperatorCopy struct {
Box string `json:"box"`
Key string `json:"key"`
Sealed string `json:"sealed"`
}
// Store is the vault's directory of secrets.
type Store struct {
dir string
// failWrite, when a test sets it, fails the write of a record: how a failure partway is proven.
failWrite func(Record) error
// HandedMax is the size at which the record of hand-outs is rolled (0: HandedMaxDefault).
HandedMax int64
handedMu sync.Mutex
rolled int
// syncs counts the record's synced appends since the vault started: how a test proves one sync per answer.
syncs int
// asksMu serialises the record of asks, so two asks for one secret at once do not both pass its bounds.
asksMu sync.Mutex
}
// The record of hand-outs is bounded: rolled at HandedMaxDefault into handed.jsonl.1 … .HandedKept, the
// oldest dropped, so a flood of asks costs at most (HandedKept+1) × HandedMaxDefault on the vault's disk.
const (
HandedMaxDefault = 1 << 20
HandedKept = 3
)
// OpenStore opens (and makes) the store at dir.
func OpenStore(dir string) (*Store, error) {
if dir == "" {
return nil, errors.New("the vault's store has no directory: MESH_VAULT_STORE is not set")
}
if err := os.MkdirAll(filepath.Join(dir, "secrets"), 0o700); err != nil {
return nil, err
}
return &Store{dir: dir}, nil
}
var secretName = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$`)
// validName says why name is not a secret's name, or nil. A name is words joined by `/`, never a path.
func validName(name string) error {
if len(name) == 0 || len(name) > 200 || !secretName.MatchString(name) {
return fmt.Errorf("%q is not a secret's name: lower-case words joined by /, at most 200 characters", name)
}
return nil
}
func (s *Store) pathOf(name string) string {
return filepath.Join(s.dir, "secrets", url.PathEscape(name)+".json")
}
// Read is the record of one secret, and whether there is one.
func (s *Store) Read(name string) (Record, bool, error) {
if err := validName(name); err != nil {
return Record{}, false, err
}
raw, err := os.ReadFile(s.pathOf(name))
if errors.Is(err, os.ErrNotExist) {
return Record{}, false, nil
}
if err != nil {
return Record{}, false, err
}
var r Record
if err := json.Unmarshal(raw, &r); err != nil {
return Record{}, false, fmt.Errorf("the record of %s is not readable: %w", name, err)
}
return r, true, nil
}
// Write keeps one record, replacing any earlier one.
func (s *Store) Write(r Record) error {
if err := validName(r.Name); err != nil {
return err
}
if s.failWrite != nil {
if err := s.failWrite(r); err != nil {
return err
}
}
raw, err := json.MarshalIndent(r, "", " ")
if err != nil {
return err
}
return writeWhole(s.pathOf(r.Name), append(raw, '\n'))
}
// Delete takes one secret out of the store: for the enrolment token and a joining node's account, which end with
// their enrolment (novox/hq ADR 0587 §5), never for a value the mesh keeps. Nothing held is no error.
func (s *Store) Delete(name string) error {
if err := validName(name); err != nil {
return err
}
path := s.pathOf(name)
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return syncDir(filepath.Dir(path))
}
// Empty says whether the store holds no secret at all.
func (s *Store) Empty() (bool, error) {
all, err := s.List()
return len(all) == 0, err
}
// Handing is one answer to `get`, or one refusal, as the store records it (as ADR 0277 §25 records asks):
// who asked, for what, when, and what it got — never the value.
type Handing struct {
At time.Time `json:"at"`
To string `json:"to"`
Name string `json:"name"`
Generation int `json:"generation,omitempty"`
Outcome string `json:"outcome"`
Why string `json:"why,omitempty"`
}
// RecordHanding appends one handing to the store's record, synced before it returns. It says whether the
// record was rolled to make room.
func (s *Store) RecordHanding(h Handing) (rolled bool, err error) {
return s.RecordHandings([]Handing{h})
}
// RecordHandings appends every handing of one answer to the store's record in one write, synced once before it returns
// (issue 1126, 2026-10-28: one sync per secret, under the one lock every node's answers share, held `own` past the
// node-engine's bound). It says whether the record was rolled to make room; nothing is written when one cannot be.
func (s *Store) RecordHandings(hs []Handing) (rolled bool, err error) {
if len(hs) == 0 {
return false, nil
}
var raw []byte
for _, h := range hs {
line, err := json.Marshal(h)
if err != nil {
return false, err
}
raw = append(append(raw, line...), '\n')
}
s.handedMu.Lock()
defer s.handedMu.Unlock()
path := filepath.Join(s.dir, "handed.jsonl")
max := s.HandedBound()
if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() && info.Size()+int64(len(raw)) > max {
if err := s.roll(path); err != nil {
return false, err
}
s.rolled++
rolled = true
}
s.syncs++
return rolled, appendSynced(path, raw)
}
// roll moves handed.jsonl to .1, .1 to .2 and so on, dropping what falls past HandedKept.
func (s *Store) roll(path string) error {
_ = os.Remove(fmt.Sprintf("%s.%d", path, HandedKept))
for i := HandedKept - 1; i >= 1; i-- {
if err := os.Rename(fmt.Sprintf("%s.%d", path, i), fmt.Sprintf("%s.%d", path, i+1)); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
}
if err := os.Rename(path, path+".1"); err != nil {
return err
}
return syncDir(s.dir)
}
// Rolled is how many times the record of hand-outs was rolled since the vault started.
func (s *Store) Rolled() int {
s.handedMu.Lock()
defer s.handedMu.Unlock()
return s.rolled
}
func appendSynced(path string, raw []byte) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return err
}
if _, err := f.Write(raw); err != nil {
f.Close()
return err
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
return f.Close()
}
// Handed is the latest handings, newest first, at most limit: the current record, then the rolled ones.
func (s *Store) Handed(limit int) ([]Handing, error) {
s.handedMu.Lock()
defer s.handedMu.Unlock()
path := filepath.Join(s.dir, "handed.jsonl")
out := []Handing{}
for i := 0; i <= HandedKept && len(out) < limit; i++ {
file := path
if i > 0 {
file = fmt.Sprintf("%s.%d", path, i)
}
raw, err := os.ReadFile(file)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return nil, err
}
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
for j := len(lines) - 1; j >= 0 && len(out) < limit; j-- {
var h Handing
if json.Unmarshal([]byte(lines[j]), &h) == nil {
out = append(out, h)
}
}
}
return out, nil
}
// HandedBound is the size at which the record of hand-outs is rolled.
func (s *Store) HandedBound() int64 {
if s.HandedMax > 0 {
return s.HandedMax
}
return HandedMaxDefault
}
// List is every record, by name.
func (s *Store) List() ([]Record, error) { return s.ListPrefix("") }
// ListPrefix is every record whose name begins with prefix, by name: only those files are read.
func (s *Store) ListPrefix(prefix string) ([]Record, error) {
entries, err := os.ReadDir(filepath.Join(s.dir, "secrets"))
if err != nil {
return nil, err
}
var out []Record
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".json") {
continue
}
name, err := url.PathUnescape(strings.TrimSuffix(e.Name(), ".json"))
if err != nil || !strings.HasPrefix(name, prefix) {
continue
}
r, ok, err := s.Read(name)
if err != nil {
return nil, err
}
if ok {
out = append(out, r)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
type operatorFile struct {
Key string `json:"key"`
Since time.Time `json:"since"`
By string `json:"by"`
}
// OperatorKey is the operator's public key the store seals to, or "" when it has none.
func (s *Store) OperatorKey() (string, error) {
raw, err := os.ReadFile(filepath.Join(s.dir, "operator.json"))
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
if err != nil {
return "", err
}
var f operatorFile
if err := json.Unmarshal(raw, &f); err != nil {
return "", err
}
return f.Key, nil
}
func (s *Store) setOperatorKey(key, by string, at time.Time) error {
raw, _ := json.MarshalIndent(operatorFile{Key: key, Since: at, By: by}, "", " ")
return writeWhole(filepath.Join(s.dir, "operator.json"), append(raw, '\n'))
}
// writeWhole writes a file readable by the vault alone, synced, then renames it into place and syncs the
// directory, so a crash leaves the old file or the new one, never neither.
func writeWhole(path string, raw []byte) error {
tmp := fmt.Sprintf("%s.%d.tmp", path, os.Getpid())
if err := writeSynced(tmp, raw); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
return syncDir(filepath.Dir(path))
}
// writeSynced writes a new file at 0600 and syncs it to disk.
func writeSynced(path string, raw []byte) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
if _, err := f.Write(raw); err != nil {
f.Close()
return err
}
if err := f.Sync(); err != nil {
f.Close()
return err
}
return f.Close()
}
// syncDir syncs a directory, so a rename or a link in it is on disk.
func syncDir(dir string) error {
d, err := os.Open(dir)
if err != nil {
return err
}
defer d.Close()
return d.Sync()
}
// The two lines an ask leaves in the record: when it opened, and how it ended.
const (
AskOpened = "opened"
AskEnded = "ended"
)
// AskRecord is one line of the record of asks for a module's own secret at a desk (ADR 0277 §25, in the vault's
// store since ADR 0349 clause 9): who asked, as the bus named them, for what, at which desk, and how it ended.
// Never a value.
type AskRecord struct {
ID string `json:"id"`
At time.Time `json:"at"`
Event string `json:"event"`
Secret string `json:"secret"`
Node string `json:"node,omitempty"`
Module string `json:"module,omitempty"`
Name string `json:"name,omitempty"`
Desk string `json:"desk,omitempty"`
AskedBy string `json:"asked_by,omitempty"`
Caller string `json:"caller,omitempty"`
Outcome string `json:"outcome,omitempty"`
}
// RecordAsk appends one line to the record of asks, synced before it returns; rolled as the record of
// hand-outs is, and it says whether it was.
func (s *Store) RecordAsk(r AskRecord) (rolled bool, err error) {
raw, err := json.Marshal(r)
if err != nil {
return false, err
}
path := filepath.Join(s.dir, "asks.jsonl")
if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() && info.Size()+int64(len(raw))+1 > s.HandedBound() {
if err := s.roll(path); err != nil {
return false, err
}
rolled = true
}
return rolled, appendSynced(path, append(raw, '\n'))
}
// asks is every line of the record of asks, oldest first, the rolled files before the current one.
func (s *Store) asks() ([]AskRecord, error) {
path := filepath.Join(s.dir, "asks.jsonl")
var out []AskRecord
for i := HandedKept; i >= 0; i-- {
file := path
if i > 0 {
file = fmt.Sprintf("%s.%d", path, i)
}
raw, err := os.ReadFile(file)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return nil, err
}
for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
var r AskRecord
if line != "" && json.Unmarshal([]byte(line), &r) == nil {
out = append(out, r)
}
}
}
return out, nil
}
// AsksSince is every line of the record of asks at or after since, oldest first.
func (s *Store) AsksSince(since time.Time) ([]AskRecord, error) {
all, err := s.asks()
if err != nil {
return nil, err
}
var out []AskRecord
for _, r := range all {
if !r.At.Before(since) {
out = append(out, r)
}
}
return out, nil
}
// Asks is the latest lines of the record of asks, newest first, at most limit.
func (s *Store) Asks(limit int) ([]AskRecord, error) {
s.asksMu.Lock()
defer s.asksMu.Unlock()
all, err := s.asks()
if err != nil {
return nil, err
}
out := []AskRecord{}
for i := len(all) - 1; i >= 0 && len(out) < limit; i-- {
out = append(out, all[i])
}
return out, nil
}