// Package secrets persists the agent's restic repo credentials in an // AEAD-encrypted file. Phase 1 stores them at rest under a 32-byte // key kept in agent.yaml (same 0600 root-only trust boundary as the // bearer token); Phase 2 will swap that for the OS keyring on // platforms that have one (DPAPI / Secret Service / kwallet). // // The wire push path (server → agent over WS as `config.update`) is // unchanged; this package owns only on-disk persistence. package secrets import ( "bytes" "encoding/json" "errors" "fmt" "os" "path/filepath" "gitea.dcglab.co.uk/steve/restic-manager/internal/crypto" ) // additionalData binds ciphertexts to the agent-secrets context, so a // blob lifted from one role's file can't be replayed into another's // row in some unrelated table that uses the same key. (Defense in // depth — the key is per-host today, but cheap to be careful.) const additionalData = "rm-agent-repo-creds-v1" // ErrNoAdmin is returned by LoadAdmin when no admin slot has been // written yet. Callers must distinguish this from a hard error: the // agent simply hasn't received an admin config.update push yet. var ErrNoAdmin = errors.New("secrets: admin slot not configured") // Repo is the plaintext shape persisted inside the AEAD blob. type Repo struct { URL string `json:"repo_url,omitempty"` Username string `json:"repo_username,omitempty"` Password string `json:"repo_password,omitempty"` } // Empty reports whether the credential set is missing the bare // minimum (URL + password) needed to run a backup. func (r Repo) Empty() bool { return r.URL == "" || r.Password == "" } // bundle is the on-disk JSON shape as of secrets v2. It holds the // everyday repo slot and an optional admin slot (prune / unlock). // Legacy files (pre-v2) contain a flat Repo object; loadBundle // transparently upgrades those on the next Save. type bundle struct { Repo Repo `json:"repo,omitempty"` Admin *Repo `json:"admin,omitempty"` } // Store reads and writes the encrypted secrets file at Path, sealed // under the 32-byte key Key. type Store struct { path string a *crypto.AEAD } // New opens a Store. The key must be exactly crypto.KeyLen bytes // (32). The file at path is not read here — call Load. func New(path string, key []byte) (*Store, error) { if path == "" { return nil, errors.New("secrets: empty path") } a, err := crypto.NewAEAD(key) if err != nil { return nil, fmt.Errorf("secrets: %w", err) } return &Store{path: path, a: a}, nil } // loadBundle reads and decrypts the on-disk blob, returning a bundle. // It handles back-compat decode: legacy flat Repo blobs are detected // by the presence of a top-level "repo_url" key and re-wrapped into // the bundle shape transparently. Returns an empty bundle when the // file does not exist yet. func (s *Store) loadBundle() (bundle, error) { body, err := os.ReadFile(s.path) if err != nil { if errors.Is(err, os.ErrNotExist) { return bundle{}, nil } return bundle{}, fmt.Errorf("secrets: read %q: %w", s.path, err) } plain, err := s.a.Decrypt(string(body), []byte(additionalData)) if err != nil { return bundle{}, fmt.Errorf("secrets: decrypt %q: %w", s.path, err) } // Try the new bundle shape first. var b bundle if err := json.Unmarshal(plain, &b); err != nil { return bundle{}, fmt.Errorf("secrets: parse %q: %w", s.path, err) } // If the bundle has an empty Repo slot but the raw JSON contains // a top-level "repo_url" key, this is a legacy flat blob — // re-unmarshal it as a Repo and slot it in. if b.Repo == (Repo{}) && bytes.Contains(plain, []byte(`"repo_url"`)) { var legacy Repo if err := json.Unmarshal(plain, &legacy); err == nil { b.Repo = legacy } } return b, nil } // saveBundle marshals b, encrypts it and writes it atomically at // mode 0600. Parent directory must already exist. func (s *Store) saveBundle(b bundle) error { body, err := json.Marshal(b) if err != nil { return fmt.Errorf("secrets: marshal: %w", err) } ct, err := s.a.Encrypt(body, []byte(additionalData)) if err != nil { return fmt.Errorf("secrets: encrypt: %w", err) } dir := filepath.Dir(s.path) tmp, err := os.CreateTemp(dir, ".secrets-*.tmp") if err != nil { return fmt.Errorf("secrets: create tmp: %w", err) } tmpPath := tmp.Name() defer func() { _ = tmp.Close() _ = os.Remove(tmpPath) // no-op once Rename succeeds }() if err := tmp.Chmod(0o600); err != nil { return fmt.Errorf("secrets: chmod tmp: %w", err) } if _, err := tmp.WriteString(ct); err != nil { return fmt.Errorf("secrets: write tmp: %w", err) } if err := tmp.Sync(); err != nil { return fmt.Errorf("secrets: fsync tmp: %w", err) } if err := tmp.Close(); err != nil { return fmt.Errorf("secrets: close tmp: %w", err) } if err := os.Rename(tmpPath, s.path); err != nil { return fmt.Errorf("secrets: rename: %w", err) } return nil } // Load returns the persisted Repo (the everyday repo slot), or a // zero-value Repo (with no error) if the file does not exist yet — // first-run agents have nothing on disk until the server pushes a // config.update. func (s *Store) Load() (Repo, error) { b, err := s.loadBundle() if err != nil { return Repo{}, err } return b.Repo, nil } // Save replaces the repo slot on disk atomically, preserving the // admin slot. Mode is 0600. Parent directory must already exist. func (s *Store) Save(r Repo) error { b, err := s.loadBundle() if err != nil { return fmt.Errorf("secrets: load before save: %w", err) } b.Repo = r return s.saveBundle(b) } // LoadAdmin returns the admin slot, or (Repo{}, ErrNoAdmin) when no // admin slot has been set. All other errors are hard failures. func (s *Store) LoadAdmin() (Repo, error) { b, err := s.loadBundle() if err != nil { return Repo{}, err } if b.Admin == nil { return Repo{}, ErrNoAdmin } return *b.Admin, nil } // SaveAdmin replaces the admin slot on disk atomically, preserving // the repo slot. Mode is 0600. func (s *Store) SaveAdmin(r Repo) error { b, err := s.loadBundle() if err != nil { return fmt.Errorf("secrets: load before save: %w", err) } b.Admin = &r return s.saveBundle(b) }