The new escrow package reaches the hub only after the household typed two words of the code back: POST /escrow/ceremony/confirm; an old controller keeps the claim order (R-1151, unreleased)
gates / gates (push) Successful in 48s
gates / gates (push) Successful in 48s
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user