From 44d6a18e439445e8e0f998f5b1a43971983ffacd Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Wed, 14 Oct 2026 00:53:38 +0000 Subject: [PATCH] mesh-vault: its store, its keys and its rules (issue 674) Co-Authored-By: Claude Opus 5.5 --- modules/mesh-vault/cmd/mesh-vault/keys.go | 121 +++++++++++++++ modules/mesh-vault/cmd/mesh-vault/store.go | 170 +++++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 modules/mesh-vault/cmd/mesh-vault/keys.go create mode 100644 modules/mesh-vault/cmd/mesh-vault/store.go diff --git a/modules/mesh-vault/cmd/mesh-vault/keys.go b/modules/mesh-vault/cmd/mesh-vault/keys.go new file mode 100644 index 00000000..8043d81b --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/keys.go @@ -0,0 +1,121 @@ +package main + +// The keys the vault meets, and the one it holds (novox/hq ADR 0113 §2, ADR 0206). +// +// - Its own key: an X25519 pair made once in its state directory, the private half never leaving it. Every +// secret is kept sealed to it at rest. +// - An asker's key: the public half only, as SPKI PEM — the form of the ADR 0206 box (mesh-sdk go/seal). +// A raw 32-byte key is refused, because a raw private key looks the same and the vault takes none. +// - The operator key: the public half, raw X25519 in base64, as `operator key make` makes it today. +// +// No private key is ever accepted: not as a key to seal to, not as a secret to hold. + +import ( + "crypto/ecdh" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "git.novox.be/novox/mesh-sdk/go/seal" + "golang.org/x/crypto/nacl/box" +) + +// OwnKeyFile is where the vault's own key lives in its state directory. +const OwnKeyFile = "vault-key.json" + +// OwnKey is the vault's own key pair, made once in dir. Written whole, then linked into place, so a second +// process reads the first's key and never half of it. +func OwnKey(dir string) (seal.KeyPair, error) { + if dir == "" { + return seal.KeyPair{}, errors.New("the vault has no state directory: MESH_VAULT_STATE is not set") + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return seal.KeyPair{}, err + } + file := filepath.Join(dir, OwnKeyFile) + if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) { + k, err := seal.GenerateKeyPair() + if err != nil { + return seal.KeyPair{}, err + } + raw, _ := json.Marshal(k) + tmp := filepath.Join(dir, fmt.Sprintf(".vault-key.%d.json", os.Getpid())) + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + return seal.KeyPair{}, err + } + _ = os.Link(tmp, file) // fails when another made it first, which is right + _ = os.Remove(tmp) + } + raw, err := os.ReadFile(file) + if err != nil { + return seal.KeyPair{}, err + } + var k seal.KeyPair + if err := json.Unmarshal(raw, &k); err != nil || k.PublicKey == "" || k.PrivateKey == "" { + return seal.KeyPair{}, fmt.Errorf("%s is not the vault's key", file) + } + return k, nil +} + +var privateKeyPEM = regexp.MustCompile(`-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----`) + +// privateKeyIn says whether text carries a private key. +func privateKeyIn(text string) bool { + return privateKeyPEM.MatchString(text) || strings.Contains(text, "PRIVATE KEY") +} + +// askerKey is an asker's public key, checked and as PEM: SPKI, X25519, and never a private key. +func askerKey(key string) (string, error) { + if privateKeyIn(key) { + return "", errors.New("that is a private key, and a private key is never sent to the vault (ADR 0113 §2): send the public half") + } + block, rest := pem.Decode([]byte(strings.TrimSpace(key))) + if block == nil || block.Type != "PUBLIC KEY" || strings.TrimSpace(string(rest)) != "" { + return "", errors.New("the key to seal to is one X25519 public key as SPKI PEM (`-----BEGIN PUBLIC KEY-----`), the form of the sealed box (ADR 0206)") + } + k, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return "", fmt.Errorf("the key to seal to is not a public key: %w", err) + } + pub, ok := k.(*ecdh.PublicKey) + if !ok || pub.Curve() != ecdh.X25519() { + return "", errors.New("the key to seal to is not an X25519 key") + } + return string(pem.EncodeToMemory(block)), nil +} + +// checkOperatorKey checks the operator's public key: raw X25519, base64. +func checkOperatorKey(key string) (string, error) { + key = strings.TrimSpace(key) + if privateKeyIn(key) { + return "", errors.New("that is a private key: the operator key's private half never enters the mesh (ADR 0113 §2)") + } + raw, err := base64.StdEncoding.DecodeString(key) + if err != nil || len(raw) != 32 { + return "", errors.New("the operator key is a public X25519 key, 32 bytes in base64, as `operator key make` writes it") + } + return key, nil +} + +// operatorSeal seals value to the operator key in the box the operator opens today. +func operatorSeal(key string, value []byte) (string, error) { + raw, err := base64.StdEncoding.DecodeString(key) + if err != nil || len(raw) != 32 { + return "", errors.New("the operator key is not usable") + } + var pub [32]byte + copy(pub[:], raw) + sealed, err := box.SealAnonymous(nil, value, &pub, rand.Reader) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(sealed), nil +} diff --git a/modules/mesh-vault/cmd/mesh-vault/store.go b/modules/mesh-vault/cmd/mesh-vault/store.go new file mode 100644 index 00000000..887331a4 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/store.go @@ -0,0 +1,170 @@ +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" + "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"` +} + +// 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 } + +// 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 + } + raw, err := json.MarshalIndent(r, "", " ") + if err != nil { + return err + } + return writeWhole(s.pathOf(r.Name), append(raw, '\n')) +} + +// List is every record, by name. +func (s *Store) List() ([]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 { + 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, then renames it into place. +func writeWhole(path string, raw []byte) error { + tmp := fmt.Sprintf("%s.%d.tmp", path, os.Getpid()) + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + return err + } + return os.Rename(tmp, path) +}