Files
metacrypt/internal/server/server_test.go
Kyle Isom 64d921827e Add MEK rotation, per-engine DEKs, and v2 ciphertext format (audit #6, #22)
Implement a two-level key hierarchy: the MEK now wraps per-engine DEKs
stored in a new barrier_keys table, rather than encrypting all barrier
entries directly. A v2 ciphertext format (0x02) embeds the key ID so the
barrier can resolve which DEK to use on decryption. v1 ciphertext remains
supported for backward compatibility.

Key changes:
- crypto: EncryptV2/DecryptV2/ExtractKeyID for v2 ciphertext with key IDs
- barrier: key registry (CreateKey, RotateKey, ListKeys, MigrateToV2, ReWrapKeys)
- seal: RotateMEK re-wraps DEKs without re-encrypting data
- engine: Mount auto-creates per-engine DEK
- REST + gRPC: barrier/keys, barrier/rotate-mek, barrier/rotate-key, barrier/migrate
- proto: BarrierService (v1 + v2) with ListKeys, RotateMEK, RotateKey, Migrate
- db: migration v2 adds barrier_keys table

Also includes: security audit report, CSRF protection, engine design specs
(sshca, transit, user), path-bound AAD migration tool, policy engine
enhancements, and ARCHITECTURE.md updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 18:27:44 -07:00

339 lines
11 KiB
Go

package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"log/slog"
"github.com/go-chi/chi/v5"
"git.wntrmute.dev/kyle/metacrypt/internal/barrier"
"git.wntrmute.dev/kyle/metacrypt/internal/config"
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
"git.wntrmute.dev/kyle/metacrypt/internal/db"
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
"git.wntrmute.dev/kyle/metacrypt/internal/policy"
"git.wntrmute.dev/kyle/metacrypt/internal/seal"
// auth is used indirectly via the server
"git.wntrmute.dev/kyle/metacrypt/internal/auth"
)
func setupTestServer(t *testing.T) (*Server, *seal.Manager, chi.Router) {
t.Helper()
dir := t.TempDir()
database, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
_ = db.Migrate(database)
b := barrier.NewAESGCMBarrier(database)
sealMgr := seal.NewManager(database, b, slog.Default())
_ = sealMgr.CheckInitialized()
// Auth requires MCIAS client which we can't create in tests easily,
// so we pass nil and avoid auth-dependent routes in these tests.
authenticator := auth.NewAuthenticator(nil, slog.Default())
policyEngine := policy.NewEngine(b)
engineRegistry := engine.NewRegistry(b, slog.Default())
cfg := &config.Config{
Server: config.ServerConfig{
ListenAddr: ":0",
TLSCert: "cert.pem",
TLSKey: "key.pem",
},
Database: config.DatabaseConfig{Path: filepath.Join(dir, "test.db")},
MCIAS: config.MCIASConfig{ServerURL: "https://mcias.test"},
Seal: config.SealConfig{
Argon2Time: 1,
Argon2Memory: 64 * 1024,
Argon2Threads: 1,
},
}
logger := slog.Default()
srv := New(cfg, sealMgr, authenticator, policyEngine, engineRegistry, logger, "test")
r := chi.NewRouter()
srv.registerRoutes(r)
return srv, sealMgr, r
}
func TestStatusEndpoint(t *testing.T) {
_, _, mux := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status code: got %d, want %d", w.Code, http.StatusOK)
}
var resp map[string]interface{}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp["state"] != "uninitialized" {
t.Errorf("state: got %q, want %q", resp["state"], "uninitialized")
}
}
func TestInitEndpoint(t *testing.T) {
_, _, mux := setupTestServer(t)
body := `{"password":"test-password"}`
req := httptest.NewRequest(http.MethodPost, "/v1/init", strings.NewReader(body))
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status code: got %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String())
}
var resp map[string]interface{}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp["state"] != "unsealed" {
t.Errorf("state: got %q, want %q", resp["state"], "unsealed")
}
// Second init should fail.
req2 := httptest.NewRequest(http.MethodPost, "/v1/init", strings.NewReader(body))
w2 := httptest.NewRecorder()
mux.ServeHTTP(w2, req2)
if w2.Code != http.StatusConflict {
t.Errorf("double init: got %d, want %d", w2.Code, http.StatusConflict)
}
}
func TestUnsealEndpoint(t *testing.T) {
_, sealMgr, mux := setupTestServer(t)
// Initialize first.
params := crypto.Argon2Params{Time: 1, Memory: 64 * 1024, Threads: 1}
_ = sealMgr.Initialize(context.Background(), []byte("password"), params)
_ = sealMgr.Seal()
// Unseal with wrong password.
body := `{"password":"wrong"}`
req := httptest.NewRequest(http.MethodPost, "/v1/unseal", strings.NewReader(body))
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("wrong password: got %d, want %d", w.Code, http.StatusUnauthorized)
}
// Unseal with correct password.
body = `{"password":"password"}`
req = httptest.NewRequest(http.MethodPost, "/v1/unseal", strings.NewReader(body))
w = httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("correct password: got %d, want %d. Body: %s", w.Code, http.StatusOK, w.Body.String())
}
}
func TestStatusMethodNotAllowed(t *testing.T) {
_, _, mux := setupTestServer(t)
req := httptest.NewRequest(http.MethodPost, "/v1/status", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("POST /v1/status: got %d, want %d", w.Code, http.StatusMethodNotAllowed)
}
}
func TestRootNotFound(t *testing.T) {
_, _, mux := setupTestServer(t)
// The vault server no longer serves a web UI at /; that is handled by metacrypt-web.
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("root: got %d, want %d", w.Code, http.StatusNotFound)
}
}
func unsealServer(t *testing.T, sealMgr *seal.Manager, _ interface{}) {
t.Helper()
params := crypto.Argon2Params{Time: 1, Memory: 64 * 1024, Threads: 1}
if err := sealMgr.Initialize(context.Background(), []byte("password"), params); err != nil {
t.Fatalf("initialize: %v", err)
}
}
func makeEngineRequest(mount, operation string) string {
return `{"mount":"` + mount + `","operation":"` + operation + `","data":{}}`
}
func withTokenInfo(r *http.Request, info *auth.TokenInfo) *http.Request {
return r.WithContext(context.WithValue(r.Context(), tokenInfoKey, info))
}
// TestEngineRequestPolicyDeniesNonAdmin verifies that a non-admin user without
// an explicit allow rule is denied by the policy engine.
func TestEngineRequestPolicyDeniesNonAdmin(t *testing.T) {
srv, sealMgr, _ := setupTestServer(t)
unsealServer(t, sealMgr, nil)
body := makeEngineRequest("pki", "list-issuers")
req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body))
req = withTokenInfo(req, &auth.TokenInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false})
w := httptest.NewRecorder()
srv.handleEngineRequest(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 Forbidden for non-admin without policy rule, got %d: %s", w.Code, w.Body.String())
}
}
// TestEngineRequestPolicyAllowsAdmin verifies that admin users bypass policy.
func TestEngineRequestPolicyAllowsAdmin(t *testing.T) {
srv, sealMgr, _ := setupTestServer(t)
unsealServer(t, sealMgr, nil)
body := makeEngineRequest("pki", "list-issuers")
req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body))
req = withTokenInfo(req, &auth.TokenInfo{Username: "admin", Roles: []string{"admin"}, IsAdmin: true})
w := httptest.NewRecorder()
srv.handleEngineRequest(w, req)
// Admin bypasses policy; will fail with mount-not-found (404), not forbidden (403).
if w.Code == http.StatusForbidden {
t.Errorf("admin should not be forbidden by policy, got 403: %s", w.Body.String())
}
}
// TestEngineRequestPolicyAllowsWithRule verifies that a non-admin user with an
// explicit allow rule is permitted to proceed.
func TestEngineRequestPolicyAllowsWithRule(t *testing.T) {
srv, sealMgr, _ := setupTestServer(t)
unsealServer(t, sealMgr, nil)
ctx := context.Background()
_ = srv.policy.CreateRule(ctx, &policy.Rule{
ID: "allow-user-read",
Priority: 100,
Effect: policy.EffectAllow,
Roles: []string{"user"},
Resources: []string{"engine/*/*"},
Actions: []string{"read"},
})
body := makeEngineRequest("pki", "list-issuers")
req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body))
req = withTokenInfo(req, &auth.TokenInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false})
w := httptest.NewRecorder()
srv.handleEngineRequest(w, req)
// Policy allows; will fail with mount-not-found (404), not forbidden (403).
if w.Code == http.StatusForbidden {
t.Errorf("user with allow rule should not be forbidden, got 403: %s", w.Body.String())
}
}
// TestEngineRequestAdminOnlyBlocksNonAdmin verifies that admin-only operations
// via the generic endpoint are rejected for non-admin users.
func TestEngineRequestAdminOnlyBlocksNonAdmin(t *testing.T) {
srv, sealMgr, _ := setupTestServer(t)
unsealServer(t, sealMgr, nil)
for _, op := range []string{"create-issuer", "delete-cert", "create-key", "rotate-key", "create-profile", "provision"} {
body := makeEngineRequest("test-mount", op)
req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body))
req = withTokenInfo(req, &auth.TokenInfo{Username: "alice", Roles: []string{"user"}, IsAdmin: false})
w := httptest.NewRecorder()
srv.handleEngineRequest(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("operation %q: expected 403 for non-admin, got %d", op, w.Code)
}
}
}
// TestEngineRequestAdminOnlyAllowsAdmin verifies that admin-only operations
// via the generic endpoint are allowed for admin users.
func TestEngineRequestAdminOnlyAllowsAdmin(t *testing.T) {
srv, sealMgr, _ := setupTestServer(t)
unsealServer(t, sealMgr, nil)
for _, op := range []string{"create-issuer", "delete-cert", "create-key", "rotate-key", "create-profile", "provision"} {
body := makeEngineRequest("test-mount", op)
req := httptest.NewRequest(http.MethodPost, "/v1/engine/request", strings.NewReader(body))
req = withTokenInfo(req, &auth.TokenInfo{Username: "admin", Roles: []string{"admin"}, IsAdmin: true})
w := httptest.NewRecorder()
srv.handleEngineRequest(w, req)
// Admin passes the admin check; will get 404 (mount not found) not 403.
if w.Code == http.StatusForbidden {
t.Errorf("operation %q: admin should not be forbidden, got 403", op)
}
}
}
// TestOperationAction verifies the action classification of operations.
func TestOperationAction(t *testing.T) {
tests := map[string]string{
// Read operations.
"list-issuers": policy.ActionRead,
"list-certs": policy.ActionRead,
"get-cert": policy.ActionRead,
"get-root": policy.ActionRead,
"get-chain": policy.ActionRead,
"get-issuer": policy.ActionRead,
"list-keys": policy.ActionRead,
"get-key": policy.ActionRead,
"get-public-key": policy.ActionRead,
"list-users": policy.ActionRead,
"get-profile": policy.ActionRead,
"list-profiles": policy.ActionRead,
// Granular crypto operations (including batch variants).
"encrypt": policy.ActionEncrypt,
"batch-encrypt": policy.ActionEncrypt,
"decrypt": policy.ActionDecrypt,
"batch-decrypt": policy.ActionDecrypt,
"sign": policy.ActionSign,
"sign-host": policy.ActionSign,
"sign-user": policy.ActionSign,
"verify": policy.ActionVerify,
"hmac": policy.ActionHMAC,
// Write operations.
"issue": policy.ActionWrite,
"renew": policy.ActionWrite,
"create-issuer": policy.ActionWrite,
"delete-issuer": policy.ActionWrite,
"sign-csr": policy.ActionWrite,
"revoke": policy.ActionWrite,
}
for op, want := range tests {
if got := operationAction(op); got != want {
t.Errorf("operationAction(%q) = %q, want %q", op, got, want)
}
}
}
func TestTokenInfoFromContext(t *testing.T) {
ctx := context.Background()
if info := TokenInfoFromContext(ctx); info != nil {
t.Error("expected nil from empty context")
}
info := &auth.TokenInfo{Username: "test", IsAdmin: true}
ctx = context.WithValue(ctx, tokenInfoKey, info)
got := TokenInfoFromContext(ctx)
if got == nil || got.Username != "test" {
t.Error("expected token info from context")
}
}