From 5f060e3d1e669e8f0db89a88c14d18f7ac3a1141 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 5 Oct 2026 21:18:14 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- REUSE.md | 1 + hub/cmd/hub/main.go | 7 + hub/cmd/hub/r879_wiring_test.go | 29 ++ hub/internal/api/appliance.go | 4 +- hub/internal/api/handler.go | 30 ++ hub/internal/api/r879_box_seal_test.go | 82 +++++ hub/internal/configgen/configgen.go | 4 + .../configgen/r879_unreadable_test.go | 23 ++ hub/internal/store/pbsdr.go | 17 +- hub/internal/store/r879_box_seal.go | 176 +++++++++++ hub/internal/store/r879_box_seal_test.go | 290 ++++++++++++++++++ hub/internal/store/store.go | 145 +++++++-- 12 files changed, 781 insertions(+), 27 deletions(-) create mode 100644 hub/cmd/hub/r879_wiring_test.go create mode 100644 hub/internal/api/r879_box_seal_test.go create mode 100644 hub/internal/configgen/r879_unreadable_test.go create mode 100644 hub/internal/store/r879_box_seal.go create mode 100644 hub/internal/store/r879_box_seal_test.go diff --git a/REUSE.md b/REUSE.md index ce8cd451..dfbd3203 100644 --- a/REUSE.md +++ b/REUSE.md @@ -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`: `.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) diff --git a/hub/cmd/hub/main.go b/hub/cmd/hub/main.go index 9c78909e..a574e94d 100644 --- a/hub/cmd/hub/main.go +++ b/hub/cmd/hub/main.go @@ -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) diff --git a/hub/cmd/hub/r879_wiring_test.go b/hub/cmd/hub/r879_wiring_test.go new file mode 100644 index 00000000..9efd1d3d --- /dev/null +++ b/hub/cmd/hub/r879_wiring_test.go @@ -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") + } +} diff --git a/hub/internal/api/appliance.go b/hub/internal/api/appliance.go index 14497676..22782fc4 100644 --- a/hub/internal/api/appliance.go +++ b/hub/internal/api/appliance.go @@ -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 } diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index 13002f7f..018f7384 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -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 diff --git a/hub/internal/api/r879_box_seal_test.go b/hub/internal/api/r879_box_seal_test.go new file mode 100644 index 00000000..597249b4 --- /dev/null +++ b/hub/internal/api/r879_box_seal_test.go @@ -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 +} diff --git a/hub/internal/configgen/configgen.go b/hub/internal/configgen/configgen.go index 24f36742..d4672376 100644 --- a/hub/internal/configgen/configgen.go +++ b/hub/internal/configgen/configgen.go @@ -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 { diff --git a/hub/internal/configgen/r879_unreadable_test.go b/hub/internal/configgen/r879_unreadable_test.go new file mode 100644 index 00000000..0758e81b --- /dev/null +++ b/hub/internal/configgen/r879_unreadable_test.go @@ -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) + } +} diff --git a/hub/internal/store/pbsdr.go b/hub/internal/store/pbsdr.go index 6c7bcef5..d0ba11f9 100644 --- a/hub/internal/store/pbsdr.go +++ b/hub/internal/store/pbsdr.go @@ -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 } diff --git a/hub/internal/store/r879_box_seal.go b/hub/internal/store/r879_box_seal.go new file mode 100644 index 00000000..29e11711 --- /dev/null +++ b/hub/internal/store/r879_box_seal.go @@ -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 +} diff --git a/hub/internal/store/r879_box_seal_test.go b/hub/internal/store/r879_box_seal_test.go new file mode 100644 index 00000000..19dddd0d --- /dev/null +++ b/hub/internal/store/r879_box_seal_test.go @@ -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") + } +} diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index 39e2b7b4..8ab9cb9c 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -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 }