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:
@@ -70,12 +70,21 @@ func (f *fakeRecoveryProvider) StartStackServices(_ string, services []string) e
|
||||
return f.startSvcErr
|
||||
}
|
||||
|
||||
// TestCaptureRecoveryUnitIsSecretFree proves the captured unit (a) contains compose+config+manifest,
|
||||
// (b) enumerates the existing dumps, and (c) is SECRET-FREE: a secret value present in the SOURCE
|
||||
// app.yaml does NOT appear anywhere in the unit, because the capture writes the stripped NonSecretEnv
|
||||
// (not the raw app.yaml). The manifest records the secret NAMES + data_key flag for recovery-from-guest.
|
||||
func TestCaptureRecoveryUnitIsSecretFree(t *testing.T) {
|
||||
const secretVal = "SUPERSECRETVALUE-do-not-leak"
|
||||
// TestCaptureRecoveryUnitCarriesPortableSecretsOnly proves the captured unit (a) contains
|
||||
// compose+config+manifest, (b) enumerates the existing dumps, and (c) implements the D5 secret split:
|
||||
// the PORTABLE class is written into the unit's app.yaml, and the WITHHELD class appears NOWHERE in
|
||||
// the unit.
|
||||
//
|
||||
// This test replaces TestCaptureRecoveryUnitIsSecretFree, whose global "no secret value appears in the
|
||||
// unit" invariant D5 deliberately overturns for the portable class. The wrong-outcome half — the
|
||||
// withheld value must still leak nowhere — is kept verbatim, because that is the half that is still a
|
||||
// security boundary.
|
||||
func TestCaptureRecoveryUnitCarriesPortableSecretsOnly(t *testing.T) {
|
||||
const (
|
||||
dataKeyVal = "DATAKEY-must-travel-or-the-data-is-unreadable"
|
||||
dbPwVal = "DBPASSWORD-must-travel-or-the-app-cannot-authenticate"
|
||||
withheldVal = "ADMINLOGIN-must-never-reach-the-drive"
|
||||
)
|
||||
tmp := t.TempDir()
|
||||
stackDir := filepath.Join(tmp, "stack")
|
||||
drive := filepath.Join(tmp, "drive") // in-guest namespace root (basename need not be felhom-data)
|
||||
@@ -83,25 +92,30 @@ func TestCaptureRecoveryUnitIsSecretFree(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Source stack files — the raw app.yaml DELIBERATELY holds a secret to prove it's not copied.
|
||||
mustWrite(t, filepath.Join(stackDir, "docker-compose.yml"),
|
||||
"services:\n app:\n image: example/app:1.2.3\n")
|
||||
mustWrite(t, filepath.Join(stackDir, ".felhom.yml"), "display_name: Example\n")
|
||||
// The SOURCE app.yaml holds the withheld admin login too, so the leak check below is not vacuous:
|
||||
// it fails if anything ever copies the raw app.yaml into the unit instead of the generated one.
|
||||
mustWrite(t, filepath.Join(stackDir, "app.yaml"),
|
||||
"deployed: true\nenv:\n DB_PASSWORD: "+secretVal+"\n SUBDOMAIN: example\n")
|
||||
"deployed: true\nenv:\n DB_PASSWORD: "+dbPwVal+"\n ADMIN_PASSWORD: "+withheldVal+
|
||||
"\n SUBDOMAIN: example\n")
|
||||
|
||||
// Pre-existing dumps (written by the dump flow before capture).
|
||||
mustWrite(t, filepath.Join(AppDBDumpPath(drive, "example"), "example-postgres.sql"), "dump")
|
||||
mustWrite(t, filepath.Join(AppVolumeDumpPath(drive, "example"), "example_data.tar"), "tar")
|
||||
|
||||
// RecoveryInfo as the adapter would build it: secret values already stripped from NonSecretEnv.
|
||||
// RecoveryInfo as the adapter builds it: NonSecretEnv holds no secret, PortableSecrets holds the
|
||||
// decrypted portable class, and ADMIN_PASSWORD is named in SecretEnvVars but NOT portable.
|
||||
info := RecoveryInfo{
|
||||
StackDir: stackDir,
|
||||
DisplayName: "Example",
|
||||
ImagePins: []string{"example/app:1.2.3"},
|
||||
NonSecretEnv: map[string]string{"SUBDOMAIN": "example", "HDD_PATH": drive},
|
||||
SecretEnvVars: []string{"DB_PASSWORD", "SECRET_KEY"},
|
||||
DataKeyEnvVars: []string{"SECRET_KEY"},
|
||||
StackDir: stackDir,
|
||||
DisplayName: "Example",
|
||||
ImagePins: []string{"example/app:1.2.3"},
|
||||
NonSecretEnv: map[string]string{"SUBDOMAIN": "example", "HDD_PATH": drive},
|
||||
SecretEnvVars: []string{"DB_PASSWORD", "SECRET_KEY", "ADMIN_PASSWORD"},
|
||||
DataKeyEnvVars: []string{"SECRET_KEY"},
|
||||
PortableSecretEnvVars: []string{"DB_PASSWORD", "SECRET_KEY"},
|
||||
PortableSecrets: map[string]string{"DB_PASSWORD": dbPwVal, "SECRET_KEY": dataKeyVal},
|
||||
}
|
||||
m := &Manager{
|
||||
logger: log.New(io.Discard, "", 0),
|
||||
@@ -136,8 +150,8 @@ func TestCaptureRecoveryUnitIsSecretFree(t *testing.T) {
|
||||
if len(man.ImagePins) != 1 || man.ImagePins[0] != "example/app:1.2.3" {
|
||||
t.Errorf("image pins: %v", man.ImagePins)
|
||||
}
|
||||
if len(man.SecretEnvVars) != 2 {
|
||||
t.Errorf("secret env-var names: %v (want 2)", man.SecretEnvVars)
|
||||
if len(man.SecretEnvVars) != 3 {
|
||||
t.Errorf("secret env-var names: %v (want 3)", man.SecretEnvVars)
|
||||
}
|
||||
if len(man.DataKeyEnvVars) != 1 || man.DataKeyEnvVars[0] != "SECRET_KEY" {
|
||||
t.Errorf("data-key env-vars: %v", man.DataKeyEnvVars)
|
||||
@@ -145,21 +159,48 @@ func TestCaptureRecoveryUnitIsSecretFree(t *testing.T) {
|
||||
if len(man.DBDumps) != 1 || len(man.VolumeDumps) != 1 {
|
||||
t.Errorf("dumps enumerated: db=%v vol=%v", man.DBDumps, man.VolumeDumps)
|
||||
}
|
||||
|
||||
// app.yaml in the unit must carry the non-secret env but NOT the secret value.
|
||||
appy := mustRead(t, filepath.Join(composeDir, "app.yaml"))
|
||||
if !strings.Contains(appy, "SUBDOMAIN") {
|
||||
t.Errorf("stripped app.yaml missing non-secret env: %s", appy)
|
||||
// D5: schema 2 + the carried names, so the restore can tell secrets from plain config.
|
||||
if man.SchemaVersion != 2 {
|
||||
t.Errorf("schema version = %d, want 2 (D5 units carry secrets)", man.SchemaVersion)
|
||||
}
|
||||
if len(man.PortableSecretEnvVars) != 2 {
|
||||
t.Errorf("portable secret names: %v (want DB_PASSWORD + SECRET_KEY)", man.PortableSecretEnvVars)
|
||||
}
|
||||
// The manifest is 0644 — it must record NAMES, never a value.
|
||||
if s := string(mfData); strings.Contains(s, dbPwVal) || strings.Contains(s, dataKeyVal) {
|
||||
t.Error("SECRET LEAK: a secret VALUE reached manifest.json (names only)")
|
||||
}
|
||||
|
||||
// SECRET-FREE invariant: the secret value must not appear ANYWHERE in the unit.
|
||||
// app.yaml in the unit must carry the non-secret env AND the portable secrets.
|
||||
appyPath := filepath.Join(composeDir, "app.yaml")
|
||||
appy := mustRead(t, appyPath)
|
||||
if !strings.Contains(appy, "SUBDOMAIN") {
|
||||
t.Errorf("unit app.yaml missing non-secret env: %s", appy)
|
||||
}
|
||||
// THE CONSEQUENCE of D5 at capture time: without these two values on the drive, a guest-less
|
||||
// restore cannot read the data sitting beside them.
|
||||
if !strings.Contains(appy, dataKeyVal) {
|
||||
t.Error("data-encrypting key did NOT travel — a guest-less restore would be impossible")
|
||||
}
|
||||
if !strings.Contains(appy, dbPwVal) {
|
||||
t.Error("DB password did NOT travel — the restored app could not authenticate to its own data")
|
||||
}
|
||||
// Secret-bearing ⇒ owner-only.
|
||||
if fi, err := os.Stat(appyPath); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if perm := fi.Mode().Perm(); perm != 0600 {
|
||||
t.Errorf("unit app.yaml mode = %04o, want 0600 (it carries secrets)", perm)
|
||||
}
|
||||
|
||||
// THE WRONG-OUTCOME CHECK: the withheld class must appear NOWHERE in the unit. This is the half of
|
||||
// the old secret-free invariant that D5 does not relax.
|
||||
unitRoot := RecoveryUnitPath(drive, "example")
|
||||
_ = filepath.WalkDir(unitRoot, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(mustRead(t, path), secretVal) {
|
||||
t.Errorf("SECRET LEAK: %q found in %s", secretVal, path)
|
||||
if strings.Contains(mustRead(t, path), withheldVal) {
|
||||
t.Errorf("SECRET LEAK: withheld admin login %q found in %s", withheldVal, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user