27086783da
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>
100 lines
2.2 KiB
Go
100 lines
2.2 KiB
Go
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)
|
|
}
|
|
}
|