diff --git a/hub/cmd/hub/main.go b/hub/cmd/hub/main.go index a574e94d..2eae95ec 100644 --- a/hub/cmd/hub/main.go +++ b/hub/cmd/hub/main.go @@ -136,6 +136,9 @@ func (m MailConfig) effectiveFromDomains() []string { func main() { configPath := flag.String("config", "/etc/felhom-hub/hub.yaml", "Path to configuration file") showVersion := flag.Bool("version", false, "Show version and exit") + // R-879 roll-back: open the sealed box secrets back to plaintext so a pre-R-879 hub (<= 0.137.0) + // can run on this database, then exit. Same -config and OFFSITE_SECRET_KEY as the server. + unsealBox := flag.Bool("unseal-box-secrets", false, "R-879 roll-back: unseal box API keys, passphrases and PBS tokens to plaintext, then exit") flag.Parse() if *showVersion { @@ -188,6 +191,11 @@ func main() { // that is NOT in the database (Secret/offsite-secret-key). Without it the hub cannot store, read or // use any sub-account password — provisioning and the key registrar refuse (fail-closed), and the // legacy plaintext rows stay as they are until the key arrives. + if *unsealBox { + code := runUnsealBoxSecrets(dataStore, logger) + dataStore.Close() + os.Exit(code) + } offsiteKeyReady := false if v := os.Getenv("OFFSITE_SECRET_KEY"); v == "" { logger.Printf("[ERROR] OFFSITE_SECRET_KEY unset — off-site passwords cannot be sealed; provisioning and the key registrar are DISABLED") @@ -1015,3 +1023,25 @@ func pruneAll(s *store.Store, maxDays int, logger *log.Logger) { logger.Printf("[INFO] Pruned %d stale app issues", n) } } + +// runUnsealBoxSecrets is `felhom-hub -unseal-box-secrets` (R-879 roll-back). It installs the sealing key +// exactly as the server does, opens every sealed box secret back to plaintext (all or nothing), logs +// counts only, and returns the process exit code. It never starts the server and never seals anything. +func runUnsealBoxSecrets(st *store.Store, logger *log.Logger) int { + key, err := store.ParseOffsiteSecretKey(os.Getenv("OFFSITE_SECRET_KEY")) + if err != nil { + logger.Printf("[ERROR] unseal-box-secrets: OFFSITE_SECRET_KEY: %v — nothing changed", err) + return 2 + } + if err := st.SetOffsiteSecretKey(key); err != nil { + logger.Printf("[ERROR] unseal-box-secrets: %v — nothing changed", err) + return 2 + } + n, err := st.UnsealBoxSecrets() + if err != nil { + logger.Printf("[ERROR] unseal-box-secrets: %v", err) + return 1 + } + logger.Printf("[INFO] unseal-box-secrets: %d sealed value(s) opened back to plaintext; a hub <= 0.137.0 can run on this database now", n) + return 0 +} diff --git a/hub/cmd/hub/r879_unseal_cmd_test.go b/hub/cmd/hub/r879_unseal_cmd_test.go new file mode 100644 index 00000000..fd1c3459 --- /dev/null +++ b/hub/cmd/hub/r879_unseal_cmd_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "bytes" + "database/sql" + "encoding/hex" + "log" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" + _ "modernc.org/sqlite" +) + +// R-879 roll-back command, run the way `felhom-hub -unseal-box-secrets` runs it: key from the env, exit +// code, counts-only log. Afterwards the 0.137.0-shaped `WHERE api_key = ?` finds the box again. +func TestR879_UnsealCommand(t *testing.T) { + path := filepath.Join(t.TempDir(), "hub.db") + st, err := store.New(path, log.New(&bytes.Buffer{}, "", 0)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + const hostKey = "cmd-host-key-0123456789abcdef" + if err := st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: hostKey}); err != nil { + t.Fatal(err) + } + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + defer db.Close() + oldLookup := func() string { + var id string + _ = db.QueryRow(`SELECT host_id FROM hosts WHERE api_key = ?`, hostKey).Scan(&id) + return id + } + if oldLookup() != "" { + t.Fatal("precondition: the key should be sealed") + } + + var logBuf bytes.Buffer + t.Setenv("OFFSITE_SECRET_KEY", "") + if code := runUnsealBoxSecrets(st, log.New(&logBuf, "", 0)); code == 0 { + t.Fatal("unseal with no key exited 0") + } + t.Setenv("OFFSITE_SECRET_KEY", hex.EncodeToString([]byte("felhom-hub-test-only-seal-key-32"))) + if code := runUnsealBoxSecrets(st, log.New(&logBuf, "", 0)); code != 0 { + t.Fatalf("unseal exit = %d, log: %s", code, logBuf.String()) + } + if got := oldLookup(); got != "h1" { + t.Fatalf("0.137.0-shaped lookup after the command = %q, want h1", got) + } + if strings.Contains(logBuf.String(), hostKey) || !strings.Contains(logBuf.String(), "1 sealed value(s) opened") { + t.Fatalf("log must carry the count and never the value: %s", logBuf.String()) + } +} diff --git a/hub/cmd/hub/r879_wiring_test.go b/hub/cmd/hub/r879_wiring_test.go index 9efd1d3d..3bcc5abf 100644 --- a/hub/cmd/hub/r879_wiring_test.go +++ b/hub/cmd/hub/r879_wiring_test.go @@ -4,6 +4,8 @@ import ( "go/ast" "go/parser" "go/token" + "os" + "strings" "testing" ) @@ -27,3 +29,19 @@ func TestR879_MainSealsLegacyBoxSecrets(t *testing.T) { t.Fatal("cmd/hub/main.go never calls SealLegacyBoxSecrets — legacy box secrets stay in plaintext") } } + +// R-879 roll-back wiring: the -unseal-box-secrets flag must reach UnsealBoxSecrets and must exit BEFORE +// the start-up sealing runs (otherwise the command would unseal and the same process re-seal). +func TestR879_UnsealFlagIsWiredBeforeSealing(t *testing.T) { + src, err := os.ReadFile("main.go") + if err != nil { + t.Fatal(err) + } + s := string(src) + flagAt := strings.Index(s, `"unseal-box-secrets"`) + runAt := strings.Index(s, "code := runUnsealBoxSecrets(dataStore, logger)") + sealAt := strings.Index(s, "dataStore.SealLegacyBoxSecrets()") + if flagAt < 0 || runAt < 0 || sealAt < 0 || runAt > sealAt || !strings.Contains(s, "st.UnsealBoxSecrets()") { + t.Fatalf("unseal wiring broken: flag@%d run@%d seal@%d", flagAt, runAt, sealAt) + } +} diff --git a/hub/internal/store/r879_box_seal.go b/hub/internal/store/r879_box_seal.go index 29e11711..59c6c840 100644 --- a/hub/internal/store/r879_box_seal.go +++ b/hub/internal/store/r879_box_seal.go @@ -114,18 +114,8 @@ func (s *Store) SealLegacyBoxSecrets() (int, error) { if s.sealer == nil { return 0, ErrNoSealKey } - type col struct { - table, idCol, col string - withHash bool - } - cols := []col{ - {"hosts", "host_id", "api_key", true}, - {"customer_configs", "customer_id", "api_key", true}, - {"customer_configs", "customer_id", "retrieval_password", false}, - {"host_pbs_secrets", "host_id", "value", false}, - } n := 0 - for _, c := range cols { + for _, c := range r879Columns { rows, err := s.db.Query(`SELECT ` + c.idCol + `, ` + c.col + ` FROM ` + c.table + ` WHERE ` + c.col + ` <> '' AND ` + c.col + ` NOT LIKE 'enc:v1:%'`) if err != nil { @@ -174,3 +164,75 @@ func (s *Store) sealAPIKey(key string) (sealed, hash string, err error) { } return sealed, hash, nil } + +// r879Columns are the four sealed box-facing columns, in one place for the seal and the reverse. +var r879Columns = []struct { + table, idCol, col string + withHash bool +}{ + {"hosts", "host_id", "api_key", true}, + {"customer_configs", "customer_id", "api_key", true}, + {"customer_configs", "customer_id", "retrieval_password", false}, + {"host_pbs_secrets", "host_id", "value", false}, +} + +// UnsealBoxSecrets is the ROLL-BACK primitive for R-879: it opens every sealed value in the four +// columns back to plaintext, so a hub older than R-879 (which looks keys up with `WHERE api_key = ?` +// and serves the passphrase column as it is) works on this database again. api_key_hash is kept +// (an older hub ignores it; a newer one re-seals at its next start). +// +// All or nothing: every sealed value is opened FIRST; if any one does not open (missing/wrong key, +// corrupt) it returns an error and changes NOTHING. The writes then go in one transaction, each guarded +// by the sealed value it read (a row rewritten meanwhile is left alone and not counted). Idempotent: a +// second run finds nothing sealed and returns 0. Values are never logged. +// Run by `felhom-hub -unseal-box-secrets` (cmd/hub). Pinned by TestR879_UnsealRestoresPreR879Lookup. +func (s *Store) UnsealBoxSecrets() (int, error) { + if s.sealer == nil { + return 0, ErrNoSealKey + } + type todo struct { + table, idCol, col, id, sealed, plain string + } + var all []todo + for _, c := range r879Columns { + rows, err := s.db.Query(`SELECT ` + c.idCol + `, ` + c.col + ` FROM ` + c.table + ` WHERE ` + c.col + ` LIKE 'enc:v1:%'`) + if err != nil { + return 0, fmt.Errorf("%s.%s: %w", c.table, c.col, err) + } + for rows.Next() { + t := todo{table: c.table, idCol: c.idCol, col: c.col} + if err := rows.Scan(&t.id, &t.sealed); err != nil { + rows.Close() + return 0, err + } + all = append(all, t) + } + rows.Close() + } + for i := range all { + pt, err := s.openSecret(all[i].sealed) + if err != nil { + return 0, fmt.Errorf("%s.%s for %s does not open — nothing was changed: %w", all[i].table, all[i].col, all[i].id, err) + } + all[i].plain = pt + } + tx, err := s.db.Begin() + if err != nil { + return 0, err + } + defer tx.Rollback() + n := 0 + for _, t := range all { + res, err := tx.Exec(`UPDATE `+t.table+` SET `+t.col+` = ? WHERE `+t.idCol+` = ? AND `+t.col+` = ?`, t.plain, t.id, t.sealed) + if err != nil { + return 0, fmt.Errorf("%s.%s: %w", t.table, t.col, err) + } + if k, _ := res.RowsAffected(); k > 0 { + n++ + } + } + if err := tx.Commit(); err != nil { + return 0, err + } + return n, nil +} diff --git a/hub/internal/store/r879_box_seal_test.go b/hub/internal/store/r879_box_seal_test.go index 19dddd0d..7455e84c 100644 --- a/hub/internal/store/r879_box_seal_test.go +++ b/hub/internal/store/r879_box_seal_test.go @@ -288,3 +288,54 @@ func TestR879_BoxAuthSurvivesFailedSealing(t *testing.T) { t.Fatal("the stored passphrase was damaged while the key was wrong") } } + +// The roll-back: after UnsealBoxSecrets a hub older than R-879 works on the database again — its lookup +// is literally `WHERE api_key = ?`, and it serves retrieval_password / host_pbs_secrets.value as stored. +// With a wrong key nothing changes; a second run is a no-op. +func TestR879_UnsealRestoresPreR879Lookup(t *testing.T) { + st := sealTestStore(t) + r879Seed(t, st) + before := r879Raw(t, st, `SELECT api_key FROM hosts WHERE host_id='h1'`) + + // Wrong key: refused, all or nothing. + if err := st.SetOffsiteSecretKey([]byte("another-key-of-exactly-32-bytes!")); err != nil { + t.Fatal(err) + } + if n, err := st.UnsealBoxSecrets(); err == nil || n != 0 { + t.Fatalf("UnsealBoxSecrets with a wrong key = %d, %v — want a refusal", n, err) + } + if got := r879Raw(t, st, `SELECT api_key FROM hosts WHERE host_id='h1'`); got != before { + t.Fatal("a refused unseal changed a row") + } + if err := st.SetOffsiteSecretKey([]byte("felhom-hub-test-only-seal-key-32")); err != nil { + t.Fatal(err) + } + + n, err := st.UnsealBoxSecrets() + if err != nil || n != 4 { + t.Fatalf("UnsealBoxSecrets = %d, %v — want the four sealed values", n, err) + } + // The 0.137.0-shaped queries, verbatim. + if got := r879Raw(t, st, `SELECT host_id FROM hosts WHERE api_key = ?`, r879HostKey); got != "h1" { + t.Fatalf("pre-R-879 host lookup found %q", got) + } + if got := r879Raw(t, st, `SELECT customer_id FROM customer_configs WHERE api_key = ?`, r879CustKey); got != "c1" { + t.Fatalf("pre-R-879 controller lookup found %q", got) + } + if got := r879Raw(t, st, `SELECT retrieval_password FROM customer_configs WHERE customer_id='c1'`); got != r879Pass { + t.Fatalf("passphrase column after unseal = %q", got) + } + if got := r879Raw(t, st, `SELECT value FROM host_pbs_secrets WHERE host_id='h1'`); got != r879PBSToken { + t.Fatalf("PBS token column after unseal = %q", got) + } + if n, err := st.UnsealBoxSecrets(); err != nil || n != 0 { + t.Fatalf("second UnsealBoxSecrets = %d, %v — want a no-op", n, err) + } + // And forward again: the current code still authenticates, and a re-seal works. + if h, _ := st.GetHostByAPIKey(r879HostKey); h == nil { + t.Fatal("after an unseal the current hub no longer authenticates the box") + } + if n, err := st.SealLegacyBoxSecrets(); err != nil || n != 4 { + t.Fatalf("re-seal after unseal = %d, %v", n, err) + } +}