From b67fe1ffb16eb22c7c78477e66528f667313f354 Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Wed, 14 Oct 2026 01:19:36 +0000 Subject: [PATCH] mesh-vault: sync before rename, record hand-outs, refuse a new key beside secrets, take the operator key as PEM only (review 250) Co-Authored-By: Claude Opus 5.5 --- modules/mesh-vault/cmd/mesh-vault/keys.go | 69 ++++++++++--- modules/mesh-vault/cmd/mesh-vault/store.go | 107 ++++++++++++++++++++- modules/mesh-vault/module.json | 9 +- 3 files changed, 168 insertions(+), 17 deletions(-) diff --git a/modules/mesh-vault/cmd/mesh-vault/keys.go b/modules/mesh-vault/cmd/mesh-vault/keys.go index 8043d81b..033d324d 100644 --- a/modules/mesh-vault/cmd/mesh-vault/keys.go +++ b/modules/mesh-vault/cmd/mesh-vault/keys.go @@ -6,7 +6,8 @@ package main // 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. +// - The operator key: the public half, taken as SPKI PEM only and kept raw in base64, the form `operator key +// make` writes and the NaCl box the operator opens uses. // // No private key is ever accepted: not as a key to seal to, not as a secret to hold. @@ -31,9 +32,14 @@ import ( // 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) { +// OwnKey is the vault's own key pair, made once in dir. Written whole and synced, then linked into place and +// the directory synced, so a second process reads the first's key and never half of it. +// +// **A key is made only for an empty store.** When the key file is missing and the store holds secrets, the +// key they are sealed to is lost or misplaced, and a new key would open none of them: the vault refuses to +// start, loudly, rather than seal new secrets to a key beside old ones nothing opens. The operator copies +// are what re-raise it (ADR 0113 §21). +func OwnKey(dir string, store *Store) (seal.KeyPair, error) { if dir == "" { return seal.KeyPair{}, errors.New("the vault has no state directory: MESH_VAULT_STATE is not set") } @@ -42,17 +48,31 @@ func OwnKey(dir string) (seal.KeyPair, error) { } file := filepath.Join(dir, OwnKeyFile) if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) { + if store != nil { + empty, err := store.Empty() + if err != nil { + return seal.KeyPair{}, fmt.Errorf("the vault's key is missing and its store cannot be read: %w", err) + } + if !empty { + return seal.KeyPair{}, fmt.Errorf("THE VAULT'S KEY IS MISSING (%s) WHILE ITS STORE HOLDS SECRETS SEALED TO IT: "+ + "the vault does not start and makes no new key, which would open none of them — put the key back, "+ + "or re-raise the vault from the operator copies (novox/hq ADR 0113 §21)", file) + } + } 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 { + if err := writeSynced(tmp, raw); err != nil { return seal.KeyPair{}, err } _ = os.Link(tmp, file) // fails when another made it first, which is right _ = os.Remove(tmp) + if err := syncDir(dir); err != nil { + return seal.KeyPair{}, err + } } raw, err := os.ReadFile(file) if err != nil { @@ -92,17 +112,44 @@ func askerKey(key string) (string, error) { return string(pem.EncodeToMemory(block)), nil } -// checkOperatorKey checks the operator's public key: raw X25519, base64. +// checkOperatorKey takes the operator's public key as SPKI PEM — never a private key, and never a raw key, +// which cannot be told from a private one — and gives it in the raw base64 form the operator's NaCl box and +// `secret recover` use. 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") + block, rest := pem.Decode([]byte(strings.TrimSpace(key))) + if block == nil || block.Type != "PUBLIC KEY" || strings.TrimSpace(string(rest)) != "" { + return "", errors.New("the operator key is one X25519 public key as SPKI PEM (`-----BEGIN PUBLIC KEY-----`); " + + "a raw key is refused, because a raw private key looks the same") } - return key, nil + k, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return "", fmt.Errorf("the operator key is not a public key: %w", err) + } + pub, ok := k.(*ecdh.PublicKey) + if !ok || pub.Curve() != ecdh.X25519() { + return "", errors.New("the operator key is not an X25519 key") + } + return base64.StdEncoding.EncodeToString(pub.Bytes()), nil +} + +// publicPEM wraps a raw X25519 public key, base64, as SPKI PEM. +func publicPEM(raw string) (string, error) { + b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(raw)) + if err != nil || len(b) != 32 { + return "", errors.New("not a raw X25519 key in base64") + } + pub, err := ecdh.X25519().NewPublicKey(b) + if err != nil { + return "", err + } + der, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + return "", err + } + return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})), nil } // operatorSeal seals value to the operator key in the box the operator opens today. diff --git a/modules/mesh-vault/cmd/mesh-vault/store.go b/modules/mesh-vault/cmd/mesh-vault/store.go index 887331a4..cde51126 100644 --- a/modules/mesh-vault/cmd/mesh-vault/store.go +++ b/modules/mesh-vault/cmd/mesh-vault/store.go @@ -48,7 +48,11 @@ type OperatorCopy struct { } // Store is the vault's directory of secrets. -type Store struct{ dir string } +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 +} // OpenStore opens (and makes) the store at dir. func OpenStore(dir string) (*Store, error) { @@ -99,6 +103,11 @@ 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 @@ -106,6 +115,64 @@ func (s *Store) Write(r Record) error { return writeWhole(s.pathOf(r.Name), append(raw, '\n')) } +// 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. +func (s *Store) RecordHanding(h Handing) error { + raw, err := json.Marshal(h) + if err != nil { + return err + } + f, err := os.OpenFile(filepath.Join(s.dir, "handed.jsonl"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return err + } + if _, err := f.Write(append(raw, '\n')); 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. +func (s *Store) Handed(limit int) ([]Handing, error) { + raw, err := os.ReadFile(filepath.Join(s.dir, "handed.jsonl")) + if errors.Is(err, os.ErrNotExist) { + return []Handing{}, nil + } + if err != nil { + return nil, err + } + lines := strings.Split(strings.TrimSpace(string(raw)), "\n") + out := []Handing{} + for i := len(lines) - 1; i >= 0 && len(out) < limit; i-- { + var h Handing + if json.Unmarshal([]byte(lines[i]), &h) == nil { + out = append(out, h) + } + } + return out, nil +} + // List is every record, by name. func (s *Store) List() ([]Record, error) { entries, err := os.ReadDir(filepath.Join(s.dir, "secrets")) @@ -160,11 +227,43 @@ func (s *Store) setOperatorKey(key, by string, at time.Time) error { 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. +// 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 := os.WriteFile(tmp, raw, 0o600); err != nil { + if err := writeSynced(tmp, raw); err != nil { return err } - return os.Rename(tmp, path) + 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() } diff --git a/modules/mesh-vault/module.json b/modules/mesh-vault/module.json index 3f9eb97c..abba5ba2 100644 --- a/modules/mesh-vault/module.json +++ b/modules/mesh-vault/module.json @@ -13,7 +13,6 @@ "emits": [ "provisioned", "rotated", - "deprovisioned", "secret-changed" ], "receives": { @@ -111,7 +110,13 @@ "tools": [ "secret_export", "secret_holders", - "secret_verify" + "secret_verify", + "secret_handed", + "secret_vault_export", + "provisioner_retirement", + "provisioner_retire_approve", + "provisioner_retire_reject", + "provisioner_delete" ], "claims": [ {