v0.105.0: fork-4 offsite password custody — hand-off + atomicity gate + DR inject + coord

Pairs with agent v0.77.0. StageEscrowSecret pushes the repo password to the
agent (POST /escrow/stage-secret) at offsite-enable → EscrowState="pending".
Atomicity gate: RunOffboxBackup (scheduler + handler) refuses until
EscrowState="escrowed" (operator POST /backup/offbox/confirm-escrow after the
escrow ceremony) — no un-recoverable offsite ciphertext can exist. DR:
POST /backup/offbox/inject-password pre-places a recovered 64-hex password 0600
(honored by WriteOffboxSecrets' IsNotExist guard; refuses clobber without
force). DR recipe gains non-secret offsite_restic coords (DRResticCoord); SFTP
key regenerated at DR, not escrowed. New settings.OffboxTarget.EscrowState.
Tests + atomicity & inject companion red-proofs green; UI gates pass. 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 15:13:59 +02:00
parent bde43f3a74
commit 0b09a799cb
14 changed files with 498 additions and 5 deletions
+6
View File
@@ -165,6 +165,12 @@ func BuildReport(
// Allowlist-only (the boundary): NO env/secret fields. The hub assembles it with the agent half.
r.DRRecipe = BuildDRRecipeAppHalf(cfg.Customer.ID, cfg.Customer.Name, cfg.Customer.Domain,
stackMgr.GetStacks(), readComposeFile)
// fork-4: attach the non-secret offsite restic repo coordinates so DR knows where to recover from.
if backupMgr != nil {
if host, user, port, repoPath, ok := backupMgr.OffboxCoord(); ok {
r.DRRecipe.OffsiteRestic = &DRResticCoord{Host: host, User: user, Port: port, RepoPath: repoPath}
}
}
if debug && logger != nil {
logger.Printf("[DEBUG] [report] BuildReport: complete — containers=%d, health=%s, deployed=%d, available=%d, app_telemetry=%d",
+13
View File
@@ -36,6 +36,19 @@ type DRRecipeAppHalf struct {
RecipeVersion int `json:"recipe_version"`
Customer DRCustomer `json:"customer"`
Apps []AppRecipe `json:"apps"`
// OffsiteRestic (fork-4) is the non-secret location of the offsite restic repo, so DR knows WHERE to
// recover from. nil when offsite is not configured. Coordinates ONLY — see DRResticCoord.
OffsiteRestic *DRResticCoord `json:"offsite_restic,omitempty"`
}
// DRResticCoord is the offsite restic repo's non-secret coordinates. The repo PASSWORD rides the R-escrow
// (IdentityBundle.ResticRepoPassword); the SFTP access key is regenerated at DR (a fresh sub-account key) —
// so NEITHER appears here. All field names deliberately clear the _NoSecrets regex (no password/key/token).
type DRResticCoord struct {
Host string `json:"host"`
User string `json:"user"`
Port int `json:"port"`
RepoPath string `json:"repo_path"`
}
// DRCustomer is the customer identity — public identifiers only.
@@ -99,6 +99,36 @@ func TestBuildAppRecipe_AllowlistIsLoadBearing(t *testing.T) {
}
}
// Scenario E (fork-4) — the OffsiteRestic DR coord carries coordinates ONLY (no password/key), clears the
// secret-name regex, and its fields are emitted. Extends the _NoSecrets boundary to the new field.
func TestDRResticCoord_NoSecrets(t *testing.T) {
half := &DRRecipeAppHalf{
RecipeVersion: DRRecipeVersion,
Customer: DRCustomer{ID: "cust", Display: "Cust", Domain: "demo-felhom.eu"},
Apps: []AppRecipe{},
OffsiteRestic: &DRResticCoord{
Host: "u629193-sub1.your-storagebox.de", User: "u629193-sub1", Port: 23, RepoPath: "/home/felhom-demo-repo",
},
}
b, err := json.Marshal(half)
if err != nil {
t.Fatal(err)
}
// (1) no credential-shaped KEY name survived (the DRResticCoord field names must clear the regex).
assertNoSecretKeys(t, b)
out := string(b)
// (2) positive: the coordinate fields ARE emitted (not a vacuous pass).
for _, want := range []string{`"offsite_restic"`, `"host":"u629193-sub1.your-storagebox.de"`, `"user":"u629193-sub1"`, `"port":23`, `"repo_path":"/home/felhom-demo-repo"`} {
if !strings.Contains(out, want) {
t.Errorf("DR coord missing %s in %s", want, out)
}
}
// (3) the password/key field names must never appear.
if strings.Contains(out, "repo_password") || strings.Contains(out, "ssh_key") || strings.Contains(out, "password") {
t.Fatalf("DR coord must carry NO password/key: %s", out)
}
}
func TestAppStorageBindings(t *testing.T) {
got := appStorageBindings(rommCompose, "/mnt/felhom-drives/felhom-flash")
want := map[string]StorageBinding{