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:
2026-07-09 15:13:59 +02:00
parent bde43f3a74
commit 0b09a799cb
14 changed files with 498 additions and 5 deletions
+73
View File
@@ -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
+130
View File
@@ -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 {