5f060e3d1e
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
83 lines
3.5 KiB
Go
83 lines
3.5 KiB
Go
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
|
|
}
|