R-879: seal box API keys, owner passphrases, controller keys and PBS-DR tokens at rest

hosts.api_key, customer_configs.api_key / retrieval_password and host_pbs_secrets.value now hold the
R-821/R-133 seal (enc:v1:, OFFSITE_SECRET_KEY). The two API keys get an api_key_hash lookup twin
(SHA-256, backfilled keyless in migrate()), so box authentication never needs the sealing key; a row
with no hash is matched on its plaintext only while it is plaintext. SealLegacyBoxSecrets seals legacy
rows at start-up (idempotent, non-fatal). A sealed value that does not open sets SecretsUnreadable:
serve/compare paths answer 500, saves refuse the record, the PBS token is not burned.

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:18:14 +02:00
parent 86def579ed
commit 5f060e3d1e
12 changed files with 781 additions and 27 deletions
+1
View File
@@ -74,6 +74,7 @@
| `offsitekeys.Registrar` (`Install` / `Confirm` / `Audit` / `OpenWindow` / `CloseWindow` / `MoveAside`) | hub/internal/offsitekeys/offsitekeys.go | `(ctx, Target, password, …)` | EVERY write to a sub-account's `.ssh/authorized_keys` and every repo move-aside | **The only writer of that file, and the only deleter on a sub-account (`DeleteSetAside`: `<repo>.orphaned-*` only, decision 74).** `read()` is read-only (R-827). Uses the provider's port-23 restricted shell (`dd of=` takes stdin, `mv` overwrites, `test` does NOT exist — measured); an unpinned line is a deletion route and is dropped on every install; the window line goes FIRST (first match wins). Never `rm`. |
| `offsitekeys.Service` (`RegisterKey`, `ConfirmKey`, `AuditAll`, `OpenWindowFor`, `CloseWindowFor`, `SweepExpiredWindows`) | hub/internal/offsitekeys/service.go | — | Binding the registrar to the store, descriptor and operator events | The box-facing API (`/api/v1/offsite/register-key…`) answers with NO credential — pinned by `TestOffsiteKeyEndpoints_AuthAndNoPasswordInAnyResponse`. |
| `(*Store).SaveOneTimeSecret` / `OffsitePassword` / `SealLegacyOffsiteSecrets` | hub/internal/store/offsite_seal.go | — | Storing / reading the sub-account password | **Sealed AES-256-GCM; no key → refused (fail-closed).** Under `go test` every store gets a fixed key (`testing.Testing()`); production needs `OFFSITE_SECRET_KEY`. Never serve the value to a box. |
| `(*Store).sealAtRest` / `openAtRest` / `sealAPIKey` / `apiKeyHash` / `SealLegacyBoxSecrets` | hub/internal/store/r879_box_seal.go | — | Any box-facing secret column (`hosts.api_key`, `customer_configs.api_key` / `retrieval_password`, `host_pbs_secrets.value`) | **R-879: same seal; a looked-up key is matched on `api_key_hash`, never opened** — box auth needs no sealing key. A sealed value that does not open sets `SecretsUnreadable` (field ""): serve/compare paths 500 on it and `SaveCustomerConfig` / `UpsertHost` refuse it (a load-modify-save would blank the secret). A new writer of an API key must write the hash in the same statement (`sealAPIKey`). Pinned by `store/r879_box_seal_test.go`, `api/r879_box_seal_test.go`. |
### Host views & lifecycle / offsite endpoints (v0.47.0, hub/internal/web + store)
+7
View File
@@ -208,6 +208,13 @@ func main() {
} else {
logger.Printf("[INFO] console passwords sealed at rest (%d legacy plaintext row(s) sealed now)", n)
}
// R-879: the box API keys, owner passphrases, controller API keys and PBS-DR tokens — same key,
// same seal. A failure part-way is logged, never fatal: boxes still authenticate (hash lookup).
if n, serr := dataStore.SealLegacyBoxSecrets(); serr != nil {
logger.Printf("[ERROR] sealing legacy box secrets failed after %d value(s): %v", n, serr)
} else {
logger.Printf("[INFO] box secrets sealed at rest (%d legacy plaintext value(s) sealed now)", n)
}
}
logger.Printf("[INFO] Database opened at %s", dbPath)
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"testing"
)
// R-879 seam wiring: main() must CALL SealLegacyBoxSecrets, or every box key, owner passphrase and PBS
// token written before the change stays in plaintext in hub.db. RED-PROOF: comment the call out → FAIL.
func TestR879_MainSealsLegacyBoxSecrets(t *testing.T) {
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
found := false
ast.Inspect(f, func(n ast.Node) bool {
if c, ok := n.(*ast.CallExpr); ok {
if sel, ok := c.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "SealLegacyBoxSecrets" {
found = true
}
}
return true
})
if !found {
t.Fatal("cmd/hub/main.go never calls SealLegacyBoxSecrets — legacy box secrets stay in plaintext")
}
}
+2 -2
View File
@@ -208,8 +208,8 @@ func (h *Handler) handleAppliancePoll(w http.ResponseWriter, r *http.Request) {
return
}
cc, err := h.store.GetCustomerConfig(appl.CustomerID)
if err != nil || cc == nil {
h.logger.Printf("[ERROR] appliance deliver: bound customer %q missing: %v", appl.CustomerID, err)
if err != nil || cc == nil || cc.SecretsUnreadable { // R-879: never deliver an empty passphrase
h.logger.Printf("[ERROR] appliance deliver: bound customer %q missing or its sealed secrets do not open: %v", appl.CustomerID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
+30
View File
@@ -1101,6 +1101,12 @@ func (h *Handler) handleHostEnroll(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Not found", http.StatusNotFound)
return
}
// R-879: a sealed passphrase that did not open is a hub fault (500), never a wrong password.
if cc.SecretsUnreadable {
h.logger.Printf("[ERROR] customer %s: sealed secrets do not open (OFFSITE_SECRET_KEY)", cc.CustomerID)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if subtle.ConstantTimeCompare([]byte(password), []byte(cc.RetrievalPassword)) != 1 {
http.Error(w, "Unauthorized: invalid password", http.StatusUnauthorized)
return
@@ -1114,6 +1120,12 @@ func (h *Handler) handleHostEnroll(w http.ResponseWriter, r *http.Request) {
return
}
if existing != nil {
// R-879: never re-serve an empty key because the seal did not open.
if existing.SecretsUnreadable || existing.APIKey == "" {
h.logger.Printf("[ERROR] host-enroll: host %s key unreadable (sealed key does not open)", existing.HostID)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"host_id": existing.HostID, "api_key": existing.APIKey})
@@ -2570,6 +2582,12 @@ func (h *Handler) handleRecovery(w http.ResponseWriter, r *http.Request, custome
return
}
// R-879: a sealed passphrase that did not open is a hub fault (500), never a wrong password.
if cfg.SecretsUnreadable {
h.logger.Printf("[ERROR] customer %s: sealed secrets do not open (OFFSITE_SECRET_KEY)", cfg.CustomerID)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if subtle.ConstantTimeCompare([]byte(password), []byte(cfg.RetrievalPassword)) != 1 {
http.Error(w, "Unauthorized: invalid password", http.StatusUnauthorized)
return
@@ -2635,6 +2653,12 @@ func (h *Handler) handleConfigRetrieve(w http.ResponseWriter, r *http.Request, c
return
}
// R-879: a sealed passphrase that did not open is a hub fault (500), never a wrong password.
if cfg.SecretsUnreadable {
h.logger.Printf("[ERROR] customer %s: sealed secrets do not open (OFFSITE_SECRET_KEY)", cfg.CustomerID)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
// Constant-time comparison to prevent timing attacks
if subtle.ConstantTimeCompare([]byte(password), []byte(cfg.RetrievalPassword)) != 1 {
http.Error(w, "Unauthorized: invalid password", http.StatusUnauthorized)
@@ -2717,6 +2741,12 @@ func (h *Handler) handleArtifactManifest(w http.ResponseWriter, r *http.Request,
http.Error(w, "Not found", http.StatusNotFound)
return
}
// R-879: a sealed passphrase that did not open is a hub fault (500), never a wrong password.
if cfg.SecretsUnreadable {
h.logger.Printf("[ERROR] customer %s: sealed secrets do not open (OFFSITE_SECRET_KEY)", cfg.CustomerID)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if subtle.ConstantTimeCompare([]byte(password), []byte(cfg.RetrievalPassword)) != 1 {
http.Error(w, "Unauthorized: invalid password", http.StatusUnauthorized)
return
+82
View File
@@ -0,0 +1,82 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-879 at the endpoints the boxes call: with the secrets sealed, (1) the agent's host-report and the
// controller's event auth still accept the right key and refuse a wrong one; (2) with a WRONG sealing key
// the boxes still authenticate, while every path that would serve or compare a sealed value answers 500 —
// never an empty key, never "wrong password" for a right one.
func r879Get(h *Handler, path, pw string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/api/v1"+path, nil)
req.Header.Set("X-Retrieval-Password", pw)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
return rr
}
func TestR879_EndpointsWithSealedSecrets(t *testing.T) {
h, st, _ := newTestHandler(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey-r879", RetrievalPassword: "owner-pass"}); err != nil {
t.Fatal(err)
}
if err := st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY-r879"}); err != nil {
t.Fatal(err)
}
if rr := do(h, http.MethodPost, "/host-report", "HKEY-r879", validReportBody("h1")); rr.Code != 200 {
t.Fatalf("host-report with the right key = %d %s", rr.Code, rr.Body.String())
}
if rr := do(h, http.MethodPost, "/host-report", "HKEY-wrong", validReportBody("h1")); rr.Code != http.StatusUnauthorized {
t.Fatalf("host-report with a wrong key = %d, want 401", rr.Code)
}
if _, isGlobal, ok := h.checkAuthCustomer(bearerReq("ckey-r879")); !ok || isGlobal {
t.Fatal("controller key no longer authenticates")
}
if _, _, ok := h.checkAuthCustomer(bearerReq("ckey-wrong")); ok {
t.Fatal("a wrong controller key authenticated")
}
// The passphrase compare still sees the opened value (503 = past the compare, no template here).
if rr := r879Get(h, "/config/c1", "owner-pass"); rr.Code != http.StatusServiceUnavailable {
t.Fatalf("config retrieve with the right passphrase = %d, want 503 (compare passed)", rr.Code)
}
if rr := r879Get(h, "/config/c1", "nope"); rr.Code != http.StatusUnauthorized {
t.Fatalf("config retrieve with a wrong passphrase = %d, want 401", rr.Code)
}
// Re-enroll re-serves the opened host key.
if rr := doEnroll(h, "c1", "owner-pass"); rr.Code != 200 || !strings.Contains(rr.Body.String(), "HKEY-r879") {
t.Fatalf("re-enroll = %d %s, want the existing key", rr.Code, rr.Body.String())
}
// The hub now runs with a WRONG sealing key.
if err := st.SetOffsiteSecretKey([]byte("another-key-of-exactly-32-bytes!")); err != nil {
t.Fatal(err)
}
if rr := do(h, http.MethodPost, "/host-report", "HKEY-r879", validReportBody("h1")); rr.Code != 200 {
t.Fatalf("with a wrong sealing key the agent is locked out: %d", rr.Code)
}
if _, _, ok := h.checkAuthCustomer(bearerReq("ckey-r879")); !ok {
t.Fatal("with a wrong sealing key the controller is locked out")
}
for _, p := range []string{"/config/c1", "/recovery/c1", "/artifacts/c1"} {
if rr := r879Get(h, p, "owner-pass"); rr.Code != http.StatusInternalServerError {
t.Errorf("%s with an unopenable passphrase = %d, want 500", p, rr.Code)
}
}
rr := doEnroll(h, "c1", "owner-pass")
if rr.Code != http.StatusInternalServerError || strings.Contains(rr.Body.String(), "api_key") {
t.Fatalf("re-enroll with an unopenable key = %d %s, want 500 and no key", rr.Code, rr.Body.String())
}
}
func bearerReq(tok string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/api/v1/event", nil)
req.Header.Set("Authorization", "Bearer "+tok)
return req
}
+4
View File
@@ -20,6 +20,10 @@ import (
// are baked into web.claim_code_* so a Day-0 box is claim-gated from its FIRST boot (the
// controller's precedence: a set password always wins; the hash alone never overrides one).
func Generate(templateYAML string, cfg *store.CustomerConfig, claimState *store.ClaimState) (string, error) {
// R-879: a config whose sealed API key did not open would ship a box with an empty hub key.
if cfg.SecretsUnreadable {
return "", fmt.Errorf("customer %s: sealed secrets do not open (OFFSITE_SECRET_KEY)", cfg.CustomerID)
}
// Parse template into generic map
var base map[string]interface{}
if err := yaml.Unmarshal([]byte(templateYAML), &base); err != nil {
@@ -0,0 +1,23 @@
package configgen
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-879: a config whose sealed secrets did not open must not become a controller.yaml with an empty
// hub key — Generate refuses it.
func TestR879_GenerateRefusesUnreadableSecrets(t *testing.T) {
cfg := &store.CustomerConfig{CustomerID: "c1", ConfigJSON: "{}", SecretsUnreadable: true}
out, err := Generate("hub: {}\n", cfg, nil)
if err == nil || strings.Contains(out, "api_key") {
t.Fatalf("Generate = %q, %v — want a refusal", out, err)
}
cfg.SecretsUnreadable = false
cfg.APIKey = "k1"
if out, err := Generate("hub: {}\n", cfg, nil); err != nil || !strings.Contains(out, "k1") {
t.Fatalf("readable config = %q, %v", out, err)
}
}
+15 -2
View File
@@ -3,6 +3,7 @@ package store
import (
"database/sql"
"encoding/json"
"fmt"
"time"
)
@@ -23,8 +24,13 @@ import (
// The UPSERT and the read are one statement (RETURNING), so two concurrent mints cannot both
// report the same generation.
func (s *Store) SaveHostPBSSecret(hostID, value string) (int64, error) {
// R-879: sealed at rest like every other served secret; no key → refused (ErrNoSealKey).
sealed, err := s.sealAtRest(value)
if err != nil {
return 0, err
}
var gen int64
err := s.db.QueryRow(`
err = s.db.QueryRow(`
INSERT INTO host_pbs_secrets (host_id, value, created_at, consumed_at, generation)
VALUES (?, ?, datetime('now'), NULL, 1)
ON CONFLICT(host_id) DO UPDATE SET
@@ -33,7 +39,7 @@ func (s *Store) SaveHostPBSSecret(hostID, value string) (int64, error) {
consumed_at = NULL,
generation = host_pbs_secrets.generation + 1
RETURNING generation`,
hostID, value).Scan(&gen)
hostID, sealed).Scan(&gen)
return gen, err
}
@@ -62,6 +68,13 @@ func (s *Store) ConsumeHostPBSSecret(hostID string) (string, error) {
if err != nil {
return "", err // sql.ErrNoRows when absent OR already consumed
}
// R-879: open BEFORE marking consumed — a value the hub cannot open (missing/wrong key) is an error
// and stays un-consumed, so the agent retries once the key is right instead of losing its one serve.
plain, ok := s.openAtRest("host_pbs_secrets.value", hostID, value)
if !ok {
return "", fmt.Errorf("host_pbs_secrets %s: sealed secret does not open", hostID)
}
value = plain
if _, err := tx.Exec(`UPDATE host_pbs_secrets SET consumed_at = datetime('now') WHERE host_id = ?`, hostID); err != nil {
return "", err
}
+176
View File
@@ -0,0 +1,176 @@
package store
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
)
// ── R-879: the box-facing secrets are sealed at rest too ─────────────────────────────────────────────
//
// Until this change a copy of hub.db held, readable without any key: every box's hub API key
// (`hosts.api_key`), every household's owner passphrase and controller API key
// (`customer_configs.retrieval_password`, `customer_configs.api_key`) and the PBS-DR token values
// (`host_pbs_secrets.value`, kept after use). A stolen copy let an attacker report as any box.
//
// Now all four columns hold the SAME seal as R-821 / R-133 (`enc:v1:`, AES-256-GCM, OFFSITE_SECRET_KEY,
// `05` §16.2). Every one of them is SERVED again in plaintext somewhere (the box key at a re-enroll, the
// customer key inside the generated controller.yaml, the passphrase on the operator page and to the
// appliance, the PBS token on a re-stage), so a one-way hash cannot replace the value — it is sealed.
//
// The two API keys are also LOOKED UP: a box authenticates with its key on every report. Opening every
// row to find a match would be slow and would make authentication depend on the sealing key. So each key
// column has a twin `api_key_hash` = SHA-256 of the key (the keys are 256-bit random, a hash of one is not
// guessable), and the lookup is an indexed equality on the hash. Two properties follow, both pinned:
//
// - **authentication never needs the sealing key** — a hub whose key is missing, wrong, or whose
// start-up sealing failed half-way still lets every box in (TestR879_BoxAuthSurvivesFailedSealing);
// - **a row with no hash yet is matched on its plaintext column** — only while it is still plaintext,
// never on a sealed value (a sealed string presented as a key must not authenticate:
// TestR879_SealedValueIsNotAKey).
//
// The hash is compared by SQLite, not in constant time; what an observer could learn from the timing is
// a prefix of SHA-256(guess), which says nothing about the real key.
//
// Writing a new value without a key is REFUSED (ErrNoSealKey), as §16.2 states for every sealed column —
// a hub that cannot seal must not fall back to plaintext. Reading a legacy plaintext value still works
// (the start-up sealing may not have run); a sealed value that does not open leaves the field empty and
// sets SecretsUnreadable, and every serve/compare path answers 500 on that flag rather than serving or
// comparing an empty string.
// apiKeyHash is the lookup twin of a box/controller API key. Domain-separated so it can never equal a
// hash computed for another purpose.
func apiKeyHash(key string) string {
sum := sha256.Sum256([]byte("felhom-hub-api-key-v1:" + key))
return hex.EncodeToString(sum[:])
}
// openAtRest returns the plaintext of a stored box secret. A legacy plaintext value (not yet sealed) is
// returned as it is; a sealed value is opened with the key. ok=false when a sealed value cannot be opened
// (no key / wrong key / corrupt) — the caller marks the record unreadable and never serves "".
func (s *Store) openAtRest(table, id, stored string) (plain string, ok bool) {
if !strings.HasPrefix(stored, sealPrefix) {
return stored, true
}
pt, err := s.openSecret(stored)
if err != nil {
if s.logger != nil {
s.logger.Printf("[ERROR] store: sealed %s secret for %s does not open (%v)", table, id, err)
}
return "", false
}
return pt, true
}
// sealAtRest seals a box secret for writing. An empty value stays empty (nothing to protect, and some
// legacy rows carry ""). No key → ErrNoSealKey.
func (s *Store) sealAtRest(plain string) (string, error) {
if plain == "" {
return "", nil
}
return s.sealSecret(plain)
}
// backfillAPIKeyHashes fills `api_key_hash` for every row that has none and still holds a plaintext
// key. Needs NO sealing key, so it runs inside migrate() on every start — the hash index is complete
// before any box reports, whatever happens to the sealing step later. Idempotent.
func (s *Store) backfillAPIKeyHashes() error {
for _, t := range []struct{ table, idCol string }{{"hosts", "host_id"}, {"customer_configs", "customer_id"}} {
rows, err := s.db.Query(`SELECT ` + t.idCol + `, api_key FROM ` + t.table +
` WHERE api_key_hash = '' AND api_key <> '' AND api_key NOT LIKE 'enc:v1:%'`)
if err != nil {
return fmt.Errorf("%s: %w", t.table, err)
}
type row struct{ id, k string }
var todo []row
for rows.Next() {
var r row
if err := rows.Scan(&r.id, &r.k); err != nil {
rows.Close()
return err
}
todo = append(todo, r)
}
rows.Close()
for _, r := range todo {
if _, err := s.db.Exec(`UPDATE `+t.table+` SET api_key_hash = ? WHERE `+t.idCol+` = ? AND api_key = ?`,
apiKeyHash(r.k), r.id, r.k); err != nil {
return fmt.Errorf("%s: %w", t.table, err)
}
}
}
return nil
}
// SealLegacyBoxSecrets seals, in place, every R-879 column still holding plaintext: hosts.api_key,
// customer_configs.api_key, customer_configs.retrieval_password and host_pbs_secrets.value. An API key is
// sealed in the SAME statement that (re)writes its hash, so a sealed key never lacks its lookup twin.
// Idempotent; returns how many values it sealed. Values are never logged. Called at start-up right after
// the key is installed (cmd/hub/main.go), beside SealLegacyOffsiteSecrets / SealLegacyRecoverySecrets.
// A failure part-way leaves the remaining rows plaintext and STILL authenticating (their hash, or their
// plaintext, matches) — TestR879_BoxAuthSurvivesFailedSealing.
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 {
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 {
return n, fmt.Errorf("%s.%s: %w", c.table, c.col, err)
}
type row struct{ id, v string }
var todo []row
for rows.Next() {
var r row
if err := rows.Scan(&r.id, &r.v); err != nil {
rows.Close()
return n, err
}
todo = append(todo, r)
}
rows.Close()
for _, r := range todo {
sealed, err := s.sealSecret(r.v)
if err != nil {
return n, err
}
if c.withHash {
_, err = s.db.Exec(`UPDATE `+c.table+` SET `+c.col+` = ?, api_key_hash = ? WHERE `+c.idCol+` = ? AND `+c.col+` = ?`,
sealed, apiKeyHash(r.v), r.id, r.v)
} else {
_, err = s.db.Exec(`UPDATE `+c.table+` SET `+c.col+` = ? WHERE `+c.idCol+` = ? AND `+c.col+` = ?`,
sealed, r.id, r.v)
}
if err != nil {
return n, fmt.Errorf("%s.%s: %w", c.table, c.col, err)
}
n++
}
}
return n, nil
}
// sealAPIKey returns the sealed key and its lookup twin, written together by every API-key writer.
func (s *Store) sealAPIKey(key string) (sealed, hash string, err error) {
sealed, err = s.sealAtRest(key)
if err != nil {
return "", "", err
}
if key != "" {
hash = apiKeyHash(key)
}
return sealed, hash, nil
}
+290
View File
@@ -0,0 +1,290 @@
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")
}
}
+122 -23
View File
@@ -864,6 +864,20 @@ func (s *Store) migrate() error {
if err := s.migrateOSUpdates(); err != nil {
return fmt.Errorf("os_updates: %w", err)
}
// R-879: the API keys' lookup twins (r879_box_seal.go). Backfilled here, keyless, on every start —
// the index is complete before any box reports, whatever happens to the sealing step in main.
s.db.Exec(`ALTER TABLE hosts ADD COLUMN api_key_hash TEXT NOT NULL DEFAULT ''`)
s.db.Exec(`ALTER TABLE customer_configs ADD COLUMN api_key_hash TEXT NOT NULL DEFAULT ''`)
// Neither failure stops the hub: a row with no hash is matched on its plaintext key (GetHostByAPIKey),
// so refusing to start here would lock every box out for nothing.
if _, err := s.db.Exec(`
CREATE INDEX IF NOT EXISTS idx_hosts_api_key_hash ON hosts(api_key_hash);
CREATE INDEX IF NOT EXISTS idx_customer_configs_api_key_hash ON customer_configs(api_key_hash);`); err != nil && s.logger != nil {
s.logger.Printf("[ERROR] api_key_hash index: %v (lookups fall back to a scan)", err)
}
if err := s.backfillAPIKeyHashes(); err != nil && s.logger != nil {
s.logger.Printf("[ERROR] api_key_hash backfill: %v (unhashed rows still match on their plaintext key)", err)
}
return nil
}
@@ -1552,6 +1566,10 @@ type CustomerConfig struct {
Email string
RetrievalPassword string
APIKey string
// SecretsUnreadable (R-879) is set when a SEALED RetrievalPassword / APIKey could not be opened
// (missing or wrong OFFSITE_SECRET_KEY): those fields are then "" and must be neither served nor
// compared, and SaveCustomerConfig refuses the record so a load-modify-save cannot blank them.
SecretsUnreadable bool
ConfigJSON string // JSON object with customer-specific override fields
Status string // "active" or "blocked"
// MinControllerVersion is the per-customer minimum controller version (managed-update FLOOR
@@ -1583,16 +1601,29 @@ type CustomerConfig struct {
// (min_controller_version), block/unblock status, and retrieval-password regen are deliberately NOT
// config.yaml content and intentionally do NOT bump it (they have their own signals or none).
func (s *Store) SaveCustomerConfig(cfg *CustomerConfig) error {
_, err := s.db.Exec(`
// R-879: both secrets are sealed at rest; the API key's lookup twin is written beside it.
if cfg.SecretsUnreadable {
return fmt.Errorf("store: customer %s: refusing to save a config whose sealed secrets did not open", cfg.CustomerID)
}
sealedPass, err := s.sealAtRest(cfg.RetrievalPassword)
if err != nil {
return err
}
sealedKey, keyHash, err := s.sealAPIKey(cfg.APIKey)
if err != nil {
return err
}
_, err = s.db.Exec(`
INSERT INTO customer_configs (customer_id, customer_name, domain, email,
retrieval_password, api_key, config_json, min_controller_version, dr_tier, language, config_version, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, datetime('now'))
retrieval_password, api_key, api_key_hash, config_json, min_controller_version, dr_tier, language, config_version, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, datetime('now'))
ON CONFLICT(customer_id) DO UPDATE SET
customer_name = excluded.customer_name,
domain = excluded.domain,
email = excluded.email,
retrieval_password = excluded.retrieval_password,
api_key = excluded.api_key,
api_key_hash = excluded.api_key_hash,
config_json = excluded.config_json,
min_controller_version = excluded.min_controller_version,
dr_tier = excluded.dr_tier,
@@ -1600,12 +1631,21 @@ func (s *Store) SaveCustomerConfig(cfg *CustomerConfig) error {
config_version = customer_configs.config_version + 1,
updated_at = datetime('now')`,
cfg.CustomerID, cfg.CustomerName, cfg.Domain, cfg.Email,
cfg.RetrievalPassword, cfg.APIKey, cfg.ConfigJSON, cfg.MinControllerVersion, cfg.DRTier,
sealedPass, sealedKey, keyHash, cfg.ConfigJSON, cfg.MinControllerVersion, cfg.DRTier,
createdLanguage(cfg.Language),
)
return err
}
// openCustomerSecrets opens the two sealed columns of a scanned config in place (R-879). A value that
// does not open leaves the field "" and sets SecretsUnreadable; legacy plaintext passes through.
func (s *Store) openCustomerSecrets(cfg *CustomerConfig) {
var ok1, ok2 bool
cfg.RetrievalPassword, ok1 = s.openAtRest("customer_configs.retrieval_password", cfg.CustomerID, cfg.RetrievalPassword)
cfg.APIKey, ok2 = s.openAtRest("customer_configs.api_key", cfg.CustomerID, cfg.APIKey)
cfg.SecretsUnreadable = !ok1 || !ok2
}
// createdLanguage keeps only a language the hub can write mail in, defaulting to Hungarian.
//
// Unlike reportedLanguage this one DOES default: an absent creation-time pick is genuinely
@@ -1638,6 +1678,7 @@ func (s *Store) GetCustomerConfig(customerID string) (*CustomerConfig, error) {
}
cfg.CreatedAt = parseSQLiteTime(createdAt)
cfg.UpdatedAt = parseSQLiteTime(updatedAt)
s.openCustomerSecrets(&cfg)
return &cfg, nil
}
@@ -1807,6 +1848,7 @@ func (s *Store) ListCustomerConfigs() ([]CustomerConfig, error) {
}
cfg.CreatedAt = parseSQLiteTime(createdAt)
cfg.UpdatedAt = parseSQLiteTime(updatedAt)
s.openCustomerSecrets(&cfg)
configs = append(configs, cfg)
}
return configs, rows.Err()
@@ -1848,14 +1890,23 @@ func (s *Store) DeleteCustomerConfig(customerID string) error {
// GetCustomerConfigByAPIKey looks up a customer config by its unique API key.
// Returns nil if no matching key is found.
//
// R-879: matched on the key's hash (indexed; needs no sealing key), or — only for a row whose hash was
// never written and whose key is still plaintext — on the plaintext. An empty key or a sealed blob
// presented as a key never matches (TestR879_SealedValueIsNotAKey).
func (s *Store) GetCustomerConfigByAPIKey(apiKey string) (*CustomerConfig, error) {
if apiKey == "" || strings.HasPrefix(apiKey, sealPrefix) {
return nil, nil
}
var cfg CustomerConfig
var createdAt, updatedAt string
err := s.db.QueryRow(`
SELECT customer_id, customer_name, domain, email,
retrieval_password, api_key, config_json, status, min_controller_version, dr_tier, language, config_version, created_at, updated_at
FROM customer_configs WHERE api_key = ?`,
apiKey,
FROM customer_configs
WHERE api_key_hash = ? OR (api_key_hash = '' AND api_key = ? AND api_key NOT LIKE 'enc:v1:%')
LIMIT 1`,
apiKeyHash(apiKey), apiKey,
).Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email,
&cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &cfg.MinControllerVersion,
&cfg.DRTier, &cfg.Language, &cfg.ConfigVersion, &createdAt, &updatedAt)
@@ -1867,6 +1918,7 @@ func (s *Store) GetCustomerConfigByAPIKey(apiKey string) (*CustomerConfig, error
}
cfg.CreatedAt = parseSQLiteTime(createdAt)
cfg.UpdatedAt = parseSQLiteTime(updatedAt)
s.openCustomerSecrets(&cfg)
return &cfg, nil
}
@@ -2470,10 +2522,14 @@ func (s *Store) IsCustomerBlocked(customerID string) bool {
// UpdateRetrievalPassword updates the retrieval password for a customer config.
func (s *Store) UpdateRetrievalPassword(customerID, newPassword string) error {
_, err := s.db.Exec(`
sealed, err := s.sealAtRest(newPassword) // R-879
if err != nil {
return err
}
_, err = s.db.Exec(`
UPDATE customer_configs SET retrieval_password = ?, updated_at = datetime('now')
WHERE customer_id = ?`,
newPassword, customerID,
sealed, customerID,
)
return err
}
@@ -2702,9 +2758,12 @@ func parseDiskSummary(reportJSON string) string {
// Host is one customer agent. Mixes operator-intent columns (Desired*, DRRecord —
// INERT until slice 10) with box-reported reality (AgentVersion, LastReportAt).
type Host struct {
HostID string
CustomerID string
APIKey string
HostID string
CustomerID string
APIKey string
// SecretsUnreadable (R-879): the SEALED api_key did not open (missing/wrong OFFSITE_SECRET_KEY);
// APIKey is "" and must not be served, and UpsertHost refuses the record.
SecretsUnreadable bool
AgentVersion string
LastReportAt *time.Time
DesiredJSON string
@@ -2762,7 +2821,21 @@ func GuestID(hostID string, vmid int) string {
return hostID + "/" + strconv.Itoa(vmid)
}
func scanHost(scan func(dest ...any) error) (*Host, error) {
// scanHost scans one hosts row and opens its sealed api_key (R-879). A key that does not open leaves
// APIKey "" with SecretsUnreadable set — never an error, so a wrong key cannot break host pages or
// report handling (authentication itself never reads this field: GetHostByAPIKey matches on the hash).
func (s *Store) scanHost(scan func(dest ...any) error) (*Host, error) {
h, err := scanHostRaw(scan)
if err != nil {
return nil, err
}
var ok bool
h.APIKey, ok = s.openAtRest("hosts.api_key", h.HostID, h.APIKey)
h.SecretsUnreadable = !ok
return h, nil
}
func scanHostRaw(scan func(dest ...any) error) (*Host, error) {
var h Host
var lastReport, recoveryUntil sql.NullString
var createdAt, updatedAt string
@@ -2789,17 +2862,31 @@ const hostSelectCols = `host_id, customer_id, api_key, agent_version, last_repor
// GetHostByAPIKey looks up a host by its per-host hub key. Returns nil (no error)
// if no match — parallels GetCustomerConfigByAPIKey.
//
// R-879: matched on the key's hash (indexed, needs NO sealing key — a box authenticates even when the
// hub's key is missing or wrong), or, only for a legacy row with no hash and a still-plaintext key, on
// the plaintext. An empty key or a sealed blob presented as a key never matches. The returned APIKey is
// the presented key (it matched), so this path never needs to open the seal.
func (s *Store) GetHostByAPIKey(apiKey string) (*Host, error) {
h, err := scanHost(s.db.QueryRow(`SELECT `+hostSelectCols+` FROM hosts WHERE api_key = ?`, apiKey).Scan)
if apiKey == "" || strings.HasPrefix(apiKey, sealPrefix) {
return nil, nil
}
h, err := scanHostRaw(s.db.QueryRow(`SELECT `+hostSelectCols+` FROM hosts
WHERE api_key_hash = ? OR (api_key_hash = '' AND api_key = ? AND api_key NOT LIKE 'enc:v1:%')
LIMIT 1`, apiKeyHash(apiKey), apiKey).Scan)
if err == sql.ErrNoRows {
return nil, nil
}
return h, err
if err != nil {
return nil, err
}
h.APIKey = apiKey
return h, nil
}
// GetHost looks up a host by id. Returns nil (no error) if not found.
func (s *Store) GetHost(hostID string) (*Host, error) {
h, err := scanHost(s.db.QueryRow(`SELECT `+hostSelectCols+` FROM hosts WHERE host_id = ?`, hostID).Scan)
h, err := s.scanHost(s.db.QueryRow(`SELECT `+hostSelectCols+` FROM hosts WHERE host_id = ?`, hostID).Scan)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -2813,7 +2900,7 @@ func (s *Store) GetHost(hostID string) (*Host, error) {
// the most-recently-updated wins (we never mint a duplicate on a reuse). Uses the
// idx_hosts_customer index. Mirrors GetHostByAPIKey's nil-on-not-found contract.
func (s *Store) GetHostByCustomer(customerID string) (*Host, error) {
h, err := scanHost(s.db.QueryRow(`SELECT `+hostSelectCols+
h, err := s.scanHost(s.db.QueryRow(`SELECT `+hostSelectCols+
` FROM hosts WHERE customer_id = ? ORDER BY updated_at DESC LIMIT 1`, customerID).Scan)
if err == sql.ErrNoRows {
return nil, nil
@@ -2855,7 +2942,7 @@ func (s *Store) ListHostsByCustomer(customerID string) ([]Host, error) {
defer rows.Close()
var hosts []Host
for rows.Next() {
h, err := scanHost(rows.Scan)
h, err := s.scanHost(rows.Scan)
if err != nil {
return nil, err
}
@@ -2873,7 +2960,7 @@ func (s *Store) ListHosts() ([]Host, error) {
defer rows.Close()
var hosts []Host
for rows.Next() {
h, err := scanHost(rows.Scan)
h, err := s.scanHost(rows.Scan)
if err != nil {
return nil, err
}
@@ -3061,14 +3148,22 @@ func (s *Store) LatestHostDeletion(customerID string) (*HostDeletion, error) {
// NOT touch the reality columns (agent_version/last_report_at) or the inert intent
// columns (desired_*/dr_record_json) — those are owned elsewhere.
func (s *Store) UpsertHost(h *Host) error {
_, err := s.db.Exec(`
INSERT INTO hosts (host_id, customer_id, api_key, updated_at)
VALUES (?, ?, ?, datetime('now'))
if h.SecretsUnreadable {
return fmt.Errorf("store: host %s: refusing to save a host whose sealed api_key did not open", h.HostID)
}
sealed, keyHash, err := s.sealAPIKey(h.APIKey) // R-879
if err != nil {
return err
}
_, err = s.db.Exec(`
INSERT INTO hosts (host_id, customer_id, api_key, api_key_hash, updated_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(host_id) DO UPDATE SET
customer_id = excluded.customer_id,
api_key = excluded.api_key,
api_key_hash = excluded.api_key_hash,
updated_at = datetime('now')`,
h.HostID, h.CustomerID, h.APIKey,
h.HostID, h.CustomerID, sealed, keyHash,
)
return err
}
@@ -3473,7 +3568,11 @@ func (s *Store) ClearRecoveryMode(hostID string) error {
// RotateHostAPIKey replaces a host's API key (the re-enroll credential rotation — the old box's hub
// access is revoked the instant this commits; purely hub-internal, no Cloudflare/PBS write needed).
func (s *Store) RotateHostAPIKey(hostID, newAPIKey string) error {
res, err := s.db.Exec(`UPDATE hosts SET api_key = ?, updated_at = datetime('now') WHERE host_id = ?`, newAPIKey, hostID)
sealed, keyHash, err := s.sealAPIKey(newAPIKey) // R-879
if err != nil {
return err
}
res, err := s.db.Exec(`UPDATE hosts SET api_key = ?, api_key_hash = ?, updated_at = datetime('now') WHERE host_id = ?`, sealed, keyHash, hostID)
if err != nil {
return err
}