Add vault seal/unseal lifecycle
- New internal/vault package: thread-safe Vault struct with seal/unseal state, key material zeroing, and key derivation - REST: POST /v1/vault/unseal, POST /v1/vault/seal, GET /v1/vault/status; health returns sealed status - UI: /unseal page with passphrase form, redirect when sealed - gRPC: sealedInterceptor rejects RPCs when sealed - Middleware: RequireUnsealed blocks all routes except exempt paths; RequireAuth reads pubkey from vault at request time - Startup: server starts sealed when passphrase unavailable - All servers share single *vault.Vault by pointer - CSRF manager derives key lazily from vault Security: Key material is zeroed on seal. Sealed middleware runs before auth. Handlers fail closed if vault becomes sealed mid-request. Unseal endpoint is rate-limited (3/s burst 5). No CSRF on unseal page (no session to protect; chicken-and-egg with master key). Passphrase never logged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user