v0.78.0: DELETE /escrow/stage-secret — idempotent staged-secret wipe

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-09 22:19:10 +02:00
parent 4449118783
commit 98ea1ed49c
4 changed files with 70 additions and 0 deletions
+20
View File
@@ -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")
}
}
+37
View File
@@ -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
+2
View File
@@ -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
}