diff --git a/CHANGELOG.md b/CHANGELOG.md index 903d7a7..9eee782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,28 @@ +## v0.77.0 — fork-4: escrow the offsite restic repo password under R (2026-07-09) + +Makes the restic-offsite repo password recoverable at DR by riding the existing customer-recovery-code (R) +zero-knowledge escrow (age-under-R, alongside the IdentityBundle), validated by +`felhom.eu/documentation/audits/SPIKE-restic-password-custody-2026-07-09.md`. Additive; the PBS-K escrow +path is untouched. + +- `internal/escrow/identity.go`: `IdentityBundle` gains `ResticRepoPassword` (`restic_repo_password,omitempty`) + — rides the existing `WrapIdentityBundle`/`UnwrapIdentityBundle` age-under-R path (self-verified by + `escrow.Create`). Added `AttachResticPassword` (mirrors `AttachWGKey`), `StagedResticPasswordPath`, and + `WipeStagedResticPassword`. Pre-fork-4 blobs lack the field and CANNOT be retro-fitted (R never retained) + — the controller's atomicity gate ensures no offsite ciphertext exists until the key is escrowed. +- `internal/localapi`: `POST /escrow/stage-secret` (`withGuest`, `scopedFromBody`) transiently stages the + controller-pushed restic password (0600, atomic tmp+rename, **never logged** — field name only, value + never echoed), overwritten on re-push. Stage path injectable via `Options.EscrowStagePath` (default the + canonical `StagedResticPasswordPath`) for testability. +- `cmd/felhom-agent/main.go` (`runSelftestEscrowCreate`): the escrow-create ceremony auto-injects the staged + password into the `IdentityBundle` (mirrors the WG-key auto-inject) and **wipes** the staging file after a + successful create. The ceremony stays operator-invoked (`--selftest=escrow-create`). +- Tests: `IdentityBundle` round-trip carries `ResticRepoPassword` byte-exact + not-in-blob + wrong-R fails + closed; `AttachResticPassword` (missing/present/empty); stage endpoint stages 0600 + non-secret ack + + cross-guest 403 + **value-not-in-log**. +- NOT yet live-validated — the supervised escrow ceremony (enable→stage→escrow-create→confirm→gated run) is + the operator-run follow-up. + ## v0.76.0 — restore-test full-fidelity verification (GL-5b / go-live G12) (2026-07-08) Closes GL-5 finding #2's mirror image: the restore-test's live-source-config bind-override path diff --git a/CONTEXT.md b/CONTEXT.md index 2fda20a..21680bb 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -5,6 +5,14 @@ ## Current +- **v0.77.0** (2026-07-09) — **fork-4: escrow the offsite restic repo password under R.** `IdentityBundle` + gains `ResticRepoPassword` (rides the existing age-under-R `WrapIdentityBundle` path — validated by the + custody spike `febdc56`). New `POST /escrow/stage-secret` (`withGuest`) transiently stages the + controller-pushed password (0600, never logged), which the `--selftest=escrow-create` ceremony + auto-injects into the bundle and then wipes. `AttachResticPassword`/`StagedResticPasswordPath`/ + `WipeStagedResticPassword` added. Pairs with controller v0.105.0 (push + atomicity gate + DR inject + + `DRResticCoord`). **NOT yet live-validated** — the supervised escrow ceremony is operator-run. + - **v0.76.0** (2026-07-08, LIVE on felhom-pve + **PUBLISHED sha `9828c5f7…f50b`** — THE Day-0 manifest bump target; **0.75.0 superseded unpublished**) — **GL-5b / G12: restore-test full-fidelity**. Params derive from the ARCHIVE's embedded config (`drRestoreOverrides`, same as diff --git a/cmd/felhom-agent/main.go b/cmd/felhom-agent/main.go index 46526d3..fae88b8 100644 --- a/cmd/felhom-agent/main.go +++ b/cmd/felhom-agent/main.go @@ -1710,6 +1710,26 @@ func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slo logger.Info("escrow: identity bundle: +wg_private_key") } } + // fork-4: auto-inject the staged offsite restic repo password (controller-pushed via the local API) + // into the escrowed identity, so DR can recover the offsite DATA key with the one recovery code R. + // Field NAME only in logs. No staged file → clean no-attach (pre-fork-4 behavior). Wiped after create. + resticStaged := false + { + probe := identity + if probe == nil { + probe = &escrow.IdentityBundle{} + } + attached, err := escrow.AttachResticPassword(probe, escrow.StagedResticPasswordPath()) + if err != nil { + fmt.Fprintf(os.Stderr, "selftest=escrow-create: %v\n", err) + return 1 + } + if attached { + identity = probe + resticStaged = true + logger.Info("escrow: identity bundle: +restic_repo_password") + } + } fmt.Printf("=== felhom-agent %s selftest=escrow-create (storage=%s posture=%s identity=%v) ===\n", version, storage, escrow.DefaultPosture, identity != nil) // NB: nothing about R is logged. The logger never sees R; only stdout does, once. @@ -1726,6 +1746,13 @@ func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slo fmt.Fprintln(os.Stderr, " [FAIL] escrow create:", err) return 1 } + // fork-4: the staged restic password is now sealed inside the R-wrapped blob — wipe the transient + // 0600 staging file so it never lingers on disk (field name only; a wipe failure is a loud warn). + if resticStaged { + if werr := escrow.WipeStagedResticPassword(); werr != nil { + logger.Warn("escrow: could not wipe the staged restic password after create", "err", werr) + } + } // Surface R EXACTLY ONCE — to stdout, with a write-it-down banner. Never logged/persisted. fmt.Println() diff --git a/internal/escrow/identity.go b/internal/escrow/identity.go index 457a527..5a7f05a 100644 --- a/internal/escrow/identity.go +++ b/internal/escrow/identity.go @@ -30,6 +30,47 @@ type IdentityBundle struct { // blobs created before S3 lack it and CANNOT be retro-fitted (R is never retained) — S5 DR // falls back to fresh-key re-registration, which keeps the box's /32 (hub S2 re-key-in-place). WGPrivateKey string `json:"wg_private_key,omitempty"` + // ResticRepoPassword is the offsite restic repo password (fork-4). OPTIONAL: escrow blobs created + // before fork-4 lack it and CANNOT be retro-fitted (R is never retained). It is the DATA key for the + // offsite tier — irreplaceable (unlike the SFTP access key, which is regenerable at DR). The + // controller's atomicity gate ensures no offsite ciphertext exists until this is escrowed. + ResticRepoPassword string `json:"restic_repo_password,omitempty"` +} + +// StagedResticPasswordPath is the well-known 0600 file where the controller-pushed restic repo password +// is transiently staged (by the local API) for the escrow-create ceremony to pick up, then wiped. A fixed +// path so the local-API writer and the CLI ceremony reader agree without threading config through. +func StagedResticPasswordPath() string { + return filepath.Join("/var/lib/felhom-agent", "escrow-stage", "restic_repo_password") +} + +// WipeStagedResticPassword removes the staged restic password (called by the ceremony after a successful +// escrow-create — the secret now lives only inside the R-wrapped blob). A missing file is a clean no-op. +func WipeStagedResticPassword() error { + if err := os.Remove(StagedResticPasswordPath()); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("escrow: wipe staged restic password: %w", err) + } + return nil +} + +// AttachResticPassword injects the offsite restic repo password from the staged 0600 file into the bundle +// when it exists (fork-4 escrow-create auto-inject). Returns whether it attached. The VALUE is validated +// (non-empty) but NEVER logged by callers — log the field NAME only (mirrors AttachWGKey). A missing file +// is a clean no-attach (pre-fork-4 behavior, byte-compatible bundle). +func AttachResticPassword(b *IdentityBundle, stagePath string) (bool, error) { + raw, err := os.ReadFile(stagePath) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("escrow: reading staged restic password: %w", err) + } + pw := strings.TrimSpace(string(raw)) + if pw == "" { + return false, fmt.Errorf("escrow: staged restic password file %s is empty", stagePath) + } + b.ResticRepoPassword = pw + return true, nil } // AttachWGKey injects the offsite WG private key into the bundle when the key file exists (S3 diff --git a/internal/escrow/identity_test.go b/internal/escrow/identity_test.go index aa7f28e..9b48909 100644 --- a/internal/escrow/identity_test.go +++ b/internal/escrow/identity_test.go @@ -3,7 +3,9 @@ package escrow import ( "bytes" "context" + "os" "os/exec" + "path/filepath" "runtime" "testing" ) @@ -65,6 +67,61 @@ func TestIdentity_RoundTrip(t *testing.T) { } } +// fork-4: the IdentityBundle carries the offsite restic repo password under R, byte-exact and encrypted; +// a wrong R fails closed. (The spike proved a recovered value opens the real repo; this guards the field.) +func TestIdentity_RoundTrip_CarriesResticPassword(t *testing.T) { + ensureAge(t) + ctx := context.Background() + const R = "throwaway-correct-horse-battery-staple-fork4" + const pw = "deadbeefcafef00d0123456789abcdef0123456789abcdef0123456789abcdef" // 64 hex, synthetic + bundle := IdentityBundle{TunnelToken: "tt", PBSToken: "pt", ResticRepoPassword: pw} + blob, err := WrapIdentityBundle(ctx, bundle, R) + if err != nil { + t.Fatalf("WrapIdentityBundle: %v", err) + } + if bytes.Contains(blob, []byte(pw)) { + t.Fatal("the blob leaks the restic password plaintext — not encrypted") + } + got, err := UnwrapIdentityBundle(ctx, blob, R) + if err != nil { + t.Fatalf("UnwrapIdentityBundle: %v", err) + } + if got.ResticRepoPassword != pw { + t.Fatalf("recovered restic password not byte-exact: got %q", got.ResticRepoPassword) + } + if got != bundle { + t.Fatalf("recovered bundle = %+v, want %+v", got, bundle) + } + if _, err := UnwrapIdentityBundle(ctx, blob, R+"-WRONG"); err == nil { + t.Fatal("a wrong recovery code must fail closed (no bundle, no restic password)") + } +} + +// AttachResticPassword: missing file → clean no-attach; staged file → trimmed value attached; empty → error. +func TestAttachResticPassword(t *testing.T) { + b := &IdentityBundle{} + if ok, err := AttachResticPassword(b, filepath.Join(t.TempDir(), "absent")); ok || err != nil { + t.Fatalf("missing staged file must be a clean no-attach, got ok=%v err=%v", ok, err) + } + f := filepath.Join(t.TempDir(), "pw") + if err := os.WriteFile(f, []byte(" abc123def \n"), 0o600); err != nil { + t.Fatal(err) + } + ok, err := AttachResticPassword(b, f) + if err != nil || !ok { + t.Fatalf("attach from staged file: ok=%v err=%v", ok, err) + } + if b.ResticRepoPassword != "abc123def" { + t.Fatalf("want trimmed value, got %q", b.ResticRepoPassword) + } + if err := os.WriteFile(f, []byte(" \n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := AttachResticPassword(&IdentityBundle{}, f); err == nil { + t.Fatal("an empty staged file must error (an operator would want to know)") + } +} + // Wrong R fails CLOSED — no bundle emitted. func TestIdentity_WrongRFailsClosed(t *testing.T) { ensureAge(t) diff --git a/internal/localapi/escrow_stage.go b/internal/localapi/escrow_stage.go new file mode 100644 index 0000000..f967222 --- /dev/null +++ b/internal/localapi/escrow_stage.go @@ -0,0 +1,57 @@ +package localapi + +import ( + "net/http" + "os" + "path/filepath" + "strings" +) + +// fork-4 — the controller pushes the offsite restic repo password here so the escrow-create ceremony can +// wrap it under the customer recovery code R (age-under-R, alongside the IdentityBundle). This is TRANSIENT +// custody, not storage: the value is written 0600, NEVER logged (field name only), overwritten on re-push, +// and wiped by the ceremony after a successful escrow. The transport is the already-proven withGuest POST +// channel; the new part is the staging hygiene. + +type stageEscrowSecretRequest struct { + VMID int `json:"vmid"` + ResticRepoPassword string `json:"restic_repo_password"` +} + +// handleStageEscrowSecret stages the pushed offsite restic repo password (0600) for escrow-create. The +// secret value never touches a log line or the response. +func (s *Server) handleStageEscrowSecret(w http.ResponseWriter, r *http.Request, vmid int) { + var req stageEscrowSecretRequest + if !decodeBody(w, r, &req) { + return + } + if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) { + return + } + pw := strings.TrimSpace(req.ResticRepoPassword) + if pw == "" { + writeErr(w, http.StatusBadRequest, "restic_repo_password is required") + return + } + path := s.escrowStagePath + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + s.logger.Error("local-api: escrow stage dir", "vmid", vmid, "err", err) + writeErr(w, http.StatusInternalServerError, "could not stage the secret") + return + } + // Write via a 0600 temp + rename so a re-push is atomic and no partial file is ever readable. + tmp := path + ".tmp" + if err := os.WriteFile(tmp, []byte(pw), 0o600); err != nil { + s.logger.Error("local-api: escrow stage write", "vmid", vmid, "err", err) // err carries no secret + writeErr(w, http.StatusInternalServerError, "could not stage the secret") + return + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + s.logger.Error("local-api: escrow stage rename", "vmid", vmid, "err", err) + writeErr(w, http.StatusInternalServerError, "could not stage the secret") + return + } + s.logger.Info("local-api: staged offsite restic repo password for escrow (field name only)", "vmid", vmid) + writeOK(w, map[string]any{"staged": true}) +} diff --git a/internal/localapi/escrow_stage_test.go b/internal/localapi/escrow_stage_test.go new file mode 100644 index 0000000..ada8531 --- /dev/null +++ b/internal/localapi/escrow_stage_test.go @@ -0,0 +1,91 @@ +package localapi + +import ( + "bytes" + "encoding/json" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestStageEscrowSecret_StagesScopesAndHidesValue: the pushed restic password is staged 0600, the ack is +// non-secret, a cross-guest push is refused, and the value NEVER appears in a log line. +func TestStageEscrowSecret_StagesScopesAndHidesValue(t *testing.T) { + srv := newTestServerS(t, &fakeGuests{}, &fakeBackups{}, &fakeStore{}, nil) + stagePath := filepath.Join(t.TempDir(), "escrow-stage", "restic_repo_password") + srv.escrowStagePath = stagePath + h := srv.Handler() + + const secret = "d78466fcbf595b488ba8b962fcc521f68fbe6f36b8b8e57815a6124bccf4526b" + + // happy path: token A → vmid 8200 + w := do(t, h, "POST", "/escrow/stage-secret", "A", `{"vmid":8200,"restic_repo_password":"`+secret+`"}`) + if w.Code != 200 { + t.Fatalf("stage: got %d body=%s", w.Code, w.Body.String()) + } + // EFFECT: the 0600 file holds the secret byte-exact + got, err := os.ReadFile(stagePath) + if err != nil { + t.Fatalf("staged file missing: %v", err) + } + if string(got) != secret { + t.Fatal("staged file content does not match the pushed secret") + } + if fi, _ := os.Stat(stagePath); os.PathSeparator == '/' && fi.Mode().Perm() != 0o600 { + t.Errorf("stage file must be 0600, got %v", fi.Mode().Perm()) + } + // ack is non-secret {ok, data:{staged:true}} and does NOT echo the value + if strings.Contains(w.Body.String(), secret) { + t.Fatal("the response echoed the secret value") + } + var ack struct { + OK bool `json:"ok"` + Data struct { + Staged bool `json:"staged"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &ack); err != nil || !ack.OK || !ack.Data.Staged { + t.Fatalf("want {ok,data.staged:true}, got %s", w.Body.String()) + } + + // cross-guest: token A (8200) claiming vmid 9300 → 403, and the staged secret is NOT overwritten + w2 := do(t, h, "POST", "/escrow/stage-secret", "A", `{"vmid":9300,"restic_repo_password":"attacker"}`) + if w2.Code != 403 { + t.Fatalf("cross-guest push must be 403, got %d", w2.Code) + } + if got2, _ := os.ReadFile(stagePath); string(got2) != secret { + t.Fatal("cross-guest push must not overwrite the staged secret") + } + + // empty password → 400 + if w3 := do(t, h, "POST", "/escrow/stage-secret", "A", `{"vmid":8200,"restic_repo_password":""}`); w3.Code != 400 { + t.Fatalf("empty password must be 400, got %d", w3.Code) + } +} + +// TestStageEscrowSecret_NoSecretInLog: the staged value never appears in a log line (hygiene is load-bearing). +func TestStageEscrowSecret_NoSecretInLog(t *testing.T) { + var logbuf bytes.Buffer + srv, err := NewServer(Options{ + ListenAddr: "127.0.0.1:0", + Guests: &fakeGuests{}, + Backups: &fakeBackups{}, + Store: &fakeStore{}, + Storage: fakeStorage{}, + Tokens: staticTokens{"A": 8200}, + BackupCadence: time.Hour, + EscrowStagePath: filepath.Join(t.TempDir(), "s", "pw"), + Logger: slog.New(slog.NewTextHandler(&logbuf, nil)), + }) + if err != nil { + t.Fatal(err) + } + const secret = "cafef00ddeadbeef0123456789abcdef0123456789abcdef0123456789abcdef" + do(t, srv.Handler(), "POST", "/escrow/stage-secret", "A", `{"vmid":8200,"restic_repo_password":"`+secret+`"}`) + if strings.Contains(logbuf.String(), secret) { + t.Fatalf("the staged secret value leaked into a log line") + } +} diff --git a/internal/localapi/server.go b/internal/localapi/server.go index 9936df1..495bb2f 100644 --- a/internal/localapi/server.go +++ b/internal/localapi/server.go @@ -14,6 +14,7 @@ import ( "sync" "time" + "gitea.dooplex.hu/admin/felhom-agent/internal/escrow" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" "gitea.dooplex.hu/admin/felhom-agent/internal/storage" @@ -93,6 +94,10 @@ type Options struct { // SmbCredsDir is where the agent writes the 0600 SMB credentials files (out-of-band). "" → // /var/lib/felhom-agent/smb-creds. SmbCredsDir string + // EscrowStagePath is the 0600 file where POST /escrow/stage-secret transiently stages the + // controller-pushed restic repo password (fork-4). "" → escrow.StagedResticPasswordPath() (the + // canonical path the escrow-create ceremony reads). Injectable so the stage handler is testable. + EscrowStagePath string // ControllerSwap runs guest commands (pct exec) for the agentic controller-update swap (Phase 1). // OPTIONAL — when nil, POST /controller/swap reports "not configured". Satisfied by *GuestBinder. ControllerSwap GuestExecutor @@ -171,6 +176,7 @@ type Server struct { netStorage NetworkStorageOps // Part A1: NAS network mounts (optional) netMountRoot string // the user-data namespace root for the network-mount role gate smbCredsDir string // where SMB creds files are written (out-of-band, 0600) + escrowStagePath string // fork-4: 0600 staging file for the pushed restic repo password intent IntentRecorder // slice 10 P3 (optional) guestBinds *GuestBindStore // F9 startup bind re-assert record (optional) formatJobs *FormatJobStore // F20-BUG3 detached-format job record (optional) @@ -246,7 +252,8 @@ func NewServer(o Options) (*Server, error) { guestAttach: o.GuestAttach, netStorage: o.NetStorage, netMountRoot: storage.NetworkMountRoot, - smbCredsDir: o.SmbCredsDir, + smbCredsDir: o.SmbCredsDir, + escrowStagePath: o.EscrowStagePath, intent: o.Intent, guestBinds: o.GuestBinds, formatJobs: o.FormatJobs, @@ -257,6 +264,9 @@ func NewServer(o Options) (*Server, error) { jobs: map[int]*backupJob{}, swapInFlight: map[int]bool{}, } + if s.escrowStagePath == "" { + s.escrowStagePath = escrow.StagedResticPasswordPath() + } s.reresolveWipe = s.reresolveDurableForWipe s.reresolveBlank = s.reresolveDurableForBlankFormat s.deviceDurableID = storage.DeviceDurableID @@ -301,6 +311,9 @@ func (s *Server) Handler() http.Handler { // agentic controller update (Phase 1): in-guest image swap + rollback, owned by the agent. mux.HandleFunc("POST /controller/swap", s.withGuest(s.handleControllerSwap)) mux.HandleFunc("GET /controller/swap/status", s.withGuest(s.handleControllerSwapStatus)) + + // fork-4: stage the controller-pushed offsite restic repo password for the escrow-create ceremony. + mux.HandleFunc("POST /escrow/stage-secret", s.withGuest(s.handleStageEscrowSecret)) return mux }