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:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user