3457415117
Recovery-mode toggle (global key, bounded auto-expiry) gates re-enroll + restore-directive serving. Re-enroll rotates the agent<->hub credential to the new box (old key revoked); returns the opaque escrow blobs + non-secret directive. Store gains recovery_mode_until + identity_blob + directive_json. Hub holds no usable secret + no Cloudflare write-power (operator-side rotation). Doc 03 §9: slice 10 CLOSED. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
118 lines
5.2 KiB
Go
118 lines
5.2 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
|
)
|
|
|
|
// Recovery-mode arm is global-key-only; re-enroll is REFUSED unless recovery mode is active.
|
|
func TestReEnroll_GatedOnRecoveryMode(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
seedHost(t, st, "h1", "c1", "OLDKEY")
|
|
|
|
// Re-enroll with recovery mode OFF → 403.
|
|
if rr := do(h, http.MethodPost, "/hosts/h1/re-enroll", "", `{"new_api_key":"NEWKEY"}`); rr.Code != http.StatusForbidden {
|
|
t.Fatalf("re-enroll without recovery mode = %d, want 403", rr.Code)
|
|
}
|
|
// Arm recovery mode requires the global key (per-host key refused).
|
|
if rr := do(h, http.MethodPut, "/admin/hosts/h1/recovery-mode", "OLDKEY", `{"ttl_seconds":600}`); rr.Code != http.StatusForbidden {
|
|
t.Errorf("per-host arm recovery = %d, want 403", rr.Code)
|
|
}
|
|
if rr := do(h, http.MethodPut, "/admin/hosts/h1/recovery-mode", globalKey, `{"ttl_seconds":600}`); rr.Code != http.StatusOK {
|
|
t.Fatalf("global arm recovery = %d, want 200", rr.Code)
|
|
}
|
|
|
|
// Now re-enroll succeeds, rotates the credential, returns the directive.
|
|
rr := do(h, http.MethodPost, "/hosts/h1/re-enroll", "", `{"new_api_key":"NEWKEY"}`)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("re-enroll in recovery mode = %d body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var resp struct {
|
|
APIKeyRotated bool `json:"api_key_rotated"`
|
|
NewAPIKey string `json:"new_api_key"`
|
|
}
|
|
json.Unmarshal(rr.Body.Bytes(), &resp)
|
|
if !resp.APIKeyRotated || resp.NewAPIKey != "NEWKEY" {
|
|
t.Errorf("re-enroll resp = %+v, want rotated NEWKEY", resp)
|
|
}
|
|
}
|
|
|
|
// Re-enroll ROTATES the hub credential: the old key no longer authenticates; the new one does.
|
|
func TestReEnroll_RevokesOldKey(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "p"})
|
|
seedHost(t, st, "h1", "c1", "OLDKEY")
|
|
do(h, http.MethodPut, "/admin/hosts/h1/recovery-mode", globalKey, `{"ttl_seconds":600}`)
|
|
|
|
// Before: the OLD key authenticates a host-report.
|
|
if rr := do(h, http.MethodPost, "/host-report", "OLDKEY", validReportBody("h1")); rr.Code != 200 {
|
|
t.Fatalf("pre-rotate host-report with OLD key = %d, want 200", rr.Code)
|
|
}
|
|
// Re-enroll → rotate to NEWKEY.
|
|
if rr := do(h, http.MethodPost, "/hosts/h1/re-enroll", "", `{"new_api_key":"NEWKEY"}`); rr.Code != 200 {
|
|
t.Fatalf("re-enroll = %d", rr.Code)
|
|
}
|
|
// After: the OLD key is REVOKED (401), the NEW key works.
|
|
if rr := do(h, http.MethodPost, "/host-report", "OLDKEY", validReportBody("h1")); rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("post-rotate OLD key = %d, want 401 (revoked)", rr.Code)
|
|
}
|
|
if rr := do(h, http.MethodPost, "/host-report", "NEWKEY", validReportBody("h1")); rr.Code != 200 {
|
|
t.Errorf("post-rotate NEW key = %d, want 200", rr.Code)
|
|
}
|
|
}
|
|
|
|
// The restore directive (the opaque blobs) is served ONLY in recovery mode, and expires.
|
|
func TestRestoreDirective_GatedAndExpires(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
seedHost(t, st, "h1", "c1", "HKEY")
|
|
// Seed a DR bundle: K-escrow row + identity blob + directive.
|
|
st.SaveHostEscrow("h1", []byte("opaque-K-escrow"), "01:36:e9:…", "zero_knowledge", time.Now().UTC().Format(time.RFC3339))
|
|
st.SaveHostDRBundle("h1", []byte("opaque-identity"), `{"pbs_repo":"r","tunnel_id":"t","expected_key_fingerprint":"01:36:e9:…"}`)
|
|
|
|
// Not in recovery mode → 403.
|
|
if rr := do(h, http.MethodGet, "/hosts/h1/restore-directive", "HKEY", ""); rr.Code != http.StatusForbidden {
|
|
t.Fatalf("directive without recovery mode = %d, want 403", rr.Code)
|
|
}
|
|
// Arm recovery mode → served, with both opaque blobs + directive.
|
|
do(h, http.MethodPut, "/admin/hosts/h1/recovery-mode", globalKey, `{"ttl_seconds":600}`)
|
|
rr := do(h, http.MethodGet, "/hosts/h1/restore-directive", "HKEY", "")
|
|
if rr.Code != 200 {
|
|
t.Fatalf("directive in recovery mode = %d", rr.Code)
|
|
}
|
|
var d struct {
|
|
KEscrowB64 string `json:"k_escrow_b64"`
|
|
IdentityEscrowB64 string `json:"identity_escrow_b64"`
|
|
Directive json.RawMessage `json:"directive"`
|
|
}
|
|
json.Unmarshal(rr.Body.Bytes(), &d)
|
|
kb, _ := base64.StdEncoding.DecodeString(d.KEscrowB64)
|
|
ib, _ := base64.StdEncoding.DecodeString(d.IdentityEscrowB64)
|
|
if string(kb) != "opaque-K-escrow" || string(ib) != "opaque-identity" {
|
|
t.Errorf("served blobs wrong: K=%q identity=%q", kb, ib)
|
|
}
|
|
|
|
// Simulate EXPIRY: set recovery_mode_until in the past → directive refused again.
|
|
st.SetRecoveryMode("h1", time.Now().UTC().Add(-time.Minute))
|
|
if rr := do(h, http.MethodGet, "/hosts/h1/restore-directive", "HKEY", ""); rr.Code != http.StatusForbidden {
|
|
t.Errorf("expired recovery mode directive = %d, want 403", rr.Code)
|
|
}
|
|
}
|
|
|
|
// Clearing recovery mode (global key) disables re-enroll.
|
|
func TestRecoveryMode_Clear(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
seedHost(t, st, "h1", "c1", "HKEY")
|
|
do(h, http.MethodPut, "/admin/hosts/h1/recovery-mode", globalKey, `{"ttl_seconds":600}`)
|
|
if rr := do(h, http.MethodDelete, "/admin/hosts/h1/recovery-mode", globalKey, ""); rr.Code != 200 {
|
|
t.Fatalf("clear recovery = %d", rr.Code)
|
|
}
|
|
if rr := do(h, http.MethodPost, "/hosts/h1/re-enroll", "", `{"new_api_key":"X"}`); rr.Code != http.StatusForbidden {
|
|
t.Errorf("re-enroll after clear = %d, want 403", rr.Code)
|
|
}
|
|
}
|