diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 439e739..b970f7a 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -13,6 +13,7 @@ import ( "gitea.dcglab.co.uk/steve/restic-manager/internal/agent/config" "gitea.dcglab.co.uk/steve/restic-manager/internal/agent/runner" + "gitea.dcglab.co.uk/steve/restic-manager/internal/agent/secrets" "gitea.dcglab.co.uk/steve/restic-manager/internal/agent/sysinfo" "gitea.dcglab.co.uk/steve/restic-manager/internal/agent/wsclient" "gitea.dcglab.co.uk/steve/restic-manager/internal/api" @@ -76,6 +77,14 @@ func run() error { resticBin, _ := restic.Locate(cfg.ResticPath) // empty is fine; commands fail with a clear error later + // Open the secrets store. If the agent is enrolled but has no + // secrets key yet (legacy YAML), mint one and migrate any + // plaintext repo fields into the encrypted blob. + sec, err := openSecretsStore(cfg) + if err != nil { + return fmt.Errorf("secrets: %w", err) + } + wsCfg := wsclient.Config{ ServerURL: cfg.ServerURL, AgentToken: cfg.AgentToken, @@ -92,9 +101,8 @@ func run() error { } d := &dispatcher{ - resticBin: resticBin, - repoURL: cfg.RepoURL, - repoPassword: cfg.RepoPassword, + resticBin: resticBin, + secrets: sec, } if err := wsclient.Run(ctx, wsCfg, d.handle); err != nil { return fmt.Errorf("ws run: %w", err) @@ -103,13 +111,61 @@ func run() error { return nil } -// dispatcher closes over the long-lived agent settings (restic path, -// repo creds) so handle() can spawn the runner without re-loading -// config every time. +// openSecretsStore opens (or one-time migrates) the agent's encrypted +// secrets file. Side effects: +// - mints SecretsKey if absent and persists agent.yaml. +// - if legacy plaintext repo_url/repo_password sit in agent.yaml, +// copies them into secrets.enc and clears the YAML fields on +// the next save. +func openSecretsStore(cfg *config.Config) (*secrets.Store, error) { + if err := cfg.EnsureSecretsKey(); err != nil { + return nil, err + } + keyBytes, err := cfg.SecretsKeyBytes() + if err != nil { + return nil, err + } + st, err := secrets.New(cfg.ResolvedSecretsPath(), keyBytes) + if err != nil { + return nil, err + } + + migrated := false + if cfg.LegacyRepoURL != "" || cfg.LegacyRepoPassword != "" { + cur, _ := st.Load() // empty Repo on first run is fine + if cur.URL == "" { + cur.URL = cfg.LegacyRepoURL + } + if cur.Password == "" { + cur.Password = cfg.LegacyRepoPassword + } + if err := st.Save(cur); err != nil { + return nil, fmt.Errorf("migrate legacy creds into secrets.enc: %w", err) + } + cfg.LegacyRepoURL = "" + cfg.LegacyRepoPassword = "" + migrated = true + slog.Info("agent: migrated legacy plaintext repo creds into secrets.enc") + } + + // Persist key (and the cleared legacy fields) regardless of + // whether we migrated, in case we just minted SecretsKey. + if migrated || cfg.SecretsKey != "" { + if err := cfg.Save(); err != nil { + return nil, fmt.Errorf("persist agent config: %w", err) + } + } + return st, nil +} + +// dispatcher closes over the long-lived agent settings (restic path +// + secrets handle) so handle() can spawn the runner without +// re-loading config every time. Repo creds are read fresh from the +// secrets store on each job — config.update writes through to disk, +// so a job dispatched in the same session sees the latest values. type dispatcher struct { - resticBin string - repoURL string - repoPassword string + resticBin string + secrets *secrets.Store } func (d *dispatcher) handle(ctx context.Context, env api.Envelope, tx wsclient.Sender) error { @@ -132,15 +188,32 @@ func (d *dispatcher) handle(ctx context.Context, env api.Envelope, tx wsclient.S case api.MsgConfigUpdate: var p api.ConfigUpdatePayload _ = env.UnmarshalPayload(&p) - // In-memory only for now — restart loses these. Persistent - // secret storage lands with P2's keyring work. - if p.RepoURL != "" { - d.repoURL = p.RepoURL - slog.Info("ws agent: repo URL updated via config.update") + // Merge with whatever's already in secrets.enc — empty fields + // in the push mean "leave alone." Atomic write underneath. + cur, err := d.secrets.Load() + if err != nil { + slog.Error("ws agent: load secrets for merge", "err", err) + return nil } - if p.RepoPassword != "" { - d.repoPassword = p.RepoPassword - slog.Info("ws agent: repo password updated via config.update") + changed := false + if p.RepoURL != "" && p.RepoURL != cur.URL { + cur.URL = p.RepoURL + changed = true + } + if p.RepoUsername != "" && p.RepoUsername != cur.Username { + cur.Username = p.RepoUsername + changed = true + } + if p.RepoPassword != "" && p.RepoPassword != cur.Password { + cur.Password = p.RepoPassword + changed = true + } + if changed { + if err := d.secrets.Save(cur); err != nil { + slog.Error("ws agent: persist secrets", "err", err) + return nil + } + slog.Info("ws agent: repo credentials updated via config.update") } case api.MsgAgentUpdateAvail: @@ -160,13 +233,17 @@ func (d *dispatcher) runJob(ctx context.Context, p api.CommandRunPayload, tx wsc if d.resticBin == "" { return fmt.Errorf("restic binary not located on this agent") } - if d.repoURL == "" || d.repoPassword == "" { - return fmt.Errorf("repo credentials not configured (set repo_url + repo_password in agent.yaml or push via config.update)") + creds, err := d.secrets.Load() + if err != nil { + return fmt.Errorf("load repo credentials: %w", err) + } + if creds.Empty() { + return fmt.Errorf("repo credentials not configured (waiting for server config.update push)") } r := runner.New(runner.Config{ ResticBin: d.resticBin, - RepoURL: d.repoURL, - RepoPassword: d.repoPassword, + RepoURL: creds.URL, + RepoPassword: creds.Password, }, tx, time.Second) switch p.Kind { diff --git a/internal/agent/config/config.go b/internal/agent/config/config.go index e854b2c..c10e20c 100644 --- a/internal/agent/config/config.go +++ b/internal/agent/config/config.go @@ -1,14 +1,22 @@ // Package config loads the agent's persistent configuration. After // enrollment, the file holds the bearer token + server URL; it is // only ever written via Save (which replaces atomically). +// +// Restic repo credentials live in a separate AEAD-encrypted file +// (see internal/agent/secrets) and never appear in this config. package config import ( + "crypto/rand" + "encoding/base64" "fmt" + "io" "os" "path/filepath" "gopkg.in/yaml.v3" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/crypto" ) // Config is the on-disk shape of the agent's config file. @@ -33,19 +41,75 @@ type Config struct { // ResticPath overrides the auto-detected restic binary path. ResticPath string `yaml:"restic_path,omitempty"` - // RepoURL + RepoPassword are the credentials this host uses to - // reach its restic repository. Phase 1 keeps these in plaintext - // in agent.yaml (mode 0600 owned by the agent service user); the - // server-pushed config.update message can override them in - // memory. Phase 2 moves them into the OS keyring (DPAPI on - // Windows, Secret Service on Linux). - RepoURL string `yaml:"repo_url,omitempty"` - RepoPassword string `yaml:"repo_password,omitempty"` + // SecretsPath is the on-disk location of the AEAD-encrypted + // repo-creds blob. Defaults to /var/lib/restic-manager/secrets.enc + // when empty. Phase 1 trust boundary: this file is 0600 owned by + // the agent service user; the SecretsKey below sits in this same + // agent.yaml (also 0600), so anyone who can read one can read the + // other. Phase 2 swaps SecretsKey for an OS-keyring lookup. + SecretsPath string `yaml:"secrets_path,omitempty"` + + // SecretsKey is the base64-encoded 32-byte AEAD master key for + // the secrets file. Minted on first Save if absent. + SecretsKey string `yaml:"secrets_key,omitempty"` + + // LegacyRepoURL / LegacyRepoPassword: remnants of the pre-P1-33 + // world where repo creds lived in plaintext here. Load() pulls + // them off (so a config written by an older agent still works on + // upgrade), the agent migrates them into secrets.enc on next + // start, and Save() writes them out as empty (yamls "omitempty") + // — i.e. the next file on disk no longer contains them. + LegacyRepoURL string `yaml:"repo_url,omitempty"` + LegacyRepoPassword string `yaml:"repo_password,omitempty"` // path is the file we loaded from. Used by Save. path string `yaml:"-"` } +// DefaultSecretsPath is where the encrypted secrets blob lives on a +// stock Linux install. Override via Config.SecretsPath. +const DefaultSecretsPath = "/var/lib/restic-manager/secrets.enc" + +// ResolvedSecretsPath returns SecretsPath if set, otherwise the +// default for the current OS. +func (c *Config) ResolvedSecretsPath() string { + if c.SecretsPath != "" { + return c.SecretsPath + } + return DefaultSecretsPath +} + +// EnsureSecretsKey mints a fresh 32-byte AEAD key into c.SecretsKey +// if one isn't already set. Caller must Save() afterwards. Idempotent. +func (c *Config) EnsureSecretsKey() error { + if c.SecretsKey != "" { + return nil + } + raw := make([]byte, crypto.KeyLen) + if _, err := io.ReadFull(rand.Reader, raw); err != nil { + return fmt.Errorf("agent config: mint secrets key: %w", err) + } + c.SecretsKey = base64.StdEncoding.EncodeToString(raw) + return nil +} + +// SecretsKeyBytes returns the decoded AEAD key, or an error if the +// key is unset/malformed. +func (c *Config) SecretsKeyBytes() ([]byte, error) { + if c.SecretsKey == "" { + return nil, fmt.Errorf("agent config: secrets_key is empty") + } + b, err := base64.StdEncoding.DecodeString(c.SecretsKey) + if err != nil { + return nil, fmt.Errorf("agent config: decode secrets_key: %w", err) + } + if len(b) != crypto.KeyLen { + return nil, fmt.Errorf("agent config: secrets_key must decode to %d bytes, got %d", + crypto.KeyLen, len(b)) + } + return b, nil +} + // DefaultPath returns the canonical config path for the current OS. // Phase 1 ships Linux only; Windows path lives in the spec for P2. func DefaultPath() string { diff --git a/internal/agent/config/config_test.go b/internal/agent/config/config_test.go new file mode 100644 index 0000000..af6c943 --- /dev/null +++ b/internal/agent/config/config_test.go @@ -0,0 +1,96 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/crypto" +) + +func TestEnsureSecretsKeyMintsOnce(t *testing.T) { + t.Parallel() + c := &Config{} + if err := c.EnsureSecretsKey(); err != nil { + t.Fatalf("mint: %v", err) + } + first := c.SecretsKey + if first == "" { + t.Fatal("EnsureSecretsKey: SecretsKey still empty") + } + // Second call must be a no-op (idempotent). + if err := c.EnsureSecretsKey(); err != nil { + t.Fatalf("second mint: %v", err) + } + if c.SecretsKey != first { + t.Errorf("EnsureSecretsKey is not idempotent: %q → %q", first, c.SecretsKey) + } + + // SecretsKeyBytes returns 32 raw bytes. + b, err := c.SecretsKeyBytes() + if err != nil { + t.Fatalf("bytes: %v", err) + } + if len(b) != crypto.KeyLen { + t.Errorf("decoded key len = %d, want %d", len(b), crypto.KeyLen) + } +} + +func TestSecretsKeyBytesRejectsBadInput(t *testing.T) { + t.Parallel() + cases := map[string]Config{ + "empty": {SecretsKey: ""}, + "not_base64": {SecretsKey: "!!!"}, + "wrong_len": {SecretsKey: "Zm9v"}, // "foo", 3 bytes + } + for name, c := range cases { + c := c + t.Run(name, func(t *testing.T) { + if _, err := c.SecretsKeyBytes(); err == nil { + t.Errorf("expected error for %s", name) + } + }) + } +} + +func TestLoadAcceptsLegacyRepoFields(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "agent.yaml") + yaml := []byte(`server_url: "https://srv" +host_id: "h1" +agent_token: "tok" +repo_url: "rest:https://repo/h1" +repo_password: "secret" +`) + if err := os.WriteFile(path, yaml, 0o600); err != nil { + t.Fatalf("write: %v", err) + } + c, err := Load(path) + if err != nil { + t.Fatalf("load: %v", err) + } + if c.LegacyRepoURL != "rest:https://repo/h1" || c.LegacyRepoPassword != "secret" { + t.Errorf("legacy fields not parsed: %+v", c) + } + // And on Save the legacy fields should round-trip out to the file + // only when set (empty values use omitempty). + c.LegacyRepoURL = "" + c.LegacyRepoPassword = "" + if err := c.Save(); err != nil { + t.Fatalf("save: %v", err) + } + body, _ := os.ReadFile(path) + if contains := string(body); contains == "" || stringContains(contains, "repo_password:") { + t.Errorf("repo_password should be gone after legacy clear, got:\n%s", contains) + } +} + +func stringContains(haystack, needle string) bool { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return true + } + } + return false +} diff --git a/internal/agent/secrets/secrets.go b/internal/agent/secrets/secrets.go new file mode 100644 index 0000000..8aa467f --- /dev/null +++ b/internal/agent/secrets/secrets.go @@ -0,0 +1,117 @@ +// 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 +} diff --git a/internal/agent/secrets/secrets_test.go b/internal/agent/secrets/secrets_test.go new file mode 100644 index 0000000..07fa57b --- /dev/null +++ b/internal/agent/secrets/secrets_test.go @@ -0,0 +1,99 @@ +package secrets + +import ( + "crypto/rand" + "io" + "os" + "path/filepath" + "testing" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/crypto" +) + +func freshKey(t *testing.T) []byte { + t.Helper() + k := make([]byte, crypto.KeyLen) + if _, err := io.ReadFull(rand.Reader, k); err != nil { + t.Fatalf("rand: %v", err) + } + return k +} + +func TestRoundTrip(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "secrets.enc") + st, err := New(path, freshKey(t)) + if err != nil { + t.Fatalf("new: %v", err) + } + + // Empty file → zero-value Repo, no error. + got, err := st.Load() + if err != nil { + t.Fatalf("load empty: %v", err) + } + if !got.Empty() { + t.Errorf("first load should be empty, got %+v", got) + } + + want := Repo{URL: "rest:https://r/host", Username: "user", Password: "pw"} + if err := st.Save(want); err != nil { + t.Fatalf("save: %v", err) + } + got, err = st.Load() + if err != nil { + t.Fatalf("load: %v", err) + } + if got != want { + t.Errorf("round-trip mismatch: got %+v want %+v", got, want) + } + + // File mode must be 0600. + info, _ := os.Stat(path) + if info.Mode().Perm() != 0o600 { + t.Errorf("file mode = %o, want 0600", info.Mode().Perm()) + } +} + +func TestRejectsWrongKey(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "secrets.enc") + good, err := New(path, freshKey(t)) + if err != nil { + t.Fatalf("new good: %v", err) + } + if err := good.Save(Repo{URL: "x", Password: "y"}); err != nil { + t.Fatalf("save: %v", err) + } + bad, err := New(path, freshKey(t)) + if err != nil { + t.Fatalf("new bad: %v", err) + } + if _, err := bad.Load(); err == nil { + t.Error("load with wrong key must fail; got nil") + } +} + +func TestSaveIsAtomic(t *testing.T) { + t.Parallel() + // Sanity check: after Save(), there are no leftover .tmp files. + dir := t.TempDir() + path := filepath.Join(dir, "secrets.enc") + st, err := New(path, freshKey(t)) + if err != nil { + t.Fatalf("new: %v", err) + } + if err := st.Save(Repo{URL: "x", Password: "y"}); err != nil { + t.Fatalf("save: %v", err) + } + entries, _ := os.ReadDir(dir) + if len(entries) != 1 { + var names []string + for _, e := range entries { + names = append(names, e.Name()) + } + t.Errorf("dir should hold one file post-save, got %v", names) + } +}