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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user