mesh-vault: the tools the TypeScript vault offered, in Go (issue 674)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
package main
|
||||
|
||||
// The custody ledger (novox/hq ADR 0085, design 24), carried over from the TypeScript vault as it was:
|
||||
// for each pair credential the controller delivers to this vault as the `secret` provider, who holds it,
|
||||
// since when, its fingerprint and every rotation — and never the value. The controller still mints and
|
||||
// seals those until the secrets move to the vault (design 57's V3, V4); this pass records what it delivered,
|
||||
// so `secret_holders` and `secret_verify` answer as before. Its files are the same JSON, read as they are.
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// isoMillis is JavaScript's toISOString, the form the ledger's times were first written in.
|
||||
const isoMillis = "2006-01-02T15:04:05.000Z"
|
||||
|
||||
// Held is one holder of a secret this vault provides: everything the vault knows of it, and no value.
|
||||
type Held struct {
|
||||
As string `json:"as"`
|
||||
Consumer string `json:"consumer"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Length int `json:"length"`
|
||||
Since string `json:"since"`
|
||||
Changed string `json:"changed"`
|
||||
Rotations int `json:"rotations"`
|
||||
History []Earlier `json:"history"`
|
||||
}
|
||||
|
||||
// Earlier is a fingerprint a rotation replaced, and when.
|
||||
type Earlier struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Until string `json:"until"`
|
||||
}
|
||||
|
||||
// Outcome is what recording a delivery found.
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
Granted Outcome = "granted"
|
||||
Rotated Outcome = "rotated"
|
||||
Unchanged Outcome = "unchanged"
|
||||
)
|
||||
|
||||
// Fingerprint is sha256 of a value, as `sha256:<hex>`: the one thing about a secret that may be said.
|
||||
func Fingerprint(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Ledger is the directory of holders.
|
||||
type Ledger struct{ dir string }
|
||||
|
||||
// NewLedger opens (and makes) the ledger at dir.
|
||||
func NewLedger(dir string) (*Ledger, error) {
|
||||
if dir == "" {
|
||||
return nil, errors.New("MESH_VAULT_LEDGER is not set: the vault has nowhere to keep its ledger")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Ledger{dir: dir}, nil
|
||||
}
|
||||
|
||||
var login = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`)
|
||||
|
||||
func (l *Ledger) pathOf(as string) (string, error) {
|
||||
if !login.MatchString(as) || strings.Contains(as, "..") {
|
||||
return "", fmt.Errorf("a login is a name, not a path: %q", as)
|
||||
}
|
||||
return filepath.Join(l.dir, as+".json"), nil
|
||||
}
|
||||
|
||||
// Record says the mesh delivered value for as: the same value again changes nothing, another is a rotation.
|
||||
// The value is fingerprinted here and goes no further.
|
||||
func (l *Ledger) Record(as, consumer, value string, now time.Time) (Held, Outcome, error) {
|
||||
path, err := l.pathOf(as)
|
||||
if err != nil {
|
||||
return Held{}, "", err
|
||||
}
|
||||
fp, at := Fingerprint(value), now.UTC().Format(isoMillis)
|
||||
before, ok := l.Get(as)
|
||||
if !ok {
|
||||
h := Held{As: as, Consumer: consumer, Fingerprint: fp, Length: len(value), Since: at, Changed: at, History: []Earlier{}}
|
||||
return h, Granted, l.write(path, h)
|
||||
}
|
||||
if before.Fingerprint == fp && before.Length == len(value) {
|
||||
return before, Unchanged, nil
|
||||
}
|
||||
h := before
|
||||
h.Consumer, h.Fingerprint, h.Length, h.Changed = consumer, fp, len(value), at
|
||||
h.Rotations++
|
||||
h.History = append(append([]Earlier{}, before.History...), Earlier{Fingerprint: before.Fingerprint, Until: at})
|
||||
return h, Rotated, l.write(path, h)
|
||||
}
|
||||
|
||||
// Withdraw forgets a holder, saying whether there was one.
|
||||
func (l *Ledger) Withdraw(as string) bool {
|
||||
path, err := l.pathOf(as)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return os.Remove(path) == nil
|
||||
}
|
||||
|
||||
// Get is one holder.
|
||||
func (l *Ledger) Get(as string) (Held, bool) {
|
||||
path, err := l.pathOf(as)
|
||||
if err != nil {
|
||||
return Held{}, false
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Held{}, false
|
||||
}
|
||||
var h Held
|
||||
if json.Unmarshal(raw, &h) != nil {
|
||||
return Held{}, false
|
||||
}
|
||||
return h, true
|
||||
}
|
||||
|
||||
// List is every holder, by login.
|
||||
func (l *Ledger) List() []Held {
|
||||
entries, err := os.ReadDir(l.dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []Held
|
||||
for _, e := range entries {
|
||||
if strings.HasSuffix(e.Name(), ".json") {
|
||||
if h, ok := l.Get(strings.TrimSuffix(e.Name(), ".json")); ok {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].As < out[j].As })
|
||||
return out
|
||||
}
|
||||
|
||||
func (l *Ledger) write(path string, h Held) error {
|
||||
raw, err := json.MarshalIndent(h, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeWhole(path, append(raw, '\n'))
|
||||
}
|
||||
|
||||
// Contribution is one entry of the contributions file the mesh delivers to the vault.
|
||||
type Contribution struct {
|
||||
From string `json:"from,omitempty"`
|
||||
Node string `json:"node,omitempty"`
|
||||
As string `json:"as"`
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
|
||||
// Contributions is what the mesh asks this vault to serve: the entries with a login and a secret file.
|
||||
func Contributions(receives string) ([]Contribution, error) {
|
||||
raw, err := os.ReadFile(receives)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var doc struct {
|
||||
Given []Contribution `json:"given"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Contribution
|
||||
for _, g := range doc.Given {
|
||||
if g.As != "" && g.Secret != "" {
|
||||
out = append(out, g)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// delivered is the value the mesh delivered for c, without the newline a file may end with.
|
||||
func delivered(c Contribution) (string, error) {
|
||||
raw, err := os.ReadFile(c.Secret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the delivered secret is not readable: %v", err)
|
||||
}
|
||||
return strings.TrimSuffix(string(raw), "\n"), nil
|
||||
}
|
||||
|
||||
// DeliveredFingerprint is the fingerprint and length of what the mesh currently delivers for c.
|
||||
func DeliveredFingerprint(c Contribution) (string, int, error) {
|
||||
v, err := delivered(c)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return Fingerprint(v), len(v), nil
|
||||
}
|
||||
|
||||
// Custody is the pass over the contributions file that keeps the ledger.
|
||||
type Custody struct {
|
||||
Ledger *Ledger
|
||||
Receives string
|
||||
Emit func(event string, body map[string]any) error
|
||||
Now func() time.Time
|
||||
Log func(format string, args ...any)
|
||||
}
|
||||
|
||||
// Pass records every delivered value and withdraws every holder no longer asked for — but only from a
|
||||
// contributions file it could read whole: an unreadable one withdraws nobody.
|
||||
func (c Custody) Pass() {
|
||||
asked, err := Contributions(c.Receives)
|
||||
if err != nil {
|
||||
c.Log("the contributions file is not readable (%s): %v — nothing withdrawn", c.Receives, err)
|
||||
return
|
||||
}
|
||||
keep := map[string]bool{}
|
||||
for _, a := range asked {
|
||||
keep[a.As] = true
|
||||
value, err := delivered(a)
|
||||
if err != nil {
|
||||
c.Log("%s: %v", a.As, err)
|
||||
continue
|
||||
}
|
||||
held, outcome, err := c.Ledger.Record(a.As, a.Node, value, c.Now())
|
||||
if err != nil {
|
||||
c.Log("%s: %v", a.As, err)
|
||||
continue
|
||||
}
|
||||
if outcome == Unchanged {
|
||||
continue
|
||||
}
|
||||
event := "provisioned"
|
||||
if outcome == Rotated {
|
||||
event = "rotated"
|
||||
}
|
||||
c.Log("%s: %s (%s…, rotations %d)", held.As, outcome, held.Fingerprint[:19], held.Rotations)
|
||||
c.say(event, map[string]any{"consumer": held.Consumer, "as": held.As, "fingerprint": held.Fingerprint, "rotations": held.Rotations})
|
||||
}
|
||||
for _, h := range c.Ledger.List() {
|
||||
if !keep[h.As] && c.Ledger.Withdraw(h.As) {
|
||||
c.Log("%s: withdrawn", h.As)
|
||||
c.say("deprovisioned", map[string]any{"as": h.As})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c Custody) say(event string, body map[string]any) {
|
||||
if c.Emit == nil {
|
||||
return
|
||||
}
|
||||
if err := c.Emit(event, body); err != nil {
|
||||
c.Log("%s could not be said: %v", event, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package main
|
||||
|
||||
// What the vault serves: the seat's verb `get`, and the three tools the TypeScript vault offered —
|
||||
// secret_export, secret_holders and secret_verify — answering as they did. None returns a value: `get`
|
||||
// returns one sealed to the asker's key, the others fingerprints and ciphertext only.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Env is what the tools work on. A part the module's words did not give is nil, and a tool that needs it
|
||||
// says so.
|
||||
type Env struct {
|
||||
Vault *Vault
|
||||
Ledger *Ledger
|
||||
Receives string
|
||||
Root string
|
||||
// Broken is why the vault could not be built, said by `get`.
|
||||
Broken error
|
||||
}
|
||||
|
||||
// toolDef is a tool with its caller given apart from its arguments, so a test can be the runtime.
|
||||
type toolDef struct {
|
||||
Name string
|
||||
Description string
|
||||
Input map[string]any
|
||||
run func(caller string, a map[string]any) (any, error)
|
||||
}
|
||||
|
||||
// Run answers a call the runtime made: the caller is the one it proved.
|
||||
func (t toolDef) Run(a map[string]any) (any, error) { return t.run(stdio.CallerOf(a), a) }
|
||||
|
||||
func (t toolDef) runAs(caller string, a map[string]any) (any, error) { return t.run(caller, a) }
|
||||
|
||||
func str(description string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": description}
|
||||
}
|
||||
|
||||
func definitions(env *Env) []toolDef {
|
||||
return []toolDef{
|
||||
{Name: Seat + ".get",
|
||||
Description: "One secret, sealed to the public key the asker sends (the sealed box of ADR 0206), with its " +
|
||||
"generation; answered only to an asker the vault allows, as the runtime proved it (ADR 0349 §11). " +
|
||||
"Never the value in the clear, and never to a private key.",
|
||||
Input: map[string]any{"name": str("the secret's name"),
|
||||
"public_key": str("the asker's X25519 public key as SPKI PEM — never a private key")},
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
if env.Vault == nil {
|
||||
if env.Broken != nil {
|
||||
return nil, env.Broken
|
||||
}
|
||||
return nil, errors.New("the vault is not ready")
|
||||
}
|
||||
name, _ := a["name"].(string)
|
||||
key, _ := a["public_key"].(string)
|
||||
return env.Vault.Get(caller, strings.TrimSpace(name), key)
|
||||
}},
|
||||
{Name: "secret_export",
|
||||
Description: "The mesh's root secrets as this vault keeps them: every secret a module holds for itself, " +
|
||||
"sealed to the operator's key (novox/hq ADR 0085, amended). Ciphertext — nothing here can open a line " +
|
||||
"of it; the operator, holding the private key off the mesh, recovers one with " +
|
||||
"`nox secret recover --from-export` on the control-node. Also lists what is NOT recoverable: secrets " +
|
||||
"made before the mesh had an operator key.",
|
||||
Input: map[string]any{"sealed": map[string]any{"type": "boolean",
|
||||
"description": "include the sealed blobs (default true); false lists holders and the key only"}},
|
||||
run: func(_ string, a map[string]any) (any, error) {
|
||||
return exportAnswer(env.Root, a["sealed"] != false), nil
|
||||
}},
|
||||
{Name: "secret_holders",
|
||||
Description: "Who holds a secret from this vault: each consumer's login, node and module, when it was " +
|
||||
"granted, how many times it has been rotated and when, and the fingerprint of the current value. " +
|
||||
"Fingerprints only — the value is never returned.",
|
||||
run: func(string, map[string]any) (any, error) {
|
||||
if env.Ledger == nil {
|
||||
return map[string]any{"holders": []any{}, "count": 0, "error": "the vault has no ledger (MESH_VAULT_LEDGER is not set)"}, nil
|
||||
}
|
||||
var asked []Contribution
|
||||
if env.Receives != "" {
|
||||
asked, _ = Contributions(env.Receives)
|
||||
}
|
||||
holders := []map[string]any{}
|
||||
for _, h := range env.Ledger.List() {
|
||||
row := map[string]any{}
|
||||
raw, _ := json.Marshal(h)
|
||||
_ = json.Unmarshal(raw, &row)
|
||||
row["module"], row["asked"] = nil, false
|
||||
for _, c := range asked {
|
||||
if c.As == h.As {
|
||||
row["module"], row["asked"] = c.From, true
|
||||
}
|
||||
}
|
||||
holders = append(holders, row)
|
||||
}
|
||||
return map[string]any{"holders": holders, "count": len(holders)}, nil
|
||||
}},
|
||||
{Name: "secret_verify",
|
||||
Description: "Check one holder's secret without seeing it: the fingerprint the vault recorded against the " +
|
||||
"fingerprint of the value the mesh currently delivers here, and optionally against a fingerprint " +
|
||||
"computed on the holder's own machine (sha256 of the file, as `sha256:<hex>`). Two ends agreeing " +
|
||||
"proves they agree, not that either works — the login itself is the test.",
|
||||
Input: map[string]any{"as": str("the holder's login, e.g. anchor-redis"),
|
||||
"fingerprint": str("optional: sha256:<hex> of the value as the holder reads it, computed there — never the value")},
|
||||
run: func(_ string, a map[string]any) (any, error) {
|
||||
as, _ := a["as"].(string)
|
||||
given, _ := a["fingerprint"].(string)
|
||||
return verifyAnswer(env, as, given), nil
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func exportAnswer(root string, withBlobs bool) map[string]any {
|
||||
if root == "" {
|
||||
return map[string]any{"available": false, "error": "this vault keeps no root secrets (MESH_VAULT_ROOT is not set)"}
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, "export.json"))
|
||||
if err != nil {
|
||||
return map[string]any{"available": false, "error": "the mesh has not written an export here yet: " + err.Error()}
|
||||
}
|
||||
var doc struct {
|
||||
Export int `json:"export"`
|
||||
OperatorKey string `json:"operator-key"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Kept []map[string]any `json:"kept"`
|
||||
Unrecoverable []map[string]any `json:"unrecoverable"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
return map[string]any{"available": false, "error": "the export is not readable: " + err.Error()}
|
||||
}
|
||||
kept := []map[string]any{}
|
||||
for _, k := range doc.Kept {
|
||||
if !withBlobs {
|
||||
k = map[string]any{"node": k["node"], "module": k["module"], "name": k["name"], "origin": k["origin"], "made-at": k["made-at"]}
|
||||
}
|
||||
kept = append(kept, k)
|
||||
}
|
||||
if doc.Unrecoverable == nil {
|
||||
doc.Unrecoverable = []map[string]any{}
|
||||
}
|
||||
return map[string]any{"available": true, "export": doc.Export, "operator-key": doc.OperatorKey,
|
||||
"fingerprint": doc.Fingerprint, "count": len(kept), "kept": kept, "unrecoverable": doc.Unrecoverable}
|
||||
}
|
||||
|
||||
// exportedOperatorKey is the operator key in the export the mesh writes to the vault's root, or "".
|
||||
func exportedOperatorKey(root string) string {
|
||||
raw, err := os.ReadFile(filepath.Join(root, "export.json"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var doc struct {
|
||||
OperatorKey string `json:"operator-key"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &doc)
|
||||
return doc.OperatorKey
|
||||
}
|
||||
|
||||
func verifyAnswer(env *Env, as, given string) map[string]any {
|
||||
if env.Ledger == nil {
|
||||
return map[string]any{"as": as, "known": false, "error": "the vault has no ledger (MESH_VAULT_LEDGER is not set)"}
|
||||
}
|
||||
recorded, ok := env.Ledger.Get(as)
|
||||
if !ok {
|
||||
return map[string]any{"as": as, "known": false, "error": "this vault holds nothing for " + as}
|
||||
}
|
||||
var delivered, deliveredErr any
|
||||
var deliveredMatches any
|
||||
var asked *Contribution
|
||||
if env.Receives != "" {
|
||||
all, _ := Contributions(env.Receives)
|
||||
for i := range all {
|
||||
if all[i].As == as {
|
||||
asked = &all[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if asked == nil {
|
||||
deliveredErr = "the mesh does not currently ask this vault to serve that login"
|
||||
} else if fp, _, err := DeliveredFingerprint(*asked); err != nil {
|
||||
deliveredErr = err.Error()
|
||||
} else {
|
||||
delivered, deliveredMatches = fp, fp == recorded.Fingerprint
|
||||
}
|
||||
var givenAny, givenMatches, earlier any
|
||||
if given != "" {
|
||||
givenAny, givenMatches = given, given == recorded.Fingerprint
|
||||
was := false
|
||||
for _, h := range recorded.History {
|
||||
was = was || h.Fingerprint == given
|
||||
}
|
||||
earlier = was
|
||||
}
|
||||
return map[string]any{"as": recorded.As, "known": true, "recorded": recorded.Fingerprint,
|
||||
"rotations": recorded.Rotations, "changed": recorded.Changed,
|
||||
"delivered": delivered, "deliveredError": deliveredErr, "deliveredMatchesRecorded": deliveredMatches,
|
||||
"given": givenAny, "givenMatchesRecorded": givenMatches, "givenIsAnEarlierValue": earlier,
|
||||
"ok": deliveredMatches != false && givenMatches != false}
|
||||
}
|
||||
|
||||
// tools are the definitions as the SDK serves them.
|
||||
func tools(env *Env) []stdio.Tool {
|
||||
var out []stdio.Tool
|
||||
for _, d := range definitions(env) {
|
||||
out = append(out, stdio.Tool{Name: d.Name, Description: d.Description, Input: d.Input, Run: d.Run})
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user