6d7904786c
gates / gates (push) Successful in 7s
Link 7's only production caller was a --selftest reading R from an env var. Link 8 did not exist: that selftest writes the whole bundle JSON and its success message named "tunnel_token + pbs_token" -- accurate when written, a misstatement since v0.77.0 sealed the offsite repository password into the same bundle. It now names what THIS bundle carried and what it did not. POST /escrow/recover-offsite-password: the controller supplies R, the agent fetches this host's own blob from the hub (self-scoped by the per-host key), unseals it, and returns ONLY the offsite restic repository password plus its sha256. Not the tunnel token, not the PBS token, not the WG key -- the controller is a trust tier down and needs none of them. R: in memory for one call, cleared on every path, never on disk, never in argv, never logged, never echoed. A test redirects TMPDIR and asserts the tree is EMPTY afterwards -- emptiness rather than a content scan, because a content scan is defeated by a later call overwriting the leaked file, which is how the first version of that test passed its own red-proof while R sat on disk. Three distinct outcomes: no blob (404), a bundle that opens but predates the field (409), a code that does not open it (400, fail-closed at the KDF, nothing written). The wiring is asserted by an AST walk from func main() to the Options field, not by grep.
103 lines
5.1 KiB
Go
103 lines
5.1 KiB
Go
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[:]),
|
|
})
|
|
}
|