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
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:
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user