ec276dbc91
New internal/agent/secrets package: AEAD blob at /var/lib/restic-manager/secrets.enc, atomic write (os.CreateTemp + Sync + Rename), 0600. Key lives in agent.yaml as base64 (SecretsKey) — same trust boundary as the bearer token, minted on first start via EnsureSecretsKey. cmd/agent: dispatcher reads creds fresh from secrets.Load() on each job rather than from in-memory config. config.update merges the push with what's on disk and persists, so a daemon restart keeps the latest values. Legacy plaintext repo_url/repo_password in agent.yaml are silently migrated into secrets.enc on next start and stripped from the YAML on the following save. Tests: round-trip + wrong-key rejection + atomic-write post-condition for secrets; key idempotence + legacy-field parse/clear for config. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
118 lines
3.7 KiB
Go
118 lines
3.7 KiB
Go
// 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 (
|
|
"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. (Defence in
|
|
// depth — the key is per-host today, but cheap to be careful.)
|
|
const additionalData = "rm-agent-repo-creds-v1"
|
|
|
|
// 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 == "" }
|
|
|
|
// 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
|
|
}
|
|
|
|
// Load returns the persisted Repo, 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) {
|
|
body, err := os.ReadFile(s.path)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return Repo{}, nil
|
|
}
|
|
return Repo{}, fmt.Errorf("secrets: read %q: %w", s.path, err)
|
|
}
|
|
plain, err := s.a.Decrypt(string(body), []byte(additionalData))
|
|
if err != nil {
|
|
return Repo{}, fmt.Errorf("secrets: decrypt %q: %w", s.path, err)
|
|
}
|
|
var r Repo
|
|
if err := json.Unmarshal(plain, &r); err != nil {
|
|
return Repo{}, fmt.Errorf("secrets: parse %q: %w", s.path, err)
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// Save replaces the on-disk blob atomically. Mode is 0600. Parent
|
|
// directory must already exist (the install script lays it down).
|
|
func (s *Store) Save(r Repo) error {
|
|
body, err := json.Marshal(r)
|
|
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
|
|
}
|