diff --git a/modules/mesh-vault/cmd/mesh-vault/main.go b/modules/mesh-vault/cmd/mesh-vault/main.go new file mode 100644 index 00000000..eb7d9458 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/main.go @@ -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) + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/vault.go b/modules/mesh-vault/cmd/mesh-vault/vault.go new file mode 100644 index 00000000..1628248e --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/vault.go @@ -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 +}