v0.107.0: key-auth-first bridge + staged-secret wipe on escrow confirm

Key-auth-first: a KeyAuthProber seam lets the bridge skip consume+install
when the already-installed key still authenticates (pinned to the freshly
verified host key) — descriptor changes on provisioned guests no longer
loop on consume-404. Fingerprint verify still precedes everything.

Wipe-on-escrowed: confirm-escrow now calls the agent's new
DELETE /escrow/stage-secret (v0.78.0) best-effort, closing the hygiene gap
where a ceremony-less confirm left the staged password file behind.

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:26:53 +02:00
parent 42af088308
commit a38c743926
9 changed files with 241 additions and 1 deletions
@@ -1,6 +1,9 @@
package web
import (
"bytes"
"context"
"errors"
"io"
"log"
"net/http/httptest"
@@ -67,6 +70,45 @@ func TestOffboxWeb_RunGatedUntilConfirm(t *testing.T) {
}
}
// Scenario E — the confirm flip wipes the agent-staged secret; a wipe failure is logged loudly but does
// NOT fail the confirm (the state flip is the primary effect).
func TestOffboxWeb_ConfirmWipesStagedSecret(t *testing.T) {
s, sett, m := newOffboxWebServer(t)
if err := m.WriteOffboxSecrets("KEYMATERIAL", "nas.local ssh-ed25519 HOSTKEY"); err != nil {
t.Fatal(err)
}
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", Schedule: "daily",
EscrowState: "pending",
}); err != nil {
t.Fatal(err)
}
wipes := 0
s.wipeStagedEscrowFn = func(context.Context) error { wipes++; return nil }
w := httptest.NewRecorder()
s.offboxConfirmEscrowHandler(w, httptest.NewRequest("POST", "/backup/offbox/confirm-escrow", nil))
if w.Code != 302 || sett.GetOffboxTarget().EscrowState != "escrowed" {
t.Fatalf("confirm failed: code=%d state=%q", w.Code, sett.GetOffboxTarget().EscrowState)
}
if wipes != 1 {
t.Fatalf("confirm must wipe the staged secret exactly once, got %d", wipes)
}
// wipe failure → confirm still succeeds (best-effort), loud ERROR logged
var logbuf bytes.Buffer
s.logger = log.New(&logbuf, "", 0)
_ = sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.EscrowState = "pending" })
s.wipeStagedEscrowFn = func(context.Context) error { return errors.New("agent unreachable") }
w2 := httptest.NewRecorder()
s.offboxConfirmEscrowHandler(w2, httptest.NewRequest("POST", "/backup/offbox/confirm-escrow", nil))
if w2.Code != 302 || sett.GetOffboxTarget().EscrowState != "escrowed" {
t.Fatal("a wipe failure must NOT fail the confirm (state flip is primary)")
}
if !strings.Contains(logbuf.String(), "NOT wiped") {
t.Fatal("a failed wipe must log the loud NOT-wiped signal")
}
}
// The inject endpoint pre-places a recovered password (DR seam).
func TestOffboxWeb_InjectPassword(t *testing.T) {
s, _, _ := newOffboxWebServer(t)
@@ -122,9 +122,30 @@ func (s *Server) offboxConfirmEscrowHandler(w http.ResponseWriter, r *http.Reque
return
}
s.logger.Printf("[INFO] [web] off-box escrow confirmed — offsite runs enabled")
// Fork-4 hygiene: the staged copy on the agent has served its purpose — wipe it. Best-effort: a wipe
// failure is logged LOUDLY but does not fail the confirm (the state flip is the primary effect; a
// lingering file is a hygiene gap, not a correctness one — re-confirm retries the wipe).
if err := s.wipeStagedEscrow(r.Context()); err != nil {
s.logger.Printf("[ERROR] [web] escrow confirmed but the agent-staged secret was NOT wiped (re-confirm to retry): %v", err)
}
offboxRedirect(w, r, "A kulcs letétbe helyezése megerősítve — a NAS-mentés mostantól futhat.", false)
}
// wipeStagedEscrow calls the injected seam (tests), else the agent's DELETE /escrow/stage-secret over the
// pinned local-API channel (agent >= v0.78.0).
func (s *Server) wipeStagedEscrow(ctx context.Context) error {
if s.wipeStagedEscrowFn != nil {
return s.wipeStagedEscrowFn(ctx)
}
client, err := s.agentClient()
if err != nil {
return err
}
wctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
return client.WipeStagedEscrowSecret(wctx)
}
// offboxInjectPasswordHandler pre-places a RECOVERED repo password at the offbox password path (fork-4 DR
// seam) so a subsequent configure uses it and the existing offsite repo opens. Operator/DR only; the value
// is never logged. Body: {password, force?}.
+5
View File
@@ -2,6 +2,7 @@ package web
import (
"bytes"
"context"
"fmt"
"html/template"
"log"
@@ -63,6 +64,10 @@ type Server struct {
// Hub push status callback — set via SetHubPushStatus for monitoring page
hubPushStatusFn func() HubPushStatusData
// Fork-4 hygiene seam: wipes the agent-staged offsite repo password when EscrowState flips to
// escrowed (DELETE /escrow/stage-secret). nil → the default agentClient()-backed impl; tests inject.
wipeStagedEscrowFn func(ctx context.Context) error
// Asset syncer for Hub-managed assets (optional)
assetsSyncer *assets.Syncer