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