Files
felhom.eu/hub/internal/api/r879_box_seal_test.go
T
admin 5f060e3d1e 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
2026-10-05 21:45:04 +02:00

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
}