- New internal/vault package: thread-safe Vault struct with seal/unseal state, key material zeroing, and key derivation - REST: POST /v1/vault/unseal, POST /v1/vault/seal, GET /v1/vault/status; health returns sealed status - UI: /unseal page with passphrase form, redirect when sealed - gRPC: sealedInterceptor rejects RPCs when sealed - Middleware: RequireUnsealed blocks all routes except exempt paths; RequireAuth reads pubkey from vault at request time - Startup: server starts sealed when passphrase unavailable - All servers share single *vault.Vault by pointer - CSRF manager derives key lazily from vault Security: Key material is zeroed on seal. Sealed middleware runs before auth. Handlers fail closed if vault becomes sealed mid-request. Unseal endpoint is rate-limited (3/s burst 5). No CSRF on unseal page (no session to protect; chicken-and-egg with master key). Passphrase never logged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
172 lines
4.4 KiB
Go
172 lines
4.4 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
|
)
|
|
|
|
func TestHandleHealthSealed(t *testing.T) {
|
|
srv, _, _, _ := newTestServer(t)
|
|
srv.vault.Seal()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/health", nil)
|
|
rr := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("health status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
var resp map[string]string
|
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode health: %v", err)
|
|
}
|
|
if resp["status"] != "sealed" {
|
|
t.Fatalf("health status = %q, want sealed", resp["status"])
|
|
}
|
|
}
|
|
|
|
func TestHandleHealthUnsealed(t *testing.T) {
|
|
srv, _, _, _ := newTestServer(t)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/health", nil)
|
|
rr := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("health status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
var resp map[string]string
|
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode health: %v", err)
|
|
}
|
|
if resp["status"] != "ok" {
|
|
t.Fatalf("health status = %q, want ok", resp["status"])
|
|
}
|
|
}
|
|
|
|
func TestVaultStatusEndpoint(t *testing.T) {
|
|
srv, _, _, _ := newTestServer(t)
|
|
|
|
// Unsealed
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/vault/status", nil)
|
|
rr := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status code = %d, want 200", rr.Code)
|
|
}
|
|
var resp map[string]bool
|
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if resp["sealed"] {
|
|
t.Fatal("vault should be unsealed")
|
|
}
|
|
|
|
// Seal and check again
|
|
srv.vault.Seal()
|
|
req = httptest.NewRequest(http.MethodGet, "/v1/vault/status", nil)
|
|
rr = httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status code = %d, want 200", rr.Code)
|
|
}
|
|
resp = nil
|
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if !resp["sealed"] {
|
|
t.Fatal("vault should be sealed")
|
|
}
|
|
}
|
|
|
|
func TestSealedMiddlewareAPIReturns503(t *testing.T) {
|
|
srv, _, _, _ := newTestServer(t)
|
|
srv.vault.Seal()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/accounts", nil)
|
|
rr := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("sealed API status = %d, want 503", rr.Code)
|
|
}
|
|
|
|
var resp map[string]string
|
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if resp["code"] != "vault_sealed" {
|
|
t.Fatalf("error code = %q, want vault_sealed", resp["code"])
|
|
}
|
|
}
|
|
|
|
func TestSealedMiddlewareUIRedirects(t *testing.T) {
|
|
srv, _, _, _ := newTestServer(t)
|
|
srv.vault.Seal()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
|
|
rr := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusFound {
|
|
t.Fatalf("sealed UI status = %d, want 302", rr.Code)
|
|
}
|
|
loc := rr.Header().Get("Location")
|
|
if loc != "/unseal" {
|
|
t.Fatalf("redirect location = %q, want /unseal", loc)
|
|
}
|
|
}
|
|
|
|
func TestUnsealBadPassphrase(t *testing.T) {
|
|
srv, _, _, _ := newTestServer(t)
|
|
// Start sealed.
|
|
v := vault.NewSealed()
|
|
srv.vault = v
|
|
|
|
body := `{"passphrase":"wrong-passphrase"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/vault/unseal", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rr := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("unseal with bad passphrase status = %d, want 401", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestSealAlreadySealedNoop(t *testing.T) {
|
|
srv, _, priv, _ := newTestServer(t)
|
|
|
|
// Seal via API (needs admin token)
|
|
adminToken, _ := issueAdminToken(t, srv, priv, "admin")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/vault/seal", nil)
|
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
|
rr := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("seal status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
var resp map[string]string
|
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if resp["status"] != "sealed" {
|
|
t.Fatalf("seal response status = %q, want sealed", resp["status"])
|
|
}
|
|
|
|
// Vault should be sealed now
|
|
if !srv.vault.IsSealed() {
|
|
t.Fatal("vault should be sealed after seal API call")
|
|
}
|
|
}
|