Files
felhom.eu/hub/internal/store/r879_box_seal_test.go
T
admin 91e4ace9b8 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
2026-10-05 21:45:04 +02:00

342 lines
14 KiB
Go

package store
import (
"io"
"log"
"path/filepath"
"strings"
"testing"
)
// R-879: hosts.api_key, customer_configs.retrieval_password / api_key and host_pbs_secrets.value are
// sealed at rest (r879_box_seal.go); box authentication matches on a hash and never needs the key.
const (
r879HostKey = "host-key-canary-0123456789abcdef0123456789abcdef"
r879CustKey = "cust-key-canary-fedcba9876543210fedcba9876543210"
r879Pass = "owner-pass-canary-alma-korte-szilva"
r879PBSToken = "pbs-token-canary-77aa"
)
func r879Raw(t *testing.T, st *Store, q string, args ...any) string {
t.Helper()
var v string
if err := st.db.QueryRow(q, args...).Scan(&v); err != nil {
t.Fatalf("%s: %v", q, err)
}
return v
}
func r879Seed(t *testing.T, st *Store) {
t.Helper()
if err := st.SaveCustomerConfig(&CustomerConfig{CustomerID: "c1", RetrievalPassword: r879Pass, APIKey: r879CustKey, ConfigJSON: "{}"}); err != nil {
t.Fatal(err)
}
if err := st.UpsertHost(&Host{HostID: "h1", CustomerID: "c1", APIKey: r879HostKey}); err != nil {
t.Fatal(err)
}
if _, err := st.SaveHostPBSSecret("h1", r879PBSToken); err != nil {
t.Fatal(err)
}
}
// The consequence: no raw column holds any of the four secrets, and every reveal/compare path still
// returns the right plaintext.
func TestR879_RawRowsHoldNoSecret(t *testing.T) {
st := sealTestStore(t)
r879Seed(t, st)
raws := map[string]string{
"hosts.api_key": r879Raw(t, st, `SELECT api_key FROM hosts WHERE host_id='h1'`),
"customer_configs.api_key": r879Raw(t, st, `SELECT api_key FROM customer_configs WHERE customer_id='c1'`),
"customer_configs.retrieval_password": r879Raw(t, st, `SELECT retrieval_password FROM customer_configs WHERE customer_id='c1'`),
"host_pbs_secrets.value": r879Raw(t, st, `SELECT value FROM host_pbs_secrets WHERE host_id='h1'`),
}
for col, raw := range raws {
for _, canary := range []string{r879HostKey, r879CustKey, r879Pass, r879PBSToken} {
if strings.Contains(raw, canary) {
t.Errorf("%s holds a plaintext secret: %q", col, raw)
}
}
if !strings.HasPrefix(raw, sealPrefix) {
t.Errorf("%s is not sealed: %q", col, raw)
}
}
// Box auth (agent): by key.
h, err := st.GetHostByAPIKey(r879HostKey)
if err != nil || h == nil || h.HostID != "h1" || h.APIKey != r879HostKey {
t.Fatalf("GetHostByAPIKey = %+v, %v", h, err)
}
// Re-serve at re-enroll reads the opened key.
h2, err := st.GetHostByCustomer("c1")
if err != nil || h2 == nil || h2.APIKey != r879HostKey || h2.SecretsUnreadable {
t.Fatalf("GetHostByCustomer = %+v, %v", h2, err)
}
// Controller auth: by key.
c, err := st.GetCustomerConfigByAPIKey(r879CustKey)
if err != nil || c == nil || c.CustomerID != "c1" {
t.Fatalf("GetCustomerConfigByAPIKey = %+v, %v", c, err)
}
// Owner passphrase + customer key served to the box (configgen / compare).
cc, err := st.GetCustomerConfig("c1")
if err != nil || cc.RetrievalPassword != r879Pass || cc.APIKey != r879CustKey || cc.SecretsUnreadable {
t.Fatalf("GetCustomerConfig = %+v, %v", cc, err)
}
list, err := st.ListCustomerConfigs()
if err != nil || len(list) != 1 || list[0].RetrievalPassword != r879Pass {
t.Fatalf("ListCustomerConfigs = %+v, %v", list, err)
}
// PBS-DR token: served once, re-stage serves the same value once more.
if v, err := st.ConsumeHostPBSSecret("h1"); err != nil || v != r879PBSToken {
t.Fatalf("ConsumeHostPBSSecret = %q, %v", v, err)
}
if _, err := st.ConsumeHostPBSSecret("h1"); err == nil {
t.Fatal("PBS token served twice")
}
if ok, err := st.RestageHostPBSSecret("h1"); !ok || err != nil {
t.Fatalf("restage = %v, %v", ok, err)
}
if v, err := st.ConsumeHostPBSSecret("h1"); err != nil || v != r879PBSToken {
t.Fatalf("re-staged ConsumeHostPBSSecret = %q, %v", v, err)
}
// Passphrase regen and key rotation stay sealed and keep working.
if err := st.UpdateRetrievalPassword("c1", "new-pass-9"); err != nil {
t.Fatal(err)
}
if raw := r879Raw(t, st, `SELECT retrieval_password FROM customer_configs WHERE customer_id='c1'`); !strings.HasPrefix(raw, sealPrefix) {
t.Fatalf("regenerated passphrase not sealed: %q", raw)
}
if cc, _ := st.GetCustomerConfig("c1"); cc.RetrievalPassword != "new-pass-9" {
t.Fatalf("regenerated passphrase = %q", cc.RetrievalPassword)
}
if err := st.RotateHostAPIKey("h1", "rotated-key-1"); err != nil {
t.Fatal(err)
}
if h, _ := st.GetHostByAPIKey(r879HostKey); h != nil {
t.Fatal("the old key still authenticates after a rotation")
}
if h, _ := st.GetHostByAPIKey("rotated-key-1"); h == nil || h.HostID != "h1" {
t.Fatal("the rotated key does not authenticate")
}
if raw := r879Raw(t, st, `SELECT api_key FROM hosts WHERE host_id='h1'`); !strings.HasPrefix(raw, sealPrefix) {
t.Fatalf("rotated key not sealed: %q", raw)
}
if h, _ := st.GetHostByAPIKey("wrong-key"); h != nil {
t.Fatal("a wrong key authenticated")
}
}
// A sealed blob copied out of hub.db is not a key, and the empty key matches nothing.
func TestR879_SealedValueIsNotAKey(t *testing.T) {
st := sealTestStore(t)
r879Seed(t, st)
rawHost := r879Raw(t, st, `SELECT api_key FROM hosts WHERE host_id='h1'`)
rawCust := r879Raw(t, st, `SELECT api_key FROM customer_configs WHERE customer_id='c1'`)
if h, _ := st.GetHostByAPIKey(rawHost); h != nil {
t.Fatal("the sealed column value authenticated as a host")
}
if c, _ := st.GetCustomerConfigByAPIKey(rawCust); c != nil {
t.Fatal("the sealed column value authenticated as a controller")
}
// Even a hand-planted legacy-looking row (no hash, sealed value) cannot be matched by its blob.
if _, err := st.db.Exec(`UPDATE hosts SET api_key_hash = '' WHERE host_id='h1'`); err != nil {
t.Fatal(err)
}
if h, _ := st.GetHostByAPIKey(rawHost); h != nil {
t.Fatal("a sealed blob matched through the plaintext fallback")
}
if _, err := st.db.Exec(`INSERT INTO hosts (host_id, customer_id, api_key) VALUES ('empty', 'c9', '')`); err != nil {
t.Fatal(err)
}
if h, _ := st.GetHostByAPIKey(""); h != nil {
t.Fatal("the empty key authenticated")
}
}
// The migration: rows written in plaintext by an older hub (no hash column filled) get their hash at
// store open (keyless) and are sealed by SealLegacyBoxSecrets — once; a second run is a no-op.
func TestR879_MigrationSealsLegacyRowsIdempotently(t *testing.T) {
path := filepath.Join(t.TempDir(), "hub.db")
st, err := New(path, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
// Legacy rows exactly as a pre-R-879 hub wrote them.
for _, q := range []string{
`INSERT INTO hosts (host_id, customer_id, api_key) VALUES ('h1', 'c1', '` + r879HostKey + `')`,
`INSERT INTO customer_configs (customer_id, retrieval_password, api_key) VALUES ('c1', '` + r879Pass + `', '` + r879CustKey + `')`,
`INSERT INTO host_pbs_secrets (host_id, value) VALUES ('h1', '` + r879PBSToken + `')`,
} {
if _, err := st.db.Exec(q); err != nil {
t.Fatal(err)
}
}
st.Close()
st, err = New(path, log.New(io.Discard, "", 0)) // the upgrade start: migrate() backfills the hashes
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if got := r879Raw(t, st, `SELECT api_key_hash FROM hosts WHERE host_id='h1'`); got != apiKeyHash(r879HostKey) {
t.Fatalf("hosts.api_key_hash not backfilled: %q", got)
}
if got := r879Raw(t, st, `SELECT api_key_hash FROM customer_configs WHERE customer_id='c1'`); got != apiKeyHash(r879CustKey) {
t.Fatalf("customer_configs.api_key_hash not backfilled: %q", got)
}
n, err := st.SealLegacyBoxSecrets()
if err != nil || n != 4 {
t.Fatalf("SealLegacyBoxSecrets = %d, %v — want the four plaintext values", n, err)
}
for _, q := range []string{
`SELECT api_key FROM hosts WHERE host_id='h1'`,
`SELECT api_key FROM customer_configs WHERE customer_id='c1'`,
`SELECT retrieval_password FROM customer_configs WHERE customer_id='c1'`,
`SELECT value FROM host_pbs_secrets WHERE host_id='h1'`,
} {
if raw := r879Raw(t, st, q); !strings.HasPrefix(raw, sealPrefix) {
t.Fatalf("%s not sealed: %q", q, raw)
}
}
if n, err := st.SealLegacyBoxSecrets(); err != nil || n != 0 {
t.Fatalf("second SealLegacyBoxSecrets = %d, %v — want a no-op", n, err)
}
if h, _ := st.GetHostByAPIKey(r879HostKey); h == nil {
t.Fatal("the box no longer authenticates after the migration")
}
if c, _ := st.GetCustomerConfigByAPIKey(r879CustKey); c == nil {
t.Fatal("the controller no longer authenticates after the migration")
}
if cc, _ := st.GetCustomerConfig("c1"); cc.RetrievalPassword != r879Pass {
t.Fatalf("passphrase lost in the migration: %q", cc.RetrievalPassword)
}
if v, err := st.ConsumeHostPBSSecret("h1"); err != nil || v != r879PBSToken {
t.Fatalf("PBS token lost in the migration: %q, %v", v, err)
}
}
// A failed migration must not lock any box out: (a) the hash backfill never ran AND there is no key —
// plaintext rows still authenticate; (b) rows are sealed and the hub's key is then wrong or missing —
// boxes still authenticate (hash), while reads flag the record unreadable and saves refuse it.
func TestR879_BoxAuthSurvivesFailedSealing(t *testing.T) {
st := sealTestStore(t)
// (a) legacy plaintext rows with no hash, and no key at all.
if _, err := st.db.Exec(`INSERT INTO hosts (host_id, customer_id, api_key) VALUES ('old', 'c0', 'old-plain-key')`); err != nil {
t.Fatal(err)
}
if _, err := st.db.Exec(`INSERT INTO customer_configs (customer_id, retrieval_password, api_key) VALUES ('c0', 'old-pass', 'old-cust-key')`); err != nil {
t.Fatal(err)
}
st.sealer = nil
if h, _ := st.GetHostByAPIKey("old-plain-key"); h == nil || h.HostID != "old" {
t.Fatal("a legacy unhashed plaintext host key no longer authenticates")
}
if c, _ := st.GetCustomerConfigByAPIKey("old-cust-key"); c == nil || c.CustomerID != "c0" {
t.Fatal("a legacy unhashed plaintext controller key no longer authenticates")
}
if cc, _ := st.GetCustomerConfig("c0"); cc == nil || cc.RetrievalPassword != "old-pass" || cc.SecretsUnreadable {
t.Fatalf("legacy plaintext passphrase unreadable: %+v", cc)
}
if _, err := st.SealLegacyBoxSecrets(); err != ErrNoSealKey {
t.Fatalf("SealLegacyBoxSecrets without a key = %v, want ErrNoSealKey", err)
}
// No key → a NEW secret is refused, never written in plaintext.
if err := st.UpsertHost(&Host{HostID: "n", CustomerID: "c0", APIKey: "new-key"}); err != ErrNoSealKey {
t.Fatalf("UpsertHost without a key = %v, want ErrNoSealKey", err)
}
if _, err := st.SaveHostPBSSecret("old", "tok"); err != ErrNoSealKey {
t.Fatalf("SaveHostPBSSecret without a key = %v, want ErrNoSealKey", err)
}
// (b) sealed rows, then the hub restarts with a WRONG key.
if err := st.SetOffsiteSecretKey([]byte("felhom-hub-test-only-seal-key-32")); err != nil {
t.Fatal(err)
}
r879Seed(t, st)
if err := st.SetOffsiteSecretKey([]byte("another-key-of-exactly-32-bytes!")); err != nil {
t.Fatal(err)
}
if h, _ := st.GetHostByAPIKey(r879HostKey); h == nil || h.HostID != "h1" {
t.Fatal("with a wrong sealing key the box is locked out")
}
if c, _ := st.GetCustomerConfigByAPIKey(r879CustKey); c == nil || c.CustomerID != "c1" {
t.Fatal("with a wrong sealing key the controller is locked out")
}
h, err := st.GetHost("h1")
if err != nil || h == nil || !h.SecretsUnreadable || h.APIKey != "" {
t.Fatalf("GetHost with a wrong key = %+v, %v — want SecretsUnreadable and no key", h, err)
}
if err := st.UpsertHost(h); err == nil {
t.Fatal("UpsertHost saved a host whose key did not open — it would blank the stored key")
}
cc, err := st.GetCustomerConfig("c1")
if err != nil || cc == nil || !cc.SecretsUnreadable || cc.RetrievalPassword != "" {
t.Fatalf("GetCustomerConfig with a wrong key = %+v, %v", cc, err)
}
if err := st.SaveCustomerConfig(cc); err == nil {
t.Fatal("SaveCustomerConfig saved a config whose secrets did not open — it would blank them")
}
// The PBS token is not burned by a failed open: it stays un-consumed for the retry.
if _, err := st.ConsumeHostPBSSecret("h1"); err == nil {
t.Fatal("a PBS token that does not open was served")
}
if err := st.SetOffsiteSecretKey([]byte("felhom-hub-test-only-seal-key-32")); err != nil {
t.Fatal(err)
}
if v, err := st.ConsumeHostPBSSecret("h1"); err != nil || v != r879PBSToken {
t.Fatalf("after the key is fixed the PBS token = %q, %v — the failed open burned it", v, err)
}
if cc, _ := st.GetCustomerConfig("c1"); cc.RetrievalPassword != r879Pass {
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)
}
}