diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cbb91f..176054f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,22 @@ Committed, not released — the release and its vouch are the operator's. `deferUpload: false` → „json mode must defer the upload"; the `Upload:` line removed → „has no Upload". **Residual:** the customer's typed-back check after the claim is browser-only, so „confirmed" here means „claimed" — a customer who opens the code and closes the page without writing it down has replaced the package. +- **R-1151 — the new recovery code replaces the hub's package only after the household typed it back.** R-978 moved + the upload to the claim; but the wizard's typed-back check was the browser's alone, so a household that opened the + new code and closed the page without writing it down had already replaced the package their old code opens. Now a + claim with `{"defer_upload": true}` (controller ≥ 0.332.0) hands out the code with two word positions + (`verify_positions`, drawn by crypto/rand) and keeps the package; the new `POST /escrow/ceremony/confirm + {"words": [w1, w2]}` checks the two words (constant time, case and spaces aside) and only then uploads it. Right → 200; + wrong → 422, still waiting; one word → 400; nothing waiting → 404; the 30-minute window ran out → 410 and the job + reads `unconfirmed_void` (the package dropped, the previous code still works); hub failure → 502, still waiting. Only + the two words are kept for the check, never R; a new start, the window and a restart drop them with the package. + Status gains `awaiting_confirm` and `confirm_expires_in_sec`. **An old controller (≤ 0.331.0) claims with `{}` and + never confirms: it keeps the R-978 order (upload at the claim)**, so its household's ceremony still completes; a new + controller on an old agent sees no `confirm_required` and needs no confirm. No sudoers or bundle change. Tests + `TestEscrowCeremony_R1151_*` (shown but not typed back — by the window, a new start, a restart — code 1 still opens + the hub's package; typing; the old controller's order; hub failure at the confirm) and `TestPickConfirmPositions`. + Red-proved: the defer flag ignored → „a code that was shown but never typed back replaced the package: code 1 no + longer opens it (uploads=2)" in all three; the word check skipped → „one wrong word: got 200 (uploads=1)". - **R-1149 — the local whole-guest tier keeps one restore point per day for 3 days** (operator decision D17, 2026-10-19). The primary (local) tier's per-run prune is now `--prune-backups keep-daily=N` (N = `local_backup_retention`, default 3) instead of `keep-last=N` — the same at most 3 archives, but a run of extra diff --git a/REUSE.md b/REUSE.md index d1d773c..b01147e 100644 --- a/REUSE.md +++ b/REUSE.md @@ -115,7 +115,7 @@ | `escrow.Create` | internal/escrow/escrow.go | `Create(ctx, CreateOptions) (CreateResult, R, error)` | PBS-key escrow (zero-knowledge) | Recovery code returned SEPARATELY from the result (anti-log); self-verifies recoverability | | `escrow.GenerateRecoveryCode` / `joinSafe` / `RecoveryCodeSep` | internal/escrow/wordlist.go | `GenerateRecoveryCode() (string, error)` | minting the customer recovery code R | Draws from the EFF large list **filtered of every word containing `RecoveryCodeSep`** (4 entries: drop-down, felt-tip, t-shirt, yo-yo) so a code always segments back into exactly 10 words — a hyphenated word made codes ambiguous to transcribe AND flaked the test ~1/5 (v0.93.0). Generation-only: **already-issued codes stay valid**, R is verified as a whole passphrase and never re-split. Never count words by splitting the joined string — count what the generator drew | | `escrow.CeremonyBinary` / `CeremonyArgs()` / `CeremonyOutput` | internal/escrow/ceremony.go | the ONE fixed sudo self-invocation argv + the `--output=json` wire object (v2, R-978: json mode returns the package in `PendingUpload` instead of uploading) | controller-driven ceremony (v0.88.0) | SINGLE SOURCE shared by the localapi exec, the capability manifest entry, and (byte-identically) the FELHOM_ESCROW sudoers line — `TestEscrowCeremonyArgvPinned` + `TestManifestCoveredBySudoers` lock all three. Never flag-helpers, never `--`→`-` (spike §2.2) | -| localapi escrow ceremony job | internal/localapi/escrow_ceremony.go | `POST /escrow/ceremony` + status + ONE-SHOT claim + preflight | the wizard's agent half | R lives ONLY in `Server.escrowR` (NEVER the job struct — snapshots must be structurally R-free); zeroed on claim/supersede/10-min TTL (`unclaimed_void`); in-memory BY DESIGN (restart loses R safely; re-run supersedes); **R-978: the new package reaches the hub only at the claim, before R is handed out (`EscrowCeremonyConfig.Upload`); TTL and a new start wait while that upload is in flight — an abandoned ceremony leaves the previous package current**; subprocess stdout is SECRET-BEARING → parsed then zeroed, never logged | +| localapi escrow ceremony job | internal/localapi/escrow_ceremony.go | `POST /escrow/ceremony` + status + ONE-SHOT claim + preflight | the wizard's agent half | R lives ONLY in `Server.escrowR` (NEVER the job struct — snapshots must be structurally R-free); zeroed on claim/supersede/10-min TTL (`unclaimed_void`); in-memory BY DESIGN (restart loses R safely; re-run supersedes); **R-978: the new package reaches the hub only at the claim, before R is handed out (`EscrowCeremonyConfig.Upload`); TTL and a new start wait while that upload is in flight — an abandoned ceremony leaves the previous package current**; **R-1151: a claim with `{"defer_upload":true}` hands out R + two word positions and keeps the package until `POST /escrow/ceremony/confirm` brings those two words back (30-min window, only the two words kept, never R); a claim without it keeps the R-978 order (old controllers)**; subprocess stdout is SECRET-BEARING → parsed then zeroed, never logged | | `poke.Listener` + `poke.Port` | internal/poke/poke.go | `NewListener(resolve, trigger, port, logger)`; `poke.Port = 51822` | agent-plane immediate-sync (Direction-2a, v0.89.0) | Binds a contentless UDP socket EXCLUSIVELY to the box's WG /32 (`wgtunnel.LoadAssignedAddr`), fires the hub-loop out-of-band trigger. **Port 51822 is a SHARED cross-repo contract** — the hub poke sender + the ep0 `felhom-poke` forced-command target the SAME number; change one → change all three. Contentless (payload ignored), leading-edge debounced (`DebounceWindow`), WG-confined (kernel EKEYREJECTED refuses non-peer /32s). Wired only when `wg_tunnel.enabled` | | `wgtunnel.LoadAssignedAddr` | internal/wgtunnel/manager.go | `LoadAssignedAddr(stateDir) (netip.Addr, bool)` | the box's own WG /32 without a Manager | Reads `registered.json`; ok=false until registered; strips the /32 → bare addr (the poke bind target) | | `fasttick.Loop` + `fasttick.SourceFunc` | internal/fasttick/fasttick.go | `New(out chan<- struct{}, interval, logger, sources...)`; `Source.Unconverged() (bool, reason)` | agent-plane immediacy SECONDARY (v0.90.0, R-28): pulse the SAME out-of-band trigger every 30 s while ANY source is unconverged, self-disarm on convergence | STATE-BASED (no timer, nothing to journal). Every source MUST be a CACHED read (no exec/network per tick) — `desiredProvider.Generation()`, `reconcile.Engine.LastResult()` (Planned−Pending>0), `pbsdrLoop.PBSDRStatus().State=="waiting_secret"` ONLY, `wgtunnel.Manager.TunnelConvergence()`. The LOUD pbsdr states + destructive `pending_signature` are DELIBERATELY EXCLUDED (a stuck-loud box must not hammer). Pulses the cap-1 channel non-blocking (coalesces with poke/watchdog); NEVER touch `MinPollSeconds`/`clampInterval`/the ticker | diff --git a/internal/localapi/escrow_ceremony.go b/internal/localapi/escrow_ceremony.go index 9e7d139..1b03999 100644 --- a/internal/localapi/escrow_ceremony.go +++ b/internal/localapi/escrow_ceremony.go @@ -3,11 +3,15 @@ package localapi import ( "bytes" "context" + "crypto/rand" + "crypto/subtle" "encoding/json" "fmt" + "math/big" "net/http" "os/exec" "strconv" + "strings" "time" "gitea.dooplex.hu/admin/felhom-agent/internal/escrow" @@ -34,6 +38,13 @@ import ( // and abandoned (never claimed, superseded, TTL, restart) leaves the customer's written-down code // working. The TTL and a new start both wait while a claim's upload is in flight, so a package is // never stored without its code being handed out by that same claim. +// - R-1151: a claim that asks for it (`{"defer_upload": true}`, controller ≥ 0.332.0) hands out the code +// and two word positions, and keeps the package until POST /escrow/ceremony/confirm brings those two +// words back — the household's typed-back check, now checked HERE, not only in the browser. A code that +// was shown and never typed back (the page closed) never replaces the hub's package; the confirm window +// (escrowConfirmTTL) ends it. Only the two words are kept for the check, never R. A claim WITHOUT the +// flag (a controller that has not updated) keeps the R-978 order: upload at the claim. Pinned by +// TestEscrowCeremony_R1151_*. // - Nothing from stdout is ever logged; stderr (log-clean, spike §2.1) is tail-captured for // failure diagnostics only. @@ -93,8 +104,15 @@ const ( escrowPhaseDone = "done" escrowPhaseFailed = "failed" escrowPhaseVoid = "unclaimed_void" + // escrowPhaseUnconfirmed (R-1151): the code was shown but its words never came back inside the confirm + // window — the package was dropped, the hub's previous package (and the previous code) stays current. + escrowPhaseUnconfirmed = "unconfirmed_void" ) +// escrowConfirmTTL is how long a claimed code waits for its typed-back words (R-1151). Writing ten words down +// by hand takes minutes; past this the package is dropped and the household starts again. +const escrowConfirmTTL = 30 * time.Minute + // escrowCeremonyTimeout bounds the whole subprocess run. Spike-measured ceremony ≈ 2.4 s incl. // upload — 60 s is a ≥25× margin that still absorbs WAN upload latency. const escrowCeremonyTimeout = 60 * time.Second @@ -184,6 +202,52 @@ func (s *Server) wipeEscrowRLocked() { zeroBytes(s.escrowR) s.escrowR = nil s.escrowPending = nil // R-978: the package goes with its code — never uploaded once the code is gone + s.wipeEscrowConfirmLocked() +} + +// wipeEscrowConfirmLocked drops the R-1151 typed-back state: the two kept words and the wait. Caller holds escrowMu. +func (s *Server) wipeEscrowConfirmLocked() { + for i := range s.escrowConfirmWords { + zeroBytes(s.escrowConfirmWords[i]) + s.escrowConfirmWords[i] = nil + } + s.escrowAwaitConfirm = false +} + +// checkEscrowConfirmTTLLocked ends a typed-back wait that ran past escrowConfirmTTL (R-1151): the package is +// dropped (never uploaded) and the job reads unconfirmed_void. Not while a confirm is uploading. Caller holds escrowMu. +func (s *Server) checkEscrowConfirmTTLLocked() { + if !s.escrowAwaitConfirm || s.escrowClaiming || !s.nowFn().After(s.escrowConfirmExpiry) { + return + } + s.escrowPending = nil + s.wipeEscrowConfirmLocked() + if s.escrowJob != nil { + s.escrowJob.Phase = escrowPhaseUnconfirmed + s.escrowJob.UpdatedAt = s.nowFn().UTC().Format(time.RFC3339) + s.logger.Info("local-api: escrow code was not typed back in time — the new package was dropped; the previous one stays current (R-1151)", + "job_id", s.escrowJob.JobID) + } +} + +// pickConfirmPositions draws two distinct 1-based word positions out of n (n ≥ 2), smaller first. +func pickConfirmPositions(n int) ([2]int, error) { + a, err := rand.Int(rand.Reader, big.NewInt(int64(n))) + if err != nil { + return [2]int{}, err + } + b, err := rand.Int(rand.Reader, big.NewInt(int64(n-1))) + if err != nil { + return [2]int{}, err + } + i, j := int(a.Int64()), int(b.Int64()) + if j >= i { + j++ + } + if j < i { + i, j = j, i + } + return [2]int{i + 1, j + 1}, nil } // expireEscrowRLocked applies the TTL outcome: an armed, unclaimed holder is zeroed and the job @@ -323,6 +387,7 @@ func (s *Server) handleEscrowCeremonyStatus(w http.ResponseWriter, r *http.Reque } s.escrowMu.Lock() s.checkEscrowTTLLocked() + s.checkEscrowConfirmTTLLocked() if s.escrowJob == nil { s.escrowMu.Unlock() writeOK(w, map[string]any{"vmid": vmid, "phase": escrowPhaseNone}) @@ -337,6 +402,13 @@ func (s *Server) handleEscrowCeremonyStatus(w http.ResponseWriter, r *http.Reque } } claimed := s.escrowRClaimed + awaiting := s.escrowAwaitConfirm + confirmIn := 0 + if awaiting { + if d := s.escrowConfirmExpiry.Sub(s.nowFn()); d > 0 { + confirmIn = int(d.Seconds()) + } + } s.escrowMu.Unlock() writeOK(w, map[string]any{ @@ -345,6 +417,7 @@ func (s *Server) handleEscrowCeremonyStatus(w http.ResponseWriter, r *http.Reque "key_fingerprint": job.KeyFingerprint, "entropy_bits": job.EntropyBits, "restic_pw_sealed": job.ResticPwSealed, "uploaded": job.Uploaded, "claimable": claimable, "claimed": claimed, "claim_expires_in_sec": expiresIn, + "awaiting_confirm": awaiting, "confirm_expires_in_sec": confirmIn, "detail": job.Detail, }) } @@ -357,6 +430,14 @@ func (s *Server) handleEscrowCeremonyClaim(w http.ResponseWriter, r *http.Reques writeErr(w, http.StatusServiceUnavailable, "escrow ceremony not configured on this host") return } + // R-1151: a controller ≥ 0.332.0 asks for the package to wait for the typed-back words. An older controller + // sends `{}` (or nothing) and gets the R-978 order. Unknown fields are refused (decodeBody). + var req struct { + DeferUpload bool `json:"defer_upload"` + } + if !decodeBody(w, r, &req) { + return + } s.escrowMu.Lock() s.checkEscrowTTLLocked() switch { @@ -381,6 +462,47 @@ func (s *Server) handleEscrowCeremonyClaim(w http.ResponseWriter, r *http.Reques writeErr(w, http.StatusGone, "the recovery code is no longer available (already claimed or expired) — run a new ceremony; the new code supersedes") return } + // R-1151: the deferred order — hand out the code and two word positions; the package waits for + // POST /escrow/ceremony/confirm. Needs an uploader (a confirm could never store it otherwise) and a code of ≥ 2 + // words (always 10; anything else falls back to the R-978 order rather than to no check). + if req.DeferUpload && len(s.escrowPending) > 0 && s.escrowCeremony.Upload != nil { + words := strings.Split(string(s.escrowR), escrow.RecoveryCodeSep) + if len(words) >= 2 { + pos, perr := pickConfirmPositions(len(words)) + if perr == nil { + s.escrowConfirmPos = pos + s.escrowConfirmWords = [2][]byte{[]byte(words[pos[0]-1]), []byte(words[pos[1]-1])} + for i := range words { + words[i] = "" + } + s.escrowAwaitConfirm = true + s.escrowConfirmExpiry = s.nowFn().Add(escrowConfirmTTL) + recovery := string(s.escrowR) + zeroBytes(s.escrowR) // R only — the package and the two words stay for the confirm + s.escrowR = nil + s.escrowRClaimed = true + jobID := s.escrowJob.JobID + s.escrowMu.Unlock() + time.AfterFunc(escrowConfirmTTL+time.Second, func() { + s.escrowMu.Lock() + defer s.escrowMu.Unlock() + if s.escrowJob != nil && s.escrowJob.JobID == jobID { + s.checkEscrowConfirmTTLLocked() + } + }) + s.logger.Info("local-api: escrow ceremony R claimed — the package waits for the typed-back words (R-1151)", + "vmid", vmid, "job_id", jobID, "confirm_ttl_s", int(escrowConfirmTTL.Seconds())) + w.Header().Set("Cache-Control", "no-store") + writeOK(w, map[string]any{"vmid": vmid, "job_id": jobID, "recovery_code": recovery, + "confirm_required": true, "verify_positions": []int{pos[0], pos[1]}, + "confirm_expires_in_sec": int(escrowConfirmTTL.Seconds())}) + recovery = "" + _ = recovery + return + } + s.logger.Warn("local-api: escrow claim — could not draw the confirm positions; package uploads at the claim (R-978 order)", "err", perr) + } + } // R-978: store the NEW package on the hub first; only then hand out the code that opens it. Until this // upload succeeds the hub keeps the previous package, and the customer's previous code keeps working. if len(s.escrowPending) > 0 { @@ -434,11 +556,103 @@ func (s *Server) handleEscrowCeremonyClaim(w http.ResponseWriter, r *http.Reques s.logger.Info("local-api: escrow ceremony R claimed (one-shot; holder zeroed)", "vmid", vmid, "job_id", jobID) w.Header().Set("Cache-Control", "no-store") - writeOK(w, map[string]any{"vmid": vmid, "job_id": jobID, "recovery_code": recovery}) + writeOK(w, map[string]any{"vmid": vmid, "job_id": jobID, "recovery_code": recovery, "confirm_required": false}) recovery = "" // drop the reference promptly (GC caveat: best-effort) _ = recovery } +// handleEscrowCeremonyConfirm is POST /escrow/ceremony/confirm {"words": [w1, w2]} (R-1151): the two words at the +// positions the claim named, as the household typed them back. Right → the package goes to the hub now (200); wrong → +// 422 and the wait goes on; nothing waiting → 404 (or 410 after the window ran out); a failed upload → 502 and the +// wait goes on (a retry may succeed). The words are compared in constant time, case and spaces aside. +func (s *Server) handleEscrowCeremonyConfirm(w http.ResponseWriter, r *http.Request, vmid int) { + if s.escrowCeremony == nil { + writeErr(w, http.StatusServiceUnavailable, "escrow ceremony not configured on this host") + return + } + var req struct { + Words []string `json:"words"` + } + if !decodeBody(w, r, &req) { + return + } + s.escrowMu.Lock() + s.checkEscrowConfirmTTLLocked() + switch { + case s.escrowClaiming: + s.escrowMu.Unlock() + writeErr(w, http.StatusConflict, "the recovery package is being stored — try again in a moment") + return + case !s.escrowAwaitConfirm: + gone := s.escrowJob != nil && s.escrowJob.Phase == escrowPhaseUnconfirmed + s.escrowMu.Unlock() + if gone { + writeErr(w, http.StatusGone, "the new recovery code was not confirmed in time — the previous code still works; run a new ceremony") + return + } + writeErr(w, http.StatusNotFound, "no recovery code waits for confirmation") + return + case len(req.Words) != 2: + s.escrowMu.Unlock() + writeErr(w, http.StatusBadRequest, "two words are needed") + return + } + match := 1 + for i := 0; i < 2; i++ { + typed := []byte(strings.ToLower(strings.TrimSpace(req.Words[i]))) + match &= subtle.ConstantTimeCompare(typed, s.escrowConfirmWords[i]) + zeroBytes(typed) + } + req.Words = nil + if match != 1 { + s.escrowMu.Unlock() + s.logger.Info("local-api: escrow confirm — the typed words do not match; still waiting (R-1151)", "vmid", vmid) + writeErr(w, http.StatusUnprocessableEntity, "the words do not match the recovery code") + return + } + if s.escrowCeremony.Upload == nil || len(s.escrowPending) == 0 { // the claim refuses to defer without both + s.escrowMu.Unlock() + writeErr(w, http.StatusServiceUnavailable, "the recovery package cannot be stored on this host") + return + } + pkg := s.escrowPending + jobID := "" + if s.escrowJob != nil { + jobID = s.escrowJob.JobID + } + s.escrowClaiming = true + s.escrowMu.Unlock() + + start := time.Now() + base := s.baseCtx + if base == nil { + base = context.Background() + } + uctx, cancel := context.WithTimeout(base, escrowUploadTimeout) + err := s.escrowCeremony.Upload(uctx, pkg) + cancel() + + s.escrowMu.Lock() + s.escrowClaiming = false + if err != nil { + s.escrowMu.Unlock() + s.logger.Warn("local-api: escrow confirm — package upload to the hub failed; still waiting, previous package stays current (R-1151)", + "vmid", vmid, "job_id", jobID, "err", err, "duration_ms", time.Since(start).Milliseconds()) + writeErr(w, http.StatusBadGateway, "the new recovery package could not be stored on the hub — the previous code still works; try again") + return + } + s.escrowPending = nil + s.wipeEscrowConfirmLocked() + if s.escrowJob != nil && s.escrowJob.JobID == jobID { + s.escrowJob.Uploaded = true + s.escrowJob.UpdatedAt = s.nowFn().UTC().Format(time.RFC3339) + } + s.escrowMu.Unlock() + s.logger.Info("local-api: escrow confirm — words typed back; the new package is on the hub (R-1151)", + "vmid", vmid, "job_id", jobID, "duration_ms", time.Since(start).Milliseconds()) + writeOK(w, map[string]any{"vmid": vmid, "job_id": jobID, "uploaded": true}) +} + // handleEscrowPreflight is GET /escrow/preflight: the wizard's prerequisite checklist. Each item // is {id, ok, detail}. Deliberately NOT checked: the PBS key file itself — /etc/pve/priv is 0700 // root and the daemon cannot stat it; a missing key fails the ceremony fast with a clear error diff --git a/internal/localapi/escrow_r1151_test.go b/internal/localapi/escrow_r1151_test.go new file mode 100644 index 0000000..b0dd321 --- /dev/null +++ b/internal/localapi/escrow_r1151_test.go @@ -0,0 +1,223 @@ +package localapi + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + "time" +) + +// R-1151 — the new recovery code replaces the hub's package only after the household TYPED IT BACK. Before, the +// claim uploaded it (R-978), so a household that opened the new code and closed the page without writing it down +// had already replaced the package their old code opens. These tests ask the hub's current package the household's +// question — „does my written-down code still open it?". + +type deferredClaim struct { + code int + r string + positions []int + required bool + body string +} + +func claimDeferred(t *testing.T, h http.Handler) deferredClaim { + t.Helper() + w := do(t, h, "POST", "/escrow/ceremony/claim", "A", `{"defer_upload":true}`) + var env struct { + Data struct { + RecoveryCode string `json:"recovery_code"` + ConfirmRequired bool `json:"confirm_required"` + VerifyPositions []int `json:"verify_positions"` + } `json:"data"` + } + _ = json.Unmarshal(w.Body.Bytes(), &env) + return deferredClaim{w.Code, env.Data.RecoveryCode, env.Data.VerifyPositions, env.Data.ConfirmRequired, w.Body.String()} +} + +// typedBack is what a household that wrote the code down types for the claim's two positions. +func (c deferredClaim) typedBack(t *testing.T) []string { + t.Helper() + words := strings.Split(c.r, "-") + if len(c.positions) != 2 { + t.Fatalf("the claim named %d positions, want 2: %s", len(c.positions), c.body) + } + return []string{words[c.positions[0]-1], words[c.positions[1]-1]} +} + +func confirm(t *testing.T, h http.Handler, words []string) int { + t.Helper() + b, _ := json.Marshal(map[string]any{"words": words}) + return do(t, h, "POST", "/escrow/ceremony/confirm", "A", string(b)).Code +} + +// THE R-1151 CASE. Ceremony 1 is claimed and typed back (the household's code 1). Ceremony 2 is started and its code +// SHOWN (claimed) — and then the page is closed: the words never come back. Three ways it ends: the confirm window +// runs out, a new start supersedes it, the agent restarts. In every case code 1 must still open the hub's package. +// +// COMPANION RED-PROOF (observed 2026-10-19): ignore the claim's defer_upload (`if false && req.DeferUpload …`, so +// every claim takes the R-978 order and uploads at once) → all three subtests FAIL with "a code that was shown but +// never typed back replaced the package: code 1 no longer opens it (uploads=2)". Restored. +func TestEscrowCeremony_R1151_ShownButNotTypedBackKeepsFirstCode(t *testing.T) { + setup := func(t *testing.T) (*Server, *fakeEscrowHub, http.Handler, *time.Time) { + srv, hub := newEscrowTestServerHub(t, sequenceRunner(firstR, secondR, "third-code-charlie"), &fakeEscrowHub{}) + cur := testNow + srv.now = func() time.Time { return cur } + h := srv.Handler() + startAndWait(t, srv, h) + c1 := claimDeferred(t, h) + if c1.code != http.StatusOK || c1.r != firstR { + t.Fatalf("deferred claim of ceremony 1: %d %s", c1.code, c1.body) + } + if c1.required { // the household types code 1 back + if code := confirm(t, h, c1.typedBack(t)); code != http.StatusOK { + t.Fatalf("typing code 1 back: %d", code) + } + } + if !hub.opensWith(firstR) || hub.count() != 1 { + t.Fatalf("after ceremony 1 the hub must hold code 1's package (uploads=%d)", hub.count()) + } + startAndWait(t, srv, h) + if c2 := claimDeferred(t, h); c2.code != http.StatusOK || c2.r != secondR { + t.Fatalf("deferred claim of ceremony 2: %d %s", c2.code, c2.body) + } // …shown, and the page is closed + return srv, hub, h, &cur + } + check := func(t *testing.T, hub *fakeEscrowHub) { + t.Helper() + if !hub.opensWith(firstR) { + t.Fatalf("a code that was shown but never typed back replaced the package: code 1 no longer opens it (uploads=%d)", hub.count()) + } + if hub.opensWith(secondR) || hub.count() != 1 { + t.Fatalf("a package for the never-confirmed code 2 reached the hub (uploads=%d)", hub.count()) + } + } + + t.Run("the confirm window runs out", func(t *testing.T) { + _, hub, h, cur := setup(t) + *cur = cur.Add(escrowConfirmTTL + time.Minute) + check(t, hub) + st := do(t, h, "GET", "/escrow/ceremony/status", "A", "").Body.String() + if !strings.Contains(st, `"phase":"unconfirmed_void"`) || !strings.Contains(st, `"awaiting_confirm":false`) { + t.Fatalf("after the window the job must read unconfirmed_void: %s", st) + } + if code := confirm(t, h, []string{"second", "bravo"}); code != http.StatusGone { + t.Fatalf("a confirm after the window: got %d, want 410", code) + } + }) + t.Run("a new start supersedes it", func(t *testing.T) { + srv, hub, h, _ := setup(t) + startAndWait(t, srv, h) + if code := confirm(t, h, []string{"second", "bravo"}); code != http.StatusNotFound { + t.Fatalf("a confirm for the superseded code: got %d, want 404", code) + } + check(t, hub) + }) + t.Run("the agent restarts", func(t *testing.T) { + _, hub, _, _ := setup(t) + srv2, _ := newEscrowTestServerHub(t, sequenceRunner("unused"), hub) + if code := confirm(t, srv2.Handler(), []string{"second", "bravo"}); code != http.StatusNotFound { + t.Fatalf("a confirm after a restart: got %d, want 404", code) + } + check(t, hub) + }) +} + +// The positive control and the typing: wrong words store nothing and the wait goes on; the right words (any case, +// spaces around) store the package, once. +// +// COMPANION RED-PROOF (observed 2026-10-19): skip the word check (`if match != 1 && false`) → this fails with "one +// wrong word: got 200 (uploads=1), want 422 and nothing stored". Restored. +func TestEscrowCeremony_R1151_TypedBackStoresThePackage(t *testing.T) { + srv, hub := newEscrowTestServerHub(t, sequenceRunner(secondR), &fakeEscrowHub{}) + h := srv.Handler() + startAndWait(t, srv, h) + c := claimDeferred(t, h) + if c.code != http.StatusOK || !c.required || hub.count() != 0 { + t.Fatalf("a deferred claim must hand out the code and store nothing yet: %d required=%v uploads=%d", c.code, c.required, hub.count()) + } + right := c.typedBack(t) + if code := confirm(t, h, []string{right[0], "wrong"}); code != http.StatusUnprocessableEntity || hub.count() != 0 { + t.Fatalf("one wrong word: got %d (uploads=%d), want 422 and nothing stored", code, hub.count()) + } + if code := confirm(t, h, []string{right[0]}); code != http.StatusBadRequest { + t.Fatalf("one word only: got %d, want 400", code) + } + st := do(t, h, "GET", "/escrow/ceremony/status", "A", "").Body.String() + if !strings.Contains(st, `"awaiting_confirm":true`) || !strings.Contains(st, `"uploaded":false`) { + t.Fatalf("a wrong try must leave the wait open: %s", st) + } + assertNoRIn(t, "status while awaiting the words", st, secondR) + for _, w := range right { + assertNoRIn(t, "status while awaiting the words", st, `"`+w+`"`) + } + if code := confirm(t, h, []string{" " + strings.ToUpper(right[0]), right[1] + " "}); code != http.StatusOK { + t.Fatalf("the right words: got %d, want 200", code) + } + if !hub.opensWith(secondR) || hub.count() != 1 { + t.Fatalf("after the words came back the hub must hold code 2's package (uploads=%d)", hub.count()) + } + if code := confirm(t, h, right); code != http.StatusNotFound || hub.count() != 1 { + t.Fatalf("a second confirm: got %d (uploads=%d), want 404 and no second upload", code, hub.count()) + } + if st := do(t, h, "GET", "/escrow/ceremony/status", "A", "").Body.String(); !strings.Contains(st, `"uploaded":true`) { + t.Fatalf("status after the confirm: %s", st) + } +} + +// A controller that has not updated (0.331.0 and older) claims with `{}` and never confirms: it keeps the R-978 +// order — the package is stored at the claim — so its household's ceremony still completes. +func TestEscrowCeremony_R1151_OldControllerKeepsTheClaimOrder(t *testing.T) { + for _, body := range []string{"", "{}"} { + srv, hub := newEscrowTestServerHub(t, sequenceRunner(firstR), &fakeEscrowHub{}) + h := srv.Handler() + startAndWait(t, srv, h) + w := do(t, h, "POST", "/escrow/ceremony/claim", "A", body) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), firstR) || !strings.Contains(w.Body.String(), `"confirm_required":false`) { + t.Fatalf("old-shape claim %q: %d %s", body, w.Code, w.Body.String()) + } + if !hub.opensWith(firstR) || hub.count() != 1 { + t.Fatalf("old-shape claim %q must store the package at once (uploads=%d)", body, hub.count()) + } + if code := confirm(t, h, []string{"first", "alpha"}); code != http.StatusNotFound { + t.Fatalf("nothing waits after an old-shape claim: got %d, want 404", code) + } + } +} + +// The hub failing at the confirm keeps the wait open and the previous package current; a retry succeeds. +func TestEscrowCeremony_R1151_UploadFailureAtConfirmKeepsWaiting(t *testing.T) { + hub := &fakeEscrowHub{err: errors.New("hub returned HTTP 502")} + srv, _ := newEscrowTestServerHub(t, sequenceRunner(secondR), hub) + h := srv.Handler() + startAndWait(t, srv, h) + c := claimDeferred(t, h) + if code := confirm(t, h, c.typedBack(t)); code != http.StatusBadGateway { + t.Fatalf("confirm with the hub failing: got %d, want 502", code) + } + hub.mu.Lock() + hub.err = nil + hub.mu.Unlock() + if code := confirm(t, h, c.typedBack(t)); code != http.StatusOK || !hub.opensWith(secondR) { + t.Fatalf("retry after the hub recovered: %d (opens: %v)", code, hub.opensWith(secondR)) + } +} + +// The two positions are distinct, in range, smaller first — over many draws every position appears. +func TestPickConfirmPositions(t *testing.T) { + seen := map[int]bool{} + for i := 0; i < 2000; i++ { + p, err := pickConfirmPositions(10) + if err != nil { + t.Fatal(err) + } + if p[0] < 1 || p[1] > 10 || p[0] >= p[1] { + t.Fatalf("bad positions %v", p) + } + seen[p[0]], seen[p[1]] = true, true + } + if len(seen) != 10 { + t.Fatalf("only positions %v were ever drawn", seen) + } +} diff --git a/internal/localapi/server.go b/internal/localapi/server.go index 01e4d17..ea72190 100644 --- a/internal/localapi/server.go +++ b/internal/localapi/server.go @@ -439,7 +439,14 @@ type Server struct { escrowClaiming bool // R-978: a claim is uploading escrowPending (TTL and a new start wait) escrowRClaimed bool escrowRExpiry time.Time - escrowDone <-chan struct{} // closes when the detached job finishes (tests wait on it) + // R-1151: after a claim that asked to wait for the typed-back words (defer_upload), the package stays in + // escrowPending until POST /escrow/ceremony/confirm brings the two words at escrowConfirmPos back. Only those two + // words are kept (never R), zeroed on confirm, supersede and expiry. + escrowAwaitConfirm bool + escrowConfirmWords [2][]byte + escrowConfirmPos [2]int + escrowConfirmExpiry time.Time + escrowDone <-chan struct{} // closes when the detached job finishes (tests wait on it) // ceremonyRun executes the fixed-argv sudo self-invocation (tests inject canned JSON). ceremonyRun ceremonyRunner privileged PrivilegedRunner @@ -614,6 +621,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /escrow/ceremony", s.withGuest(s.handleEscrowCeremonyStart)) mux.HandleFunc("GET /escrow/ceremony/status", s.withGuest(s.handleEscrowCeremonyStatus)) mux.HandleFunc("POST /escrow/ceremony/claim", s.withGuest(s.handleEscrowCeremonyClaim)) + // R-1151: the household typed two words of the new code back — only now does its package reach the hub. + mux.HandleFunc("POST /escrow/ceremony/confirm", s.withGuest(s.handleEscrowCeremonyConfirm)) // v0.83.0 observability: the agent's always-DEBUG capture ring, for the controller's // Debug page agent tab (same auth/self-scoping wrap as every sibling route).