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