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:
44
PROGRESS.md
44
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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
102
internal/server/vault.go
Normal file
102
internal/server/vault.go
Normal file
@@ -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()})
|
||||
}
|
||||
171
internal/server/vault_test.go
Normal file
171
internal/server/vault_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
81
internal/ui/handlers_vault.go
Normal file
81
internal/ui/handlers_vault.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
67
internal/vault/derive.go
Normal file
67
internal/vault/derive.go
Normal file
@@ -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
|
||||
}
|
||||
127
internal/vault/vault.go
Normal file
127
internal/vault/vault.go
Normal file
@@ -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
|
||||
}
|
||||
149
internal/vault/vault_test.go
Normal file
149
internal/vault/vault_test.go
Normal file
@@ -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()
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
31
web/templates/unseal.html
Normal file
31
web/templates/unseal.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{{define "unseal"}}<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Unseal Vault — MCIAS</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-wrapper">
|
||||
<div class="login-box">
|
||||
<div class="brand-heading">MCIAS</div>
|
||||
<div class="brand-subtitle">Vault is Sealed</div>
|
||||
<div class="card">
|
||||
{{if .Error}}<div class="alert alert-error" role="alert">{{.Error}}</div>{{end}}
|
||||
<form id="unseal-form" method="POST" action="/unseal">
|
||||
<div class="form-group">
|
||||
<label for="passphrase">Master Passphrase</label>
|
||||
<input class="form-control" type="password" id="passphrase" name="passphrase"
|
||||
autocomplete="off" required autofocus>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" type="submit" style="width:100%">Unseal</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user