91e4ace9b8
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
342 lines
14 KiB
Go
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)
|
|
}
|
|
}
|