v0.77.0: fork-4 — escrow the offsite restic repo password under R

IdentityBundle gains ResticRepoPassword (rides existing age-under-R
WrapIdentityBundle; custody spike febdc56 proved a recovered value opens the
real repo). POST /escrow/stage-secret (withGuest, scopedFromBody) transiently
stages the controller-pushed password (0600, atomic, NEVER logged), which the
escrow-create ceremony auto-injects then wipes. Adds AttachResticPassword +
StagedResticPasswordPath + WipeStagedResticPassword; EscrowStagePath injectable
for tests. Tests: bundle carries pw byte-exact + not-in-blob + wrong-R fails
closed; stage 0600 + non-secret ack + cross-guest 403 + value-not-in-log.
Additive; PBS-K escrow untouched. NOT yet live-validated (supervised ceremony).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-09 14:56:09 +02:00
parent 7f07393623
commit 0c22b9bbf3
8 changed files with 320 additions and 1 deletions
+14 -1
View File
@@ -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
}