From 08a966b92f0b953050a3181c9f11132cdffae4c5 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 13 Jul 2026 19:01:31 +0200 Subject: [PATCH] =?UTF-8?q?v0.127.0:=20customer-facing=20escrow=20ceremony?= =?UTF-8?q?=20wizard=20(/backup/escrow)=20+=20Scenario-F=20stale-blob=20re?= =?UTF-8?q?-check=20=E2=80=94=20one-shot=20R=20reveal=20(no-store,=20typed?= =?UTF-8?q?-back),=20re-stage-first=20start=20order,=20agent=20version=20g?= =?UTF-8?q?ate=20(MinAgent=200.88.0),=20escrowed-state=20hash=20re-check?= =?UTF-8?q?=20with=20card=20warning=20(never=20flips,=20never=20blocks);?= =?UTF-8?q?=20manual-confirm=20button=20removed=20(endpoint=20stays=20depr?= =?UTF-8?q?ecated)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 40 ++ REUSE.md | 1 + controller/README.md | 23 +- controller/cmd/controller/main.go | 19 +- controller/internal/agentapi/escrow.go | 119 +++++ controller/internal/report/escrow_confirm.go | 85 +++- .../internal/report/escrow_stale_test.go | 149 +++++++ controller/internal/web/escrow_handlers.go | 302 +++++++++++++ controller/internal/web/escrow_wizard_test.go | 415 ++++++++++++++++++ controller/internal/web/handlers.go | 7 + controller/internal/web/server.go | 11 + .../web/templates/backups_escrow.html | 238 ++++++++++ .../web/templates/backups_remote.html | 26 +- 13 files changed, 1419 insertions(+), 16 deletions(-) create mode 100644 controller/internal/agentapi/escrow.go create mode 100644 controller/internal/report/escrow_stale_test.go create mode 100644 controller/internal/web/escrow_handlers.go create mode 100644 controller/internal/web/escrow_wizard_test.go create mode 100644 controller/internal/web/templates/backups_escrow.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cf9479..901b533 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ ## Changelog +### v0.127.0 — customer-facing escrow ceremony wizard + stale-blob re-check (2026-07-13) — MinAgent: 0.88.0 (wizard only; everything else unchanged) + +The missing friend-alpha piece: the recovery-code ceremony moves from operator-SSH to a +customer-driveable wizard (`/backup/escrow`). R is displayed EXACTLY ONCE in the browser +(one-shot claim, typed-back confirm); operator ruling F1 2026-07-13 accepts the single CF-tunnel +transit (same trust class as the claim code — threat model in felhom.eu +RUNBOOK-escrow-ceremony.md). Mechanics validated by SPIKE-controller-escrow-2026-07-13. + +- **Wizard** (`templates/backups_escrow.html` + `web/escrow_handlers.go`): preflight checklist → + warning copy (re-ceremony adds the supersede warning) → password re-auth (rides the LOGIN rate + limiter) → run (poll 2 s) → one-shot reveal ("Ez a kód többé nem jeleníthető meg.") → + typed-back (two random words, client-side only — R never leaves the page's JS scope; no + copy-to-clipboard by design) → finish. Void/expired → the honest "újra nem kérhető le" state. + Page + claim response `Cache-Control: no-store`; R is NEVER templated server-side, logged, or + persisted. +- **Start-handler order (load-bearing):** re-auth → **re-stage-first** (offbox configured → + `PushOffboxPasswordForEscrow`; failure ABORTS — a ceremony without the staged secret mints the + forbidden hash-less blob) → agent version gate (`AgentVersion()` ≥ 0.88.0, header absent = + older, fail-closed) → trigger. Every refusal exits with the agent untouched (seam-asserted). +- **agentapi** (`agentapi/escrow.go`): `EscrowPreflight` / `EscrowCeremonyStart` / + `EscrowCeremonyStatus` / `EscrowCeremonyClaim` (status-aware; 410 = void; claim body never + logged) over the existing envelope helpers. +- **Scenario F — stale-blob re-check** (`report/escrow_confirm.go`): `Reconcile` no longer + early-returns on non-pending; an ESCROWED box compares the ACK hash every cycle — mismatch OR + a present blob with an EMPTY hash (the spike's hash-less supersession) sets an in-memory stale + flag (surfaced on the Távoli mentés card: "A letétben lévő helyreállítási csomag nem fedi a + jelenlegi távoli mentési jelszót") + ONE warn per distinct hub hash (`warnedHash` reuse; + hash-less dedupes under a sentinel). State NEVER flips; runs NEVER block; a matching hash (or + a fresh auto-confirm) clears the flag. NOTE: the live demo's legacy hash-less blob will show + this warning honestly — the wizard is the fix. +- **Card rework** (`templates/backups_remote.html`): the deprecated manual-confirm BUTTON is gone + (the endpoint stays for legacy blobs); states: pending → "Helyreállítási kód szükséges" + CTA; + escrowed+stale → warning + "Új helyreállítási kód készítése"; escrowed clean → secondary link; + agent < 0.88.0 → "az ügynök frissítése szükséges" note, no CTA. +- Tests: call-order (stage BEFORE trigger, from pending AND escrowed), Scenario C no-stage, + security gates (wrong password 401 + rate-limit counter, 429 lockout, passwordless 403, stage + failure 502 pre-trigger, old agent 409, busy 409 — agent seam call-count 0 in each), claim + proxy no-store + 410, §8 stale truth table incl. dedupe + clear, template render states. §10 + red-proofs demonstrated (felhom.eu REPORT). + ### docs — controller.yaml.example: hub api_key literal scrubbed (2026-07-13) The example carried the REAL hub global bearer key (the `manifests/hub.yaml` committed literal, diff --git a/REUSE.md b/REUSE.md index 62a3c6b..8c8bd73 100644 --- a/REUSE.md +++ b/REUSE.md @@ -178,6 +178,7 @@ | `diskAgent` | controller/internal/web/storage_handlers.go | `*agentapi.Client` | `mockAgent` in controller/internal/web/storage_handlers_test.go | | `netAgent` + `Server.netAgentFn/netProbeFn/netListFn` | controller/internal/web/netstorage_job.go (+ server.go fields) | `*agentapi.Client` / `runNetProbe` (linux re-exec) / `agent.ListNetStorage` | `fakeNetAgent` + fn injections in controller/internal/web/netstorage_job_test.go — the NAS add orchestration never shells/TLS-dials in tests | | `Server.agentLogsFn` (func seam) | controller/internal/web/server.go | nil → `agentClient().DebugLogs` (agent GET /debug/logs) | injected in controller/internal/web/observability_test.go (incl. the pre-0.83 typed-404 notice path) | +| `escrowAgent` + `Server.escrowAgentFn/escrowStageFn/escrowStaleFn` | controller/internal/web/escrow_handlers.go (+ server.go fields) | `*agentapi.Client` / `PushOffboxPasswordForEscrow` / `report.EscrowAutoConfirmer.StaleBlob` (SetEscrowStale) | `fakeEscrowAgent` + fn injections in escrow_wizard_test.go — call-ORDER assertions (stage BEFORE trigger) + agent-never-called gates. The claim leg is the ONLY surface R crosses: no-store, never logged, never templated | | `report.SetPendingControllerLog` / `SetControllerLogSource` | controller/internal/report/selftail.go | ACK-armed consume-once self-log pull (the logtail.go shape) | selftail_test.go; source = `logBuffer.Lines`, wired once in main.go | | `util.ParseVersion` / `util.Version.Compare` | controller/internal/util/version.go | THE one semver comparator (house rule: never a second) — selfupdate aliases it; agentapi's MinAgent comparison uses it | rejects pre-release/dev/latest (callers fall back, never trust); numeric compare (0.100 > 0.81) | | `agentapi.AgentVersionReporter` + `featureMinAgent` | controller/internal/agentapi/features.go | version-first Supports (v0.82.0 header channel); probe = fallback for header-less agents | a coupled feature adds BOTH a featureProbes row AND a featureMinAgent row; v0.116.0: `SupportsWithSource` also reports HOW the verdict was reached (version/probe-cache/probe) for the gate log line | diff --git a/controller/README.md b/controller/README.md index af1aa57..43faa98 100644 --- a/controller/README.md +++ b/controller/README.md @@ -795,10 +795,20 @@ not just those with HDD data. Non-HDD apps can configure destination, method, an > real repo). **Atomicity gate:** enabling offsite pushes the password to the agent (`StageEscrowSecret` → > `POST /escrow/stage-secret`) and marks `EscrowState="pending"`; **no offsite backup runs until the escrow > is confirmed** (`OffboxRunnable()`), so an un-recoverable offsite copy can never exist. -> **Operator ceremony (pilot, supervised):** enable offsite (→ pending) → run -> `felhom-agent --selftest=escrow-create --identity-bundle --upload` (the bundle now carries -> the staged restic password, auto-injected, then wiped) → hand the customer the fresh **R** (once; -> supersedes any prior code) → `POST /backup/offbox/confirm-escrow` (or the "Letét megerősítése" button). +> **Ceremony — PRIMARY path (v0.127.0, agent ≥ v0.88.0): the customer wizard** at `/backup/escrow` +> (`web/escrow_handlers.go` + `templates/backups_escrow.html`): preflight (agent +> `GET /escrow/preflight` + version gate) → warnings (re-ceremony adds the supersede copy) → +> password re-auth (login rate limiter) → **re-stage-first** (offbox configured → +> `PushOffboxPasswordForEscrow`; a staging failure ABORTS the start — no hash-less blob, ever) → +> agent job (`POST /escrow/ceremony`, poll `GET /escrow/ceremony/status` @2 s) → **one-shot R +> reveal** (`POST /escrow/ceremony/claim`, `Cache-Control: no-store`; R exists only in the page's +> JS scope; 10-min unclaimed TTL → void, re-run supersedes) → typed-back (two random words, +> client-side) → finish. R is never templated/logged/persisted on either side. +> **Operator fallback (CLI, unchanged text mode):** enable offsite (→ pending) → run +> `felhom-agent --selftest=escrow-create --upload` (the staged restic password auto-injected, +> then wiped) → hand the customer the fresh **R** (once; supersedes any prior code) → the +> auto-confirm flips escrowed hands-free. (The manual "Letét megerősítése" button is GONE from +> the card; the deprecated endpoint remains for legacy hash-less blobs.) > **DR:** recover R → the escrow yields the password → `POST /backup/offbox/inject-password {password}` > pre-places it 0600 → configure offbox → restore. The SFTP access key is **regenerated** at DR (a fresh > sub-account key), NOT escrowed; the DR recipe carries only the non-secret `offsite_restic` @@ -813,6 +823,11 @@ not just those with HDD data. Non-HDD apps can configure destination, method, an > agent-staged secret. The canonical hasher (`backup.HashResticPassword`, trimmed-string sha256) is pinned > by a cross-repo test vector against the agent's. The manual `POST /backup/offbox/confirm-escrow` is a > **deprecated fallback** for legacy hash-less blobs (e.g. the demo's). +> **Stale-blob re-check (v0.127.0, Scenario F):** an ESCROWED box re-compares the ACK hash every +> cycle — mismatch OR a present blob with an EMPTY hash (a superseding ceremony that missed the +> staged secret) sets an in-memory stale flag (`EscrowAutoConfirmer.StaleBlob` → the Távoli +> mentés card's warning + re-ceremony CTA) + one warn per distinct hub hash. State never flips, +> runs never block; a covering blob (or a fresh auto-confirm) clears it. > - **Injection guard** (`ValidateOffboxTarget`): host/user/repo must not start with `-` (ssh > option-injection) or carry metacharacters/traversal; `OffboxConfigured` fails closed on an invalid > target. Image: `restic` + `openssh-client` (re-added; restic's sftp backend shells out to `ssh`). diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index e2abc43..43f8a9f 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -435,16 +435,24 @@ func main() { // --- Central hub pusher (declared early so backup closure can reference it) --- var hubPusher *report.Pusher + // escrowConfirmer is hoisted so the web server (built later) can read its Scenario-F + // stale-blob flag (SetEscrowStale below). nil when no hub is configured. + var escrowConfirmer *report.EscrowAutoConfirmer if cfg.Hub.URL != "" && cfg.Hub.APIKey != "" { hubPusher = report.NewPusher(&cfg.Hub, logger, cfg.Logging.Level == "debug") // SLICE 3 — hub-verified escrow auto-confirm (long-lived: the mismatch warn dedupes per hash, // not per 15-min cycle). Flips offbox pending→escrowed ONLY when the hub-recorded hash of the - // escrowed password matches the local repo password's hash; never un-confirms. - escrowConfirmer := &report.EscrowAutoConfirmer{ + // escrowed password matches the local repo password's hash; never un-confirms. v0.127.0 adds + // the escrowed-state STALE re-check (Scenario F — warn + card flag, never a state change). + escrowConfirmer = &report.EscrowAutoConfirmer{ Pending: func() bool { return backupMgr != nil && backupMgr.OffboxConfigured() && sett.GetOffboxTarget() != nil && sett.GetOffboxTarget().EscrowState == "pending" }, + Escrowed: func() bool { + return backupMgr != nil && backupMgr.OffboxConfigured() && + sett.GetOffboxTarget() != nil && sett.GetOffboxTarget().EscrowState == "escrowed" + }, LocalHash: func() (string, bool) { if backupMgr == nil { return "", false @@ -855,6 +863,10 @@ func main() { stackMgr.RecoverMigration(ctx) webServer.SetEncryptionKey(encKey) webServer.SetAppExporter(appExporter) + // Escrow wizard (v0.127.0): the Scenario-F stale-blob flag feeds the Távoli mentés card. + if escrowConfirmer != nil { + webServer.SetEscrowStale(escrowConfirmer.StaleBlob) + } webServer.SetIntegrationManager(integrationMgr) if quiesceLoop != nil { webServer.SetBackupTrigger(quiesceLoop) // "Mentés most" → app-consistent backup via the quiesce loop @@ -960,6 +972,9 @@ func main() { mux.Handle("/api/export/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeExportAPI)))) // Debug API routes handled by web server (debug-mode gating inside handler) mux.Handle("/api/debug/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeDebugAPI)))) + // Escrow ceremony wizard API (v0.127.0) — session auth + CSRF on POSTs; the claim response is + // the ONLY surface the recovery code R ever crosses (no-store, never logged). + mux.Handle("/api/escrow/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeEscrowAPI)))) // Self-update API — accepts session auth OR hub API key (for external triggering) // CsrfProtect exempts Bearer-token requests automatically. mux.Handle("/api/selfupdate/", selfUpdateAuthMiddleware(cfg, webServer, webServer.CsrfProtect(http.HandlerFunc(apiRouter.ServeHTTP)))) diff --git a/controller/internal/agentapi/escrow.go b/controller/internal/agentapi/escrow.go new file mode 100644 index 0000000..ffe8e36 --- /dev/null +++ b/controller/internal/agentapi/escrow.go @@ -0,0 +1,119 @@ +package agentapi + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +// Controller-driven escrow ceremony client methods (v0.127.0, agent ≥ v0.88.0). The claim call +// is the ONLY place the recovery code R crosses this client — its response body must never be +// logged (the shared helpers log path/status/duration only, never bodies) and the caller hands +// R straight to the wizard's claim XHR, nowhere else. + +// EscrowPreflightItem mirrors one agent preflight checklist row. +type EscrowPreflightItem struct { + ID string `json:"id"` + OK bool `json:"ok"` + Detail string `json:"detail"` +} + +// EscrowPreflightResponse mirrors GET /escrow/preflight. +type EscrowPreflightResponse struct { + VMID int `json:"vmid"` + OK bool `json:"ok"` + Items []EscrowPreflightItem `json:"items"` +} + +// EscrowPreflight fetches the agent's ceremony prerequisite checklist. +func (c *Client) EscrowPreflight(ctx context.Context) (EscrowPreflightResponse, error) { + var out EscrowPreflightResponse + body, err := c.get(ctx, "/escrow/preflight") + if err != nil { + return out, err + } + if err := json.Unmarshal(body, &out); err != nil { + return out, fmt.Errorf("agentapi: decode /escrow/preflight: %w", err) + } + return out, nil +} + +// EscrowCeremonyStartResponse mirrors the POST /escrow/ceremony 202 payload. +type EscrowCeremonyStartResponse struct { + JobID string `json:"job_id"` + Phase string `json:"phase"` +} + +// EscrowCeremonyStart triggers the agent's detached root ceremony job. Status-aware: the HTTP +// status is returned so the caller can map the agent's 409 (a ceremony already running) to its +// own house-style refusal. +func (c *Client) EscrowCeremonyStart(ctx context.Context) (EscrowCeremonyStartResponse, int, error) { + var out EscrowCeremonyStartResponse + env, status, err := c.postWithStatus(ctx, "/escrow/ceremony", struct{}{}) + if err != nil { + return out, status, err + } + if err := refusalError("/escrow/ceremony", status, env); err != nil { + return out, status, err + } + if err := json.Unmarshal(env.Data, &out); err != nil { + return out, status, fmt.Errorf("agentapi: decode /escrow/ceremony: %w", err) + } + return out, status, nil +} + +// EscrowCeremonyStatusResponse mirrors GET /escrow/ceremony/status — the NON-SECRET job view +// (R is structurally absent from the agent's payload). +type EscrowCeremonyStatusResponse struct { + Phase string `json:"phase"` // none | running | done | failed | unclaimed_void + JobID string `json:"job_id"` + KeyFingerprint string `json:"key_fingerprint"` + EntropyBits float64 `json:"entropy_bits"` + ResticPwSealed bool `json:"restic_pw_sealed"` + Uploaded bool `json:"uploaded"` + Claimable bool `json:"claimable"` + Claimed bool `json:"claimed"` + ClaimExpiresInSec int `json:"claim_expires_in_sec"` + Detail string `json:"detail"` +} + +// EscrowCeremonyStatus polls the ceremony job. +func (c *Client) EscrowCeremonyStatus(ctx context.Context) (EscrowCeremonyStatusResponse, error) { + var out EscrowCeremonyStatusResponse + body, err := c.get(ctx, "/escrow/ceremony/status") + if err != nil { + return out, err + } + if err := json.Unmarshal(body, &out); err != nil { + return out, fmt.Errorf("agentapi: decode /escrow/ceremony/status: %w", err) + } + return out, nil +} + +// EscrowCeremonyClaim performs the ONE-SHOT R claim. Returns the recovery code + the agent's +// HTTP status (410 = already claimed / expired — the wizard's void state). The code must never +// be logged, persisted, or placed anywhere but the claim XHR response; the error path carries +// the agent's reason text, never the code. +func (c *Client) EscrowCeremonyClaim(ctx context.Context) (string, int, error) { + env, status, err := c.postWithStatus(ctx, "/escrow/ceremony/claim", struct{}{}) + if err != nil { + return "", status, err + } + if status == http.StatusGone { + return "", status, fmt.Errorf("agentapi: POST /escrow/ceremony/claim: gone (claimed or expired)") + } + if err := refusalError("/escrow/ceremony/claim", status, env); err != nil { + return "", status, err + } + var out struct { + RecoveryCode string `json:"recovery_code"` + } + if err := json.Unmarshal(env.Data, &out); err != nil { + return "", status, fmt.Errorf("agentapi: decode /escrow/ceremony/claim: %w", err) + } + if out.RecoveryCode == "" { + return "", status, fmt.Errorf("agentapi: /escrow/ceremony/claim returned no code") + } + return out.RecoveryCode, status, nil +} diff --git a/controller/internal/report/escrow_confirm.go b/controller/internal/report/escrow_confirm.go index d8899a5..6995859 100644 --- a/controller/internal/report/escrow_confirm.go +++ b/controller/internal/report/escrow_confirm.go @@ -25,9 +25,15 @@ type EscrowStatus struct { // EscrowAutoConfirmer runs the auto-confirm check on each report ACK. Long-lived (one per process) so // the mismatch warning dedupes per distinct hash instead of firing every 15-minute cycle. type EscrowAutoConfirmer struct { - // Pending reports whether the offbox target is configured AND EscrowState=="pending" — the ONLY - // state this confirmer acts on. "escrowed" is never revisited (auto-UN-confirm does not exist). + // Pending reports whether the offbox target is configured AND EscrowState=="pending" — the + // confirm-flip state. "escrowed" is never flipped back (auto-UN-confirm does not exist), but + // since v0.127.0 it IS re-checked: see Escrowed + the stale-blob branch (Scenario F). Pending func() bool + // Escrowed reports whether the offbox target is configured AND EscrowState=="escrowed" — the + // v0.127.0 stale-blob re-check state (Scenario F: a superseding ceremony that did NOT cover + // the current password — e.g. a CLI run without the staged secret — must be surfaced, not + // silently ignored; the spike left the drill box in exactly that state). nil → no re-check. + Escrowed func() bool // LocalHash returns the canonical hash of the local repo password (ok=false → no password file). LocalHash func() (hash string, ok bool) // Flip transitions EscrowState pending→escrowed (settings.UpdateOffboxStatus). @@ -37,7 +43,23 @@ type EscrowAutoConfirmer struct { Logger *log.Logger mu sync.Mutex - warnedHash string // last mismatched hub hash we warned about (dedupe) + warnedHash string // last mismatched hub hash we warned about (dedupe; shared by both branches) + stale bool // Scenario F: the hub blob does not cover the CURRENT password (display-only) +} + +// staleHashlessMarker is the warnedHash dedupe sentinel for the hash-less supersession case +// (the hub hash is EMPTY there, which must still warn exactly once, and must not collide with +// the zero value of warnedHash). +const staleHashlessMarker = "(hashless)" + +// StaleBlob reports the Scenario-F display flag: EscrowState is escrowed but the hub's CURRENT +// blob does not cover the current repo password. In-memory only (recomputed from ACKs after a +// restart); NEVER blocks runs and NEVER flips state — the web card renders the warning + the +// re-ceremony CTA from it. +func (c *EscrowAutoConfirmer) StaleBlob() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.stale } func (c *EscrowAutoConfirmer) logf(f string, a ...any) { @@ -48,9 +70,19 @@ func (c *EscrowAutoConfirmer) logf(f string, a ...any) { // Reconcile applies one ACK's escrow status. Scenarios: match → flip+wipe (A); mismatch → stay pending // + warn once per hash (B); no status / no hash / no local file → stay pending silently (C, normal -// onboarding); not pending → no-op (E — already escrowed or offbox not configured). +// onboarding); escrowed → the v0.127.0 stale-blob re-check (F — warn-only, never a state change); +// otherwise → no-op (E — offbox not configured). func (c *EscrowAutoConfirmer) Reconcile(es *EscrowStatus) { - if es == nil || !c.Pending() { + if es == nil { + return + } + if !c.Pending() { + // Scenario F (v0.127.0): an ESCROWED box re-checks the hash on every ACK — a superseding + // blob that does not cover the current password must be surfaced (warn + card flag), while + // runs continue and the state stays escrowed (no auto-UN-confirm, ever). + if c.Escrowed != nil && c.Escrowed() { + c.reconcileEscrowed(es) + } return } // Fail-closed: the hash must exist AND ride a present identity blob (the hash-bearing container). @@ -69,7 +101,7 @@ func (c *EscrowAutoConfirmer) Reconcile(es *EscrowStatus) { c.warnedHash = es.ResticPwSHA256 c.mu.Unlock() if !warned { - c.logf("[WARN] [escrow-confirm] the hub's escrow blob does not cover the CURRENT repo password (hub hash %.12s… != local %.12s…) — run the escrow ceremony (felhom-agent --selftest=escrow-create --upload); staying pending", es.ResticPwSHA256, localHash) + c.logf("[WARN] [escrow-confirm] the hub's escrow blob does not cover the CURRENT repo password (hub hash %.12s… != local %.12s…) — run the escrow ceremony (wizard /backup/escrow, or felhom-agent --selftest=escrow-create --upload); staying pending", es.ResticPwSHA256, localHash) } return } @@ -85,4 +117,45 @@ func (c *EscrowAutoConfirmer) Reconcile(es *EscrowStatus) { c.logf("[ERROR] [escrow-confirm] escrowed but the agent-staged secret was NOT wiped: %v", err) } } + c.mu.Lock() + c.stale = false // a fresh hub-verified confirm clears any earlier stale flag + c.mu.Unlock() +} + +// reconcileEscrowed is the Scenario-F branch (§8 truth table, escrowed rows): compare the ACK's +// hash exactly as the pending branch does; a mismatch OR a present blob with an EMPTY hash (the +// hash-less supersession — the spike's exact case) raises the stale flag + ONE warn per distinct +// hub hash (warnedHash reuse); a match clears the flag. State is never flipped; runs never block +// (offsite backups still protect against non-total loss). +func (c *EscrowAutoConfirmer) reconcileEscrowed(es *EscrowStatus) { + localHash, ok := c.LocalHash() + if !ok { + return // no local repo password file — nothing to compare against + } + hubHash := es.ResticPwSHA256 + if hubHash != "" && hubHash == localHash { + c.mu.Lock() + c.stale = false + c.mu.Unlock() + return + } + // Stale: hash mismatch, or a blob whose hash is empty (hash-less supersession). Dedupe the + // warn per distinct hub hash; the empty hash dedupes under a sentinel so it still fires once. + dedupeKey := hubHash + if dedupeKey == "" { + dedupeKey = staleHashlessMarker + } + c.mu.Lock() + warned := c.warnedHash == dedupeKey + c.warnedHash = dedupeKey + c.stale = true + c.mu.Unlock() + if warned { + return + } + if hubHash == "" { + c.logf("[WARN] [escrow-confirm] STALE escrow: the hub's current blob carries NO password hash (hash-less supersession) — the stored recovery bundle does not cover the offsite password; create a new recovery code (wizard /backup/escrow). State stays escrowed; runs continue") + return + } + c.logf("[WARN] [escrow-confirm] STALE escrow: the hub's current blob does not cover the CURRENT repo password (hub hash %.12s… != local %.12s…) — create a new recovery code (wizard /backup/escrow). State stays escrowed; runs continue", hubHash, localHash) } diff --git a/controller/internal/report/escrow_stale_test.go b/controller/internal/report/escrow_stale_test.go new file mode 100644 index 0000000..6f2e67c --- /dev/null +++ b/controller/internal/report/escrow_stale_test.go @@ -0,0 +1,149 @@ +package report + +import ( + "context" + "log" + "strings" + "testing" + + "bytes" +) + +// Scenario F (v0.127.0) — the escrowed-state stale-blob re-check. The §8 truth table's NEW rows: +// an ESCROWED box whose hub blob does not cover the current password (hash mismatch, or a present +// blob with an EMPTY hash — the spike's hash-less supersession) raises a display-only stale flag +// + ONE warn per distinct hub hash. State never flips; nothing blocks. + +type staleHarness struct { + *confirmerHarness + escrowed bool +} + +func newStaleHarness(t *testing.T) *staleHarness { + t.Helper() + h := &staleHarness{confirmerHarness: &confirmerHarness{local: hubHash, localOK: true, logbuf: &bytes.Buffer{}}} + h.escrowed = true // the box state under test + h.c = &EscrowAutoConfirmer{ + Pending: func() bool { return h.pending }, + Escrowed: func() bool { return h.escrowed }, + LocalHash: func() (string, bool) { return h.local, h.localOK }, + Flip: func() error { h.flips++; return nil }, + Wipe: func(context.Context) error { h.wipes++; return nil }, + Logger: log.New(h.logbuf, "", 0), + } + return h +} + +// escrowed + hash mismatch → stale flag + ONE warn (deduped per distinct hub hash), no flip. +func TestEscrowStale_MismatchWarnsOnceAndFlags(t *testing.T) { + h := newStaleHarness(t) + h.local = otherHash + + h.c.Reconcile(matchStatus(hubHash)) + if !h.c.StaleBlob() { + t.Fatal("mismatch on an escrowed box must raise the stale flag") + } + if h.flips != 0 { + t.Fatal("the stale re-check must NEVER flip state (no auto-UN-confirm)") + } + if got := strings.Count(h.logbuf.String(), "STALE escrow"); got != 1 { + t.Fatalf("want exactly 1 STALE warn, got %d: %s", got, h.logbuf.String()) + } + // Dedupe: the same hub hash again → still exactly one warn; the flag stays up. + h.c.Reconcile(matchStatus(hubHash)) + if got := strings.Count(h.logbuf.String(), "STALE escrow"); got != 1 { + t.Fatalf("same stale hash must warn ONCE, got %d", got) + } + if !h.c.StaleBlob() { + t.Fatal("flag must persist across deduped ACKs") + } + // A NEW distinct stale hash → warns again. + h.c.Reconcile(matchStatus("2222222222222222222222222222222222222222222222222222222222222222")) + if got := strings.Count(h.logbuf.String(), "STALE escrow"); got != 2 { + t.Fatalf("a new distinct stale hash must warn again, got %d", got) + } +} + +// escrowed + blob present with an EMPTY hash (the spike's exact hash-less supersession) → stale +// + one warn under the hashless dedupe sentinel. +func TestEscrowStale_HashlessBlobWarnsOnce(t *testing.T) { + h := newStaleHarness(t) + + h.c.Reconcile(&EscrowStatus{IdentityBlobPresent: true}) // blob present, hash empty + if !h.c.StaleBlob() { + t.Fatal("a hash-less superseding blob must raise the stale flag") + } + if got := strings.Count(h.logbuf.String(), "NO password hash"); got != 1 { + t.Fatalf("want the hash-less warn once, got %d: %s", got, h.logbuf.String()) + } + h.c.Reconcile(&EscrowStatus{IdentityBlobPresent: false}) // K-only legacy shape — still hash-less + if got := strings.Count(h.logbuf.String(), "NO password hash"); got != 1 { + t.Fatalf("hash-less must dedupe under its sentinel, got %d warns", got) + } +} + +// escrowed + hash MATCHES → clears an earlier stale flag; no warn on the clean path. +func TestEscrowStale_MatchClearsFlag(t *testing.T) { + h := newStaleHarness(t) + h.local = otherHash + h.c.Reconcile(matchStatus(hubHash)) // go stale + if !h.c.StaleBlob() { + t.Fatal("setup: expected stale") + } + h.local = hubHash + h.c.Reconcile(matchStatus(hubHash)) // a covering blob arrives (re-ceremony ran) + if h.c.StaleBlob() { + t.Fatal("a matching hash must CLEAR the stale flag") + } + // And a clean box never warns. + h2 := newStaleHarness(t) + h2.c.Reconcile(matchStatus(hubHash)) + if h2.c.StaleBlob() || strings.Contains(h2.logbuf.String(), "STALE") { + t.Fatalf("match must be silent: %s", h2.logbuf.String()) + } +} + +// Not-escrowed / no-local-password / nil-status rows: the re-check never runs (silent). +func TestEscrowStale_SilentRows(t *testing.T) { + h := newStaleHarness(t) + h.escrowed = false // offbox not configured (or any non-escrowed state) + h.c.Reconcile(matchStatus(otherHash)) + if h.c.StaleBlob() || h.logbuf.Len() != 0 { + t.Fatalf("non-escrowed must be silent: %s", h.logbuf.String()) + } + + h2 := newStaleHarness(t) + h2.localOK = false // no local repo password file + h2.c.Reconcile(matchStatus(otherHash)) + if h2.c.StaleBlob() || h2.logbuf.Len() != 0 { + t.Fatal("no local password → nothing to compare → silent") + } + + h3 := newStaleHarness(t) + h3.c.Reconcile(nil) // no escrow row at all (blob absent — out of the truth table) + if h3.c.StaleBlob() || h3.logbuf.Len() != 0 { + t.Fatal("nil status must be silent") + } +} + +// A fresh pending→escrowed auto-confirm clears any stale leftovers (the flag must not survive a +// successful re-ceremony's confirm). +func TestEscrowStale_AutoConfirmClears(t *testing.T) { + h := newStaleHarness(t) + h.local = otherHash + h.c.Reconcile(matchStatus(hubHash)) // stale while escrowed + if !h.c.StaleBlob() { + t.Fatal("setup: expected stale") + } + // The re-ceremony re-staged + re-uploaded; the box re-enters pending (edit flow) and the new + // blob covers the local password → auto-confirm path runs and must clear the flag. + h.escrowed = false + h.pending = true + h.c.Reconcile(matchStatus(otherHash)) + if h.flips != 1 { + t.Fatal("setup: auto-confirm should have flipped") + } + if h.c.StaleBlob() { + t.Fatal("a hub-verified auto-confirm must clear the stale flag") + } +} diff --git a/controller/internal/web/escrow_handlers.go b/controller/internal/web/escrow_handlers.go new file mode 100644 index 0000000..e6a0bc4 --- /dev/null +++ b/controller/internal/web/escrow_handlers.go @@ -0,0 +1,302 @@ +package web + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" + "gitea.dooplex.hu/admin/felhom-controller/internal/util" + "golang.org/x/crypto/bcrypt" +) + +// Controller-driven escrow ceremony wizard (v0.127.0, agent ≥ v0.88.0; mechanics validated by +// SPIKE-controller-escrow-2026-07-13). The customer runs the ceremony from /backup/escrow: +// preflight → warnings → password re-auth → the agent's detached root job → the ONE-SHOT R +// reveal (claim XHR only — R is NEVER templated server-side into HTML) → typed-back confirm. +// +// R-handling absolutes (§9 rule 4 of the task): R is never logged (either repo, any level, +// including the debug ring), never persisted, never placed in any payload except the claim XHR +// response (Cache-Control: no-store), never echoed in errors. + +// minEscrowAgentVersion is the MinAgent for the ceremony endpoints (the v0.88.0 localapi job). +// Gate: AgentVersion() compare, HEADER ABSENT = OLDER (fail-closed — unlike the probe-based +// Supports, an unknown agent must not be triggered blind; the preflight/stage traffic populates +// the passive version header, so a live 0.88+ agent is always known by the time start runs). +const minEscrowAgentVersion = "0.88.0" + +// escrowAgent is the narrow agent surface the wizard needs (*agentapi.Client satisfies it; +// tests inject fakes to assert call order and refusal short-circuits). +type escrowAgent interface { + EscrowPreflight(ctx context.Context) (agentapi.EscrowPreflightResponse, error) + EscrowCeremonyStart(ctx context.Context) (agentapi.EscrowCeremonyStartResponse, int, error) + EscrowCeremonyStatus(ctx context.Context) (agentapi.EscrowCeremonyStatusResponse, error) + EscrowCeremonyClaim(ctx context.Context) (string, int, error) + AgentVersion() string +} + +// SetEscrowStale wires the Scenario-F stale-blob flag source (report.EscrowAutoConfirmer.StaleBlob). +// Init-time only, like every Set*. +func (s *Server) SetEscrowStale(fn func() bool) { s.escrowStaleFn = fn } + +// escrowStale reads the stale-blob display flag (false when unwired). +func (s *Server) escrowStale() bool { + return s.escrowStaleFn != nil && s.escrowStaleFn() +} + +// escrowAgentConn resolves the agent surface (seam-first; default = the shared pinned client). +func (s *Server) escrowAgentConn() (escrowAgent, error) { + if s.escrowAgentFn != nil { + return s.escrowAgentFn() + } + return s.agentClient() +} + +// escrowStage re-stages the CURRENT offbox repo password to the agent (re-stage-first: a ceremony +// without the staged secret would mint the forbidden hash-less blob). Seam-first for tests. +func (s *Server) escrowStage(ctx context.Context) error { + if s.escrowStageFn != nil { + return s.escrowStageFn(ctx) + } + client, err := s.agentClient() + if err != nil { + return err + } + return s.backupMgr.PushOffboxPasswordForEscrow(ctx, client.StageEscrowSecret) +} + +// escrowAgentSupported reports whether ver (the passive X-Felhom-Agent-Version capture) is at +// least minEscrowAgentVersion. "" (header-less agent or no traffic yet) = OLDER — fail-closed. +func escrowAgentSupported(ver string) bool { + if ver == "" { + return false + } + av, err := util.ParseVersion(ver) + if err != nil { + return false + } + mv, err := util.ParseVersion(minEscrowAgentVersion) + if err != nil { + return false + } + return av.Compare(mv) >= 0 +} + +// escrowJSON writes the {ok,data,error} envelope the /api/* surface speaks. +func escrowJSON(w http.ResponseWriter, code int, data map[string]any, errMsg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(map[string]any{"ok": errMsg == "", "data": data, "error": errMsg}) +} + +// ServeEscrowAPI dispatches /api/escrow/* (session-authed + CSRF-protected at the mux, like the +// disk API). +func (s *Server) ServeEscrowAPI(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/escrow/preflight" && r.Method == http.MethodGet: + s.escrowPreflightAPIHandler(w, r) + case r.URL.Path == "/api/escrow/start" && r.Method == http.MethodPost: + s.escrowStartAPIHandler(w, r) + case r.URL.Path == "/api/escrow/status" && r.Method == http.MethodGet: + s.escrowStatusAPIHandler(w, r) + case r.URL.Path == "/api/escrow/claim" && r.Method == http.MethodPost: + s.escrowClaimAPIHandler(w, r) + default: + escrowJSON(w, http.StatusNotFound, nil, "ismeretlen végpont") + } +} + +// escrowWizardPageHandler renders /backup/escrow (GET). The page itself is no-store: it hosts +// the R reveal, and a cached copy of ANY wizard state is one copy too many. +func (s *Server) escrowWizardPageHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + offboxTgt := s.settings.GetOffboxTarget() + escrowed := offboxTgt != nil && offboxTgt.EscrowState == "escrowed" + agentVer := "" + if agent, err := s.escrowAgentConn(); err == nil { + agentVer = agent.AgentVersion() + } + data := map[string]interface{}{ + "Title": "Helyreállítási kód", + "CustomerName": s.cfg.Customer.Name, + "Domain": s.cfg.Customer.Domain, + "ActivePage": "backups-remote", + "Receremony": escrowed || s.escrowStale(), // the supersede warning variant + "OffboxConfigured": s.backupMgr != nil && s.backupMgr.OffboxConfigured(), + "AgentSupported": escrowAgentSupported(agentVer), + } + s.executeTemplate(w, r, "backups_escrow", data) +} + +// escrowPreflightAPIHandler proxies the agent checklist + the controller-side facts the wizard +// renders (agent version gate, offbox/escrow state for the supersede copy). +func (s *Server) escrowPreflightAPIHandler(w http.ResponseWriter, r *http.Request) { + agent, err := s.escrowAgentConn() + if err != nil { + escrowJSON(w, http.StatusBadGateway, nil, "Az ügynök nem elérhető.") + return + } + pf, err := agent.EscrowPreflight(r.Context()) + if err != nil { + s.logger.Printf("[WARN] [web] escrow preflight: %v", err) + escrowJSON(w, http.StatusBadGateway, nil, "Az előfeltételek ellenőrzése nem sikerült — az ügynök nem válaszol.") + return + } + offboxTgt := s.settings.GetOffboxTarget() + escrowState := "" + if offboxTgt != nil { + escrowState = offboxTgt.EscrowState + } + agentOK := escrowAgentSupported(agent.AgentVersion()) + escrowJSON(w, http.StatusOK, map[string]any{ + "ok": pf.OK && agentOK, "items": pf.Items, + "agent_supported": agentOK, + "offbox_configured": s.backupMgr != nil && s.backupMgr.OffboxConfigured(), + "escrow_state": escrowState, + "stale": s.escrowStale(), + }, "") +} + +// escrowStartAPIHandler is the wizard's run trigger. Order (load-bearing, Scenario A/E): +// (1) password re-auth — rides the LOGIN rate limiter; (2) re-stage-first when offbox is +// configured, ABORT on failure (a ceremony without the staged secret mints the forbidden +// hash-less blob); (3) agent version gate (the stage/preflight traffic has populated the passive +// header by now); (4) trigger the agent job. Every refusal exits BEFORE the agent is called. +func (s *Server) escrowStartAPIHandler(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + + // (1) Re-auth. A passwordless box cannot re-auth — refuse (the claim gate normally prevents + // this state; a legacy-open box must claim/set a password first). + hash := s.effectivePasswordHash() + if hash == "" { + escrowJSON(w, http.StatusForbidden, nil, "A vezérlőpult jelszava nincs beállítva — előbb állítson be jelszót.") + return + } + ip := requestIP(r) + if s.escrowRateLimited(ip) { + s.logger.Printf("[WARN] [web] escrow start rate limited for %s", ip) + escrowJSON(w, http.StatusTooManyRequests, nil, "Túl sok sikertelen próbálkozás, próbálja újra 1 perc múlva.") + return + } + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(r.FormValue("password"))); err != nil { + s.logger.Printf("[WARN] [web] escrow start: failed re-auth from %s", r.RemoteAddr) + s.recordEscrowAuthFailure(ip) + escrowJSON(w, http.StatusUnauthorized, nil, "Hibás jelszó.") + return + } + s.clearAuthFailures(ip) + + // (2) Re-stage-first (only when offsite is configured — Scenario C boxes skip it). + if s.backupMgr != nil && s.backupMgr.OffboxConfigured() { + if err := s.escrowStage(r.Context()); err != nil { + s.logger.Printf("[WARN] [web] escrow start: re-stage failed (ceremony NOT started): %v", err) // err carries no secret + escrowJSON(w, http.StatusBadGateway, nil, "A távoli mentés jelszavának letéti előkészítése nem sikerült — a folyamat nem indult el. Próbálja újra.") + return + } + } + + // (3) Agent + version gate. + agent, err := s.escrowAgentConn() + if err != nil { + escrowJSON(w, http.StatusBadGateway, nil, "Az ügynök nem elérhető.") + return + } + if !escrowAgentSupported(agent.AgentVersion()) { + escrowJSON(w, http.StatusConflict, nil, "A funkcióhoz az ügynök frissítése szükséges — a frissítés automatikusan megérkezik.") + return + } + + // (4) Trigger. + resp, status, err := agent.EscrowCeremonyStart(r.Context()) + if err != nil { + if status == http.StatusConflict { + escrowJSON(w, http.StatusConflict, nil, "Egy kódkészítés már folyamatban van — várja meg, míg befejeződik.") + return + } + s.logger.Printf("[WARN] [web] escrow start: agent trigger: %v", err) + escrowJSON(w, http.StatusBadGateway, nil, "A folyamat indítása nem sikerült — az ügynök nem válaszol.") + return + } + s.logger.Printf("[INFO] [web] escrow ceremony started via wizard (job %s)", resp.JobID) + escrowJSON(w, http.StatusOK, map[string]any{"job_id": resp.JobID, "phase": resp.Phase}, "") +} + +// escrowStatusAPIHandler proxies the NON-SECRET job status for the wizard's 2 s poll. +func (s *Server) escrowStatusAPIHandler(w http.ResponseWriter, r *http.Request) { + agent, err := s.escrowAgentConn() + if err != nil { + escrowJSON(w, http.StatusBadGateway, nil, "Az ügynök nem elérhető.") + return + } + st, err := agent.EscrowCeremonyStatus(r.Context()) + if err != nil { + escrowJSON(w, http.StatusBadGateway, nil, "Az állapot lekérdezése nem sikerült.") + return + } + escrowJSON(w, http.StatusOK, map[string]any{ + "phase": st.Phase, "job_id": st.JobID, + "key_fingerprint": st.KeyFingerprint, "entropy_bits": st.EntropyBits, + "restic_pw_sealed": st.ResticPwSealed, "uploaded": st.Uploaded, + "claimable": st.Claimable, "claimed": st.Claimed, + "claim_expires_in_sec": st.ClaimExpiresInSec, "detail": st.Detail, + }, "") +} + +// escrowClaimAPIHandler proxies the ONE-SHOT claim. no-store on the response; the body is never +// logged; R goes to the wizard's XHR and nowhere else. 410 relays the agent's void verdict. +func (s *Server) escrowClaimAPIHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + agent, err := s.escrowAgentConn() + if err != nil { + escrowJSON(w, http.StatusBadGateway, nil, "Az ügynök nem elérhető.") + return + } + code, status, err := agent.EscrowCeremonyClaim(r.Context()) + if err != nil { + if status == http.StatusGone { + escrowJSON(w, http.StatusGone, nil, "A kód létrejött, de nem lett megjelenítve — biztonsági okból újra nem kérhető le. Indítsa újra a folyamatot: az új kód a régit érvényteleníti.") + return + } + s.logger.Printf("[WARN] [web] escrow claim failed (status %d)", status) // reason text may echo agent detail; the code itself is never in errors + escrowJSON(w, http.StatusBadGateway, nil, "A kód lekérése nem sikerült.") + return + } + s.logger.Printf("[INFO] [web] escrow recovery code claimed (one-shot; not logged)") + escrowJSON(w, http.StatusOK, map[string]any{"recovery_code": code}, "") + code = "" // drop the reference promptly (GC caveat: best-effort) + _ = code +} + +// escrowRateLimited / recordEscrowAuthFailure / clearAuthFailures ride the SAME per-IP counter as +// the login form (loginAttempts, loginMaxAttempts, loginWindowDuration) — a wrong wizard password +// is a wrong password, wherever it was typed. +func (s *Server) escrowRateLimited(ip string) bool { + s.loginAttemptMu.Lock() + defer s.loginAttemptMu.Unlock() + attempt := s.loginAttempts[ip] + if attempt != nil && time.Since(attempt.lastFail) > loginWindowDuration { + delete(s.loginAttempts, ip) + return false + } + return attempt != nil && attempt.count >= loginMaxAttempts +} + +func (s *Server) recordEscrowAuthFailure(ip string) { + s.loginAttemptMu.Lock() + defer s.loginAttemptMu.Unlock() + if s.loginAttempts == nil { + s.loginAttempts = map[string]*loginAttempt{} + } + if s.loginAttempts[ip] == nil { + s.loginAttempts[ip] = &loginAttempt{} + } + s.loginAttempts[ip].count++ + s.loginAttempts[ip].lastFail = time.Now() +} + +func (s *Server) clearAuthFailures(ip string) { + s.loginAttemptMu.Lock() + defer s.loginAttemptMu.Unlock() + delete(s.loginAttempts, ip) +} diff --git a/controller/internal/web/escrow_wizard_test.go b/controller/internal/web/escrow_wizard_test.go new file mode 100644 index 0000000..948fb92 --- /dev/null +++ b/controller/internal/web/escrow_wizard_test.go @@ -0,0 +1,415 @@ +package web + +import ( + "context" + "fmt" + "io" + "log" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" + "golang.org/x/crypto/bcrypt" +) + +// Escrow wizard handler tests (v0.127.0). The agent + staging seams record CALL ORDER — the +// load-bearing Scenario A/B assertion is "re-stage happened BEFORE the agent trigger", and every +// Scenario E gate must exit with the agent NEVER called. + +const wizardPassword = "titkos-jelszo" + +type fakeEscrowAgent struct { + order *[]string // shared call-order log (harness-owned) + version string + startResp agentapi.EscrowCeremonyStartResponse + startStatus int + startErr error + status agentapi.EscrowCeremonyStatusResponse + claimCode string + claimStatus int + claimErr error + pf agentapi.EscrowPreflightResponse +} + +func (f *fakeEscrowAgent) EscrowPreflight(context.Context) (agentapi.EscrowPreflightResponse, error) { + *f.order = append(*f.order, "preflight") + return f.pf, nil +} +func (f *fakeEscrowAgent) EscrowCeremonyStart(context.Context) (agentapi.EscrowCeremonyStartResponse, int, error) { + *f.order = append(*f.order, "start") + return f.startResp, f.startStatus, f.startErr +} +func (f *fakeEscrowAgent) EscrowCeremonyStatus(context.Context) (agentapi.EscrowCeremonyStatusResponse, error) { + return f.status, nil +} +func (f *fakeEscrowAgent) EscrowCeremonyClaim(context.Context) (string, int, error) { + *f.order = append(*f.order, "claim") + return f.claimCode, f.claimStatus, f.claimErr +} +func (f *fakeEscrowAgent) AgentVersion() string { return f.version } + +type escrowWizardHarness struct { + s *Server + sett *settings.Settings + m *backup.Manager + agent *fakeEscrowAgent + order []string +} + +func newEscrowWizardHarness(t *testing.T) *escrowWizardHarness { + t.Helper() + tmp := t.TempDir() + lg := log.New(io.Discard, "", 0) + sett, err := settings.Load(filepath.Join(tmp, "settings.json"), lg) + if err != nil { + t.Fatal(err) + } + cfg := &config.Config{} + cfg.Paths.DataDir = tmp + hash, err := bcrypt.GenerateFromPassword([]byte(wizardPassword), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + cfg.Web.PasswordHash = string(hash) + h := &escrowWizardHarness{ + sett: sett, + m: backup.NewManager(cfg, sett, lg), + } + h.agent = &fakeEscrowAgent{ + order: &h.order, + version: "0.88.0", + startResp: agentapi.EscrowCeremonyStartResponse{JobID: "escrow-1", Phase: "running"}, + startStatus: http.StatusAccepted, + } + h.s = &Server{cfg: cfg, backupMgr: h.m, settings: sett, logger: lg} + h.s.escrowAgentFn = func() (escrowAgent, error) { return h.agent, nil } + h.s.escrowStageFn = func(context.Context) error { h.order = append(h.order, "stage"); return nil } + return h +} + +// configureOffbox makes OffboxConfigured() true with the given escrow state. +func (h *escrowWizardHarness) configureOffbox(t *testing.T, state string) { + t.Helper() + if err := h.m.WriteOffboxSecrets("KEYMATERIAL", "nas.local ssh-ed25519 HOSTKEY"); err != nil { + t.Fatal(err) + } + if err := h.sett.SetOffboxTarget(&settings.OffboxTarget{ + Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", + Schedule: "daily", EscrowState: state, + }); err != nil { + t.Fatal(err) + } + if !h.m.OffboxConfigured() { + t.Fatal("setup: offbox should be configured") + } +} + +func postStart(t *testing.T, s *Server, password string) *httptest.ResponseRecorder { + t.Helper() + form := url.Values{"password": {password}} + r := httptest.NewRequest("POST", "/api/escrow/start", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + s.escrowStartAPIHandler(w, r) + return w +} + +// Scenario A — pending offsite happy path: correct password → re-stage FIRST, then the agent +// trigger; 200 with the job id. +func TestEscrowStart_StagesBeforeTrigger(t *testing.T) { + h := newEscrowWizardHarness(t) + h.configureOffbox(t, "pending") + + w := postStart(t, h.s, wizardPassword) + if w.Code != http.StatusOK { + t.Fatalf("start: got %d (%s)", w.Code, w.Body.String()) + } + if got := strings.Join(h.order, ","); got != "stage,start" { + t.Fatalf("call order = %q, want stage BEFORE start (a ceremony without the staged secret mints a hash-less blob)", got) + } + if !strings.Contains(w.Body.String(), "escrow-1") { + t.Fatalf("response lacks the job id: %s", w.Body.String()) + } +} + +// Scenario B — re-ceremony from ESCROWED state stages too (the same order assertion). +func TestEscrowStart_ReceremonyStagesToo(t *testing.T) { + h := newEscrowWizardHarness(t) + h.configureOffbox(t, "escrowed") + + if w := postStart(t, h.s, wizardPassword); w.Code != http.StatusOK { + t.Fatalf("re-ceremony start: got %d", w.Code) + } + if got := strings.Join(h.order, ","); got != "stage,start" { + t.Fatalf("re-ceremony call order = %q, want stage,start", got) + } +} + +// Scenario C — no offbox configured: NO staging attempted; the ceremony still runs. +func TestEscrowStart_NoOffboxSkipsStaging(t *testing.T) { + h := newEscrowWizardHarness(t) + + if w := postStart(t, h.s, wizardPassword); w.Code != http.StatusOK { + t.Fatalf("start: got %d", w.Code) + } + if got := strings.Join(h.order, ","); got != "start" { + t.Fatalf("call order = %q, want start only (no staging without offbox)", got) + } +} + +// Scenario E — every gate refuses BEFORE the agent (and staging) is touched. +func TestEscrowStart_SecurityGates(t *testing.T) { + t.Run("wrong password", func(t *testing.T) { + h := newEscrowWizardHarness(t) + h.configureOffbox(t, "pending") + w := postStart(t, h.s, "rossz-jelszo") + if w.Code != http.StatusUnauthorized { + t.Fatalf("wrong password: got %d, want 401", w.Code) + } + if len(h.order) != 0 { + t.Fatalf("agent/staging touched despite failed re-auth: %v", h.order) + } + // The failure rides the LOGIN rate limiter. + h.s.loginAttemptMu.Lock() + var count int + for _, a := range h.s.loginAttempts { + count += a.count + } + h.s.loginAttemptMu.Unlock() + if count != 1 { + t.Fatalf("wrong password must increment the login rate-limit counter, got %d", count) + } + }) + t.Run("rate limited after max attempts", func(t *testing.T) { + h := newEscrowWizardHarness(t) + for i := 0; i < loginMaxAttempts; i++ { + postStart(t, h.s, "rossz-jelszo") + } + w := postStart(t, h.s, wizardPassword) // even the CORRECT password is refused inside the window + if w.Code != http.StatusTooManyRequests { + t.Fatalf("rate limit: got %d, want 429", w.Code) + } + if len(h.order) != 0 { + t.Fatalf("agent touched while rate-limited: %v", h.order) + } + }) + t.Run("passwordless box refused", func(t *testing.T) { + h := newEscrowWizardHarness(t) + h.s.cfg.Web.PasswordHash = "" + w := postStart(t, h.s, "") + if w.Code != http.StatusForbidden { + t.Fatalf("passwordless: got %d, want 403", w.Code) + } + if len(h.order) != 0 { + t.Fatal("agent touched on a passwordless box") + } + }) + t.Run("stage failure aborts before trigger", func(t *testing.T) { + h := newEscrowWizardHarness(t) + h.configureOffbox(t, "pending") + h.s.escrowStageFn = func(context.Context) error { return fmt.Errorf("agent down") } + w := postStart(t, h.s, wizardPassword) + if w.Code != http.StatusBadGateway { + t.Fatalf("stage failure: got %d, want 502", w.Code) + } + for _, c := range h.order { + if c == "start" { + t.Fatal("the ceremony started despite the failed staging (would mint a hash-less blob)") + } + } + }) + t.Run("agent too old", func(t *testing.T) { + h := newEscrowWizardHarness(t) + h.agent.version = "0.87.0" + w := postStart(t, h.s, wizardPassword) + if w.Code != http.StatusConflict { + t.Fatalf("old agent: got %d, want 409", w.Code) + } + for _, c := range h.order { + if c == "start" { + t.Fatal("an old agent must not be triggered") + } + } + }) + t.Run("header-less agent counts as older", func(t *testing.T) { + h := newEscrowWizardHarness(t) + h.agent.version = "" + if w := postStart(t, h.s, wizardPassword); w.Code != http.StatusConflict { + t.Fatalf("version-less agent: got %d, want 409", w.Code) + } + }) + t.Run("ceremony already running", func(t *testing.T) { + h := newEscrowWizardHarness(t) + h.agent.startStatus = http.StatusConflict + h.agent.startErr = fmt.Errorf("agentapi: POST /escrow/ceremony: HTTP 409: already running") + w := postStart(t, h.s, wizardPassword) + if w.Code != http.StatusConflict { + t.Fatalf("busy agent: got %d, want 409", w.Code) + } + if !strings.Contains(w.Body.String(), "folyamatban") { + t.Fatalf("busy refusal must speak Hungarian: %s", w.Body.String()) + } + }) +} + +// Scenario D (controller half) — the claim proxy: no-store on 200, the 410 void message, and the +// code appears ONLY in the claim response. +func TestEscrowClaim_ProxySemantics(t *testing.T) { + const code = "proba-kod-tiz-szo" + t.Run("success", func(t *testing.T) { + h := newEscrowWizardHarness(t) + h.agent.claimCode = code + h.agent.claimStatus = http.StatusOK + w := httptest.NewRecorder() + h.s.escrowClaimAPIHandler(w, httptest.NewRequest("POST", "/api/escrow/claim", nil)) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), code) { + t.Fatalf("claim: got %d %s", w.Code, w.Body.String()) + } + if cc := w.Header().Get("Cache-Control"); cc != "no-store" { + t.Fatalf("claim Cache-Control = %q, want no-store", cc) + } + }) + t.Run("gone", func(t *testing.T) { + h := newEscrowWizardHarness(t) + h.agent.claimStatus = http.StatusGone + h.agent.claimErr = fmt.Errorf("gone") + w := httptest.NewRecorder() + h.s.escrowClaimAPIHandler(w, httptest.NewRequest("POST", "/api/escrow/claim", nil)) + if w.Code != http.StatusGone || !strings.Contains(w.Body.String(), "újra nem kérhető le") { + t.Fatalf("gone: got %d %s", w.Code, w.Body.String()) + } + }) +} + +// The status proxy relays the agent's non-secret job view verbatim. +func TestEscrowStatus_Proxy(t *testing.T) { + h := newEscrowWizardHarness(t) + h.agent.status = agentapi.EscrowCeremonyStatusResponse{ + Phase: "done", JobID: "escrow-1", ResticPwSealed: true, Uploaded: true, + Claimable: true, ClaimExpiresInSec: 599, + } + w := httptest.NewRecorder() + h.s.escrowStatusAPIHandler(w, httptest.NewRequest("GET", "/api/escrow/status", nil)) + body := w.Body.String() + for _, want := range []string{`"phase":"done"`, `"claimable":true`, `"restic_pw_sealed":true`} { + if !strings.Contains(body, want) { + t.Fatalf("status proxy missing %s: %s", want, body) + } + } +} + +// The wizard page + card render states (Hungarian copy, no server-side R anywhere by +// construction — the template has no code variable to leak). +func TestEscrowTemplates_Render(t *testing.T) { + base := func() map[string]interface{} { + return map[string]interface{}{ + "Title": "Helyreállítási kód", "Domain": "example.hu", + "AgentSupported": true, "Receremony": false, "OffboxConfigured": true, + } + } + t.Run("wizard fresh", func(t *testing.T) { + html := renderBackupPage(t, "backups_escrow", base()) + for _, want := range []string{ + "Előfeltételek ellenőrzése", + "a mentései utolsó kulcsa", + "A folytatáshoz adja meg a bejelentkezési jelszavát", + "Kód létrehozása", + "Kód megjelenítése", + "Ez a kód többé nem jeleníthető meg.", + "nem ezen a szerveren", + "biztonsági okból újra nem kérhető le", + } { + if !strings.Contains(html, want) { + t.Errorf("wizard missing %q", want) + } + } + if strings.Contains(html, "érvényét veszti") { + t.Error("fresh wizard must not show the re-ceremony supersede warning") + } + }) + t.Run("wizard re-ceremony variant", func(t *testing.T) { + d := base() + d["Receremony"] = true + if html := renderBackupPage(t, "backups_escrow", d); !strings.Contains(html, "érvényét veszti") { + t.Error("re-ceremony wizard must show the supersede warning") + } + }) + t.Run("wizard agent too old", func(t *testing.T) { + d := base() + d["AgentSupported"] = false + html := renderBackupPage(t, "backups_escrow", d) + if !strings.Contains(html, "az ügynök frissítése szükséges") { + t.Error("old-agent wizard must show the version note") + } + if strings.Contains(html, "Kód létrehozása") { + t.Error("old-agent wizard must be inert (no start form)") + } + }) + t.Run("remote card states", func(t *testing.T) { + data := splitTestData() + data["EscrowAgentOK"] = true + data["EscrowStale"] = false + html := renderBackupPage(t, "backups_remote", data) // escrowed clean + if !strings.Contains(html, "Új helyreállítási kód készítése") { + t.Error("escrowed card must offer the secondary re-ceremony link") + } + if strings.Contains(html, "Letét megerősítése") { + t.Error("the deprecated manual-confirm button must be GONE from the card") + } + + data["EscrowStale"] = true + html = renderBackupPage(t, "backups_remote", data) + if !strings.Contains(html, "nem fedi a jelenlegi távoli mentési jelszót") { + t.Error("stale card must show the exact stale warning") + } + + pending := splitTestData() + pending["EscrowAgentOK"] = true + pending["EscrowStale"] = false + pending["Offbox"].(*settings.OffboxTarget).EscrowState = "pending" + html = renderBackupPage(t, "backups_remote", pending) + if !strings.Contains(html, "Helyreállítási kód szükséges") || !strings.Contains(html, "Helyreállítási kód létrehozása") { + t.Error("pending card must show the CTA state") + } + + old := splitTestData() + old["EscrowAgentOK"] = false + old["EscrowStale"] = false + old["Offbox"].(*settings.OffboxTarget).EscrowState = "pending" + html = renderBackupPage(t, "backups_remote", old) + if !strings.Contains(html, "az ügynök frissítése szükséges") || strings.Contains(html, `href="/backup/escrow"`) { + t.Error("old-agent card must show the version note with NO CTA") + } + }) +} + +// The preflight proxy augments the agent checklist with the version gate + offbox facts. +func TestEscrowPreflight_Augments(t *testing.T) { + h := newEscrowWizardHarness(t) + h.configureOffbox(t, "escrowed") + h.agent.pf = agentapi.EscrowPreflightResponse{OK: true, Items: []agentapi.EscrowPreflightItem{ + {ID: "pbs_storage_id", OK: true, Detail: "felhom-pbs"}, + }} + w := httptest.NewRecorder() + h.s.escrowPreflightAPIHandler(w, httptest.NewRequest("GET", "/api/escrow/preflight", nil)) + body := w.Body.String() + for _, want := range []string{`"agent_supported":true`, `"escrow_state":"escrowed"`, `"offbox_configured":true`, `"pbs_storage_id"`} { + if !strings.Contains(body, want) { + t.Fatalf("preflight missing %s: %s", want, body) + } + } + // An old agent flips both the flag and the aggregate ok. + h.agent.version = "0.87.0" + w2 := httptest.NewRecorder() + h.s.escrowPreflightAPIHandler(w2, httptest.NewRequest("GET", "/api/escrow/preflight", nil)) + if !strings.Contains(w2.Body.String(), `"agent_supported":false`) || !strings.Contains(w2.Body.String(), `"ok":false`) { + t.Fatalf("old agent must flip agent_supported + ok: %s", w2.Body.String()) + } +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 736d48f..accde10 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -710,6 +710,13 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) { func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) { data := s.backupsCommonData("backups-remote", "Biztonsági mentés — Távoli mentés", r) s.backupsOffboxData(data) + // Escrow ceremony card states (v0.127.0): the Scenario-F stale flag + the agent version gate. + data["EscrowStale"] = s.escrowStale() + agentVer := "" + if agent, err := s.escrowAgentConn(); err == nil { + agentVer = agent.AgentVersion() + } + data["EscrowAgentOK"] = escrowAgentSupported(agentVer) s.executeTemplate(w, r, "backups_remote", data) } diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index fb70409..b80273d 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -75,6 +75,14 @@ type Server struct { // escrowed (DELETE /escrow/stage-secret). nil → the default agentClient()-backed impl; tests inject. wipeStagedEscrowFn func(ctx context.Context) error + // Controller-driven escrow ceremony (v0.127.0) seams. escrowAgentFn nil → the shared + // agentClient(); escrowStageFn nil → PushOffboxPasswordForEscrow over the client; + // escrowStaleFn is the Scenario-F stale-blob flag (report.EscrowAutoConfirmer.StaleBlob, + // wired via SetEscrowStale; nil → never stale). + escrowAgentFn func() (escrowAgent, error) + escrowStageFn func(ctx context.Context) error + escrowStaleFn func() bool + // NAS add orchestration (verify-before-commit): the single-flight job slot + the two seams. // netAgentFn nil → the shared agentClient(); netProbeFn nil → runNetProbe (the uid-1000 re-exec). netAdd netAddState @@ -360,6 +368,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.offboxRunHandler(w, r) case path == "/backup/offbox/restore" && r.Method == http.MethodPost: s.offboxRestoreHandler(w, r) + // Controller-driven escrow ceremony wizard (v0.127.0): the customer-facing R flow. + case path == "/backup/escrow" && r.Method == http.MethodGet: + s.escrowWizardPageHandler(w, r) // fork-4: escrow atomicity — confirm the R-escrow ceremony; DR pre-place the recovered password. case path == "/backup/offbox/confirm-escrow" && r.Method == http.MethodPost: s.offboxConfirmEscrowHandler(w, r) diff --git a/controller/internal/web/templates/backups_escrow.html b/controller/internal/web/templates/backups_escrow.html new file mode 100644 index 0000000..72872f2 --- /dev/null +++ b/controller/internal/web/templates/backups_escrow.html @@ -0,0 +1,238 @@ +{{define "backups_escrow"}} +{{template "layout_start" .}} + + + + + +{{if not .AgentSupported}} +
A funkcióhoz az ügynök frissítése szükséges — a frissítés automatikusan megérkezik. Próbálja újra később.
+{{else}} + + +
+

1. Előfeltételek ellenőrzése

+ +

Ellenőrzés folyamatban…

+
+ + + + + + + + + + + + + + + +{{end}} +{{template "layout_end" .}} +{{end}} diff --git a/controller/internal/web/templates/backups_remote.html b/controller/internal/web/templates/backups_remote.html index 21f991e..4d8b67f 100644 --- a/controller/internal/web/templates/backups_remote.html +++ b/controller/internal/web/templates/backups_remote.html @@ -53,13 +53,31 @@ {{/* Part E: display pick — a stale zero-toggle warning is replaced once the selection changed (neutral color: the replacement is reassurance, not a deviation). */}} {{if .OffboxWarningDisplay}}

{{.OffboxWarningDisplay}}

{{end}} + {{/* Escrow ceremony card (v0.127.0): the customer-driveable wizard replaced the manual-confirm + button (that deprecated endpoint stays for legacy blobs; its button is gone). States: + pending → CTA; escrowed+stale → warning + re-ceremony CTA; escrowed clean → secondary + link; agent too old → the honest version note, no CTA. */}} {{if and .OffboxConfigured (ne .Offbox.EscrowState "escrowed")}}
-

A távoli mentés a kulcs letétbe helyezésére vár — a mentés addig nem fut (így nem keletkezik visszaállíthatatlan másolat). Futtasd a letéti szertartást, majd erősítsd meg.

-
{{.CSRFField}} - -
+

Helyreállítási kód szükséges

+

A távoli mentések csak akkor állíthatók vissza egy teljes meghibásodás után, ha létrehozza a helyreállítási kódot.

+ {{if .EscrowAgentOK}} + Helyreállítási kód létrehozása + {{else}} +

A funkcióhoz az ügynök frissítése szükséges — a frissítés automatikusan megérkezik.

+ {{end}}
+ {{else if and .OffboxConfigured .EscrowStale}} +
+

A letétben lévő helyreállítási csomag nem fedi a jelenlegi távoli mentési jelszót. Hozzon létre új helyreállítási kódot.

+ {{if .EscrowAgentOK}} + Új helyreállítási kód készítése + {{else}} +

A funkcióhoz az ügynök frissítése szükséges — a frissítés automatikusan megérkezik.

+ {{end}} +
+ {{else if .OffboxConfigured}} +

A helyreállítási kód letétbe helyezve.{{if .EscrowAgentOK}} Új helyreállítási kód készítése{{end}}

{{end}} {{if .OffboxConfigured}}