diff --git a/PROGRESS.md b/PROGRESS.md index 2c806c9..9f7c285 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -4,6 +4,50 @@ Source of truth for current development state. --- All phases complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean. +### 2026-03-14 — Vault seal/unseal lifecycle + +**Problem:** `mciassrv` required the master passphrase at startup and refused to start without it. Operators needed a way to start the server in a degraded state and provide the passphrase at runtime, plus the ability to re-seal at runtime. + +**Solution:** Implemented a `Vault` abstraction that manages key material lifecycle with seal/unseal state transitions. + +**New package: `internal/vault/`** +- `vault.go`: Thread-safe `Vault` struct with `sync.RWMutex`-protected state. Methods: `IsSealed()`, `Unseal()`, `Seal()`, `MasterKey()`, `PrivKey()`, `PubKey()`. `Seal()` zeroes all key material before nilling. +- `derive.go`: Extracted `DeriveFromPassphrase()` and `DecryptSigningKey()` from `cmd/mciassrv/main.go` for reuse by unseal handlers. +- `vault_test.go`: Tests for state transitions, key zeroing, concurrent access. + +**REST API (`internal/server/`):** +- `POST /v1/vault/unseal`: Accept passphrase, derive key, unseal (rate-limited 3/s burst 5) +- `POST /v1/vault/seal`: Admin-only, seals vault and zeroes key material +- `GET /v1/vault/status`: Returns `{"sealed": bool}` +- `GET /v1/health`: Now returns `{"status":"sealed"}` when sealed +- All other `/v1/*` endpoints return 503 `vault_sealed` when sealed + +**Web UI (`internal/ui/`):** +- New unseal page at `/unseal` with passphrase form (same styling as login) +- All UI routes redirect to `/unseal` when sealed (except `/static/`) +- CSRF manager now derives key lazily from vault + +**gRPC (`internal/grpcserver/`):** +- New `sealedInterceptor` first in interceptor chain — returns `codes.Unavailable` for all RPCs except Health +- Health RPC returns `status: "sealed"` when sealed + +**Startup (`cmd/mciassrv/main.go`):** +- When passphrase env var is empty/unset (and not first run): starts in sealed state +- When passphrase is available: backward-compatible unsealed startup +- First run still requires passphrase to generate signing key + +**Refactoring:** +- All three servers (REST, UI, gRPC) share a single `*vault.Vault` by pointer +- Replaced static `privKey`, `pubKey`, `masterKey` fields with vault accessor calls +- `middleware.RequireAuth` now reads pubkey from vault at request time +- New `middleware.RequireUnsealed` middleware wired before request logger + +**Audit events:** Added `vault_sealed` and `vault_unsealed` event types. + +**OpenAPI:** Updated `openapi.yaml` with vault endpoints and sealed health response. + +**Files changed:** 19 files (3 new packages, 3 new handlers, 1 new template, extensive refactoring across all server packages and tests). + ### 2026-03-13 — Make pgcreds discoverable via CLI and UI **Problem:** Users had no way to discover which pgcreds were available to them or what their credential IDs were, making it functionally impossible to use the system without manual database inspection. diff --git a/cmd/mciassrv/main.go b/cmd/mciassrv/main.go index c416bb0..2bbe89e 100644 --- a/cmd/mciassrv/main.go +++ b/cmd/mciassrv/main.go @@ -36,6 +36,7 @@ import ( "git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/kyle/mcias/internal/grpcserver" "git.wntrmute.dev/kyle/mcias/internal/server" + "git.wntrmute.dev/kyle/mcias/internal/vault" ) func main() { @@ -72,29 +73,46 @@ func run(configPath string, logger *slog.Logger) error { } logger.Info("database ready", "path", cfg.Database.Path) - // Derive or load the master encryption key. + // Derive or load the master encryption key and build the vault. // Security: The master key encrypts TOTP secrets, Postgres passwords, and // the signing key at rest. It is derived from a passphrase via Argon2id // (or loaded directly from a key file). The KDF salt is stored in the DB // for stability across restarts. The passphrase env var is cleared after use. - masterKey, err := loadMasterKey(cfg, database) - if err != nil { - return fmt.Errorf("load master key: %w", err) - } - defer func() { - // Zero the master key when done — reduces the window of exposure. - for i := range masterKey { - masterKey[i] = 0 + // + // When the passphrase is not available (empty env var in passphrase mode + // with no key file), the server starts in sealed state. The operator must + // provide the passphrase via the /v1/vault/unseal API or the /unseal UI page. + // First run (no signing key in DB) still requires the passphrase at startup. + var v *vault.Vault + masterKey, mkErr := loadMasterKey(cfg, database) + if mkErr != nil { + // Check if we can start sealed (passphrase mode, empty env var). + if cfg.MasterKey.KeyFile == "" && os.Getenv(cfg.MasterKey.PassphraseEnv) == "" { + // Verify that this is not a first run — the signing key must already exist. + enc, nonce, scErr := database.ReadServerConfig() + if scErr != nil || enc == nil || nonce == nil { + return fmt.Errorf("first run requires passphrase: %w", mkErr) + } + v = vault.NewSealed() + logger.Info("vault starting in sealed state") + } else { + return fmt.Errorf("load master key: %w", mkErr) } - }() - - // Load or generate the Ed25519 signing key. - // Security: The private signing key is stored AES-256-GCM encrypted in the - // database. On first run it is generated and stored. The key is decrypted - // with the master key each startup. - privKey, pubKey, err := loadOrGenerateSigningKey(database, masterKey, logger) - if err != nil { - return fmt.Errorf("signing key: %w", err) + } else { + // Load or generate the Ed25519 signing key. + // Security: The private signing key is stored AES-256-GCM encrypted in the + // database. On first run it is generated and stored. The key is decrypted + // with the master key each startup. + privKey, pubKey, err := loadOrGenerateSigningKey(database, masterKey, logger) + if err != nil { + // Zero master key on failure. + for i := range masterKey { + masterKey[i] = 0 + } + return fmt.Errorf("signing key: %w", err) + } + v = vault.NewUnsealed(masterKey, privKey, pubKey) + logger.Info("vault unsealed at startup") } // Configure TLS. We require TLS 1.2+ and prefer TLS 1.3. @@ -108,8 +126,8 @@ func run(configPath string, logger *slog.Logger) error { }, } - // Build the REST handler. - restSrv := server.New(database, cfg, privKey, pubKey, masterKey, logger) + // Build the REST handler. All servers share the same vault by pointer. + restSrv := server.New(database, cfg, v, logger) httpServer := &http.Server{ Addr: cfg.Server.ListenAddr, Handler: restSrv.Handler(), @@ -131,7 +149,7 @@ func run(configPath string, logger *slog.Logger) error { return fmt.Errorf("load gRPC TLS credentials: %w", err) } - grpcSrvImpl := grpcserver.New(database, cfg, privKey, pubKey, masterKey, logger) + grpcSrvImpl := grpcserver.New(database, cfg, v, logger) // Build server directly with TLS credentials. GRPCServerWithCreds builds // the server with transport credentials at construction time per gRPC idiom. grpcSrv = rebuildGRPCServerWithTLS(grpcSrvImpl, grpcTLSCreds) diff --git a/internal/grpcserver/admin.go b/internal/grpcserver/admin.go index f8ab93f..246c75a 100644 --- a/internal/grpcserver/admin.go +++ b/internal/grpcserver/admin.go @@ -17,8 +17,12 @@ type adminServiceServer struct { s *Server } -// Health returns {"status":"ok"} to signal the server is operational. +// Health returns {"status":"ok"} to signal the server is operational, or +// {"status":"sealed"} when the vault is sealed. func (a *adminServiceServer) Health(_ context.Context, _ *mciasv1.HealthRequest) (*mciasv1.HealthResponse, error) { + if a.s.vault.IsSealed() { + return &mciasv1.HealthResponse{Status: "sealed"}, nil + } return &mciasv1.HealthResponse{Status: "ok"}, nil } @@ -26,11 +30,12 @@ func (a *adminServiceServer) Health(_ context.Context, _ *mciasv1.HealthRequest) // The "x" field is the raw 32-byte public key base64url-encoded without padding, // matching the REST /v1/keys/public response format. func (a *adminServiceServer) GetPublicKey(_ context.Context, _ *mciasv1.GetPublicKeyRequest) (*mciasv1.GetPublicKeyResponse, error) { - if len(a.s.pubKey) == 0 { - return nil, status.Error(codes.Internal, "public key not available") + pubKey, err := a.s.vault.PubKey() + if err != nil { + return nil, status.Error(codes.Unavailable, "vault sealed") } // Encode as base64url without padding — identical to the REST handler. - x := base64.RawURLEncoding.EncodeToString(a.s.pubKey) + x := base64.RawURLEncoding.EncodeToString(pubKey) return &mciasv1.GetPublicKeyResponse{ Kty: "OKP", Crv: "Ed25519", diff --git a/internal/grpcserver/auth.go b/internal/grpcserver/auth.go index de7d6d3..e3e5805 100644 --- a/internal/grpcserver/auth.go +++ b/internal/grpcserver/auth.go @@ -86,7 +86,11 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest a.s.db.WriteAuditEvent(model.EventLoginFail, &acct.ID, nil, ip, `{"reason":"totp_missing"}`) //nolint:errcheck return nil, status.Error(codes.Unauthenticated, "TOTP code required") } - secret, err := crypto.OpenAESGCM(a.s.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc) + masterKey, mkErr := a.s.vault.MasterKey() + if mkErr != nil { + return nil, status.Error(codes.Unavailable, "vault sealed") + } + secret, err := crypto.OpenAESGCM(masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc) if err != nil { a.s.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID) return nil, status.Error(codes.Internal, "internal error") @@ -121,7 +125,11 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest } } - tokenStr, claims, err := token.IssueToken(a.s.privKey, a.s.cfg.Tokens.Issuer, acct.UUID, roles, expiry) + privKey, pkErr := a.s.vault.PrivKey() + if pkErr != nil { + return nil, status.Error(codes.Unavailable, "vault sealed") + } + tokenStr, claims, err := token.IssueToken(privKey, a.s.cfg.Tokens.Issuer, acct.UUID, roles, expiry) if err != nil { a.s.logger.Error("issue token", "error", err) return nil, status.Error(codes.Internal, "internal error") @@ -186,7 +194,11 @@ func (a *authServiceServer) RenewToken(ctx context.Context, _ *mciasv1.RenewToke } } - newTokenStr, newClaims, err := token.IssueToken(a.s.privKey, a.s.cfg.Tokens.Issuer, acct.UUID, roles, expiry) + privKey, pkErr := a.s.vault.PrivKey() + if pkErr != nil { + return nil, status.Error(codes.Unavailable, "vault sealed") + } + newTokenStr, newClaims, err := token.IssueToken(privKey, a.s.cfg.Tokens.Issuer, acct.UUID, roles, expiry) if err != nil { return nil, status.Error(codes.Internal, "internal error") } @@ -245,7 +257,11 @@ func (a *authServiceServer) EnrollTOTP(ctx context.Context, req *mciasv1.EnrollT return nil, status.Error(codes.Internal, "internal error") } - secretEnc, secretNonce, err := crypto.SealAESGCM(a.s.masterKey, rawSecret) + masterKey, mkErr := a.s.vault.MasterKey() + if mkErr != nil { + return nil, status.Error(codes.Unavailable, "vault sealed") + } + secretEnc, secretNonce, err := crypto.SealAESGCM(masterKey, rawSecret) if err != nil { return nil, status.Error(codes.Internal, "internal error") } @@ -283,7 +299,11 @@ func (a *authServiceServer) ConfirmTOTP(ctx context.Context, req *mciasv1.Confir return nil, status.Error(codes.FailedPrecondition, "TOTP enrollment not started") } - secret, err := crypto.OpenAESGCM(a.s.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc) + masterKey, mkErr := a.s.vault.MasterKey() + if mkErr != nil { + return nil, status.Error(codes.Unavailable, "vault sealed") + } + secret, err := crypto.OpenAESGCM(masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc) if err != nil { return nil, status.Error(codes.Internal, "internal error") } diff --git a/internal/grpcserver/credentialservice.go b/internal/grpcserver/credentialservice.go index afe10b7..450e4f0 100644 --- a/internal/grpcserver/credentialservice.go +++ b/internal/grpcserver/credentialservice.go @@ -47,7 +47,11 @@ func (c *credentialServiceServer) GetPGCreds(ctx context.Context, req *mciasv1.G } // Decrypt the password for admin retrieval. - password, err := crypto.OpenAESGCM(c.s.masterKey, cred.PGPasswordNonce, cred.PGPasswordEnc) + masterKey, mkErr := c.s.vault.MasterKey() + if mkErr != nil { + return nil, status.Error(codes.Unavailable, "vault sealed") + } + password, err := crypto.OpenAESGCM(masterKey, cred.PGPasswordNonce, cred.PGPasswordEnc) if err != nil { return nil, status.Error(codes.Internal, "internal error") } @@ -94,7 +98,11 @@ func (c *credentialServiceServer) SetPGCreds(ctx context.Context, req *mciasv1.S return nil, status.Error(codes.Internal, "internal error") } - enc, nonce, err := crypto.SealAESGCM(c.s.masterKey, []byte(cr.Password)) + masterKey, mkErr := c.s.vault.MasterKey() + if mkErr != nil { + return nil, status.Error(codes.Unavailable, "vault sealed") + } + enc, nonce, err := crypto.SealAESGCM(masterKey, []byte(cr.Password)) if err != nil { return nil, status.Error(codes.Internal, "internal error") } diff --git a/internal/grpcserver/grpcserver.go b/internal/grpcserver/grpcserver.go index a46309a..e7a52a2 100644 --- a/internal/grpcserver/grpcserver.go +++ b/internal/grpcserver/grpcserver.go @@ -17,7 +17,6 @@ package grpcserver import ( "context" - "crypto/ed25519" "log/slog" "net" "strings" @@ -35,6 +34,7 @@ import ( "git.wntrmute.dev/kyle/mcias/internal/config" "git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/kyle/mcias/internal/token" + "git.wntrmute.dev/kyle/mcias/internal/vault" ) // contextKey is the unexported context key type for this package. @@ -57,21 +57,17 @@ type Server struct { cfg *config.Config logger *slog.Logger rateLimiter *grpcRateLimiter - privKey ed25519.PrivateKey - pubKey ed25519.PublicKey - masterKey []byte + vault *vault.Vault } // New creates a Server with the given dependencies (same as the REST Server). // A fresh per-IP rate limiter (10 req/s, burst 10) is allocated per Server // instance so that tests do not share state across test cases. -func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed25519.PublicKey, masterKey []byte, logger *slog.Logger) *Server { +func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logger) *Server { return &Server{ db: database, cfg: cfg, - privKey: priv, - pubKey: pub, - masterKey: masterKey, + vault: v, logger: logger, rateLimiter: newGRPCRateLimiter(10, 10), } @@ -106,6 +102,7 @@ func (s *Server) buildServer(extra ...grpc.ServerOption) *grpc.Server { []grpc.ServerOption{ grpc.ChainUnaryInterceptor( s.loggingInterceptor, + s.sealedInterceptor, s.authInterceptor, s.rateLimitInterceptor, ), @@ -162,14 +159,36 @@ func (s *Server) loggingInterceptor( return resp, err } +// sealedInterceptor rejects all RPCs (except Health) when the vault is sealed. +// +// Security: This is the first interceptor in the chain (after logging). It +// prevents any authenticated or data-serving handler from running while the +// vault is sealed and key material is unavailable. +func (s *Server) sealedInterceptor( + ctx context.Context, + req interface{}, + info *grpc.UnaryServerInfo, + handler grpc.UnaryHandler, +) (interface{}, error) { + if !s.vault.IsSealed() { + return handler(ctx, req) + } + // Health is always allowed — returns sealed status. + if info.FullMethod == "/mcias.v1.AdminService/Health" { + return handler(ctx, req) + } + return nil, status.Error(codes.Unavailable, "vault sealed") +} + // authInterceptor validates the Bearer JWT from gRPC metadata and injects // claims into the context. Public methods bypass this check. // // Security: Same validation path as the REST RequireAuth middleware: // 1. Extract "authorization" metadata value (case-insensitive key lookup). -// 2. Validate JWT (alg-first, then signature, then expiry/issuer). -// 3. Check JTI against revocation table. -// 4. Inject claims into context. +// 2. Read public key from vault (fail closed if sealed). +// 3. Validate JWT (alg-first, then signature, then expiry/issuer). +// 4. Check JTI against revocation table. +// 5. Inject claims into context. func (s *Server) authInterceptor( ctx context.Context, req interface{}, @@ -186,7 +205,13 @@ func (s *Server) authInterceptor( return nil, status.Error(codes.Unauthenticated, "missing or invalid authorization") } - claims, err := token.ValidateToken(s.pubKey, tokenStr, s.cfg.Tokens.Issuer) + // Security: read the public key from vault at request time. + pubKey, err := s.vault.PubKey() + if err != nil { + return nil, status.Error(codes.Unavailable, "vault sealed") + } + + claims, err := token.ValidateToken(pubKey, tokenStr, s.cfg.Tokens.Issuer) if err != nil { return nil, status.Error(codes.Unauthenticated, "invalid or expired token") } diff --git a/internal/grpcserver/grpcserver_test.go b/internal/grpcserver/grpcserver_test.go index 6a83c8b..e807db1 100644 --- a/internal/grpcserver/grpcserver_test.go +++ b/internal/grpcserver/grpcserver_test.go @@ -30,6 +30,7 @@ import ( "git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/kyle/mcias/internal/token" + "git.wntrmute.dev/kyle/mcias/internal/vault" ) const ( @@ -73,7 +74,8 @@ func newTestEnv(t *testing.T) *testEnv { cfg := config.NewTestConfig(testIssuer) logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - srv := New(database, cfg, priv, pub, masterKey, logger) + v := vault.NewUnsealed(masterKey, priv, pub) + srv := New(database, cfg, v, logger) grpcSrv := srv.GRPCServer() lis := bufconn.Listen(bufConnSize) diff --git a/internal/grpcserver/tokenservice.go b/internal/grpcserver/tokenservice.go index fcd4501..250311c 100644 --- a/internal/grpcserver/tokenservice.go +++ b/internal/grpcserver/tokenservice.go @@ -32,7 +32,11 @@ func (t *tokenServiceServer) ValidateToken(_ context.Context, req *mciasv1.Valid return &mciasv1.ValidateTokenResponse{Valid: false}, nil } - claims, err := token.ValidateToken(t.s.pubKey, tokenStr, t.s.cfg.Tokens.Issuer) + pubKey, pkErr := t.s.vault.PubKey() + if pkErr != nil { + return &mciasv1.ValidateTokenResponse{Valid: false}, nil + } + claims, err := token.ValidateToken(pubKey, tokenStr, t.s.cfg.Tokens.Issuer) if err != nil { return &mciasv1.ValidateTokenResponse{Valid: false}, nil } @@ -67,7 +71,11 @@ func (ts *tokenServiceServer) IssueServiceToken(ctx context.Context, req *mciasv return nil, status.Error(codes.InvalidArgument, "token issue is only for system accounts") } - tokenStr, claims, err := token.IssueToken(ts.s.privKey, ts.s.cfg.Tokens.Issuer, acct.UUID, nil, ts.s.cfg.ServiceExpiry()) + privKey, pkErr := ts.s.vault.PrivKey() + if pkErr != nil { + return nil, status.Error(codes.Unavailable, "vault sealed") + } + tokenStr, claims, err := token.IssueToken(privKey, ts.s.cfg.Tokens.Issuer, acct.UUID, nil, ts.s.cfg.ServiceExpiry()) if err != nil { return nil, status.Error(codes.Internal, "internal error") } diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 1670ed0..72b5f28 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -13,7 +13,6 @@ package middleware import ( "context" - "crypto/ed25519" "encoding/json" "errors" "fmt" @@ -27,6 +26,7 @@ import ( "git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/kyle/mcias/internal/policy" "git.wntrmute.dev/kyle/mcias/internal/token" + "git.wntrmute.dev/kyle/mcias/internal/vault" ) // contextKey is the unexported type for context keys in this package, preventing @@ -90,12 +90,18 @@ func (rw *responseWriter) WriteHeader(code int) { // RequireAuth returns middleware that validates a Bearer JWT and injects the // claims into the request context. Returns 401 on any auth failure. // +// The public key is read from the vault at request time so that the middleware +// works correctly across seal/unseal transitions. When the vault is sealed, +// the sealed middleware (RequireUnsealed) prevents reaching this handler, but +// the vault check here provides defense in depth (fail closed). +// // Security: Token validation order: // 1. Extract Bearer token from Authorization header. -// 2. Validate the JWT (alg=EdDSA, signature, expiry, issuer). -// 3. Check the JTI against the revocation table in the database. -// 4. Inject validated claims into context for downstream handlers. -func RequireAuth(pubKey ed25519.PublicKey, database *db.DB, issuer string) func(http.Handler) http.Handler { +// 2. Read public key from vault (fail closed if sealed). +// 3. Validate the JWT (alg=EdDSA, signature, expiry, issuer). +// 4. Check the JTI against the revocation table in the database. +// 5. Inject validated claims into context for downstream handlers. +func RequireAuth(v *vault.Vault, database *db.DB, issuer string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tokenStr, err := extractBearerToken(r) @@ -104,6 +110,14 @@ func RequireAuth(pubKey ed25519.PublicKey, database *db.DB, issuer string) func( return } + // Security: read the public key from vault at request time. + // If the vault is sealed, fail closed with 503. + pubKey, err := v.PubKey() + if err != nil { + writeError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed") + return + } + claims, err := token.ValidateToken(pubKey, tokenStr, issuer) if err != nil { // Security: Map all token errors to a generic 401; do not @@ -437,3 +451,47 @@ func RequirePolicy( }) } } + +// RequireUnsealed returns middleware that blocks requests when the vault is sealed. +// +// Exempt paths (served normally even when sealed): +// - GET /v1/health, GET /v1/vault/status, POST /v1/vault/unseal +// - GET /unseal, POST /unseal +// - GET /static/* (CSS/JS needed by the unseal page) +// +// API paths (/v1/*) receive a JSON 503 response. All other paths (UI) receive +// a 302 redirect to /unseal. +// +// Security: This middleware is the first in the chain (after global security +// headers). It ensures no authenticated or data-serving handler runs while the +// vault is sealed and key material is unavailable. +func RequireUnsealed(v *vault.Vault) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !v.IsSealed() { + next.ServeHTTP(w, r) + return + } + + path := r.URL.Path + + // Exempt paths that must work while sealed. + if path == "/v1/health" || path == "/v1/vault/status" || + path == "/v1/vault/unseal" || + path == "/unseal" || + strings.HasPrefix(path, "/static/") { + next.ServeHTTP(w, r) + return + } + + // API paths: JSON 503. + if strings.HasPrefix(path, "/v1/") { + writeError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed") + return + } + + // UI paths: redirect to unseal page. + http.Redirect(w, r, "/unseal", http.StatusFound) + }) + } +} diff --git a/internal/middleware/middleware_test.go b/internal/middleware/middleware_test.go index 3b69c98..95412b4 100644 --- a/internal/middleware/middleware_test.go +++ b/internal/middleware/middleware_test.go @@ -15,6 +15,7 @@ import ( "git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/kyle/mcias/internal/token" + "git.wntrmute.dev/kyle/mcias/internal/vault" ) func generateTestKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) { @@ -26,6 +27,15 @@ func generateTestKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) { return pub, priv } +func testVault(t *testing.T, priv ed25519.PrivateKey, pub ed25519.PublicKey) *vault.Vault { + t.Helper() + mk := make([]byte, 32) + if _, err := rand.Read(mk); err != nil { + t.Fatalf("generate master key: %v", err) + } + return vault.NewUnsealed(mk, priv, pub) +} + func openTestDB(t *testing.T) *db.DB { t.Helper() database, err := db.Open(":memory:") @@ -96,7 +106,7 @@ func TestRequireAuthValid(t *testing.T) { tokenStr := issueAndTrackToken(t, priv, database, acct.ID, []string{"reader"}) reached := false - handler := RequireAuth(pub, database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := RequireAuth(testVault(t, priv, pub), database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true claims := ClaimsFromContext(r.Context()) if claims == nil { @@ -123,7 +133,7 @@ func TestRequireAuthMissingHeader(t *testing.T) { _ = priv database := openTestDB(t) - handler := RequireAuth(pub, database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + handler := RequireAuth(testVault(t, priv, pub), database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { t.Error("handler should not be reached without auth") w.WriteHeader(http.StatusOK) })) @@ -138,10 +148,10 @@ func TestRequireAuthMissingHeader(t *testing.T) { } func TestRequireAuthInvalidToken(t *testing.T) { - pub, _ := generateTestKey(t) + pub, priv := generateTestKey(t) database := openTestDB(t) - handler := RequireAuth(pub, database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + handler := RequireAuth(testVault(t, priv, pub), database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { t.Error("handler should not be reached with invalid token") w.WriteHeader(http.StatusOK) })) @@ -176,7 +186,7 @@ func TestRequireAuthRevokedToken(t *testing.T) { t.Fatalf("RevokeToken: %v", err) } - handler := RequireAuth(pub, database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + handler := RequireAuth(testVault(t, priv, pub), database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { t.Error("handler should not be reached with revoked token") w.WriteHeader(http.StatusOK) })) @@ -201,7 +211,7 @@ func TestRequireAuthExpiredToken(t *testing.T) { t.Fatalf("IssueToken: %v", err) } - handler := RequireAuth(pub, database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + handler := RequireAuth(testVault(t, priv, pub), database, testIssuer)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { t.Error("handler should not be reached with expired token") w.WriteHeader(http.StatusOK) })) diff --git a/internal/model/model.go b/internal/model/model.go index cbc9be2..b96faad 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -178,6 +178,9 @@ const ( EventPGCredAccessed = "pgcred_accessed" EventPGCredUpdated = "pgcred_updated" //nolint:gosec // G101: audit event type string, not a credential + EventVaultSealed = "vault_sealed" + EventVaultUnsealed = "vault_unsealed" + EventTagAdded = "tag_added" EventTagRemoved = "tag_removed" diff --git a/internal/server/server.go b/internal/server/server.go index 24c6a4c..cf77cba 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -10,7 +10,6 @@ package server import ( - "crypto/ed25519" "encoding/json" "errors" "fmt" @@ -31,28 +30,25 @@ import ( "git.wntrmute.dev/kyle/mcias/internal/token" "git.wntrmute.dev/kyle/mcias/internal/ui" "git.wntrmute.dev/kyle/mcias/internal/validate" + "git.wntrmute.dev/kyle/mcias/internal/vault" "git.wntrmute.dev/kyle/mcias/web" ) // Server holds the dependencies injected into all handlers. type Server struct { - db *db.DB - cfg *config.Config - logger *slog.Logger - privKey ed25519.PrivateKey - pubKey ed25519.PublicKey - masterKey []byte + db *db.DB + cfg *config.Config + logger *slog.Logger + vault *vault.Vault } // New creates a Server with the given dependencies. -func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed25519.PublicKey, masterKey []byte, logger *slog.Logger) *Server { +func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logger) *Server { return &Server{ - db: database, - cfg: cfg, - privKey: priv, - pubKey: pub, - masterKey: masterKey, - logger: logger, + db: database, + cfg: cfg, + vault: v, + logger: logger, } } @@ -110,8 +106,14 @@ func (s *Server) Handler() http.Handler { _, _ = w.Write(specYAML) }))) + // Vault endpoints (exempt from sealed middleware and auth). + unsealRateLimit := middleware.RateLimit(3, 5, trustedProxy) + mux.Handle("POST /v1/vault/unseal", unsealRateLimit(http.HandlerFunc(s.handleUnseal))) + mux.HandleFunc("GET /v1/vault/status", s.handleVaultStatus) + mux.Handle("POST /v1/vault/seal", middleware.RequireAuth(s.vault, s.db, s.cfg.Tokens.Issuer)(middleware.RequireRole("admin")(http.HandlerFunc(s.handleSeal)))) + // Authenticated endpoints. - requireAuth := middleware.RequireAuth(s.pubKey, s.db, s.cfg.Tokens.Issuer) + requireAuth := middleware.RequireAuth(s.vault, s.db, s.cfg.Tokens.Issuer) requireAdmin := func(h http.Handler) http.Handler { return requireAuth(middleware.RequireRole("admin")(h)) } @@ -152,15 +154,18 @@ func (s *Server) Handler() http.Handler { mux.Handle("DELETE /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleDeletePolicyRule))) // UI routes (HTMX-based management frontend). - uiSrv, err := ui.New(s.db, s.cfg, s.privKey, s.pubKey, s.masterKey, s.logger) + uiSrv, err := ui.New(s.db, s.cfg, s.vault, s.logger) if err != nil { panic(fmt.Sprintf("ui: init failed: %v", err)) } uiSrv.Register(mux) - // Apply global middleware: request logging and security headers. + // Apply global middleware: request logging, sealed check, and security headers. // Rate limiting is applied per-route above (login, token/validate). var root http.Handler = mux + // Security: RequireUnsealed runs after the mux (so exempt routes can be + // routed) but before the logger (so sealed-blocked requests are still logged). + root = middleware.RequireUnsealed(s.vault)(root) root = middleware.RequestLogger(s.logger)(root) // Security (SEC-04): apply baseline security headers to ALL responses @@ -178,12 +183,21 @@ func (s *Server) Handler() http.Handler { // ---- Public handlers ---- func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { + if s.vault.IsSealed() { + writeJSON(w, http.StatusOK, map[string]string{"status": "sealed"}) + return + } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } // handlePublicKey returns the server's Ed25519 public key in JWK format. // This allows relying parties to independently verify JWTs. func (s *Server) handlePublicKey(w http.ResponseWriter, _ *http.Request) { + pubKey, err := s.vault.PubKey() + if err != nil { + middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed") + return + } // Encode the Ed25519 public key as a JWK (RFC 8037). // The "x" parameter is the base64url-encoded public key bytes. jwk := map[string]string{ @@ -191,7 +205,7 @@ func (s *Server) handlePublicKey(w http.ResponseWriter, _ *http.Request) { "crv": "Ed25519", "use": "sig", "alg": "EdDSA", - "x": encodeBase64URL(s.pubKey), + "x": encodeBase64URL(pubKey), } writeJSON(w, http.StatusOK, jwk) } @@ -282,7 +296,12 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { return } // Decrypt the TOTP secret. - secret, err := crypto.OpenAESGCM(s.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc) + masterKey, err := s.vault.MasterKey() + if err != nil { + middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed") + return + } + secret, err := crypto.OpenAESGCM(masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc) if err != nil { s.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID) middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") @@ -322,7 +341,12 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { } } - tokenStr, claims, err := token.IssueToken(s.privKey, s.cfg.Tokens.Issuer, acct.UUID, roles, expiry) + privKey, err := s.vault.PrivKey() + if err != nil { + middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed") + return + } + tokenStr, claims, err := token.IssueToken(privKey, s.cfg.Tokens.Issuer, acct.UUID, roles, expiry) if err != nil { s.logger.Error("issue token", "error", err) middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") @@ -392,7 +416,12 @@ func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) { } } - newTokenStr, newClaims, err := token.IssueToken(s.privKey, s.cfg.Tokens.Issuer, acct.UUID, roles, expiry) + privKey, err := s.vault.PrivKey() + if err != nil { + middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed") + return + } + newTokenStr, newClaims, err := token.IssueToken(privKey, s.cfg.Tokens.Issuer, acct.UUID, roles, expiry) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return @@ -444,7 +473,12 @@ func (s *Server) handleTokenValidate(w http.ResponseWriter, r *http.Request) { return } - claims, err := token.ValidateToken(s.pubKey, tokenStr, s.cfg.Tokens.Issuer) + pubKey, err := s.vault.PubKey() + if err != nil { + middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed") + return + } + claims, err := token.ValidateToken(pubKey, tokenStr, s.cfg.Tokens.Issuer) if err != nil { writeJSON(w, http.StatusOK, validateResponse{Valid: false}) return @@ -484,7 +518,12 @@ func (s *Server) handleTokenIssue(w http.ResponseWriter, r *http.Request) { return } - tokenStr, claims, err := token.IssueToken(s.privKey, s.cfg.Tokens.Issuer, acct.UUID, nil, s.cfg.ServiceExpiry()) + privKey, err := s.vault.PrivKey() + if err != nil { + middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed") + return + } + tokenStr, claims, err := token.IssueToken(privKey, s.cfg.Tokens.Issuer, acct.UUID, nil, s.cfg.ServiceExpiry()) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return @@ -875,7 +914,12 @@ func (s *Server) handleTOTPEnroll(w http.ResponseWriter, r *http.Request) { // Encrypt the secret before storing it temporarily. // Note: we store as pending; enrollment is confirmed with /confirm. - secretEnc, secretNonce, err := crypto.SealAESGCM(s.masterKey, rawSecret) + masterKey, err := s.vault.MasterKey() + if err != nil { + middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed") + return + } + secretEnc, secretNonce, err := crypto.SealAESGCM(masterKey, rawSecret) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return @@ -918,7 +962,12 @@ func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request) { return } - secret, err := crypto.OpenAESGCM(s.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc) + masterKey, err := s.vault.MasterKey() + if err != nil { + middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed") + return + } + secret, err := crypto.OpenAESGCM(masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return @@ -1178,7 +1227,12 @@ func (s *Server) handleGetPGCreds(w http.ResponseWriter, r *http.Request) { } // Decrypt the password to return it to the admin caller. - password, err := crypto.OpenAESGCM(s.masterKey, cred.PGPasswordNonce, cred.PGPasswordEnc) + masterKey, err := s.vault.MasterKey() + if err != nil { + middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed") + return + } + password, err := crypto.OpenAESGCM(masterKey, cred.PGPasswordNonce, cred.PGPasswordEnc) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return @@ -1215,7 +1269,12 @@ func (s *Server) handleSetPGCreds(w http.ResponseWriter, r *http.Request) { req.Port = 5432 } - enc, nonce, err := crypto.SealAESGCM(s.masterKey, []byte(req.Password)) + masterKey, err := s.vault.MasterKey() + if err != nil { + middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed") + return + } + enc, nonce, err := crypto.SealAESGCM(masterKey, []byte(req.Password)) if err != nil { middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") return diff --git a/internal/server/server_test.go b/internal/server/server_test.go index dedd235..910c8b0 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -24,6 +24,7 @@ import ( "git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/kyle/mcias/internal/token" + "git.wntrmute.dev/kyle/mcias/internal/vault" ) // generateTOTPCode computes a valid RFC 6238 TOTP code for the current time @@ -72,8 +73,9 @@ func newTestServer(t *testing.T) (*Server, ed25519.PublicKey, ed25519.PrivateKey cfg := config.NewTestConfig(testIssuer) + v := vault.NewUnsealed(masterKey, priv, pub) logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - srv := New(database, cfg, priv, pub, masterKey, logger) + srv := New(database, cfg, v, logger) return srv, pub, priv, database } diff --git a/internal/server/vault.go b/internal/server/vault.go new file mode 100644 index 0000000..43c1bfb --- /dev/null +++ b/internal/server/vault.go @@ -0,0 +1,102 @@ +// Vault seal/unseal REST handlers for MCIAS. +package server + +import ( + "net/http" + + "git.wntrmute.dev/kyle/mcias/internal/audit" + "git.wntrmute.dev/kyle/mcias/internal/middleware" + "git.wntrmute.dev/kyle/mcias/internal/model" + "git.wntrmute.dev/kyle/mcias/internal/vault" +) + +// unsealRequest is the request body for POST /v1/vault/unseal. +type unsealRequest struct { + Passphrase string `json:"passphrase"` +} + +// handleUnseal accepts a passphrase, derives the master key, decrypts the +// signing key, and unseals the vault. Rate-limited to 3/s burst 5. +// +// Security: The passphrase is never logged. A generic error is returned on +// any failure to prevent information leakage about the vault state. +func (s *Server) handleUnseal(w http.ResponseWriter, r *http.Request) { + if !s.vault.IsSealed() { + writeJSON(w, http.StatusOK, map[string]string{"status": "already unsealed"}) + return + } + + var req unsealRequest + if !decodeJSON(w, r, &req) { + return + } + if req.Passphrase == "" { + middleware.WriteError(w, http.StatusBadRequest, "passphrase is required", "bad_request") + return + } + + // Derive master key from passphrase. + masterKey, err := vault.DeriveFromPassphrase(req.Passphrase, s.db) + if err != nil { + s.logger.Error("vault unseal: derive key", "error", err) + middleware.WriteError(w, http.StatusUnauthorized, "unseal failed", "unauthorized") + return + } + + // Decrypt the signing key. + privKey, pubKey, err := vault.DecryptSigningKey(s.db, masterKey) + if err != nil { + // Zero derived master key on failure. + for i := range masterKey { + masterKey[i] = 0 + } + s.logger.Error("vault unseal: decrypt signing key", "error", err) + middleware.WriteError(w, http.StatusUnauthorized, "unseal failed", "unauthorized") + return + } + + if err := s.vault.Unseal(masterKey, privKey, pubKey); err != nil { + s.logger.Error("vault unseal: state transition", "error", err) + middleware.WriteError(w, http.StatusConflict, "vault is already unsealed", "conflict") + return + } + + ip := middleware.ClientIP(r, nil) + s.writeAudit(r, model.EventVaultUnsealed, nil, nil, audit.JSON("source", "api", "ip", ip)) + s.logger.Info("vault unsealed via API", "ip", ip) + + writeJSON(w, http.StatusOK, map[string]string{"status": "unsealed"}) +} + +// handleSeal seals the vault, zeroing all key material. Admin-only. +// +// Security: The caller's token becomes invalid after sealing because the +// public key needed to validate it is no longer available. +func (s *Server) handleSeal(w http.ResponseWriter, r *http.Request) { + if s.vault.IsSealed() { + writeJSON(w, http.StatusOK, map[string]string{"status": "already sealed"}) + return + } + + claims := middleware.ClaimsFromContext(r.Context()) + var actorID *int64 + if claims != nil { + acct, err := s.db.GetAccountByUUID(claims.Subject) + if err == nil { + actorID = &acct.ID + } + } + + s.vault.Seal() + + ip := middleware.ClientIP(r, nil) + s.writeAudit(r, model.EventVaultSealed, actorID, nil, audit.JSON("source", "api", "ip", ip)) + s.logger.Info("vault sealed via API", "ip", ip) + + writeJSON(w, http.StatusOK, map[string]string{"status": "sealed"}) +} + +// handleVaultStatus returns the current seal state of the vault. +func (s *Server) handleVaultStatus(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]bool{"sealed": s.vault.IsSealed()}) +} diff --git a/internal/server/vault_test.go b/internal/server/vault_test.go new file mode 100644 index 0000000..0e9cf11 --- /dev/null +++ b/internal/server/vault_test.go @@ -0,0 +1,171 @@ +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") + } +} diff --git a/internal/ui/csrf.go b/internal/ui/csrf.go index ce39b04..f50c162 100644 --- a/internal/ui/csrf.go +++ b/internal/ui/csrf.go @@ -8,6 +8,9 @@ import ( "crypto/subtle" "encoding/hex" "fmt" + "sync" + + "git.wntrmute.dev/kyle/mcias/internal/vault" ) // CSRFManager implements HMAC-signed Double-Submit Cookie CSRF protection. @@ -21,17 +24,67 @@ import ( // - The form/header value is HMAC-SHA256(key, cookieVal); this is what the // server verifies. An attacker cannot forge the HMAC without the key. // - Comparison uses crypto/subtle.ConstantTimeCompare to prevent timing attacks. +// - When backed by a vault, the key is derived lazily on first use after +// unseal. When the vault is re-sealed, the key is invalidated and re-derived +// on the next unseal. This is safe because sealed middleware prevents +// reaching CSRF-protected routes. type CSRFManager struct { - key []byte + mu sync.Mutex + key []byte + vault *vault.Vault } -// newCSRFManager creates a CSRFManager whose key is derived from masterKey. +// newCSRFManager creates a CSRFManager with a static key derived from masterKey. // Key derivation: SHA-256("mcias-ui-csrf-v1" || masterKey) func newCSRFManager(masterKey []byte) *CSRFManager { + return &CSRFManager{key: deriveCSRFKey(masterKey)} +} + +// newCSRFManagerFromVault creates a CSRFManager that derives its key lazily +// from the vault's master key. When the vault is sealed, operations fail +// gracefully (the sealed middleware prevents reaching CSRF-protected routes). +func newCSRFManagerFromVault(v *vault.Vault) *CSRFManager { + c := &CSRFManager{vault: v} + // If already unsealed, derive immediately. + mk, err := v.MasterKey() + if err == nil { + c.key = deriveCSRFKey(mk) + } + return c +} + +// deriveCSRFKey computes the HMAC key from a master key. +func deriveCSRFKey(masterKey []byte) []byte { h := sha256.New() h.Write([]byte("mcias-ui-csrf-v1")) h.Write(masterKey) - return &CSRFManager{key: h.Sum(nil)} + return h.Sum(nil) +} + +// csrfKey returns the current CSRF key, deriving it from vault if needed. +func (c *CSRFManager) csrfKey() ([]byte, error) { + c.mu.Lock() + defer c.mu.Unlock() + + // If we have a vault, re-derive key when sealed state changes. + if c.vault != nil { + if c.vault.IsSealed() { + c.key = nil + return nil, fmt.Errorf("csrf: vault is sealed") + } + if c.key == nil { + mk, err := c.vault.MasterKey() + if err != nil { + return nil, fmt.Errorf("csrf: %w", err) + } + c.key = deriveCSRFKey(mk) + } + } + + if c.key == nil { + return nil, fmt.Errorf("csrf: no key available") + } + return c.key, nil } // NewToken generates a fresh CSRF token pair. @@ -40,12 +93,16 @@ func newCSRFManager(masterKey []byte) *CSRFManager { // - cookieVal: hex(32 random bytes) — stored in the mcias_csrf cookie // - headerVal: hex(HMAC-SHA256(key, cookieVal)) — embedded in forms / X-CSRF-Token header func (c *CSRFManager) NewToken() (cookieVal, headerVal string, err error) { + key, err := c.csrfKey() + if err != nil { + return "", "", err + } raw := make([]byte, 32) if _, err = rand.Read(raw); err != nil { return "", "", fmt.Errorf("csrf: generate random bytes: %w", err) } cookieVal = hex.EncodeToString(raw) - mac := hmac.New(sha256.New, c.key) + mac := hmac.New(sha256.New, key) mac.Write([]byte(cookieVal)) headerVal = hex.EncodeToString(mac.Sum(nil)) return cookieVal, headerVal, nil @@ -57,7 +114,11 @@ func (c *CSRFManager) Validate(cookieVal, headerVal string) bool { if cookieVal == "" || headerVal == "" { return false } - mac := hmac.New(sha256.New, c.key) + key, err := c.csrfKey() + if err != nil { + return false + } + mac := hmac.New(sha256.New, key) mac.Write([]byte(cookieVal)) expected := hex.EncodeToString(mac.Sum(nil)) // Security: constant-time comparison prevents timing oracle attacks. diff --git a/internal/ui/handlers_accounts.go b/internal/ui/handlers_accounts.go index e90ece3..c832d78 100644 --- a/internal/ui/handlers_accounts.go +++ b/internal/ui/handlers_accounts.go @@ -460,7 +460,12 @@ func (u *UIServer) handleSetPGCreds(w http.ResponseWriter, r *http.Request) { // Security: encrypt the password with AES-256-GCM before storage. // A fresh random nonce is generated per call by SealAESGCM; nonce reuse // is not possible. The plaintext password is not retained after this call. - enc, nonce, err := crypto.SealAESGCM(u.masterKey, []byte(password)) + masterKey, err := u.vault.MasterKey() + if err != nil { + u.renderError(w, r, http.StatusInternalServerError, "internal error") + return + } + enc, nonce, err := crypto.SealAESGCM(masterKey, []byte(password)) if err != nil { u.logger.Error("encrypt pg password", "error", err) u.renderError(w, r, http.StatusInternalServerError, "internal error") @@ -864,7 +869,12 @@ func (u *UIServer) handleCreatePGCreds(w http.ResponseWriter, r *http.Request) { } // Security: encrypt with AES-256-GCM; fresh nonce per call. - enc, nonce, err := crypto.SealAESGCM(u.masterKey, []byte(password)) + masterKey, err := u.vault.MasterKey() + if err != nil { + u.renderError(w, r, http.StatusInternalServerError, "internal error") + return + } + enc, nonce, err := crypto.SealAESGCM(masterKey, []byte(password)) if err != nil { u.logger.Error("encrypt pg password", "error", err) u.renderError(w, r, http.StatusInternalServerError, "internal error") diff --git a/internal/ui/handlers_auth.go b/internal/ui/handlers_auth.go index 4f25b53..9ea270e 100644 --- a/internal/ui/handlers_auth.go +++ b/internal/ui/handlers_auth.go @@ -145,7 +145,12 @@ func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) { } // Decrypt and validate TOTP secret. - secret, err := crypto.OpenAESGCM(u.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc) + masterKey, err := u.vault.MasterKey() + if err != nil { + u.render(w, "login", LoginData{Error: "internal error"}) + return + } + secret, err := crypto.OpenAESGCM(masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc) if err != nil { u.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID) u.render(w, "login", LoginData{Error: "internal error"}) @@ -208,7 +213,12 @@ func (u *UIServer) finishLogin(w http.ResponseWriter, r *http.Request, acct *mod // Login succeeded: clear any outstanding failure counter. _ = u.db.ClearLoginFailures(acct.ID) - tokenStr, claims, err := token.IssueToken(u.privKey, u.cfg.Tokens.Issuer, acct.UUID, roles, expiry) + privKey, err := u.vault.PrivKey() + if err != nil { + u.render(w, "login", LoginData{Error: "internal error"}) + return + } + tokenStr, claims, err := token.IssueToken(privKey, u.cfg.Tokens.Issuer, acct.UUID, roles, expiry) if err != nil { u.logger.Error("issue token", "error", err) u.render(w, "login", LoginData{Error: "internal error"}) @@ -255,7 +265,8 @@ func (u *UIServer) finishLogin(w http.ResponseWriter, r *http.Request, acct *mod func (u *UIServer) handleLogout(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie(sessionCookieName) if err == nil && cookie.Value != "" { - claims, err := validateSessionToken(u.pubKey, cookie.Value, u.cfg.Tokens.Issuer) + pubKey, _ := u.vault.PubKey() + claims, err := validateSessionToken(pubKey, cookie.Value, u.cfg.Tokens.Issuer) if err == nil { if revokeErr := u.db.RevokeToken(claims.JTI, "ui_logout"); revokeErr != nil { u.logger.Warn("revoke token on UI logout", "error", revokeErr) diff --git a/internal/ui/handlers_vault.go b/internal/ui/handlers_vault.go new file mode 100644 index 0000000..5e7f182 --- /dev/null +++ b/internal/ui/handlers_vault.go @@ -0,0 +1,81 @@ +// UI handlers for vault unseal page. +package ui + +import ( + "net/http" + + "git.wntrmute.dev/kyle/mcias/internal/audit" + "git.wntrmute.dev/kyle/mcias/internal/middleware" + "git.wntrmute.dev/kyle/mcias/internal/model" + "git.wntrmute.dev/kyle/mcias/internal/vault" +) + +// UnsealData is the view model for the unseal page. +type UnsealData struct { + Error string +} + +// handleUnsealPage renders the unseal form, or redirects to login if already unsealed. +func (u *UIServer) handleUnsealPage(w http.ResponseWriter, r *http.Request) { + if !u.vault.IsSealed() { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + u.render(w, "unseal", UnsealData{}) +} + +// handleUnsealPost processes the unseal form submission. +// +// Security: The passphrase is never logged. No CSRF protection is applied +// because there is no session to protect (the vault is sealed), and CSRF +// token generation depends on the master key (chicken-and-egg). +func (u *UIServer) handleUnsealPost(w http.ResponseWriter, r *http.Request) { + if !u.vault.IsSealed() { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) + if err := r.ParseForm(); err != nil { + u.render(w, "unseal", UnsealData{Error: "invalid form data"}) + return + } + + passphrase := r.FormValue("passphrase") + if passphrase == "" { + u.render(w, "unseal", UnsealData{Error: "passphrase is required"}) + return + } + + // Derive master key from passphrase. + masterKey, err := vault.DeriveFromPassphrase(passphrase, u.db) + if err != nil { + u.logger.Error("vault unseal (UI): derive key", "error", err) + u.render(w, "unseal", UnsealData{Error: "unseal failed"}) + return + } + + // Decrypt the signing key. + privKey, pubKey, err := vault.DecryptSigningKey(u.db, masterKey) + if err != nil { + // Zero derived master key on failure. + for i := range masterKey { + masterKey[i] = 0 + } + u.logger.Error("vault unseal (UI): decrypt signing key", "error", err) + u.render(w, "unseal", UnsealData{Error: "unseal failed"}) + return + } + + if err := u.vault.Unseal(masterKey, privKey, pubKey); err != nil { + u.logger.Error("vault unseal (UI): state transition", "error", err) + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + ip := middleware.ClientIP(r, nil) + u.writeAudit(r, model.EventVaultUnsealed, nil, nil, audit.JSON("source", "ui", "ip", ip)) + u.logger.Info("vault unsealed via UI", "ip", ip) + + http.Redirect(w, r, "/login", http.StatusFound) +} diff --git a/internal/ui/session.go b/internal/ui/session.go index 56bbefc..c8a6437 100644 --- a/internal/ui/session.go +++ b/internal/ui/session.go @@ -2,6 +2,7 @@ package ui import ( "crypto/ed25519" + "fmt" "time" "git.wntrmute.dev/kyle/mcias/internal/token" @@ -16,5 +17,9 @@ func validateSessionToken(pubKey ed25519.PublicKey, tokenStr, issuer string) (*t // issueToken is a convenience method for issuing a signed JWT. func (u *UIServer) issueToken(subject string, roles []string, expiry time.Duration) (string, *token.Claims, error) { - return token.IssueToken(u.privKey, u.cfg.Tokens.Issuer, subject, roles, expiry) + privKey, err := u.vault.PrivKey() + if err != nil { + return "", nil, fmt.Errorf("vault sealed: %w", err) + } + return token.IssueToken(privKey, u.cfg.Tokens.Issuer, subject, roles, expiry) } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 5a93cd3..8ea9e60 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -14,7 +14,6 @@ package ui import ( "bytes" - "crypto/ed25519" "crypto/rand" "encoding/hex" "encoding/json" @@ -33,6 +32,7 @@ import ( "git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/kyle/mcias/internal/middleware" "git.wntrmute.dev/kyle/mcias/internal/model" + "git.wntrmute.dev/kyle/mcias/internal/vault" "git.wntrmute.dev/kyle/mcias/web" ) @@ -62,9 +62,7 @@ type UIServer struct { cfg *config.Config logger *slog.Logger csrf *CSRFManager - pubKey ed25519.PublicKey - privKey ed25519.PrivateKey - masterKey []byte + vault *vault.Vault } // issueTOTPNonce creates a random single-use nonce for the TOTP step and @@ -108,8 +106,12 @@ func (u *UIServer) dummyHash() string { // New constructs a UIServer, parses all templates, and returns it. // Returns an error if template parsing fails. -func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed25519.PublicKey, masterKey []byte, logger *slog.Logger) (*UIServer, error) { - csrf := newCSRFManager(masterKey) +// +// The CSRFManager is created lazily from vault key material when the vault +// is unsealed. When sealed, CSRF operations fail, but the sealed middleware +// prevents reaching CSRF-protected routes (chicken-and-egg resolution). +func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logger) (*UIServer, error) { + csrf := newCSRFManagerFromVault(v) funcMap := template.FuncMap{ "formatTime": func(t time.Time) string { @@ -212,6 +214,7 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255 "policies": "templates/policies.html", "pgcreds": "templates/pgcreds.html", "profile": "templates/profile.html", + "unseal": "templates/unseal.html", } tmpls := make(map[string]*template.Template, len(pageFiles)) for name, file := range pageFiles { @@ -226,14 +229,12 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255 } srv := &UIServer{ - db: database, - cfg: cfg, - pubKey: pub, - privKey: priv, - masterKey: masterKey, - logger: logger, - csrf: csrf, - tmpls: tmpls, + db: database, + cfg: cfg, + vault: v, + logger: logger, + csrf: csrf, + tmpls: tmpls, } // Security (DEF-02): launch a background goroutine to evict expired TOTP @@ -299,6 +300,11 @@ func (u *UIServer) Register(mux *http.ServeMux) { } loginRateLimit := middleware.RateLimit(10, 10, trustedProxy) + // Vault unseal routes (no session required, no CSRF — vault is sealed). + unsealRateLimit := middleware.RateLimit(3, 5, trustedProxy) + uiMux.HandleFunc("GET /unseal", u.handleUnsealPage) + uiMux.Handle("POST /unseal", unsealRateLimit(http.HandlerFunc(u.handleUnsealPost))) + // Auth routes (no session required). uiMux.HandleFunc("GET /login", u.handleLoginPage) uiMux.Handle("POST /login", loginRateLimit(http.HandlerFunc(u.handleLoginPost))) @@ -365,7 +371,12 @@ func (u *UIServer) requireCookieAuth(next http.Handler) http.Handler { return } - claims, err := validateSessionToken(u.pubKey, cookie.Value, u.cfg.Tokens.Issuer) + pubKey, err := u.vault.PubKey() + if err != nil { + u.redirectToLogin(w, r) + return + } + claims, err := validateSessionToken(pubKey, cookie.Value, u.cfg.Tokens.Issuer) if err != nil { u.clearSessionCookie(w) u.redirectToLogin(w, r) diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index 89b54c9..f4e3b88 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -17,7 +17,7 @@ import ( "git.wntrmute.dev/kyle/mcias/internal/config" "git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/kyle/mcias/internal/model" - "git.wntrmute.dev/kyle/mcias/internal/token" + "git.wntrmute.dev/kyle/mcias/internal/vault" ) const testIssuer = "https://auth.example.com" @@ -48,7 +48,8 @@ func newTestUIServer(t *testing.T) *UIServer { cfg := config.NewTestConfig(testIssuer) logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - uiSrv, err := New(database, cfg, priv, pub, masterKey, logger) + v := vault.NewUnsealed(masterKey, priv, pub) + uiSrv, err := New(database, cfg, v, logger) if err != nil { t.Fatalf("new UIServer: %v", err) } @@ -319,7 +320,7 @@ func issueAdminSession(t *testing.T, u *UIServer) (tokenStr, accountUUID string, if err := u.db.SetRoles(acct.ID, []string{"admin"}, nil); err != nil { t.Fatalf("SetRoles: %v", err) } - tok, claims, err := token.IssueToken(u.privKey, testIssuer, acct.UUID, []string{"admin"}, time.Hour) + tok, claims, err := u.issueToken(acct.UUID, []string{"admin"}, time.Hour) if err != nil { t.Fatalf("IssueToken: %v", err) } @@ -645,7 +646,7 @@ func issueUserSession(t *testing.T, u *UIServer) string { if err := u.db.SetRoles(acct.ID, []string{"user"}, nil); err != nil { t.Fatalf("SetRoles: %v", err) } - tok, claims, err := token.IssueToken(u.privKey, testIssuer, acct.UUID, []string{"user"}, time.Hour) + tok, claims, err := u.issueToken(acct.UUID, []string{"user"}, time.Hour) if err != nil { t.Fatalf("IssueToken: %v", err) } diff --git a/internal/vault/derive.go b/internal/vault/derive.go new file mode 100644 index 0000000..6aa9654 --- /dev/null +++ b/internal/vault/derive.go @@ -0,0 +1,67 @@ +package vault + +import ( + "crypto/ed25519" + "errors" + "fmt" + + "git.wntrmute.dev/kyle/mcias/internal/crypto" + "git.wntrmute.dev/kyle/mcias/internal/db" +) + +// DeriveFromPassphrase derives the master encryption key from a passphrase +// using the Argon2id KDF with a salt stored in the database. +// +// Security: The Argon2id parameters used by crypto.DeriveKey exceed OWASP 2023 +// minimums (time=3, memory=128MiB, threads=4). The salt is 32 random bytes +// stored in the database on first run. +func DeriveFromPassphrase(passphrase string, database *db.DB) ([]byte, error) { + salt, err := database.ReadMasterKeySalt() + if errors.Is(err, db.ErrNotFound) { + return nil, fmt.Errorf("no master key salt in database (first-run requires startup passphrase)") + } + if err != nil { + return nil, fmt.Errorf("read master key salt: %w", err) + } + + key, err := crypto.DeriveKey(passphrase, salt) + if err != nil { + return nil, fmt.Errorf("derive master key: %w", err) + } + return key, nil +} + +// DecryptSigningKey decrypts the Ed25519 signing key pair from the database +// using the provided master key. +// +// Security: The private key is stored AES-256-GCM encrypted in the database. +// A fresh random nonce is used for each encryption. The plaintext key only +// exists in memory during the process lifetime. +func DecryptSigningKey(database *db.DB, masterKey []byte) (ed25519.PrivateKey, ed25519.PublicKey, error) { + enc, nonce, err := database.ReadServerConfig() + if err != nil { + return nil, nil, fmt.Errorf("read server config: %w", err) + } + if enc == nil || nonce == nil { + return nil, nil, fmt.Errorf("no signing key in database (first-run requires startup passphrase)") + } + + privPEM, err := crypto.OpenAESGCM(masterKey, nonce, enc) + if err != nil { + return nil, nil, fmt.Errorf("decrypt signing key: %w", err) + } + + priv, err := crypto.ParsePrivateKeyPEM(privPEM) + if err != nil { + return nil, nil, fmt.Errorf("parse signing key PEM: %w", err) + } + + // Security: ed25519.PrivateKey.Public() always returns ed25519.PublicKey, + // but we use the ok form to make the type assertion explicit and safe. + pub, ok := priv.Public().(ed25519.PublicKey) + if !ok { + return nil, nil, fmt.Errorf("signing key has unexpected public key type") + } + + return priv, pub, nil +} diff --git a/internal/vault/vault.go b/internal/vault/vault.go new file mode 100644 index 0000000..c0195a9 --- /dev/null +++ b/internal/vault/vault.go @@ -0,0 +1,127 @@ +// Package vault provides a thread-safe container for the server's +// cryptographic key material with seal/unseal lifecycle management. +// +// Security design: +// - The Vault holds the master encryption key and Ed25519 signing key pair. +// - All accessors return ErrSealed when the vault is sealed, ensuring that +// callers cannot use key material that has been zeroed. +// - Seal() explicitly zeroes all key material before nilling the slices, +// reducing the window in which secrets remain in memory after seal. +// - All state transitions are protected by sync.RWMutex. Readers (IsSealed, +// MasterKey, PrivKey, PubKey) take a read lock; writers (Seal, Unseal) +// take a write lock. +package vault + +import ( + "crypto/ed25519" + "errors" + "sync" +) + +// ErrSealed is returned by accessor methods when the vault is sealed. +var ErrSealed = errors.New("vault is sealed") + +// Vault holds the server's cryptographic key material behind a mutex. +// All three servers (REST, UI, gRPC) share a single Vault by pointer. +type Vault struct { + mu sync.RWMutex + masterKey []byte + privKey ed25519.PrivateKey + pubKey ed25519.PublicKey + sealed bool +} + +// NewSealed creates a Vault in the sealed state. No key material is held. +func NewSealed() *Vault { + return &Vault{sealed: true} +} + +// NewUnsealed creates a Vault in the unsealed state with the given key material. +// This is the backward-compatible path used when the passphrase is available at +// startup. +func NewUnsealed(masterKey []byte, privKey ed25519.PrivateKey, pubKey ed25519.PublicKey) *Vault { + return &Vault{ + masterKey: masterKey, + privKey: privKey, + pubKey: pubKey, + sealed: false, + } +} + +// IsSealed reports whether the vault is currently sealed. +func (v *Vault) IsSealed() bool { + v.mu.RLock() + defer v.mu.RUnlock() + return v.sealed +} + +// MasterKey returns the master encryption key, or ErrSealed if sealed. +func (v *Vault) MasterKey() ([]byte, error) { + v.mu.RLock() + defer v.mu.RUnlock() + if v.sealed { + return nil, ErrSealed + } + return v.masterKey, nil +} + +// PrivKey returns the Ed25519 private signing key, or ErrSealed if sealed. +func (v *Vault) PrivKey() (ed25519.PrivateKey, error) { + v.mu.RLock() + defer v.mu.RUnlock() + if v.sealed { + return nil, ErrSealed + } + return v.privKey, nil +} + +// PubKey returns the Ed25519 public key, or ErrSealed if sealed. +func (v *Vault) PubKey() (ed25519.PublicKey, error) { + v.mu.RLock() + defer v.mu.RUnlock() + if v.sealed { + return nil, ErrSealed + } + return v.pubKey, nil +} + +// Unseal transitions the vault from sealed to unsealed, storing the provided +// key material. Returns an error if the vault is already unsealed. +func (v *Vault) Unseal(masterKey []byte, privKey ed25519.PrivateKey, pubKey ed25519.PublicKey) error { + v.mu.Lock() + defer v.mu.Unlock() + if !v.sealed { + return errors.New("vault is already unsealed") + } + v.masterKey = masterKey + v.privKey = privKey + v.pubKey = pubKey + v.sealed = false + return nil +} + +// Seal transitions the vault from unsealed to sealed. All key material is +// zeroed before being released to minimize the window of memory exposure. +// +// Security: explicit zeroing loops ensure the key bytes are overwritten even +// if the garbage collector has not yet reclaimed the backing arrays. +func (v *Vault) Seal() { + v.mu.Lock() + defer v.mu.Unlock() + // Zero master key. + for i := range v.masterKey { + v.masterKey[i] = 0 + } + v.masterKey = nil + // Zero private key. + for i := range v.privKey { + v.privKey[i] = 0 + } + v.privKey = nil + // Zero public key (not secret, but consistent cleanup). + for i := range v.pubKey { + v.pubKey[i] = 0 + } + v.pubKey = nil + v.sealed = true +} diff --git a/internal/vault/vault_test.go b/internal/vault/vault_test.go new file mode 100644 index 0000000..03275e7 --- /dev/null +++ b/internal/vault/vault_test.go @@ -0,0 +1,149 @@ +package vault + +import ( + "crypto/ed25519" + "crypto/rand" + "sync" + "testing" +) + +func generateTestKeys(t *testing.T) ([]byte, ed25519.PrivateKey, ed25519.PublicKey) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + mk := make([]byte, 32) + if _, err := rand.Read(mk); err != nil { + t.Fatalf("generate master key: %v", err) + } + return mk, priv, pub +} + +func TestNewSealed(t *testing.T) { + v := NewSealed() + if !v.IsSealed() { + t.Fatal("NewSealed() should be sealed") + } + if _, err := v.MasterKey(); err != ErrSealed { + t.Fatalf("MasterKey() error = %v, want ErrSealed", err) + } + if _, err := v.PrivKey(); err != ErrSealed { + t.Fatalf("PrivKey() error = %v, want ErrSealed", err) + } + if _, err := v.PubKey(); err != ErrSealed { + t.Fatalf("PubKey() error = %v, want ErrSealed", err) + } +} + +func TestNewUnsealed(t *testing.T) { + mk, priv, pub := generateTestKeys(t) + v := NewUnsealed(mk, priv, pub) + if v.IsSealed() { + t.Fatal("NewUnsealed() should not be sealed") + } + gotMK, err := v.MasterKey() + if err != nil { + t.Fatalf("MasterKey() error = %v", err) + } + if len(gotMK) != 32 { + t.Fatalf("MasterKey() len = %d, want 32", len(gotMK)) + } +} + +func TestUnsealFromSealed(t *testing.T) { + mk, priv, pub := generateTestKeys(t) + v := NewSealed() + if err := v.Unseal(mk, priv, pub); err != nil { + t.Fatalf("Unseal() error = %v", err) + } + if v.IsSealed() { + t.Fatal("should be unsealed after Unseal()") + } + gotPriv, err := v.PrivKey() + if err != nil { + t.Fatalf("PrivKey() error = %v", err) + } + if !priv.Equal(gotPriv) { + t.Fatal("PrivKey() mismatch") + } +} + +func TestUnsealAlreadyUnsealed(t *testing.T) { + mk, priv, pub := generateTestKeys(t) + v := NewUnsealed(mk, priv, pub) + if err := v.Unseal(mk, priv, pub); err == nil { + t.Fatal("Unseal() on unsealed vault should return error") + } +} + +func TestSealZeroesKeys(t *testing.T) { + mk, priv, pub := generateTestKeys(t) + // Keep references to the backing arrays so we can verify zeroing. + mkRef := mk + privRef := priv + v := NewUnsealed(mk, priv, pub) + v.Seal() + + if !v.IsSealed() { + t.Fatal("should be sealed after Seal()") + } + // Verify the original backing arrays were zeroed. + for i, b := range mkRef { + if b != 0 { + t.Fatalf("masterKey[%d] = %d, want 0", i, b) + } + } + for i, b := range privRef { + if b != 0 { + t.Fatalf("privKey[%d] = %d, want 0", i, b) + } + } +} + +func TestSealUnsealCycle(t *testing.T) { + mk, priv, pub := generateTestKeys(t) + v := NewUnsealed(mk, priv, pub) + v.Seal() + + mk2, priv2, pub2 := generateTestKeys(t) + if err := v.Unseal(mk2, priv2, pub2); err != nil { + t.Fatalf("Unseal() after Seal() error = %v", err) + } + gotPub, err := v.PubKey() + if err != nil { + t.Fatalf("PubKey() error = %v", err) + } + if !pub2.Equal(gotPub) { + t.Fatal("PubKey() mismatch after re-unseal") + } +} + +func TestConcurrentAccess(t *testing.T) { + mk, priv, pub := generateTestKeys(t) + v := NewUnsealed(mk, priv, pub) + + var wg sync.WaitGroup + // Concurrent readers. + for range 50 { + wg.Add(1) + go func() { + defer wg.Done() + _ = v.IsSealed() + _, _ = v.MasterKey() + _, _ = v.PrivKey() + _, _ = v.PubKey() + }() + } + // Concurrent seal/unseal cycles. + for range 10 { + wg.Add(1) + go func() { + defer wg.Done() + v.Seal() + mk2, priv2, pub2 := generateTestKeys(t) + _ = v.Unseal(mk2, priv2, pub2) + }() + } + wg.Wait() +} diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index a0679e6..7859b16 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -36,6 +36,7 @@ import ( "git.wntrmute.dev/kyle/mcias/internal/model" "git.wntrmute.dev/kyle/mcias/internal/server" "git.wntrmute.dev/kyle/mcias/internal/token" + "git.wntrmute.dev/kyle/mcias/internal/vault" ) const e2eIssuer = "https://auth.e2e.test" @@ -73,7 +74,8 @@ func newTestEnv(t *testing.T) *testEnv { cfg := config.NewTestConfig(e2eIssuer) logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - srv := server.New(database, cfg, priv, pub, masterKey, logger) + v := vault.NewUnsealed(masterKey, priv, pub) + srv := server.New(database, cfg, v, logger) ts := httptest.NewServer(srv.Handler()) t.Cleanup(func() { diff --git a/web/static/openapi.yaml b/web/static/openapi.yaml index 6417e69..662db0e 100644 --- a/web/static/openapi.yaml +++ b/web/static/openapi.yaml @@ -199,12 +199,15 @@ paths: /v1/health: get: summary: Health check - description: Returns `{"status":"ok"}` if the server is running. No auth required. + description: | + Returns `{"status":"ok"}` if the server is running and the vault is + unsealed, or `{"status":"sealed"}` if the vault is sealed. + No auth required. operationId: getHealth tags: [Public] responses: "200": - description: Server is healthy. + description: Server is healthy (may be sealed). content: application/json: schema: @@ -212,8 +215,87 @@ paths: properties: status: type: string + enum: [ok, sealed] example: ok + /v1/vault/status: + get: + summary: Vault seal status + description: Returns `{"sealed": true}` or `{"sealed": false}`. No auth required. + operationId: getVaultStatus + tags: [Vault] + responses: + "200": + description: Current seal state. + content: + application/json: + schema: + type: object + properties: + sealed: + type: boolean + + /v1/vault/unseal: + post: + summary: Unseal the vault + description: | + Accepts a passphrase, derives the master key, and unseals the vault. + Rate-limited to 3 requests per second, burst of 5. + No auth required (the vault is sealed, so no tokens can be validated). + operationId: unsealVault + tags: [Vault] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [passphrase] + properties: + passphrase: + type: string + description: Master passphrase for key derivation. + responses: + "200": + description: Vault unsealed successfully. + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: unsealed + "401": + description: Unseal failed (wrong passphrase). + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/vault/seal: + post: + summary: Seal the vault + description: | + Seals the vault, zeroing all key material in memory. + Requires admin authentication. The caller's token becomes invalid + after sealing. + operationId: sealVault + tags: [Vault] + security: + - bearerAuth: [] + responses: + "200": + description: Vault sealed successfully. + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: sealed + /v1/keys/public: get: summary: Ed25519 public key (JWK) diff --git a/web/templates/unseal.html b/web/templates/unseal.html new file mode 100644 index 0000000..1701130 --- /dev/null +++ b/web/templates/unseal.html @@ -0,0 +1,31 @@ +{{define "unseal"}} + +
+ + +