P1-33: agent-side encrypted secrets store + push-on-update
CI / Test (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Build (windows/amd64) (push) Has been cancelled
CI / Build (linux/amd64) (push) Has been cancelled
CI / Build (linux/arm64) (push) Has been cancelled

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>
This commit is contained in:
2026-05-01 12:41:28 +01:00
parent 0ba56ed30d
commit ec276dbc91
5 changed files with 482 additions and 29 deletions
+72 -8
View File
@@ -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 {