444 lines
13 KiB
Go
444 lines
13 KiB
Go
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"
|
||
"sync"
|
||
"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"`
|
||
// ChangedVia is the caller the runtime proved, when it is not who ChangedBy names: the controller
|
||
// carrying a person's act (design 57's V2). Empty when they are the same.
|
||
ChangedVia string `json:"changed-via,omitempty"`
|
||
// Origin is where the value came from: made by the vault, accepted at the controller's terminal, typed at
|
||
// a desk, or issued by a provider's software (ADR 0349 clauses 8 to 10). Empty is made, as V1 wrote it.
|
||
Origin string `json:"origin,omitempty"`
|
||
// MovedFrom is, for a value the move took from the controller's store (design 57's V3), the node-engine that
|
||
// handed it in and the controller's generation for it, kept as it was.
|
||
MovedFrom *MovedFrom `json:"moved-from,omitempty"`
|
||
}
|
||
|
||
// 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
|
||
// failWrite, when a test sets it, fails the write of a record: how a failure partway is proven.
|
||
failWrite func(Record) error
|
||
// HandedMax is the size at which the record of hand-outs is rolled (0: HandedMaxDefault).
|
||
HandedMax int64
|
||
|
||
handedMu sync.Mutex
|
||
rolled int
|
||
// asksMu serialises the record of asks, so two asks for one secret at once do not both pass its bounds.
|
||
asksMu sync.Mutex
|
||
}
|
||
|
||
// The record of hand-outs is bounded: rolled at HandedMaxDefault into handed.jsonl.1 … .HandedKept, the
|
||
// oldest dropped, so a flood of asks costs at most (HandedKept+1) × HandedMaxDefault on the vault's disk.
|
||
const (
|
||
HandedMaxDefault = 1 << 20
|
||
HandedKept = 3
|
||
)
|
||
|
||
// 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
|
||
}
|
||
if s.failWrite != nil {
|
||
if err := s.failWrite(r); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
raw, err := json.MarshalIndent(r, "", " ")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
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. It says whether the
|
||
// record was rolled to make room.
|
||
func (s *Store) RecordHanding(h Handing) (rolled bool, err error) {
|
||
raw, err := json.Marshal(h)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
s.handedMu.Lock()
|
||
defer s.handedMu.Unlock()
|
||
path := filepath.Join(s.dir, "handed.jsonl")
|
||
max := s.HandedBound()
|
||
if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() && info.Size()+int64(len(raw))+1 > max {
|
||
if err := s.roll(path); err != nil {
|
||
return false, err
|
||
}
|
||
s.rolled++
|
||
rolled = true
|
||
}
|
||
return rolled, appendSynced(path, append(raw, '\n'))
|
||
}
|
||
|
||
// roll moves handed.jsonl to .1, .1 to .2 and so on, dropping what falls past HandedKept.
|
||
func (s *Store) roll(path string) error {
|
||
_ = os.Remove(fmt.Sprintf("%s.%d", path, HandedKept))
|
||
for i := HandedKept - 1; i >= 1; i-- {
|
||
if err := os.Rename(fmt.Sprintf("%s.%d", path, i), fmt.Sprintf("%s.%d", path, i+1)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||
return err
|
||
}
|
||
}
|
||
if err := os.Rename(path, path+".1"); err != nil {
|
||
return err
|
||
}
|
||
return syncDir(s.dir)
|
||
}
|
||
|
||
// Rolled is how many times the record of hand-outs was rolled since the vault started.
|
||
func (s *Store) Rolled() int {
|
||
s.handedMu.Lock()
|
||
defer s.handedMu.Unlock()
|
||
return s.rolled
|
||
}
|
||
|
||
func appendSynced(path string, raw []byte) error {
|
||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 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()
|
||
}
|
||
|
||
// Handed is the latest handings, newest first, at most limit: the current record, then the rolled ones.
|
||
func (s *Store) Handed(limit int) ([]Handing, error) {
|
||
s.handedMu.Lock()
|
||
defer s.handedMu.Unlock()
|
||
path := filepath.Join(s.dir, "handed.jsonl")
|
||
out := []Handing{}
|
||
for i := 0; i <= HandedKept && len(out) < limit; i++ {
|
||
file := path
|
||
if i > 0 {
|
||
file = fmt.Sprintf("%s.%d", path, i)
|
||
}
|
||
raw, err := os.ReadFile(file)
|
||
if errors.Is(err, os.ErrNotExist) {
|
||
continue
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
||
for j := len(lines) - 1; j >= 0 && len(out) < limit; j-- {
|
||
var h Handing
|
||
if json.Unmarshal([]byte(lines[j]), &h) == nil {
|
||
out = append(out, h)
|
||
}
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// HandedBound is the size at which the record of hand-outs is rolled.
|
||
func (s *Store) HandedBound() int64 {
|
||
if s.HandedMax > 0 {
|
||
return s.HandedMax
|
||
}
|
||
return HandedMaxDefault
|
||
}
|
||
|
||
// 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, 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 := writeSynced(tmp, raw); err != nil {
|
||
return err
|
||
}
|
||
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()
|
||
}
|
||
|
||
// The two lines an ask leaves in the record: when it opened, and how it ended.
|
||
const (
|
||
AskOpened = "opened"
|
||
AskEnded = "ended"
|
||
)
|
||
|
||
// AskRecord is one line of the record of asks for a module's own secret at a desk (ADR 0277 §25, in the vault's
|
||
// store since ADR 0349 clause 9): who asked, as the bus named them, for what, at which desk, and how it ended.
|
||
// Never a value.
|
||
type AskRecord struct {
|
||
ID string `json:"id"`
|
||
At time.Time `json:"at"`
|
||
Event string `json:"event"`
|
||
Secret string `json:"secret"`
|
||
Node string `json:"node,omitempty"`
|
||
Module string `json:"module,omitempty"`
|
||
Name string `json:"name,omitempty"`
|
||
Desk string `json:"desk,omitempty"`
|
||
AskedBy string `json:"asked_by,omitempty"`
|
||
Caller string `json:"caller,omitempty"`
|
||
Outcome string `json:"outcome,omitempty"`
|
||
}
|
||
|
||
// RecordAsk appends one line to the record of asks, synced before it returns; rolled as the record of
|
||
// hand-outs is, and it says whether it was.
|
||
func (s *Store) RecordAsk(r AskRecord) (rolled bool, err error) {
|
||
raw, err := json.Marshal(r)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
path := filepath.Join(s.dir, "asks.jsonl")
|
||
if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() && info.Size()+int64(len(raw))+1 > s.HandedBound() {
|
||
if err := s.roll(path); err != nil {
|
||
return false, err
|
||
}
|
||
rolled = true
|
||
}
|
||
return rolled, appendSynced(path, append(raw, '\n'))
|
||
}
|
||
|
||
// asks is every line of the record of asks, oldest first, the rolled files before the current one.
|
||
func (s *Store) asks() ([]AskRecord, error) {
|
||
path := filepath.Join(s.dir, "asks.jsonl")
|
||
var out []AskRecord
|
||
for i := HandedKept; i >= 0; i-- {
|
||
file := path
|
||
if i > 0 {
|
||
file = fmt.Sprintf("%s.%d", path, i)
|
||
}
|
||
raw, err := os.ReadFile(file)
|
||
if errors.Is(err, os.ErrNotExist) {
|
||
continue
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
|
||
var r AskRecord
|
||
if line != "" && json.Unmarshal([]byte(line), &r) == nil {
|
||
out = append(out, r)
|
||
}
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// AsksSince is every line of the record of asks at or after since, oldest first.
|
||
func (s *Store) AsksSince(since time.Time) ([]AskRecord, error) {
|
||
all, err := s.asks()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var out []AskRecord
|
||
for _, r := range all {
|
||
if !r.At.Before(since) {
|
||
out = append(out, r)
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// Asks is the latest lines of the record of asks, newest first, at most limit.
|
||
func (s *Store) Asks(limit int) ([]AskRecord, error) {
|
||
s.asksMu.Lock()
|
||
defer s.asksMu.Unlock()
|
||
all, err := s.asks()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := []AskRecord{}
|
||
for i := len(all) - 1; i >= 0 && len(out) < limit; i-- {
|
||
out = append(out, all[i])
|
||
}
|
||
return out, nil
|
||
}
|