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) } }