slice 10D (hub): DR capstone — recovery mode + re-enroll + directive serving (hub v0.11.0)

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>
This commit is contained in:
2026-06-11 09:48:38 +02:00
parent a22b87e6e3
commit 3457415117
7 changed files with 533 additions and 34 deletions
+117
View File
@@ -0,0 +1,117 @@
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)
}
}