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.
This commit is contained in:
@@ -9,6 +9,7 @@
|
|||||||
package secrets
|
package secrets
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -24,6 +25,11 @@ import (
|
|||||||
// depth — the key is per-host today, but cheap to be careful.)
|
// depth — the key is per-host today, but cheap to be careful.)
|
||||||
const additionalData = "rm-agent-repo-creds-v1"
|
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.
|
// Repo is the plaintext shape persisted inside the AEAD blob.
|
||||||
type Repo struct {
|
type Repo struct {
|
||||||
URL string `json:"repo_url,omitempty"`
|
URL string `json:"repo_url,omitempty"`
|
||||||
@@ -35,6 +41,15 @@ type Repo struct {
|
|||||||
// minimum (URL + password) needed to run a backup.
|
// minimum (URL + password) needed to run a backup.
|
||||||
func (r Repo) Empty() bool { return r.URL == "" || r.Password == "" }
|
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
|
// Store reads and writes the encrypted secrets file at Path, sealed
|
||||||
// under the 32-byte key Key.
|
// under the 32-byte key Key.
|
||||||
type Store struct {
|
type Store struct {
|
||||||
@@ -55,32 +70,47 @@ func New(path string, key []byte) (*Store, error) {
|
|||||||
return &Store{path: path, a: a}, nil
|
return &Store{path: path, a: a}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load returns the persisted Repo, or a zero-value Repo (with no
|
// loadBundle reads and decrypts the on-disk blob, returning a bundle.
|
||||||
// error) if the file does not exist yet — first-run agents have
|
// It handles back-compat decode: legacy flat Repo blobs are detected
|
||||||
// nothing on disk until the server pushes a config.update.
|
// by the presence of a top-level "repo_url" key and re-wrapped into
|
||||||
func (s *Store) Load() (Repo, error) {
|
// 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)
|
body, err := os.ReadFile(s.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
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))
|
plain, err := s.a.Decrypt(string(body), []byte(additionalData))
|
||||||
if err != nil {
|
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 {
|
// Try the new bundle shape first.
|
||||||
return Repo{}, fmt.Errorf("secrets: parse %q: %w", s.path, err)
|
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
|
// saveBundle marshals b, encrypts it and writes it atomically at
|
||||||
// directory must already exist (the install script lays it down).
|
// mode 0600. Parent directory must already exist.
|
||||||
func (s *Store) Save(r Repo) error {
|
func (s *Store) saveBundle(b bundle) error {
|
||||||
body, err := json.Marshal(r)
|
body, err := json.Marshal(b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("secrets: marshal: %w", err)
|
return fmt.Errorf("secrets: marshal: %w", err)
|
||||||
}
|
}
|
||||||
@@ -115,3 +145,44 @@ func (s *Store) Save(r Repo) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package secrets
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -97,3 +99,139 @@ func TestSaveIsAtomic(t *testing.T) {
|
|||||||
t.Errorf("dir should hold one file post-save, got %v", names)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user