D5: an app restore works from the drive alone (v0.188.0)

The recovery unit on the customer's drive now carries the PORTABLE secret
class, so Tier-1/Tier-2 restore no longer depends on the whole-guest tier.
A customer needs the drive and nothing else.

Part 0's rulings overturned the brief's recommendation, on evidence:
- the data_key flag is untrustworthy (4+ encryption keys the catalog itself
  labels as such are unflagged) -> R-127
- a DB password is not resettable in practice: POSTGRES_PASSWORD is ignored
  once PGDATA is non-empty, so a regenerated value leaves the app unable to
  authenticate against its own restored rows while the dump replay still
  reports success (proven on a throwaway postgres:16-alpine)

Ruling (operator): type:secret travels, type:password never does, minus the
nonPortableSecrets code register. Plaintext -- withholding the internet-
reachable class is what licenses that, and the two are coupled.

Precedence: the UNIT WINS over the guest -- the unit's secrets were captured
in the same run as the dumps beside them, so they match the data being
restored. The fail-closed data-key gate is unchanged.

Secret values are never logged; the manifest records NAMES only.
This commit is contained in:
2026-07-30 16:33:06 +02:00
parent 2f27a363d5
commit 4ed938cce4
12 changed files with 818 additions and 151 deletions
+42
View File
@@ -822,6 +822,48 @@ func SensitiveEnvVars(meta *Metadata) []string {
return vars
}
// nonPortableSecrets is the register of secrets that must NEVER travel in an on-drive recovery unit
// even though the catalog types them `secret` — i.e. credentials whose reach is NOT bounded by
// physical possession of the drive, because they authenticate against a service published to the
// internet. Keyed by catalog SLUG (never empty — LoadMetadata falls back to the directory name).
//
// It is a CODE register, not a catalog flag, deliberately: the D5 ruling is a security boundary, and
// a boundary a catalog push can silently move is not a boundary (the R-97a lesson — an invariant that
// only configuration enforced). Adding an app whose `type: secret` field gates an internet-reachable
// login means adding a row here.
//
// - vaultwarden/ADMIN_TOKEN gates the /admin panel, served on the app's own public web port.
var nonPortableSecrets = map[string]map[string]bool{
"vaultwarden": {"ADMIN_TOKEN": true},
}
// PortableSecretEnvVars returns the env-var names of secrets that TRAVEL inside the on-drive recovery
// unit (D5), in deterministic metadata order.
//
// The ruling (operator, 2026-07-30): `type: secret` travels, `type: password` does not, minus
// nonPortableSecrets. The line is drawn on REACH, not on whether a secret is nominally resettable:
//
// - Every `type: secret` field either decrypts data sitting on the SAME drive (the 5 declared
// data_keys, plus encryption keys the catalog labels as such but never flagged — see R-127) or
// authenticates to a container on an internal compose network with no external listener (the 18
// DB/root passwords, and the signing secrets). Possessing it adds nothing to possessing the
// drive, which is exactly D2's argument for keeping the DATA plaintext.
// - Every `type: password` field is an admin/UI login for a published service, so its blast radius
// is NOT bounded by the drive. Those stay in the guest and are regenerated on restore (O4).
//
// Excluding the `type: password` class is what licenses the plaintext ruling; the two are coupled and
// must not be relaxed independently.
func PortableSecretEnvVars(meta *Metadata) []string {
blocked := nonPortableSecrets[meta.Slug]
var vars []string
for _, f := range meta.DeployFields {
if f.Type == "secret" && !blocked[f.EnvVar] {
vars = append(vars, f.EnvVar)
}
}
return vars
}
// --- Secret generation ---
const alphanumChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
@@ -0,0 +1,76 @@
package stacks
import "testing"
// TestPortableSecretEnvVars pins the D5 ruling (operator, 2026-07-30) about WHICH secrets may travel
// on the customer's backup drive: `type: secret` travels, `type: password` does not, minus the
// nonPortableSecrets register.
//
// This is a security boundary, so the test asserts the CONSEQUENCE in both directions — what travels
// and, more importantly, what must not. A change that widens the class fails here.
func TestPortableSecretEnvVars(t *testing.T) {
t.Run("type=secret travels, type=password does not", func(t *testing.T) {
meta := &Metadata{Slug: "paperless-ngx", DeployFields: []DeployField{
{EnvVar: "DB_PASSWORD", Type: "secret"},
{EnvVar: "PAPERLESS_SECRET_KEY", Type: "secret"},
{EnvVar: "PAPERLESS_ADMIN_PASSWORD", Type: "password"},
{EnvVar: "SUBDOMAIN", Type: "subdomain"},
}}
got := PortableSecretEnvVars(meta)
want := []string{"DB_PASSWORD", "PAPERLESS_SECRET_KEY"}
if len(got) != len(want) {
t.Fatalf("portable = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] { // order must be stable metadata order (checksum-skip depends on it)
t.Errorf("portable[%d] = %q, want %q (got %v)", i, got[i], want[i], got)
}
}
})
t.Run("an internet-reachable admin login is WITHHELD even when typed secret", func(t *testing.T) {
meta := &Metadata{Slug: "vaultwarden", DeployFields: []DeployField{
{EnvVar: "ADMIN_TOKEN", Type: "secret"},
}}
if got := PortableSecretEnvVars(meta); len(got) != 0 {
t.Errorf("vaultwarden/ADMIN_TOKEN must NOT travel (it gates the public /admin panel), got %v", got)
}
})
t.Run("the register is scoped to its app, not the bare env-var name", func(t *testing.T) {
// Another app's ADMIN_TOKEN is not vaultwarden's and must not be caught by the register.
meta := &Metadata{Slug: "some-other-app", DeployFields: []DeployField{
{EnvVar: "ADMIN_TOKEN", Type: "secret"},
}}
if got := PortableSecretEnvVars(meta); len(got) != 1 || got[0] != "ADMIN_TOKEN" {
t.Errorf("register must be slug-scoped, got %v", got)
}
})
t.Run("no deploy fields — nothing travels", func(t *testing.T) {
if got := PortableSecretEnvVars(&Metadata{Slug: "x"}); len(got) != 0 {
t.Errorf("want empty, got %v", got)
}
})
t.Run("portable is a SUBSET of SensitiveEnvVars", func(t *testing.T) {
// The unit's app.yaml splits env into non-secret + portable using two different helpers; if
// PortableSecretEnvVars ever returned a name SensitiveEnvVars does not, that name would land in
// NonSecretEnv as well and the disjointness GetStackRecoveryInfo relies on would break.
meta := &Metadata{Slug: "nextcloud", DeployFields: []DeployField{
{EnvVar: "DB_PASSWORD", Type: "secret"},
{EnvVar: "MYSQL_ROOT_PASSWORD", Type: "secret"},
{EnvVar: "NEXTCLOUD_ADMIN_PASSWORD", Type: "password"},
{EnvVar: "DOMAIN", Type: "domain"},
}}
sensitive := make(map[string]bool)
for _, n := range SensitiveEnvVars(meta) {
sensitive[n] = true
}
for _, n := range PortableSecretEnvVars(meta) {
if !sensitive[n] {
t.Errorf("%q is portable but not sensitive — it would also land in NonSecretEnv", n)
}
}
})
}