From b7033fcfcd1e040d4ae8b2d7e0cc33c09d3c5657 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 3 May 2026 22:34:33 +0100 Subject: [PATCH] agent/secrets: separate admin slot with backwards-compatible decode Split the on-disk bundle into repo + admin slots. Legacy flat Repo blobs are detected at load time by the presence of "repo_url" at the top level and transparently promoted into the new shape on the next Save/SaveAdmin. Adds ErrNoAdmin sentinel, LoadAdmin, SaveAdmin, and three new tests. --- internal/agent/secrets/secrets.go | 101 +++++++++++++++--- internal/agent/secrets/secrets_test.go | 138 +++++++++++++++++++++++++ 2 files changed, 224 insertions(+), 15 deletions(-) diff --git a/internal/agent/secrets/secrets.go b/internal/agent/secrets/secrets.go index f5bbc82..a06dcce 100644 --- a/internal/agent/secrets/secrets.go +++ b/internal/agent/secrets/secrets.go @@ -9,6 +9,7 @@ package secrets import ( + "bytes" "encoding/json" "errors" "fmt" @@ -24,6 +25,11 @@ import ( // 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"` @@ -35,6 +41,15 @@ type Repo struct { // 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 { @@ -55,32 +70,47 @@ func New(path string, key []byte) (*Store, error) { 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) { +// 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 Repo{}, nil + return bundle{}, nil } - return Repo{}, fmt.Errorf("secrets: read %q: %w", s.path, err) + return bundle{}, 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) + return bundle{}, 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) + + // 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) } - return r, nil + + // 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 } -// 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) +// 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) } @@ -115,3 +145,44 @@ func (s *Store) Save(r Repo) error { } 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, _ := s.loadBundle() // ignore read errors; we overwrite repo slot + 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, _ := s.loadBundle() // ignore read errors; we overwrite admin slot + b.Admin = &r + return s.saveBundle(b) +} diff --git a/internal/agent/secrets/secrets_test.go b/internal/agent/secrets/secrets_test.go index 07fa57b..20856b1 100644 --- a/internal/agent/secrets/secrets_test.go +++ b/internal/agent/secrets/secrets_test.go @@ -2,6 +2,8 @@ package secrets import ( "crypto/rand" + "encoding/json" + "errors" "io" "os" "path/filepath" @@ -97,3 +99,139 @@ func TestSaveIsAtomic(t *testing.T) { t.Errorf("dir should hold one file post-save, got %v", names) } } + +func TestSecretsLoadAdminEmpty(t *testing.T) { + t.Parallel() + // No file yet: LoadAdmin must return ErrNoAdmin, not a hard error. + dir := t.TempDir() + path := filepath.Join(dir, "secrets.enc") + st, err := New(path, freshKey(t)) + if err != nil { + t.Fatalf("new: %v", err) + } + _, err = st.LoadAdmin() + if !errors.Is(err, ErrNoAdmin) { + t.Errorf("expected ErrNoAdmin, got %v", err) + } +} + +func TestSecretsAdminSlotIndependent(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) + } + + repo := Repo{URL: "rest:https://repo/host", Username: "user", Password: "pw"} + admin := Repo{URL: "rest:https://repo/host", Username: "admin", Password: "adminpw"} + + if err := st.Save(repo); err != nil { + t.Fatalf("save repo: %v", err) + } + if err := st.SaveAdmin(admin); err != nil { + t.Fatalf("save admin: %v", err) + } + + // Load returns the repo slot unchanged. + gotRepo, err := st.Load() + if err != nil { + t.Fatalf("load: %v", err) + } + if gotRepo != repo { + t.Errorf("repo slot mismatch: got %+v want %+v", gotRepo, repo) + } + + // LoadAdmin returns the admin slot. + gotAdmin, err := st.LoadAdmin() + if err != nil { + t.Fatalf("load admin: %v", err) + } + if gotAdmin != admin { + t.Errorf("admin slot mismatch: got %+v want %+v", gotAdmin, admin) + } + + // SaveAdmin a second time replaces admin only; repo unchanged. + admin2 := Repo{URL: "rest:https://repo/host", Username: "admin2", Password: "pw2"} + if err := st.SaveAdmin(admin2); err != nil { + t.Fatalf("save admin2: %v", err) + } + gotRepo2, err := st.Load() + if err != nil { + t.Fatalf("load after admin2 save: %v", err) + } + if gotRepo2 != repo { + t.Errorf("repo slot changed unexpectedly: got %+v want %+v", gotRepo2, repo) + } + gotAdmin2, err := st.LoadAdmin() + if err != nil { + t.Fatalf("load admin2: %v", err) + } + if gotAdmin2 != admin2 { + t.Errorf("admin2 slot mismatch: got %+v want %+v", gotAdmin2, admin2) + } +} + +func TestSecretsLegacyFlatBlobMigrates(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "secrets.enc") + key := freshKey(t) + + // Write a legacy flat Repo blob directly — bypassing bundle wrapping. + legacy := Repo{URL: "rest:https://legacy/host", Username: "legacyuser", Password: "legacypw"} + plain, err := json.Marshal(legacy) + if err != nil { + t.Fatalf("marshal legacy: %v", err) + } + a, err := crypto.NewAEAD(key) + if err != nil { + t.Fatalf("aead: %v", err) + } + ct, err := a.Encrypt(plain, []byte(additionalData)) + if err != nil { + t.Fatalf("encrypt legacy: %v", err) + } + if err := os.WriteFile(path, []byte(ct), 0o600); err != nil { + t.Fatalf("write legacy file: %v", err) + } + + // Open via secrets.New + Load — must return the legacy Repo. + st, err := New(path, key) + if err != nil { + t.Fatalf("new: %v", err) + } + got, err := st.Load() + if err != nil { + t.Fatalf("load legacy: %v", err) + } + if got != legacy { + t.Errorf("legacy decode mismatch: got %+v want %+v", got, legacy) + } + + // SaveAdmin should write both slots; re-opening must have both. + admin := Repo{URL: "rest:https://legacy/host", Username: "admin", Password: "adminpw"} + if err := st.SaveAdmin(admin); err != nil { + t.Fatalf("save admin after legacy: %v", err) + } + + st2, err := New(path, key) + if err != nil { + t.Fatalf("reopen: %v", err) + } + gotRepo, err := st2.Load() + if err != nil { + t.Fatalf("load repo after migration: %v", err) + } + if gotRepo != legacy { + t.Errorf("repo after migration: got %+v want %+v", gotRepo, legacy) + } + gotAdmin, err := st2.LoadAdmin() + if err != nil { + t.Fatalf("load admin after migration: %v", err) + } + if gotAdmin != admin { + t.Errorf("admin after migration: got %+v want %+v", gotAdmin, admin) + } +}