mesh-vault: its store, its keys and its rules (issue 674)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user