R-879: roll-back command felhom-hub -unseal-box-secrets

Opens the four sealed box-secret columns back to plaintext (all or nothing; keeps api_key_hash;
idempotent; counts only in the log) and exits before any start-up sealing, so hub 0.137.0 can run on
the database again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-05 21:24:37 +02:00
parent e221476ff5
commit 91e4ace9b8
5 changed files with 230 additions and 11 deletions
+30
View File
@@ -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
}
+58
View File
@@ -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())
}
}
+18
View File
@@ -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)
}
}
+73 -11
View File
@@ -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
}
+51
View File
@@ -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)
}
}