mesh-vault: get, secret-changed and the custody ledger (issue 674)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 00:54:22 +00:00
co-authored by Claude Opus 5.5
parent 44d6a18e43
commit 11fc3258b4
2 changed files with 298 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
// mesh-vault (novox/hq ADR 0085, ADR 0113, ADR 0349 §7 and §11): one binary, launched by its node's runtime
// and speaking MCP to it over stdio through the Go SDK. It holds the `mesh-vault` seat: it keeps every
// secret in its own store, sealed at rest and with a copy sealed to the operator key; it answers the seat's
// verb `get` with a secret sealed to the asker's key; it raises `secret-changed` without a value. Beside
// that it keeps the custody ledger of the pair credentials the controller still delivers to it.
//
// stdout is the MCP channel; everything this module says, it says on stderr.
package main
import (
"errors"
"fmt"
"os"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func say(format string, args ...any) {
fmt.Fprintf(os.Stderr, "[mesh-vault] "+format+"\n", args...)
}
func emit(event string, body map[string]any) error { return stdio.Emit(event, body) }
// build makes the vault from the module's words: directories, never values.
func build() *Env {
env := &Env{Receives: os.Getenv("MESH_RECEIVES"), Root: os.Getenv("MESH_VAULT_ROOT")}
if l, err := NewLedger(os.Getenv("MESH_VAULT_LEDGER")); err == nil {
env.Ledger = l
} else {
say("%v", err)
}
store, err1 := OpenStore(os.Getenv("MESH_VAULT_STORE"))
own, err2 := OwnKey(os.Getenv("MESH_VAULT_STATE"))
if err := errors.Join(err1, err2); err != nil {
env.Broken = fmt.Errorf("the vault could not open its store or its key: %w", err)
say("%v", env.Broken)
return env
}
// Askers is left nil: the vault that runs answers nobody until who may ask arrives (askers.go).
env.Vault = &Vault{Store: store, Own: own, Emit: emit, Now: time.Now, Log: say}
return env
}
// adoptOperatorKey takes the operator key from the export the mesh writes to the vault's root (what
// `operator key set` recorded), when it differs from the one the store seals to.
func adoptOperatorKey(env *Env) {
if env.Vault == nil || env.Root == "" {
return
}
key := exportedOperatorKey(env.Root)
if key == "" {
return
}
if _, err := env.Vault.SetOperatorKey(key, "the mesh's export"); err != nil {
say("the operator key in the mesh's export is not taken: %v", err)
}
}
func main() {
env := build()
go func() {
custody := Custody{Ledger: env.Ledger, Receives: env.Receives, Emit: emit, Now: time.Now, Log: say}
tick := time.NewTicker(time.Minute)
defer tick.Stop()
for {
adoptOperatorKey(env)
if env.Ledger != nil && env.Receives != "" {
custody.Pass()
}
<-tick.C
}
}()
if err := stdio.Serve("", tools(env)); err != nil {
say("%v", err)
os.Exit(1)
}
}
+220
View File
@@ -0,0 +1,220 @@
package main
// The vault's rules (novox/hq ADR 0349 §7 and §11; ADR 0113 §1, §2, §22): it holds every secret sealed at
// rest, with a copy sealed to the operator key; it hands one out only on request and reply, sealed to the
// asker's key, and only to an asker it allows; it says a secret changed — who, when, never a value.
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/seal"
)
// Seat is the role this module holds.
const Seat = "mesh-vault"
// ChangedEvent is what the vault raises when a secret it holds changes: who, when, which, its generation.
const ChangedEvent = "secret-changed"
// Vault holds the secrets.
type Vault struct {
Store *Store
// Own is the vault's own key; the private half never leaves its state directory.
Own seal.KeyPair
// Askers decides who may ask `get` for what. Nil allows nobody.
Askers Askers
Emit func(event string, body map[string]any) error
Now func() time.Time
Log func(format string, args ...any)
mu sync.Mutex
}
// Changed is what one change to a secret was. Never its value.
type Changed struct {
Name string `json:"name"`
Generation int `json:"generation"`
At time.Time `json:"at"`
By string `json:"by"`
Unchanged bool `json:"unchanged,omitempty"`
}
// Answer is `get`'s reply: the secret sealed to the asker's key, and which generation it is.
type Answer struct {
Name string `json:"name"`
Generation int `json:"generation"`
ChangedAt time.Time `json:"changed-at"`
Sealed seal.Box `json:"sealed"`
}
func (v *Vault) log(format string, args ...any) {
if v.Log != nil {
v.Log(format, args...)
}
}
// Put keeps value as the secret name: sealed to the vault's key and to the operator key, a new generation
// when it differs from the one held, and `secret-changed` said. The same value again changes nothing.
// Refused: a private key (it stays where it was made), and any value while the vault has no operator key —
// a secret only the vault could open is what ADR 0113 §22 rules out.
func (v *Vault) Put(name, value, by string) (Changed, error) {
if err := validName(name); err != nil {
return Changed{}, err
}
if value == "" {
return Changed{}, fmt.Errorf("%s: a secret is not empty", name)
}
if privateKeyIn(value) {
return Changed{}, fmt.Errorf("%s: that is a private key, and a private key stays where it was made and never reaches the vault (ADR 0113 §2)", name)
}
if by == "" {
by = "the vault"
}
v.mu.Lock()
defer v.mu.Unlock()
operator, err := v.Store.OperatorKey()
if err != nil {
return Changed{}, err
}
if operator == "" {
return Changed{}, fmt.Errorf("%s is not kept: the vault has no operator key to seal its copy to (ADR 0113 §22) — the mesh writes it with the export once `operator key set` has been run", name)
}
before, held, err := v.Store.Read(name)
if err != nil {
return Changed{}, err
}
if held {
if old, err := seal.Open(before.AtRest, v.Own.PrivateKey); err == nil && old == value {
return Changed{Name: name, Generation: before.Generation, At: before.ChangedAt, By: before.ChangedBy, Unchanged: true}, nil
}
}
atRest, err := seal.Seal(value, v.Own.PublicKey)
if err != nil {
return Changed{}, err
}
forOperator, err := operatorSeal(operator, []byte(value))
if err != nil {
return Changed{}, err
}
now := v.Now().UTC()
rec := Record{Name: name, Generation: 1, AtRest: atRest,
Operator: OperatorCopy{Box: OperatorBox, Key: operator, Sealed: forOperator},
MadeAt: now, ChangedAt: now, ChangedBy: by}
if held {
rec.Generation, rec.MadeAt = before.Generation+1, before.MadeAt
}
if err := v.Store.Write(rec); err != nil {
return Changed{}, err
}
c := Changed{Name: name, Generation: rec.Generation, At: now, By: by}
v.announce(c)
return c, nil
}
// Mint makes a fresh value for name and keeps it: 30 random bytes, base64url without padding — 40
// characters, the controller's shape, which fits the tightest backend a minted password reaches.
func (v *Vault) Mint(name, by string) (Changed, error) {
raw := make([]byte, 30)
if _, err := rand.Read(raw); err != nil {
return Changed{}, err
}
return v.Put(name, base64.RawURLEncoding.EncodeToString(raw), by)
}
// announce raises `secret-changed`: who, when, which and its generation — never a value or a sealed copy.
func (v *Vault) announce(c Changed) {
if v.Emit == nil {
return
}
body := map[string]any{"name": c.Name, "by": c.By, "at": c.At.Format(time.RFC3339Nano), "generation": c.Generation}
if err := v.Emit(ChangedEvent, body); err != nil {
v.log("%s is kept at generation %d, but %s could not be said: %v", c.Name, c.Generation, ChangedEvent, err)
}
}
// Get answers caller with the secret name sealed to publicKey, if caller may ask for it. The caller is the
// bus user the runtime proved, never an argument; an unproven call is refused.
func (v *Vault) Get(caller, name, publicKey string) (Answer, error) {
if caller == "" || caller == stdio.Unproven {
return Answer{}, errors.New("the vault answers only a caller the runtime proved")
}
askers := v.Askers
if askers == nil {
askers = NoAsker{}
}
if ok, why := askers.May(caller, name); !ok {
v.log("refused %s to %s: %s", name, caller, why)
return Answer{}, fmt.Errorf("%s may not ask for %s: %s", caller, name, why)
}
key, err := askerKey(publicKey)
if err != nil {
return Answer{}, err
}
rec, held, err := v.Store.Read(name)
if err != nil {
return Answer{}, err
}
if !held {
return Answer{}, fmt.Errorf("the vault holds no secret named %s", name)
}
value, err := seal.Open(rec.AtRest, v.Own.PrivateKey)
if err != nil {
return Answer{}, fmt.Errorf("%s does not open with the vault's key: %w", name, err)
}
sealed, err := seal.Seal(value, key)
if err != nil {
return Answer{}, err
}
v.log("handed %s generation %d to %s, sealed to its key", name, rec.Generation, caller)
return Answer{Name: name, Generation: rec.Generation, ChangedAt: rec.ChangedAt, Sealed: sealed}, nil
}
// SetOperatorKey makes key the operator key and seals every secret's operator copy to it again, so none
// stays sealed to a key the operator may no longer hold. It returns how many copies were sealed again.
func (v *Vault) SetOperatorKey(key, by string) (int, error) {
key, err := checkOperatorKey(key)
if err != nil {
return 0, err
}
v.mu.Lock()
defer v.mu.Unlock()
if current, err := v.Store.OperatorKey(); err != nil {
return 0, err
} else if current == key {
return 0, nil
}
if err := v.Store.setOperatorKey(key, by, v.Now().UTC()); err != nil {
return 0, err
}
all, err := v.Store.List()
if err != nil {
return 0, err
}
n := 0
for _, rec := range all {
if rec.Operator.Key == key {
continue
}
value, err := seal.Open(rec.AtRest, v.Own.PrivateKey)
if err != nil {
return n, fmt.Errorf("%s does not open with the vault's key, so its operator copy is still sealed to the earlier key: %w", rec.Name, err)
}
sealed, err := operatorSeal(key, []byte(value))
if err != nil {
return n, err
}
rec.Operator = OperatorCopy{Box: OperatorBox, Key: key, Sealed: sealed}
if err := v.Store.Write(rec); err != nil {
return n, err
}
n++
}
v.log("the operator key is now %s (by %s); %d operator copies sealed to it again", key, by, n)
return n, nil
}