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.
|
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
|
### 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.
|
**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/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/grpcserver"
|
"git.wntrmute.dev/kyle/mcias/internal/grpcserver"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/server"
|
"git.wntrmute.dev/kyle/mcias/internal/server"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -72,30 +73,47 @@ func run(configPath string, logger *slog.Logger) error {
|
|||||||
}
|
}
|
||||||
logger.Info("database ready", "path", cfg.Database.Path)
|
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
|
// Security: The master key encrypts TOTP secrets, Postgres passwords, and
|
||||||
// the signing key at rest. It is derived from a passphrase via Argon2id
|
// 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
|
// (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.
|
// for stability across restarts. The passphrase env var is cleared after use.
|
||||||
masterKey, err := loadMasterKey(cfg, database)
|
//
|
||||||
if err != nil {
|
// When the passphrase is not available (empty env var in passphrase mode
|
||||||
return fmt.Errorf("load master key: %w", err)
|
// 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)
|
||||||
}
|
}
|
||||||
defer func() {
|
v = vault.NewSealed()
|
||||||
// Zero the master key when done — reduces the window of exposure.
|
logger.Info("vault starting in sealed state")
|
||||||
for i := range masterKey {
|
} else {
|
||||||
masterKey[i] = 0
|
return fmt.Errorf("load master key: %w", mkErr)
|
||||||
}
|
}
|
||||||
}()
|
} else {
|
||||||
|
|
||||||
// Load or generate the Ed25519 signing key.
|
// Load or generate the Ed25519 signing key.
|
||||||
// Security: The private signing key is stored AES-256-GCM encrypted in the
|
// 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
|
// database. On first run it is generated and stored. The key is decrypted
|
||||||
// with the master key each startup.
|
// with the master key each startup.
|
||||||
privKey, pubKey, err := loadOrGenerateSigningKey(database, masterKey, logger)
|
privKey, pubKey, err := loadOrGenerateSigningKey(database, masterKey, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Zero master key on failure.
|
||||||
|
for i := range masterKey {
|
||||||
|
masterKey[i] = 0
|
||||||
|
}
|
||||||
return fmt.Errorf("signing key: %w", err)
|
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.
|
// Configure TLS. We require TLS 1.2+ and prefer TLS 1.3.
|
||||||
// Security: HTTPS/gRPC-TLS is mandatory; no plaintext listener is provided.
|
// Security: HTTPS/gRPC-TLS is mandatory; no plaintext listener is provided.
|
||||||
@@ -108,8 +126,8 @@ func run(configPath string, logger *slog.Logger) error {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the REST handler.
|
// Build the REST handler. All servers share the same vault by pointer.
|
||||||
restSrv := server.New(database, cfg, privKey, pubKey, masterKey, logger)
|
restSrv := server.New(database, cfg, v, logger)
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: cfg.Server.ListenAddr,
|
Addr: cfg.Server.ListenAddr,
|
||||||
Handler: restSrv.Handler(),
|
Handler: restSrv.Handler(),
|
||||||
@@ -131,7 +149,7 @@ func run(configPath string, logger *slog.Logger) error {
|
|||||||
return fmt.Errorf("load gRPC TLS credentials: %w", err)
|
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
|
// Build server directly with TLS credentials. GRPCServerWithCreds builds
|
||||||
// the server with transport credentials at construction time per gRPC idiom.
|
// the server with transport credentials at construction time per gRPC idiom.
|
||||||
grpcSrv = rebuildGRPCServerWithTLS(grpcSrvImpl, grpcTLSCreds)
|
grpcSrv = rebuildGRPCServerWithTLS(grpcSrvImpl, grpcTLSCreds)
|
||||||
|
|||||||
@@ -17,8 +17,12 @@ type adminServiceServer struct {
|
|||||||
s *Server
|
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) {
|
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
|
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,
|
// The "x" field is the raw 32-byte public key base64url-encoded without padding,
|
||||||
// matching the REST /v1/keys/public response format.
|
// matching the REST /v1/keys/public response format.
|
||||||
func (a *adminServiceServer) GetPublicKey(_ context.Context, _ *mciasv1.GetPublicKeyRequest) (*mciasv1.GetPublicKeyResponse, error) {
|
func (a *adminServiceServer) GetPublicKey(_ context.Context, _ *mciasv1.GetPublicKeyRequest) (*mciasv1.GetPublicKeyResponse, error) {
|
||||||
if len(a.s.pubKey) == 0 {
|
pubKey, err := a.s.vault.PubKey()
|
||||||
return nil, status.Error(codes.Internal, "public key not available")
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.Unavailable, "vault sealed")
|
||||||
}
|
}
|
||||||
// Encode as base64url without padding — identical to the REST handler.
|
// 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{
|
return &mciasv1.GetPublicKeyResponse{
|
||||||
Kty: "OKP",
|
Kty: "OKP",
|
||||||
Crv: "Ed25519",
|
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
|
a.s.db.WriteAuditEvent(model.EventLoginFail, &acct.ID, nil, ip, `{"reason":"totp_missing"}`) //nolint:errcheck
|
||||||
return nil, status.Error(codes.Unauthenticated, "TOTP code required")
|
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 {
|
if err != nil {
|
||||||
a.s.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID)
|
a.s.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID)
|
||||||
return nil, status.Error(codes.Internal, "internal error")
|
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 {
|
if err != nil {
|
||||||
a.s.logger.Error("issue token", "error", err)
|
a.s.logger.Error("issue token", "error", err)
|
||||||
return nil, status.Error(codes.Internal, "internal error")
|
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 {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Internal, "internal error")
|
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")
|
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 {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Internal, "internal error")
|
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")
|
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 {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Internal, "internal error")
|
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.
|
// 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 {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Internal, "internal error")
|
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")
|
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 {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Internal, "internal error")
|
return nil, status.Error(codes.Internal, "internal error")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ package grpcserver
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/ed25519"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -35,6 +34,7 @@ import (
|
|||||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
// contextKey is the unexported context key type for this package.
|
// contextKey is the unexported context key type for this package.
|
||||||
@@ -57,21 +57,17 @@ type Server struct {
|
|||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
rateLimiter *grpcRateLimiter
|
rateLimiter *grpcRateLimiter
|
||||||
privKey ed25519.PrivateKey
|
vault *vault.Vault
|
||||||
pubKey ed25519.PublicKey
|
|
||||||
masterKey []byte
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a Server with the given dependencies (same as the REST Server).
|
// 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
|
// 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.
|
// 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{
|
return &Server{
|
||||||
db: database,
|
db: database,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
privKey: priv,
|
vault: v,
|
||||||
pubKey: pub,
|
|
||||||
masterKey: masterKey,
|
|
||||||
logger: logger,
|
logger: logger,
|
||||||
rateLimiter: newGRPCRateLimiter(10, 10),
|
rateLimiter: newGRPCRateLimiter(10, 10),
|
||||||
}
|
}
|
||||||
@@ -106,6 +102,7 @@ func (s *Server) buildServer(extra ...grpc.ServerOption) *grpc.Server {
|
|||||||
[]grpc.ServerOption{
|
[]grpc.ServerOption{
|
||||||
grpc.ChainUnaryInterceptor(
|
grpc.ChainUnaryInterceptor(
|
||||||
s.loggingInterceptor,
|
s.loggingInterceptor,
|
||||||
|
s.sealedInterceptor,
|
||||||
s.authInterceptor,
|
s.authInterceptor,
|
||||||
s.rateLimitInterceptor,
|
s.rateLimitInterceptor,
|
||||||
),
|
),
|
||||||
@@ -162,14 +159,36 @@ func (s *Server) loggingInterceptor(
|
|||||||
return resp, err
|
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
|
// authInterceptor validates the Bearer JWT from gRPC metadata and injects
|
||||||
// claims into the context. Public methods bypass this check.
|
// claims into the context. Public methods bypass this check.
|
||||||
//
|
//
|
||||||
// Security: Same validation path as the REST RequireAuth middleware:
|
// Security: Same validation path as the REST RequireAuth middleware:
|
||||||
// 1. Extract "authorization" metadata value (case-insensitive key lookup).
|
// 1. Extract "authorization" metadata value (case-insensitive key lookup).
|
||||||
// 2. Validate JWT (alg-first, then signature, then expiry/issuer).
|
// 2. Read public key from vault (fail closed if sealed).
|
||||||
// 3. Check JTI against revocation table.
|
// 3. Validate JWT (alg-first, then signature, then expiry/issuer).
|
||||||
// 4. Inject claims into context.
|
// 4. Check JTI against revocation table.
|
||||||
|
// 5. Inject claims into context.
|
||||||
func (s *Server) authInterceptor(
|
func (s *Server) authInterceptor(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req interface{},
|
req interface{},
|
||||||
@@ -186,7 +205,13 @@ func (s *Server) authInterceptor(
|
|||||||
return nil, status.Error(codes.Unauthenticated, "missing or invalid authorization")
|
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 {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Unauthenticated, "invalid or expired token")
|
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/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -73,7 +74,8 @@ func newTestEnv(t *testing.T) *testEnv {
|
|||||||
cfg := config.NewTestConfig(testIssuer)
|
cfg := config.NewTestConfig(testIssuer)
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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()
|
grpcSrv := srv.GRPCServer()
|
||||||
|
|
||||||
lis := bufconn.Listen(bufConnSize)
|
lis := bufconn.Listen(bufConnSize)
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ func (t *tokenServiceServer) ValidateToken(_ context.Context, req *mciasv1.Valid
|
|||||||
return &mciasv1.ValidateTokenResponse{Valid: false}, nil
|
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 {
|
if err != nil {
|
||||||
return &mciasv1.ValidateTokenResponse{Valid: false}, 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")
|
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 {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Internal, "internal error")
|
return nil, status.Error(codes.Internal, "internal error")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/ed25519"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -27,6 +26,7 @@ import (
|
|||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"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
|
// 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
|
// RequireAuth returns middleware that validates a Bearer JWT and injects the
|
||||||
// claims into the request context. Returns 401 on any auth failure.
|
// 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:
|
// Security: Token validation order:
|
||||||
// 1. Extract Bearer token from Authorization header.
|
// 1. Extract Bearer token from Authorization header.
|
||||||
// 2. Validate the JWT (alg=EdDSA, signature, expiry, issuer).
|
// 2. Read public key from vault (fail closed if sealed).
|
||||||
// 3. Check the JTI against the revocation table in the database.
|
// 3. Validate the JWT (alg=EdDSA, signature, expiry, issuer).
|
||||||
// 4. Inject validated claims into context for downstream handlers.
|
// 4. Check the JTI against the revocation table in the database.
|
||||||
func RequireAuth(pubKey ed25519.PublicKey, database *db.DB, issuer string) func(http.Handler) http.Handler {
|
// 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 func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
tokenStr, err := extractBearerToken(r)
|
tokenStr, err := extractBearerToken(r)
|
||||||
@@ -104,6 +110,14 @@ func RequireAuth(pubKey ed25519.PublicKey, database *db.DB, issuer string) func(
|
|||||||
return
|
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)
|
claims, err := token.ValidateToken(pubKey, tokenStr, issuer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Security: Map all token errors to a generic 401; do not
|
// 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/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
func generateTestKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
|
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
|
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 {
|
func openTestDB(t *testing.T) *db.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
database, err := db.Open(":memory:")
|
database, err := db.Open(":memory:")
|
||||||
@@ -96,7 +106,7 @@ func TestRequireAuthValid(t *testing.T) {
|
|||||||
tokenStr := issueAndTrackToken(t, priv, database, acct.ID, []string{"reader"})
|
tokenStr := issueAndTrackToken(t, priv, database, acct.ID, []string{"reader"})
|
||||||
|
|
||||||
reached := false
|
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
|
reached = true
|
||||||
claims := ClaimsFromContext(r.Context())
|
claims := ClaimsFromContext(r.Context())
|
||||||
if claims == nil {
|
if claims == nil {
|
||||||
@@ -123,7 +133,7 @@ func TestRequireAuthMissingHeader(t *testing.T) {
|
|||||||
_ = priv
|
_ = priv
|
||||||
database := openTestDB(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 without auth")
|
t.Error("handler should not be reached without auth")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
@@ -138,10 +148,10 @@ func TestRequireAuthMissingHeader(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRequireAuthInvalidToken(t *testing.T) {
|
func TestRequireAuthInvalidToken(t *testing.T) {
|
||||||
pub, _ := generateTestKey(t)
|
pub, priv := generateTestKey(t)
|
||||||
database := openTestDB(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")
|
t.Error("handler should not be reached with invalid token")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
@@ -176,7 +186,7 @@ func TestRequireAuthRevokedToken(t *testing.T) {
|
|||||||
t.Fatalf("RevokeToken: %v", err)
|
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")
|
t.Error("handler should not be reached with revoked token")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
@@ -201,7 +211,7 @@ func TestRequireAuthExpiredToken(t *testing.T) {
|
|||||||
t.Fatalf("IssueToken: %v", err)
|
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")
|
t.Error("handler should not be reached with expired token")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -178,6 +178,9 @@ const (
|
|||||||
EventPGCredAccessed = "pgcred_accessed"
|
EventPGCredAccessed = "pgcred_accessed"
|
||||||
EventPGCredUpdated = "pgcred_updated" //nolint:gosec // G101: audit event type string, not a credential
|
EventPGCredUpdated = "pgcred_updated" //nolint:gosec // G101: audit event type string, not a credential
|
||||||
|
|
||||||
|
EventVaultSealed = "vault_sealed"
|
||||||
|
EventVaultUnsealed = "vault_unsealed"
|
||||||
|
|
||||||
EventTagAdded = "tag_added"
|
EventTagAdded = "tag_added"
|
||||||
EventTagRemoved = "tag_removed"
|
EventTagRemoved = "tag_removed"
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/ed25519"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -31,6 +30,7 @@ import (
|
|||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/ui"
|
"git.wntrmute.dev/kyle/mcias/internal/ui"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/validate"
|
"git.wntrmute.dev/kyle/mcias/internal/validate"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||||
"git.wntrmute.dev/kyle/mcias/web"
|
"git.wntrmute.dev/kyle/mcias/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,19 +39,15 @@ type Server struct {
|
|||||||
db *db.DB
|
db *db.DB
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
privKey ed25519.PrivateKey
|
vault *vault.Vault
|
||||||
pubKey ed25519.PublicKey
|
|
||||||
masterKey []byte
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a Server with the given dependencies.
|
// 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{
|
return &Server{
|
||||||
db: database,
|
db: database,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
privKey: priv,
|
vault: v,
|
||||||
pubKey: pub,
|
|
||||||
masterKey: masterKey,
|
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,8 +106,14 @@ func (s *Server) Handler() http.Handler {
|
|||||||
_, _ = w.Write(specYAML)
|
_, _ = 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.
|
// 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 {
|
requireAdmin := func(h http.Handler) http.Handler {
|
||||||
return requireAuth(middleware.RequireRole("admin")(h))
|
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)))
|
mux.Handle("DELETE /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleDeletePolicyRule)))
|
||||||
|
|
||||||
// UI routes (HTMX-based management frontend).
|
// 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 {
|
if err != nil {
|
||||||
panic(fmt.Sprintf("ui: init failed: %v", err))
|
panic(fmt.Sprintf("ui: init failed: %v", err))
|
||||||
}
|
}
|
||||||
uiSrv.Register(mux)
|
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).
|
// Rate limiting is applied per-route above (login, token/validate).
|
||||||
var root http.Handler = mux
|
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)
|
root = middleware.RequestLogger(s.logger)(root)
|
||||||
|
|
||||||
// Security (SEC-04): apply baseline security headers to ALL responses
|
// Security (SEC-04): apply baseline security headers to ALL responses
|
||||||
@@ -178,12 +183,21 @@ func (s *Server) Handler() http.Handler {
|
|||||||
// ---- Public handlers ----
|
// ---- Public handlers ----
|
||||||
|
|
||||||
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
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"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handlePublicKey returns the server's Ed25519 public key in JWK format.
|
// handlePublicKey returns the server's Ed25519 public key in JWK format.
|
||||||
// This allows relying parties to independently verify JWTs.
|
// This allows relying parties to independently verify JWTs.
|
||||||
func (s *Server) handlePublicKey(w http.ResponseWriter, _ *http.Request) {
|
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).
|
// Encode the Ed25519 public key as a JWK (RFC 8037).
|
||||||
// The "x" parameter is the base64url-encoded public key bytes.
|
// The "x" parameter is the base64url-encoded public key bytes.
|
||||||
jwk := map[string]string{
|
jwk := map[string]string{
|
||||||
@@ -191,7 +205,7 @@ func (s *Server) handlePublicKey(w http.ResponseWriter, _ *http.Request) {
|
|||||||
"crv": "Ed25519",
|
"crv": "Ed25519",
|
||||||
"use": "sig",
|
"use": "sig",
|
||||||
"alg": "EdDSA",
|
"alg": "EdDSA",
|
||||||
"x": encodeBase64URL(s.pubKey),
|
"x": encodeBase64URL(pubKey),
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, jwk)
|
writeJSON(w, http.StatusOK, jwk)
|
||||||
}
|
}
|
||||||
@@ -282,7 +296,12 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Decrypt the TOTP secret.
|
// 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 {
|
if err != nil {
|
||||||
s.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID)
|
s.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID)
|
||||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
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 {
|
if err != nil {
|
||||||
s.logger.Error("issue token", "error", err)
|
s.logger.Error("issue token", "error", err)
|
||||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
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 {
|
if err != nil {
|
||||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
return
|
return
|
||||||
@@ -444,7 +473,12 @@ func (s *Server) handleTokenValidate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusOK, validateResponse{Valid: false})
|
writeJSON(w, http.StatusOK, validateResponse{Valid: false})
|
||||||
return
|
return
|
||||||
@@ -484,7 +518,12 @@ func (s *Server) handleTokenIssue(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
return
|
return
|
||||||
@@ -875,7 +914,12 @@ func (s *Server) handleTOTPEnroll(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Encrypt the secret before storing it temporarily.
|
// Encrypt the secret before storing it temporarily.
|
||||||
// Note: we store as pending; enrollment is confirmed with /confirm.
|
// 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 {
|
if err != nil {
|
||||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
return
|
return
|
||||||
@@ -918,7 +962,12 @@ func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
return
|
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.
|
// 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 {
|
if err != nil {
|
||||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
return
|
return
|
||||||
@@ -1215,7 +1269,12 @@ func (s *Server) handleSetPGCreds(w http.ResponseWriter, r *http.Request) {
|
|||||||
req.Port = 5432
|
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 {
|
if err != nil {
|
||||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"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
|
// 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)
|
cfg := config.NewTestConfig(testIssuer)
|
||||||
|
|
||||||
|
v := vault.NewUnsealed(masterKey, priv, pub)
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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
|
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"
|
"crypto/subtle"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CSRFManager implements HMAC-signed Double-Submit Cookie CSRF protection.
|
// 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
|
// - The form/header value is HMAC-SHA256(key, cookieVal); this is what the
|
||||||
// server verifies. An attacker cannot forge the HMAC without the key.
|
// server verifies. An attacker cannot forge the HMAC without the key.
|
||||||
// - Comparison uses crypto/subtle.ConstantTimeCompare to prevent timing attacks.
|
// - 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 {
|
type CSRFManager struct {
|
||||||
|
mu sync.Mutex
|
||||||
key []byte
|
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)
|
// Key derivation: SHA-256("mcias-ui-csrf-v1" || masterKey)
|
||||||
func newCSRFManager(masterKey []byte) *CSRFManager {
|
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 := sha256.New()
|
||||||
h.Write([]byte("mcias-ui-csrf-v1"))
|
h.Write([]byte("mcias-ui-csrf-v1"))
|
||||||
h.Write(masterKey)
|
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.
|
// 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
|
// - cookieVal: hex(32 random bytes) — stored in the mcias_csrf cookie
|
||||||
// - headerVal: hex(HMAC-SHA256(key, cookieVal)) — embedded in forms / X-CSRF-Token header
|
// - headerVal: hex(HMAC-SHA256(key, cookieVal)) — embedded in forms / X-CSRF-Token header
|
||||||
func (c *CSRFManager) NewToken() (cookieVal, headerVal string, err error) {
|
func (c *CSRFManager) NewToken() (cookieVal, headerVal string, err error) {
|
||||||
|
key, err := c.csrfKey()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
raw := make([]byte, 32)
|
raw := make([]byte, 32)
|
||||||
if _, err = rand.Read(raw); err != nil {
|
if _, err = rand.Read(raw); err != nil {
|
||||||
return "", "", fmt.Errorf("csrf: generate random bytes: %w", err)
|
return "", "", fmt.Errorf("csrf: generate random bytes: %w", err)
|
||||||
}
|
}
|
||||||
cookieVal = hex.EncodeToString(raw)
|
cookieVal = hex.EncodeToString(raw)
|
||||||
mac := hmac.New(sha256.New, c.key)
|
mac := hmac.New(sha256.New, key)
|
||||||
mac.Write([]byte(cookieVal))
|
mac.Write([]byte(cookieVal))
|
||||||
headerVal = hex.EncodeToString(mac.Sum(nil))
|
headerVal = hex.EncodeToString(mac.Sum(nil))
|
||||||
return cookieVal, headerVal, nil
|
return cookieVal, headerVal, nil
|
||||||
@@ -57,7 +114,11 @@ func (c *CSRFManager) Validate(cookieVal, headerVal string) bool {
|
|||||||
if cookieVal == "" || headerVal == "" {
|
if cookieVal == "" || headerVal == "" {
|
||||||
return false
|
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))
|
mac.Write([]byte(cookieVal))
|
||||||
expected := hex.EncodeToString(mac.Sum(nil))
|
expected := hex.EncodeToString(mac.Sum(nil))
|
||||||
// Security: constant-time comparison prevents timing oracle attacks.
|
// 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.
|
// Security: encrypt the password with AES-256-GCM before storage.
|
||||||
// A fresh random nonce is generated per call by SealAESGCM; nonce reuse
|
// A fresh random nonce is generated per call by SealAESGCM; nonce reuse
|
||||||
// is not possible. The plaintext password is not retained after this call.
|
// 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 {
|
if err != nil {
|
||||||
u.logger.Error("encrypt pg password", "error", err)
|
u.logger.Error("encrypt pg password", "error", err)
|
||||||
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
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.
|
// 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 {
|
if err != nil {
|
||||||
u.logger.Error("encrypt pg password", "error", err)
|
u.logger.Error("encrypt pg password", "error", err)
|
||||||
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
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.
|
// 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 {
|
if err != nil {
|
||||||
u.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID)
|
u.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID)
|
||||||
u.render(w, "login", LoginData{Error: "internal error"})
|
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.
|
// Login succeeded: clear any outstanding failure counter.
|
||||||
_ = u.db.ClearLoginFailures(acct.ID)
|
_ = 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 {
|
if err != nil {
|
||||||
u.logger.Error("issue token", "error", err)
|
u.logger.Error("issue token", "error", err)
|
||||||
u.render(w, "login", LoginData{Error: "internal error"})
|
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) {
|
func (u *UIServer) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
cookie, err := r.Cookie(sessionCookieName)
|
cookie, err := r.Cookie(sessionCookieName)
|
||||||
if err == nil && cookie.Value != "" {
|
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 err == nil {
|
||||||
if revokeErr := u.db.RevokeToken(claims.JTI, "ui_logout"); revokeErr != nil {
|
if revokeErr := u.db.RevokeToken(claims.JTI, "ui_logout"); revokeErr != nil {
|
||||||
u.logger.Warn("revoke token on UI logout", "error", revokeErr)
|
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 (
|
import (
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"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.
|
// 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) {
|
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/ed25519"
|
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -33,6 +32,7 @@ import (
|
|||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||||
"git.wntrmute.dev/kyle/mcias/web"
|
"git.wntrmute.dev/kyle/mcias/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -62,9 +62,7 @@ type UIServer struct {
|
|||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
csrf *CSRFManager
|
csrf *CSRFManager
|
||||||
pubKey ed25519.PublicKey
|
vault *vault.Vault
|
||||||
privKey ed25519.PrivateKey
|
|
||||||
masterKey []byte
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// issueTOTPNonce creates a random single-use nonce for the TOTP step and
|
// 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.
|
// New constructs a UIServer, parses all templates, and returns it.
|
||||||
// Returns an error if template parsing fails.
|
// 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{
|
funcMap := template.FuncMap{
|
||||||
"formatTime": func(t time.Time) string {
|
"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",
|
"policies": "templates/policies.html",
|
||||||
"pgcreds": "templates/pgcreds.html",
|
"pgcreds": "templates/pgcreds.html",
|
||||||
"profile": "templates/profile.html",
|
"profile": "templates/profile.html",
|
||||||
|
"unseal": "templates/unseal.html",
|
||||||
}
|
}
|
||||||
tmpls := make(map[string]*template.Template, len(pageFiles))
|
tmpls := make(map[string]*template.Template, len(pageFiles))
|
||||||
for name, file := range pageFiles {
|
for name, file := range pageFiles {
|
||||||
@@ -228,9 +231,7 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
|
|||||||
srv := &UIServer{
|
srv := &UIServer{
|
||||||
db: database,
|
db: database,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
pubKey: pub,
|
vault: v,
|
||||||
privKey: priv,
|
|
||||||
masterKey: masterKey,
|
|
||||||
logger: logger,
|
logger: logger,
|
||||||
csrf: csrf,
|
csrf: csrf,
|
||||||
tmpls: tmpls,
|
tmpls: tmpls,
|
||||||
@@ -299,6 +300,11 @@ func (u *UIServer) Register(mux *http.ServeMux) {
|
|||||||
}
|
}
|
||||||
loginRateLimit := middleware.RateLimit(10, 10, trustedProxy)
|
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).
|
// Auth routes (no session required).
|
||||||
uiMux.HandleFunc("GET /login", u.handleLoginPage)
|
uiMux.HandleFunc("GET /login", u.handleLoginPage)
|
||||||
uiMux.Handle("POST /login", loginRateLimit(http.HandlerFunc(u.handleLoginPost)))
|
uiMux.Handle("POST /login", loginRateLimit(http.HandlerFunc(u.handleLoginPost)))
|
||||||
@@ -365,7 +371,12 @@ func (u *UIServer) requireCookieAuth(next http.Handler) http.Handler {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
u.clearSessionCookie(w)
|
u.clearSessionCookie(w)
|
||||||
u.redirectToLogin(w, r)
|
u.redirectToLogin(w, r)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
"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"
|
const testIssuer = "https://auth.example.com"
|
||||||
@@ -48,7 +48,8 @@ func newTestUIServer(t *testing.T) *UIServer {
|
|||||||
cfg := config.NewTestConfig(testIssuer)
|
cfg := config.NewTestConfig(testIssuer)
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("new UIServer: %v", err)
|
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 {
|
if err := u.db.SetRoles(acct.ID, []string{"admin"}, nil); err != nil {
|
||||||
t.Fatalf("SetRoles: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("IssueToken: %v", err)
|
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 {
|
if err := u.db.SetRoles(acct.ID, []string{"user"}, nil); err != nil {
|
||||||
t.Fatalf("SetRoles: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("IssueToken: %v", err)
|
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/model"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/server"
|
"git.wntrmute.dev/kyle/mcias/internal/server"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||||
|
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
const e2eIssuer = "https://auth.e2e.test"
|
const e2eIssuer = "https://auth.e2e.test"
|
||||||
@@ -73,7 +74,8 @@ func newTestEnv(t *testing.T) *testEnv {
|
|||||||
|
|
||||||
cfg := config.NewTestConfig(e2eIssuer)
|
cfg := config.NewTestConfig(e2eIssuer)
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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())
|
ts := httptest.NewServer(srv.Handler())
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
|
|||||||
@@ -199,12 +199,15 @@ paths:
|
|||||||
/v1/health:
|
/v1/health:
|
||||||
get:
|
get:
|
||||||
summary: Health check
|
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
|
operationId: getHealth
|
||||||
tags: [Public]
|
tags: [Public]
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Server is healthy.
|
description: Server is healthy (may be sealed).
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@@ -212,8 +215,87 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
|
enum: [ok, sealed]
|
||||||
example: ok
|
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:
|
/v1/keys/public:
|
||||||
get:
|
get:
|
||||||
summary: Ed25519 public key (JWK)
|
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