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:
@@ -14,7 +14,6 @@ package ui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -33,6 +32,7 @@ import (
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||
"git.wntrmute.dev/kyle/mcias/web"
|
||||
)
|
||||
|
||||
@@ -62,9 +62,7 @@ type UIServer struct {
|
||||
cfg *config.Config
|
||||
logger *slog.Logger
|
||||
csrf *CSRFManager
|
||||
pubKey ed25519.PublicKey
|
||||
privKey ed25519.PrivateKey
|
||||
masterKey []byte
|
||||
vault *vault.Vault
|
||||
}
|
||||
|
||||
// issueTOTPNonce creates a random single-use nonce for the TOTP step and
|
||||
@@ -108,8 +106,12 @@ func (u *UIServer) dummyHash() string {
|
||||
|
||||
// New constructs a UIServer, parses all templates, and returns it.
|
||||
// Returns an error if template parsing fails.
|
||||
func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed25519.PublicKey, masterKey []byte, logger *slog.Logger) (*UIServer, error) {
|
||||
csrf := newCSRFManager(masterKey)
|
||||
//
|
||||
// The CSRFManager is created lazily from vault key material when the vault
|
||||
// is unsealed. When sealed, CSRF operations fail, but the sealed middleware
|
||||
// prevents reaching CSRF-protected routes (chicken-and-egg resolution).
|
||||
func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logger) (*UIServer, error) {
|
||||
csrf := newCSRFManagerFromVault(v)
|
||||
|
||||
funcMap := template.FuncMap{
|
||||
"formatTime": func(t time.Time) string {
|
||||
@@ -212,6 +214,7 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
|
||||
"policies": "templates/policies.html",
|
||||
"pgcreds": "templates/pgcreds.html",
|
||||
"profile": "templates/profile.html",
|
||||
"unseal": "templates/unseal.html",
|
||||
}
|
||||
tmpls := make(map[string]*template.Template, len(pageFiles))
|
||||
for name, file := range pageFiles {
|
||||
@@ -226,14 +229,12 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
|
||||
}
|
||||
|
||||
srv := &UIServer{
|
||||
db: database,
|
||||
cfg: cfg,
|
||||
pubKey: pub,
|
||||
privKey: priv,
|
||||
masterKey: masterKey,
|
||||
logger: logger,
|
||||
csrf: csrf,
|
||||
tmpls: tmpls,
|
||||
db: database,
|
||||
cfg: cfg,
|
||||
vault: v,
|
||||
logger: logger,
|
||||
csrf: csrf,
|
||||
tmpls: tmpls,
|
||||
}
|
||||
|
||||
// Security (DEF-02): launch a background goroutine to evict expired TOTP
|
||||
@@ -299,6 +300,11 @@ func (u *UIServer) Register(mux *http.ServeMux) {
|
||||
}
|
||||
loginRateLimit := middleware.RateLimit(10, 10, trustedProxy)
|
||||
|
||||
// Vault unseal routes (no session required, no CSRF — vault is sealed).
|
||||
unsealRateLimit := middleware.RateLimit(3, 5, trustedProxy)
|
||||
uiMux.HandleFunc("GET /unseal", u.handleUnsealPage)
|
||||
uiMux.Handle("POST /unseal", unsealRateLimit(http.HandlerFunc(u.handleUnsealPost)))
|
||||
|
||||
// Auth routes (no session required).
|
||||
uiMux.HandleFunc("GET /login", u.handleLoginPage)
|
||||
uiMux.Handle("POST /login", loginRateLimit(http.HandlerFunc(u.handleLoginPost)))
|
||||
@@ -365,7 +371,12 @@ func (u *UIServer) requireCookieAuth(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := validateSessionToken(u.pubKey, cookie.Value, u.cfg.Tokens.Issuer)
|
||||
pubKey, err := u.vault.PubKey()
|
||||
if err != nil {
|
||||
u.redirectToLogin(w, r)
|
||||
return
|
||||
}
|
||||
claims, err := validateSessionToken(pubKey, cookie.Value, u.cfg.Tokens.Issuer)
|
||||
if err != nil {
|
||||
u.clearSessionCookie(w)
|
||||
u.redirectToLogin(w, r)
|
||||
|
||||
Reference in New Issue
Block a user