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:
@@ -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})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user