v0.105.0: fork-4 offsite password custody — hand-off + atomicity gate + DR inject + coord
Pairs with agent v0.77.0. StageEscrowSecret pushes the repo password to the agent (POST /escrow/stage-secret) at offsite-enable → EscrowState="pending". Atomicity gate: RunOffboxBackup (scheduler + handler) refuses until EscrowState="escrowed" (operator POST /backup/offbox/confirm-escrow after the escrow ceremony) — no un-recoverable offsite ciphertext can exist. DR: POST /backup/offbox/inject-password pre-places a recovered 64-hex password 0600 (honored by WriteOffboxSecrets' IsNotExist guard; refuses clobber without force). DR recipe gains non-secret offsite_restic coords (DRResticCoord); SFTP key regenerated at DR, not escrowed. New settings.OffboxTarget.EscrowState. Tests + atomicity & inject companion red-proofs green; UI gates pass. NOT yet live-validated (supervised ceremony). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -161,6 +161,73 @@ func (m *Manager) OffboxConfigured() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// offboxRepoPwPattern matches a valid restic repo password (generateOffboxPassword = 32 rand bytes → 64 hex).
|
||||
var offboxRepoPwPattern = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
|
||||
|
||||
// PushOffboxPasswordForEscrow reads the 0600 repo password and hands it to `stage` (the agent push), so
|
||||
// the web/handler caller never sees the value — used by the enable flow to escrow-stage the offsite key.
|
||||
func (m *Manager) PushOffboxPasswordForEscrow(ctx context.Context, stage func(ctx context.Context, pw string) error) error {
|
||||
pw, err := os.ReadFile(m.offboxPwPath())
|
||||
if err != nil {
|
||||
return fmt.Errorf("read offbox password: %w", err)
|
||||
}
|
||||
return stage(ctx, strings.TrimSpace(string(pw)))
|
||||
}
|
||||
|
||||
// InjectOffboxPassword pre-places a RECOVERED repo password at offboxPwPath (fork-4 DR seam) so a
|
||||
// subsequent WriteOffboxSecrets uses it instead of generating a new one. Refuses to clobber an existing
|
||||
// password unless force. Written 0600 via tmp+rename. The value is NEVER logged.
|
||||
func (m *Manager) InjectOffboxPassword(pw string, force bool) error {
|
||||
pw = strings.TrimSpace(pw)
|
||||
if !offboxRepoPwPattern.MatchString(pw) {
|
||||
return fmt.Errorf("invalid repo password (expected 64 hex characters)")
|
||||
}
|
||||
if _, err := os.Stat(m.offboxPwPath()); err == nil && !force {
|
||||
return fmt.Errorf("a repo password already exists (pass force to overwrite)")
|
||||
}
|
||||
if err := os.MkdirAll(m.offboxDir(), 0o700); err != nil {
|
||||
return fmt.Errorf("offbox dir: %w", err)
|
||||
}
|
||||
tmp := m.offboxPwPath() + ".tmp"
|
||||
if err := os.WriteFile(tmp, []byte(pw), 0o600); err != nil {
|
||||
return fmt.Errorf("write injected password: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, m.offboxPwPath()); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("place injected password: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// offboxEscrowed reports whether the offsite repo password is confirmed escrowed under R (fork-4).
|
||||
func (m *Manager) offboxEscrowed() bool {
|
||||
t := m.settings.GetOffboxTarget()
|
||||
return t != nil && t.EscrowState == "escrowed"
|
||||
}
|
||||
|
||||
// OffboxRunnable reports whether an off-box RUN may proceed: configured AND escrowed. Config/UI still work
|
||||
// when not runnable — only actual backup writes are gated (the atomicity guarantee). For the run handler.
|
||||
func (m *Manager) OffboxRunnable() bool { return m.OffboxConfigured() && m.offboxEscrowed() }
|
||||
|
||||
// OffboxCoord returns the non-secret offsite repo coordinates for the DR recipe (fork-4). ok=false when no
|
||||
// offbox target is configured. NEVER returns the repo password or the SSH key (those are escrowed/regenerable).
|
||||
func (m *Manager) OffboxCoord() (host, user string, port int, repoPath string, ok bool) {
|
||||
t := m.settings.GetOffboxTarget()
|
||||
if t == nil || t.Host == "" || t.User == "" || t.RepoPath == "" {
|
||||
return "", "", 0, "", false
|
||||
}
|
||||
return t.Host, t.User, t.Port, t.RepoPath, true
|
||||
}
|
||||
|
||||
// OffboxEscrowState returns the current escrow state ("" | "pending" | "escrowed") for the UI/handlers.
|
||||
func (m *Manager) OffboxEscrowState() string {
|
||||
t := m.settings.GetOffboxTarget()
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.EscrowState
|
||||
}
|
||||
|
||||
// offboxBaseArgs builds the restic global args (repo + sftp.args carrying the ConnectTimeout, key, pinned
|
||||
// known_hosts, port) and the env (RESTIC_PASSWORD_FILE). The ConnectTimeout is MANDATORY (fail-fast).
|
||||
func (m *Manager) offboxBaseArgs(t *settings.OffboxTarget) ([]string, []string) {
|
||||
@@ -212,6 +279,12 @@ func (m *Manager) RunOffboxBackup(ctx context.Context) error {
|
||||
if !m.OffboxConfigured() {
|
||||
return fmt.Errorf("off-box backup not configured")
|
||||
}
|
||||
// fork-4 atomicity gate: no offsite RUN until the repo password is confirmed escrowed under R, so no
|
||||
// un-recoverable offsite ciphertext can exist. Not an error (config/UI still work) — a skip.
|
||||
if !m.offboxEscrowed() {
|
||||
m.logger.Printf("[INFO] [offbox] skipped — pending key escrow (no offsite run until the repo password is escrowed under R)")
|
||||
return nil
|
||||
}
|
||||
if m.migrationActive() {
|
||||
m.logger.Printf("[INFO] [offbox] skipped — migration in progress")
|
||||
return nil
|
||||
|
||||
@@ -32,6 +32,7 @@ func newOffboxManager(t *testing.T) (*Manager, *settings.Settings) {
|
||||
m := NewManager(cfg, sett, logger)
|
||||
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
|
||||
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", Schedule: "daily",
|
||||
EscrowState: "escrowed", // fork-4: default the harness to escrowed so behavioral run tests exercise the run path
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -498,6 +499,135 @@ func TestOffbox_NoAppsToggledIsCleanOK(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- fork-4: atomicity gate + DR inject + coord ---
|
||||
|
||||
// setPending overrides the harness's escrowed default to pending.
|
||||
func setEscrowState(t *testing.T, sett *settings.Settings, state string) {
|
||||
t.Helper()
|
||||
if err := sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.EscrowState = state }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario A — a toggled app with a present unit is NOT backed up while escrow is pending (atomicity).
|
||||
func TestOffbox_PendingEscrowBlocksRun(t *testing.T) {
|
||||
m, sett := newOffboxManager(t)
|
||||
setEscrowState(t, sett, "pending")
|
||||
usb := t.TempDir()
|
||||
addSchedulablePath(t, sett, usb)
|
||||
writeUnit(t, m.namespaceRoot(usb), "app1", "2026-07-01T00:00:00Z")
|
||||
_ = sett.SetAppOffbox("app1", true)
|
||||
rr := &recordingOffboxRunner{}
|
||||
m.SetOffboxRunner(rr.run)
|
||||
if err := m.RunOffboxBackup(context.Background()); err != nil {
|
||||
t.Fatalf("pending escrow must be a clean skip, got %v", err)
|
||||
}
|
||||
if len(rr.backupSrc) != 0 {
|
||||
t.Fatalf("NO offsite backup may run while escrow is pending, got %v", rr.backupSrc)
|
||||
}
|
||||
if !m.OffboxConfigured() {
|
||||
t.Fatal("config must still be valid while pending (only RUNS are gated)")
|
||||
}
|
||||
if m.OffboxRunnable() {
|
||||
t.Fatal("OffboxRunnable must be false while pending")
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario B — confirming escrow flips to escrowed and the run then proceeds.
|
||||
func TestOffbox_ConfirmEscrowEnablesRun(t *testing.T) {
|
||||
m, sett := newOffboxManager(t)
|
||||
setEscrowState(t, sett, "pending")
|
||||
usb := t.TempDir()
|
||||
addSchedulablePath(t, sett, usb)
|
||||
writeUnit(t, m.namespaceRoot(usb), "app1", "2026-07-01T00:00:00Z")
|
||||
_ = sett.SetAppOffbox("app1", true)
|
||||
rr := &recordingOffboxRunner{}
|
||||
m.SetOffboxRunner(rr.run)
|
||||
if err := m.RunOffboxBackup(context.Background()); err != nil || len(rr.backupSrc) != 0 {
|
||||
t.Fatalf("must be blocked while pending (err=%v src=%v)", err, rr.backupSrc)
|
||||
}
|
||||
setEscrowState(t, sett, "escrowed")
|
||||
if !m.OffboxRunnable() {
|
||||
t.Fatal("must be runnable after confirm")
|
||||
}
|
||||
if err := m.RunOffboxBackup(context.Background()); err != nil {
|
||||
t.Fatalf("run after confirm: %v", err)
|
||||
}
|
||||
if len(rr.backupSrc) != 1 {
|
||||
t.Fatalf("must back up after confirm, got %v", rr.backupSrc)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario C — a pre-placed (DR-injected) recovered password is honored, not regenerated.
|
||||
func TestOffbox_InjectPasswordPrePlaced(t *testing.T) {
|
||||
m, _ := newOffboxManager(t)
|
||||
_ = os.Remove(m.offboxPwPath()) // simulate a fresh controller (no password yet)
|
||||
const recovered = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
if err := m.InjectOffboxPassword(recovered, false); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
if err := m.WriteOffboxSecrets("newkey", "nas.local ssh-ed25519 NEWKEY"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := os.ReadFile(m.offboxPwPath())
|
||||
if string(got) != recovered {
|
||||
t.Fatalf("injected password was overwritten (len now %d) — the existing repo would be unopenable", len(got))
|
||||
}
|
||||
// refuse to clobber an existing password without force
|
||||
if err := m.InjectOffboxPassword("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", false); err == nil {
|
||||
t.Fatal("inject must refuse to clobber an existing password without force")
|
||||
}
|
||||
// force overwrites
|
||||
const forced = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
|
||||
if err := m.InjectOffboxPassword(forced, true); err != nil {
|
||||
t.Fatalf("force inject: %v", err)
|
||||
}
|
||||
if got2, _ := os.ReadFile(m.offboxPwPath()); string(got2) != forced {
|
||||
t.Fatal("force inject must overwrite")
|
||||
}
|
||||
// invalid (non-hex / wrong length) rejected
|
||||
if err := m.InjectOffboxPassword("not-a-valid-hex-password", false); err == nil {
|
||||
t.Fatal("an invalid repo password must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// Companion to C — WITHOUT inject, WriteOffboxSecrets generates a DIFFERENT password (so the recovered
|
||||
// one is load-bearing: a fresh gen could never open the existing offsite repo).
|
||||
func TestOffbox_NoInjectGeneratesDifferentPassword(t *testing.T) {
|
||||
m, _ := newOffboxManager(t)
|
||||
_ = os.Remove(m.offboxPwPath())
|
||||
const recovered = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
if err := m.WriteOffboxSecrets("k", "kh"); err != nil { // no inject → generates fresh
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, rerr := os.ReadFile(m.offboxPwPath())
|
||||
if rerr != nil {
|
||||
t.Fatal(rerr)
|
||||
}
|
||||
got := strings.TrimSpace(string(raw))
|
||||
if got == recovered {
|
||||
t.Fatal("the freshly generated password coincided with the recovered one (impossible with 256-bit entropy)")
|
||||
}
|
||||
if len(got) != 64 {
|
||||
t.Fatalf("generated repo password must be 64 hex, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario E (backup half) — OffboxCoord returns the non-secret coordinates, ok=false when unconfigured.
|
||||
func TestOffbox_CoordForDR(t *testing.T) {
|
||||
m, sett := newOffboxManager(t)
|
||||
host, user, port, repo, ok := m.OffboxCoord()
|
||||
if !ok || host != "nas.local" || user != "felhom" || port != 22 || repo != "/srv/repo" {
|
||||
t.Fatalf("coord = %s/%s/%d/%s ok=%v", host, user, port, repo, ok)
|
||||
}
|
||||
if err := sett.SetOffboxTarget(&settings.OffboxTarget{}); err != nil { // empty target
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, _, ok := m.OffboxCoord(); ok {
|
||||
t.Fatal("an unconfigured target must yield ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
func runtimeIsUnix() bool { return os.PathSeparator == '/' }
|
||||
|
||||
func contains(ss []string, want string) bool {
|
||||
|
||||
Reference in New Issue
Block a user