- Fix #61: handleRotateKey and handleDeleteUser now zeroize stored privBytes instead of calling Bytes() (which returns a copy). New state populates privBytes; old references nil'd for GC. - Add audit logging subsystem (internal/audit) with structured event recording for cryptographic operations. - Add audit log engine spec (engines/auditlog.md). - Add ValidateName checks across all engines for path traversal (#48). - Update AUDIT.md: all High findings resolved (0 open). - Add REMEDIATION.md with detailed remediation tracking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
454 lines
15 KiB
Go
454 lines
15 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, nil, 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, nil, 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":{}}`
|
|
}
|
|
|
|
// stubEngine is a minimal engine implementation for testing the generic endpoint.
|
|
type stubEngine struct {
|
|
engineType engine.EngineType
|
|
}
|
|
|
|
func (e *stubEngine) Type() engine.EngineType { return e.engineType }
|
|
func (e *stubEngine) Initialize(_ context.Context, _ barrier.Barrier, _ string, _ map[string]interface{}) error {
|
|
return nil
|
|
}
|
|
func (e *stubEngine) Unseal(_ context.Context, _ barrier.Barrier, _ string) error { return nil }
|
|
func (e *stubEngine) Seal() error { return nil }
|
|
func (e *stubEngine) HandleRequest(_ context.Context, req *engine.Request) (*engine.Response, error) {
|
|
return &engine.Response{Data: map[string]interface{}{"ok": true}}, nil
|
|
}
|
|
|
|
// mountStubEngine registers a factory and mounts a stub engine of the given type.
|
|
func mountStubEngine(t *testing.T, srv *Server, name string, engineType engine.EngineType) {
|
|
t.Helper()
|
|
srv.engines.RegisterFactory(engineType, func() engine.Engine {
|
|
return &stubEngine{engineType: engineType}
|
|
})
|
|
if err := srv.engines.Mount(context.Background(), name, engineType, nil); err != nil {
|
|
// Ignore "already exists" from re-mounting the same name.
|
|
if !strings.Contains(err.Error(), "already exists") {
|
|
t.Fatalf("mount stub %q as %s: %v", name, engineType, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
mountStubEngine(t, srv, "pki", engine.EngineTypeCA)
|
|
|
|
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)
|
|
mountStubEngine(t, srv, "pki", engine.EngineTypeCA)
|
|
|
|
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; stub engine returns 200.
|
|
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)
|
|
mountStubEngine(t, srv, "pki", engine.EngineTypeCA)
|
|
|
|
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; stub engine returns 200.
|
|
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)
|
|
|
|
// Mount stub engines so the admin-only lookup can resolve engine types.
|
|
mountStubEngine(t, srv, "ca-mount", engine.EngineTypeCA)
|
|
mountStubEngine(t, srv, "transit-mount", engine.EngineTypeTransit)
|
|
mountStubEngine(t, srv, "sshca-mount", engine.EngineTypeSSHCA)
|
|
mountStubEngine(t, srv, "user-mount", engine.EngineTypeUser)
|
|
|
|
cases := []struct {
|
|
mount string
|
|
op string
|
|
}{
|
|
{"ca-mount", "create-issuer"},
|
|
{"ca-mount", "delete-cert"},
|
|
{"transit-mount", "create-key"},
|
|
{"transit-mount", "rotate-key"},
|
|
{"sshca-mount", "create-profile"},
|
|
{"user-mount", "provision"},
|
|
}
|
|
for _, tc := range cases {
|
|
body := makeEngineRequest(tc.mount, tc.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("%s/%s: expected 403 for non-admin, got %d", tc.mount, tc.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)
|
|
|
|
mountStubEngine(t, srv, "ca-mount", engine.EngineTypeCA)
|
|
mountStubEngine(t, srv, "transit-mount", engine.EngineTypeTransit)
|
|
mountStubEngine(t, srv, "sshca-mount", engine.EngineTypeSSHCA)
|
|
mountStubEngine(t, srv, "user-mount", engine.EngineTypeUser)
|
|
|
|
cases := []struct {
|
|
mount string
|
|
op string
|
|
}{
|
|
{"ca-mount", "create-issuer"},
|
|
{"ca-mount", "delete-cert"},
|
|
{"transit-mount", "create-key"},
|
|
{"transit-mount", "rotate-key"},
|
|
{"sshca-mount", "create-profile"},
|
|
{"user-mount", "provision"},
|
|
}
|
|
for _, tc := range cases {
|
|
body := makeEngineRequest(tc.mount, tc.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; stub engine returns 200.
|
|
if w.Code == http.StatusForbidden {
|
|
t.Errorf("%s/%s: admin should not be forbidden, got 403", tc.mount, tc.op)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEngineRequestUserRotateKeyOnUserMount verifies that a non-admin user
|
|
// can call rotate-key on a user engine mount (not blocked by transit's admin gate).
|
|
func TestEngineRequestUserRotateKeyOnUserMount(t *testing.T) {
|
|
srv, sealMgr, _ := setupTestServer(t)
|
|
unsealServer(t, sealMgr, nil)
|
|
|
|
mountStubEngine(t, srv, "user-mount", engine.EngineTypeUser)
|
|
|
|
// Create a policy rule allowing user operations.
|
|
ctx := context.Background()
|
|
_ = srv.policy.CreateRule(ctx, &policy.Rule{
|
|
ID: "allow-user-ops",
|
|
Priority: 100,
|
|
Effect: policy.EffectAllow,
|
|
Roles: []string{"user"},
|
|
Resources: []string{"engine/*/*"},
|
|
Actions: []string{"any"},
|
|
})
|
|
|
|
body := makeEngineRequest("user-mount", "rotate-key")
|
|
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)
|
|
|
|
// rotate-key on a user mount should NOT be blocked as admin-only.
|
|
if w.Code == http.StatusForbidden {
|
|
t.Errorf("user rotate-key on user mount should not be forbidden, got 403: %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestEngineRequestUserRotateKeyOnTransitMount verifies that a non-admin user
|
|
// is blocked from calling rotate-key on a transit engine mount.
|
|
func TestEngineRequestUserRotateKeyOnTransitMount(t *testing.T) {
|
|
srv, sealMgr, _ := setupTestServer(t)
|
|
unsealServer(t, sealMgr, nil)
|
|
|
|
mountStubEngine(t, srv, "transit-mount", engine.EngineTypeTransit)
|
|
|
|
body := makeEngineRequest("transit-mount", "rotate-key")
|
|
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("user rotate-key on transit mount should be 403, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|