From 98ea1ed49c3756ca9e0b9f6870a8946ceea37ff2 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 9 Jul 2026 22:19:10 +0200 Subject: [PATCH] =?UTF-8?q?v0.78.0:=20DELETE=20/escrow/stage-secret=20?= =?UTF-8?q?=E2=80=94=20idempotent=20staged-secret=20wipe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The staged offsite repo password was wiped only by the escrow-create ceremony; a confirm without a fresh ceremony (password already escrowed) left the 0600 staged file behind. The controller calls this on every EscrowState flip to escrowed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6 --- CHANGELOG.md | 11 ++++++++ internal/localapi/escrow_stage.go | 20 ++++++++++++++ internal/localapi/escrow_stage_test.go | 37 ++++++++++++++++++++++++++ internal/localapi/server.go | 2 ++ 4 files changed, 70 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eee782..65ffabd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## v0.78.0 — fork-4 hygiene: DELETE /escrow/stage-secret (staged-secret wipe) (2026-07-09) + +Part of the offsite-provisioning hardening bundle (pairs with controller v0.107.0 + hub v0.39.0). The staged +restic repo password was wiped only by the escrow-create ceremony; a confirm WITHOUT a fresh ceremony (the +password already escrowed — the live e2e's Option A) left the 0600 staged file behind indefinitely. + +- `internal/localapi`: `DELETE /escrow/stage-secret` (withGuest) — removes the staged file (+ any stale + `.tmp` partial). **Idempotent**: absent file → clean 200 `{removed:false}`. The controller calls it + whenever `EscrowState` flips to `escrowed`. No value ever logged (nothing to log — it's a removal). +- Test: stage → wipe (file GONE) → re-wipe idempotent → 401 unauthenticated. + ## v0.77.0 — fork-4: escrow the offsite restic repo password under R (2026-07-09) Makes the restic-offsite repo password recoverable at DR by riding the existing customer-recovery-code (R) diff --git a/internal/localapi/escrow_stage.go b/internal/localapi/escrow_stage.go index f967222..00bba77 100644 --- a/internal/localapi/escrow_stage.go +++ b/internal/localapi/escrow_stage.go @@ -55,3 +55,23 @@ func (s *Server) handleStageEscrowSecret(w http.ResponseWriter, r *http.Request, s.logger.Info("local-api: staged offsite restic repo password for escrow (field name only)", "vmid", vmid) writeOK(w, map[string]any{"staged": true}) } + +// handleWipeStagedEscrowSecret removes the staged offsite secret once its escrow is confirmed (or on +// operator cleanup). Idempotent: an absent file is a clean 200 {removed:false}. Closes the fork-4 hygiene +// gap where a confirm WITHOUT a fresh ceremony (the password was already escrowed) left the staged 0600 +// file behind indefinitely. +func (s *Server) handleWipeStagedEscrowSecret(w http.ResponseWriter, r *http.Request, vmid int) { + path := s.escrowStagePath + _ = os.Remove(path + ".tmp") // a stale partial from an interrupted stage, if any + err := os.Remove(path) + switch { + case err == nil: + s.logger.Info("local-api: wiped staged escrow secret", "vmid", vmid) + writeOK(w, map[string]any{"removed": true}) + case os.IsNotExist(err): + writeOK(w, map[string]any{"removed": false}) + default: + s.logger.Error("local-api: wipe staged escrow secret", "vmid", vmid, "err", err) + writeErr(w, http.StatusInternalServerError, "could not remove the staged secret") + } +} diff --git a/internal/localapi/escrow_stage_test.go b/internal/localapi/escrow_stage_test.go index ada8531..3dc8e56 100644 --- a/internal/localapi/escrow_stage_test.go +++ b/internal/localapi/escrow_stage_test.go @@ -66,6 +66,43 @@ func TestStageEscrowSecret_StagesScopesAndHidesValue(t *testing.T) { } } +// TestWipeStagedEscrowSecret: DELETE removes the staged file (EFFECT: file gone), is idempotent when +// absent, and requires auth. +func TestWipeStagedEscrowSecret(t *testing.T) { + srv := newTestServerS(t, &fakeGuests{}, &fakeBackups{}, &fakeStore{}, nil) + stagePath := filepath.Join(t.TempDir(), "escrow-stage", "restic_repo_password") + srv.escrowStagePath = stagePath + h := srv.Handler() + + // stage something first + if w := do(t, h, "POST", "/escrow/stage-secret", "A", `{"vmid":8200,"restic_repo_password":"deadbeef"}`); w.Code != 200 { + t.Fatalf("stage: got %d", w.Code) + } + if _, err := os.Stat(stagePath); err != nil { + t.Fatalf("staged file must exist before the wipe: %v", err) + } + + // wipe → removed:true, file gone + w := do(t, h, "DELETE", "/escrow/stage-secret", "A", "") + if w.Code != 200 || !strings.Contains(w.Body.String(), `"removed":true`) { + t.Fatalf("wipe: got %d body=%s", w.Code, w.Body.String()) + } + if _, err := os.Stat(stagePath); !os.IsNotExist(err) { + t.Fatal("staged file must be GONE after the wipe") + } + + // idempotent: wipe again → 200 removed:false + w2 := do(t, h, "DELETE", "/escrow/stage-secret", "A", "") + if w2.Code != 200 || !strings.Contains(w2.Body.String(), `"removed":false`) { + t.Fatalf("re-wipe must be a clean idempotent 200 removed:false, got %d body=%s", w2.Code, w2.Body.String()) + } + + // auth required + if w3 := do(t, h, "DELETE", "/escrow/stage-secret", "NOPE", ""); w3.Code != 401 { + t.Fatalf("unauthenticated wipe must be 401, got %d", w3.Code) + } +} + // TestStageEscrowSecret_NoSecretInLog: the staged value never appears in a log line (hygiene is load-bearing). func TestStageEscrowSecret_NoSecretInLog(t *testing.T) { var logbuf bytes.Buffer diff --git a/internal/localapi/server.go b/internal/localapi/server.go index 495bb2f..b948a1e 100644 --- a/internal/localapi/server.go +++ b/internal/localapi/server.go @@ -314,6 +314,8 @@ func (s *Server) Handler() http.Handler { // fork-4: stage the controller-pushed offsite restic repo password for the escrow-create ceremony. mux.HandleFunc("POST /escrow/stage-secret", s.withGuest(s.handleStageEscrowSecret)) + // fork-4 hygiene: wipe the staged secret once escrowed (controller calls this on confirm). Idempotent. + mux.HandleFunc("DELETE /escrow/stage-secret", s.withGuest(s.handleWipeStagedEscrowSecret)) return mux }