package localapi import ( "context" "crypto/sha256" "encoding/hex" "errors" "net/http" "strings" "time" "gitea.dooplex.hu/admin/felhom-agent/internal/escrow" ) // R-199 (agent v0.125.0) — the in-guest controller asks the agent to recover the offsite repository // password from the hub's sealed bundle, using the customer's recovery code R. // // WHY THE AGENT AND NOT THE CONTROLLER. Three reasons, all structural: the unsealing binary (`age`) // is an agent runtime dependency and is deliberately absent from the controller image; the sealed // blob is a HOST-scoped object whose only writer is this agent under the per-host key, so the read is // that write's mirror; and the controller is a trust tier down — it should receive one field, not a // bundle it has no use for. // // R'S HANDLING, WHICH IS THE TIGHTEST RULE IN THIS FLOW. R is the one secret in the system that // cannot be rotated, re-issued or recovered — it exists only in the customer's hands. Here it: // - arrives in the request body over the already-pinned local-API channel (the operator accepted // that crossing on 2026-08-04; the acceptance covers the CHANNEL, not carelessness at either end); // - is held in memory for the duration of one call and cleared on BOTH paths; // - is never written to disk, never an argument in a process list, and never logged at any level, // including inside an error; // - is never echoed: no response this endpoint can emit contains it. // // The request-level DEBUG middleware logs method/path/status/duration and never bodies — see // `logRequests`. Do not add a body dump. // // THE RESPONSE CARRIES THE PASSWORD AND ITS HASH. The hash is what this session's proof compares // (compare by hash, never by value). The password itself is present because the next link — placing a // recovered password so the existing repository opens — needs it, and building a hash-only seam now // would have to be torn out to add it. The controller's diagnostic reads only the hash. type recoverOffsitePasswordRequest struct { VMID int `json:"vmid"` // RecoveryCode is the customer's R. NEVER logged, never persisted, never echoed. RecoveryCode string `json:"recovery_code"` } // handleRecoverOffsitePassword fetches this host's sealed bundle, unseals it with R and returns only // the offsite repository password (plus its sha256, for hash-only comparison by the caller). func (s *Server) handleRecoverOffsitePassword(w http.ResponseWriter, r *http.Request, vmid int) { var req recoverOffsitePasswordRequest if !decodeBody(w, r, &req) { return } if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) { return } R := strings.TrimSpace(req.RecoveryCode) req.RecoveryCode = "" // drop the decoded copy immediately if R == "" { writeErr(w, http.StatusBadRequest, "recovery_code is required") return } if s.escrowRecovery == nil { R = "" writeErr(w, http.StatusServiceUnavailable, "offsite key recovery is not configured on this agent (no hub client)") return } ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) defer cancel() s.logger.Info("local-api: recovering the offsite repository password from the sealed escrow (R via body, never logged/persisted)", "vmid", vmid) pw, err := s.escrowRecovery.RecoverOffsiteRepoPassword(ctx, R) R = "" // cleared on BOTH paths, before anything else can happen if err != nil { // Each situation gets its own status and its own words. None of them names a secret. switch { case errors.Is(err, escrow.ErrNoEscrowBlob): s.logger.Warn("local-api: offsite key recovery: the hub holds no sealed bundle for this host", "vmid", vmid) writeErr(w, http.StatusNotFound, "the hub holds no sealed recovery bundle for this host — no escrow ceremony has run") case errors.Is(err, escrow.ErrNoResticPassword): s.logger.Warn("local-api: offsite key recovery: the bundle opened but predates the repository-password field", "vmid", vmid) writeErr(w, http.StatusConflict, "the recovery code opened the bundle, but it carries NO offsite repository password (sealed before that field existed; it cannot be retro-fitted)") default: // Includes the fail-closed wrong-code case. The agent log records the STEP, never the code. s.logger.Warn("local-api: offsite key recovery FAILED (wrong recovery code, or the blob could not be fetched)", "vmid", vmid, "err", err) writeErr(w, http.StatusBadRequest, "the recovery code did not open the sealed bundle, or the bundle could not be fetched — nothing was written") } return } sum := sha256.Sum256([]byte(strings.TrimSpace(pw))) // §8.6's lesson, applied: say exactly WHAT was recovered and what was NOT, so nobody reading this // concludes the wrong thing about the bundle's contents (which is how link 8 came to be missing). s.logger.Info("local-api: offsite repository password RECOVERED from the sealed escrow — returning that field ONLY "+ "(the tunnel token, the PBS token and the WG key stay inside the agent and are not returned)", "vmid", vmid, "restic_pw_sha256", hex.EncodeToString(sum[:])) writeOK(w, map[string]any{ "restic_repo_password": pw, "restic_pw_sha256": hex.EncodeToString(sum[:]), }) }