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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user