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:
@@ -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)
|
## 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
|
Closes GL-5 finding #2's mirror image: the restore-test's live-source-config bind-override path
|
||||||
|
|||||||
@@ -5,6 +5,14 @@
|
|||||||
|
|
||||||
## Current
|
## 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
|
- **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
|
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
|
full-fidelity**. Params derive from the ARCHIVE's embedded config (`drRestoreOverrides`, same as
|
||||||
|
|||||||
@@ -1710,6 +1710,26 @@ func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slo
|
|||||||
logger.Info("escrow: identity bundle: +wg_private_key")
|
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)
|
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.
|
// 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)
|
fmt.Fprintln(os.Stderr, " [FAIL] escrow create:", err)
|
||||||
return 1
|
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.
|
// Surface R EXACTLY ONCE — to stdout, with a write-it-down banner. Never logged/persisted.
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|||||||
@@ -30,6 +30,47 @@ type IdentityBundle struct {
|
|||||||
// blobs created before S3 lack it and CANNOT be retro-fitted (R is never retained) — S5 DR
|
// 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).
|
// 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"`
|
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
|
// AttachWGKey injects the offsite WG private key into the bundle when the key file exists (S3
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package escrow
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"testing"
|
"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.
|
// Wrong R fails CLOSED — no bundle emitted.
|
||||||
func TestIdentity_WrongRFailsClosed(t *testing.T) {
|
func TestIdentity_WrongRFailsClosed(t *testing.T) {
|
||||||
ensureAge(t)
|
ensureAge(t)
|
||||||
|
|||||||
@@ -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"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/escrow"
|
||||||
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||||
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
||||||
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
"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). "" →
|
// SmbCredsDir is where the agent writes the 0600 SMB credentials files (out-of-band). "" →
|
||||||
// /var/lib/felhom-agent/smb-creds.
|
// /var/lib/felhom-agent/smb-creds.
|
||||||
SmbCredsDir string
|
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).
|
// 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.
|
// OPTIONAL — when nil, POST /controller/swap reports "not configured". Satisfied by *GuestBinder.
|
||||||
ControllerSwap GuestExecutor
|
ControllerSwap GuestExecutor
|
||||||
@@ -171,6 +176,7 @@ type Server struct {
|
|||||||
netStorage NetworkStorageOps // Part A1: NAS network mounts (optional)
|
netStorage NetworkStorageOps // Part A1: NAS network mounts (optional)
|
||||||
netMountRoot string // the user-data namespace root for the network-mount role gate
|
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)
|
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)
|
intent IntentRecorder // slice 10 P3 (optional)
|
||||||
guestBinds *GuestBindStore // F9 startup bind re-assert record (optional)
|
guestBinds *GuestBindStore // F9 startup bind re-assert record (optional)
|
||||||
formatJobs *FormatJobStore // F20-BUG3 detached-format job record (optional)
|
formatJobs *FormatJobStore // F20-BUG3 detached-format job record (optional)
|
||||||
@@ -246,7 +252,8 @@ func NewServer(o Options) (*Server, error) {
|
|||||||
guestAttach: o.GuestAttach,
|
guestAttach: o.GuestAttach,
|
||||||
netStorage: o.NetStorage,
|
netStorage: o.NetStorage,
|
||||||
netMountRoot: storage.NetworkMountRoot,
|
netMountRoot: storage.NetworkMountRoot,
|
||||||
smbCredsDir: o.SmbCredsDir,
|
smbCredsDir: o.SmbCredsDir,
|
||||||
|
escrowStagePath: o.EscrowStagePath,
|
||||||
intent: o.Intent,
|
intent: o.Intent,
|
||||||
guestBinds: o.GuestBinds,
|
guestBinds: o.GuestBinds,
|
||||||
formatJobs: o.FormatJobs,
|
formatJobs: o.FormatJobs,
|
||||||
@@ -257,6 +264,9 @@ func NewServer(o Options) (*Server, error) {
|
|||||||
jobs: map[int]*backupJob{},
|
jobs: map[int]*backupJob{},
|
||||||
swapInFlight: map[int]bool{},
|
swapInFlight: map[int]bool{},
|
||||||
}
|
}
|
||||||
|
if s.escrowStagePath == "" {
|
||||||
|
s.escrowStagePath = escrow.StagedResticPasswordPath()
|
||||||
|
}
|
||||||
s.reresolveWipe = s.reresolveDurableForWipe
|
s.reresolveWipe = s.reresolveDurableForWipe
|
||||||
s.reresolveBlank = s.reresolveDurableForBlankFormat
|
s.reresolveBlank = s.reresolveDurableForBlankFormat
|
||||||
s.deviceDurableID = storage.DeviceDurableID
|
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.
|
// 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("POST /controller/swap", s.withGuest(s.handleControllerSwap))
|
||||||
mux.HandleFunc("GET /controller/swap/status", s.withGuest(s.handleControllerSwapStatus))
|
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
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user