mesh-vault: sync before rename, record hand-outs, refuse a new key beside secrets, take the operator key as PEM only (review 250)
mesh/delivery-group group pkg/57-v1-vault-in-go delivering: 1 of 2 delivered
mesh/merge-gate head b67fe1ffb16e merged into trunk 8aa37e8b835d — pass: the change touches no module of the mesh's graph
mesh/repo-check head b67fe1ffb16e merged into trunk 8aa37e8b835d — fail: its merge-check.sh failed: files asking for a setting without saying whether it is…
mesh/delivery superseded: a newer head of the same pull request

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 01:19:36 +00:00
co-authored by Claude Opus 5.5
parent 2000c02559
commit b67fe1ffb1
3 changed files with 168 additions and 17 deletions
+58 -11
View File
@@ -6,7 +6,8 @@ package main
// secret is kept sealed to it at rest. // 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). // - 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. // 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. // 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. // OwnKeyFile is where the vault's own key lives in its state directory.
const OwnKeyFile = "vault-key.json" 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 // OwnKey is the vault's own key pair, made once in dir. Written whole and synced, then linked into place and
// process reads the first's key and never half of it. // the directory synced, so a second process reads the first's key and never half of it.
func OwnKey(dir string) (seal.KeyPair, error) { //
// **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 == "" { if dir == "" {
return seal.KeyPair{}, errors.New("the vault has no state directory: MESH_VAULT_STATE is not set") 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) file := filepath.Join(dir, OwnKeyFile)
if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) { 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() k, err := seal.GenerateKeyPair()
if err != nil { if err != nil {
return seal.KeyPair{}, err return seal.KeyPair{}, err
} }
raw, _ := json.Marshal(k) raw, _ := json.Marshal(k)
tmp := filepath.Join(dir, fmt.Sprintf(".vault-key.%d.json", os.Getpid())) 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 return seal.KeyPair{}, err
} }
_ = os.Link(tmp, file) // fails when another made it first, which is right _ = os.Link(tmp, file) // fails when another made it first, which is right
_ = os.Remove(tmp) _ = os.Remove(tmp)
if err := syncDir(dir); err != nil {
return seal.KeyPair{}, err
}
} }
raw, err := os.ReadFile(file) raw, err := os.ReadFile(file)
if err != nil { if err != nil {
@@ -92,17 +112,44 @@ func askerKey(key string) (string, error) {
return string(pem.EncodeToMemory(block)), nil 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) { func checkOperatorKey(key string) (string, error) {
key = strings.TrimSpace(key)
if privateKeyIn(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)") 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) block, rest := pem.Decode([]byte(strings.TrimSpace(key)))
if err != nil || len(raw) != 32 { if block == nil || block.Type != "PUBLIC KEY" || strings.TrimSpace(string(rest)) != "" {
return "", errors.New("the operator key is a public X25519 key, 32 bytes in base64, as `operator key make` writes it") 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. // operatorSeal seals value to the operator key in the box the operator opens today.
+103 -4
View File
@@ -48,7 +48,11 @@ type OperatorCopy struct {
} }
// Store is the vault's directory of secrets. // 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. // OpenStore opens (and makes) the store at dir.
func OpenStore(dir string) (*Store, error) { func OpenStore(dir string) (*Store, error) {
@@ -99,6 +103,11 @@ func (s *Store) Write(r Record) error {
if err := validName(r.Name); err != nil { if err := validName(r.Name); err != nil {
return err return err
} }
if s.failWrite != nil {
if err := s.failWrite(r); err != nil {
return err
}
}
raw, err := json.MarshalIndent(r, "", " ") raw, err := json.MarshalIndent(r, "", " ")
if err != nil { if err != nil {
return err return err
@@ -106,6 +115,64 @@ func (s *Store) Write(r Record) error {
return writeWhole(s.pathOf(r.Name), append(raw, '\n')) 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. // List is every record, by name.
func (s *Store) List() ([]Record, error) { func (s *Store) List() ([]Record, error) {
entries, err := os.ReadDir(filepath.Join(s.dir, "secrets")) 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')) 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 { func writeWhole(path string, raw []byte) error {
tmp := fmt.Sprintf("%s.%d.tmp", path, os.Getpid()) 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 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()
} }
+7 -2
View File
@@ -13,7 +13,6 @@
"emits": [ "emits": [
"provisioned", "provisioned",
"rotated", "rotated",
"deprovisioned",
"secret-changed" "secret-changed"
], ],
"receives": { "receives": {
@@ -111,7 +110,13 @@
"tools": [ "tools": [
"secret_export", "secret_export",
"secret_holders", "secret_holders",
"secret_verify" "secret_verify",
"secret_handed",
"secret_vault_export",
"provisioner_retirement",
"provisioner_retire_approve",
"provisioner_retire_reject",
"provisioner_delete"
], ],
"claims": [ "claims": [
{ {