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
|
||||
|
||||
Reference in New Issue
Block a user