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:
@@ -8,6 +8,9 @@ import (
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||
)
|
||||
|
||||
// CSRFManager implements HMAC-signed Double-Submit Cookie CSRF protection.
|
||||
@@ -21,17 +24,67 @@ import (
|
||||
// - The form/header value is HMAC-SHA256(key, cookieVal); this is what the
|
||||
// server verifies. An attacker cannot forge the HMAC without the key.
|
||||
// - Comparison uses crypto/subtle.ConstantTimeCompare to prevent timing attacks.
|
||||
// - When backed by a vault, the key is derived lazily on first use after
|
||||
// unseal. When the vault is re-sealed, the key is invalidated and re-derived
|
||||
// on the next unseal. This is safe because sealed middleware prevents
|
||||
// reaching CSRF-protected routes.
|
||||
type CSRFManager struct {
|
||||
key []byte
|
||||
mu sync.Mutex
|
||||
key []byte
|
||||
vault *vault.Vault
|
||||
}
|
||||
|
||||
// newCSRFManager creates a CSRFManager whose key is derived from masterKey.
|
||||
// newCSRFManager creates a CSRFManager with a static key derived from masterKey.
|
||||
// Key derivation: SHA-256("mcias-ui-csrf-v1" || masterKey)
|
||||
func newCSRFManager(masterKey []byte) *CSRFManager {
|
||||
return &CSRFManager{key: deriveCSRFKey(masterKey)}
|
||||
}
|
||||
|
||||
// newCSRFManagerFromVault creates a CSRFManager that derives its key lazily
|
||||
// from the vault's master key. When the vault is sealed, operations fail
|
||||
// gracefully (the sealed middleware prevents reaching CSRF-protected routes).
|
||||
func newCSRFManagerFromVault(v *vault.Vault) *CSRFManager {
|
||||
c := &CSRFManager{vault: v}
|
||||
// If already unsealed, derive immediately.
|
||||
mk, err := v.MasterKey()
|
||||
if err == nil {
|
||||
c.key = deriveCSRFKey(mk)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// deriveCSRFKey computes the HMAC key from a master key.
|
||||
func deriveCSRFKey(masterKey []byte) []byte {
|
||||
h := sha256.New()
|
||||
h.Write([]byte("mcias-ui-csrf-v1"))
|
||||
h.Write(masterKey)
|
||||
return &CSRFManager{key: h.Sum(nil)}
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
// csrfKey returns the current CSRF key, deriving it from vault if needed.
|
||||
func (c *CSRFManager) csrfKey() ([]byte, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// If we have a vault, re-derive key when sealed state changes.
|
||||
if c.vault != nil {
|
||||
if c.vault.IsSealed() {
|
||||
c.key = nil
|
||||
return nil, fmt.Errorf("csrf: vault is sealed")
|
||||
}
|
||||
if c.key == nil {
|
||||
mk, err := c.vault.MasterKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("csrf: %w", err)
|
||||
}
|
||||
c.key = deriveCSRFKey(mk)
|
||||
}
|
||||
}
|
||||
|
||||
if c.key == nil {
|
||||
return nil, fmt.Errorf("csrf: no key available")
|
||||
}
|
||||
return c.key, nil
|
||||
}
|
||||
|
||||
// NewToken generates a fresh CSRF token pair.
|
||||
@@ -40,12 +93,16 @@ func newCSRFManager(masterKey []byte) *CSRFManager {
|
||||
// - cookieVal: hex(32 random bytes) — stored in the mcias_csrf cookie
|
||||
// - headerVal: hex(HMAC-SHA256(key, cookieVal)) — embedded in forms / X-CSRF-Token header
|
||||
func (c *CSRFManager) NewToken() (cookieVal, headerVal string, err error) {
|
||||
key, err := c.csrfKey()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
raw := make([]byte, 32)
|
||||
if _, err = rand.Read(raw); err != nil {
|
||||
return "", "", fmt.Errorf("csrf: generate random bytes: %w", err)
|
||||
}
|
||||
cookieVal = hex.EncodeToString(raw)
|
||||
mac := hmac.New(sha256.New, c.key)
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write([]byte(cookieVal))
|
||||
headerVal = hex.EncodeToString(mac.Sum(nil))
|
||||
return cookieVal, headerVal, nil
|
||||
@@ -57,7 +114,11 @@ func (c *CSRFManager) Validate(cookieVal, headerVal string) bool {
|
||||
if cookieVal == "" || headerVal == "" {
|
||||
return false
|
||||
}
|
||||
mac := hmac.New(sha256.New, c.key)
|
||||
key, err := c.csrfKey()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write([]byte(cookieVal))
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
// Security: constant-time comparison prevents timing oracle attacks.
|
||||
|
||||
@@ -460,7 +460,12 @@ func (u *UIServer) handleSetPGCreds(w http.ResponseWriter, r *http.Request) {
|
||||
// Security: encrypt the password with AES-256-GCM before storage.
|
||||
// A fresh random nonce is generated per call by SealAESGCM; nonce reuse
|
||||
// is not possible. The plaintext password is not retained after this call.
|
||||
enc, nonce, err := crypto.SealAESGCM(u.masterKey, []byte(password))
|
||||
masterKey, err := u.vault.MasterKey()
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
enc, nonce, err := crypto.SealAESGCM(masterKey, []byte(password))
|
||||
if err != nil {
|
||||
u.logger.Error("encrypt pg password", "error", err)
|
||||
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||
@@ -864,7 +869,12 @@ func (u *UIServer) handleCreatePGCreds(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Security: encrypt with AES-256-GCM; fresh nonce per call.
|
||||
enc, nonce, err := crypto.SealAESGCM(u.masterKey, []byte(password))
|
||||
masterKey, err := u.vault.MasterKey()
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
enc, nonce, err := crypto.SealAESGCM(masterKey, []byte(password))
|
||||
if err != nil {
|
||||
u.logger.Error("encrypt pg password", "error", err)
|
||||
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||
|
||||
@@ -145,7 +145,12 @@ func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Decrypt and validate TOTP secret.
|
||||
secret, err := crypto.OpenAESGCM(u.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
|
||||
masterKey, err := u.vault.MasterKey()
|
||||
if err != nil {
|
||||
u.render(w, "login", LoginData{Error: "internal error"})
|
||||
return
|
||||
}
|
||||
secret, err := crypto.OpenAESGCM(masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
|
||||
if err != nil {
|
||||
u.logger.Error("decrypt TOTP secret", "error", err, "account_id", acct.ID)
|
||||
u.render(w, "login", LoginData{Error: "internal error"})
|
||||
@@ -208,7 +213,12 @@ func (u *UIServer) finishLogin(w http.ResponseWriter, r *http.Request, acct *mod
|
||||
// Login succeeded: clear any outstanding failure counter.
|
||||
_ = u.db.ClearLoginFailures(acct.ID)
|
||||
|
||||
tokenStr, claims, err := token.IssueToken(u.privKey, u.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
|
||||
privKey, err := u.vault.PrivKey()
|
||||
if err != nil {
|
||||
u.render(w, "login", LoginData{Error: "internal error"})
|
||||
return
|
||||
}
|
||||
tokenStr, claims, err := token.IssueToken(privKey, u.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
|
||||
if err != nil {
|
||||
u.logger.Error("issue token", "error", err)
|
||||
u.render(w, "login", LoginData{Error: "internal error"})
|
||||
@@ -255,7 +265,8 @@ func (u *UIServer) finishLogin(w http.ResponseWriter, r *http.Request, acct *mod
|
||||
func (u *UIServer) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err == nil && cookie.Value != "" {
|
||||
claims, err := validateSessionToken(u.pubKey, cookie.Value, u.cfg.Tokens.Issuer)
|
||||
pubKey, _ := u.vault.PubKey()
|
||||
claims, err := validateSessionToken(pubKey, cookie.Value, u.cfg.Tokens.Issuer)
|
||||
if err == nil {
|
||||
if revokeErr := u.db.RevokeToken(claims.JTI, "ui_logout"); revokeErr != nil {
|
||||
u.logger.Warn("revoke token on UI logout", "error", revokeErr)
|
||||
|
||||
81
internal/ui/handlers_vault.go
Normal file
81
internal/ui/handlers_vault.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// UI handlers for vault unseal page.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/audit"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||
)
|
||||
|
||||
// UnsealData is the view model for the unseal page.
|
||||
type UnsealData struct {
|
||||
Error string
|
||||
}
|
||||
|
||||
// handleUnsealPage renders the unseal form, or redirects to login if already unsealed.
|
||||
func (u *UIServer) handleUnsealPage(w http.ResponseWriter, r *http.Request) {
|
||||
if !u.vault.IsSealed() {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
u.render(w, "unseal", UnsealData{})
|
||||
}
|
||||
|
||||
// handleUnsealPost processes the unseal form submission.
|
||||
//
|
||||
// Security: The passphrase is never logged. No CSRF protection is applied
|
||||
// because there is no session to protect (the vault is sealed), and CSRF
|
||||
// token generation depends on the master key (chicken-and-egg).
|
||||
func (u *UIServer) handleUnsealPost(w http.ResponseWriter, r *http.Request) {
|
||||
if !u.vault.IsSealed() {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
u.render(w, "unseal", UnsealData{Error: "invalid form data"})
|
||||
return
|
||||
}
|
||||
|
||||
passphrase := r.FormValue("passphrase")
|
||||
if passphrase == "" {
|
||||
u.render(w, "unseal", UnsealData{Error: "passphrase is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Derive master key from passphrase.
|
||||
masterKey, err := vault.DeriveFromPassphrase(passphrase, u.db)
|
||||
if err != nil {
|
||||
u.logger.Error("vault unseal (UI): derive key", "error", err)
|
||||
u.render(w, "unseal", UnsealData{Error: "unseal failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt the signing key.
|
||||
privKey, pubKey, err := vault.DecryptSigningKey(u.db, masterKey)
|
||||
if err != nil {
|
||||
// Zero derived master key on failure.
|
||||
for i := range masterKey {
|
||||
masterKey[i] = 0
|
||||
}
|
||||
u.logger.Error("vault unseal (UI): decrypt signing key", "error", err)
|
||||
u.render(w, "unseal", UnsealData{Error: "unseal failed"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := u.vault.Unseal(masterKey, privKey, pubKey); err != nil {
|
||||
u.logger.Error("vault unseal (UI): state transition", "error", err)
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
ip := middleware.ClientIP(r, nil)
|
||||
u.writeAudit(r, model.EventVaultUnsealed, nil, nil, audit.JSON("source", "ui", "ip", ip))
|
||||
u.logger.Info("vault unsealed via UI", "ip", ip)
|
||||
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package ui
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||
@@ -16,5 +17,9 @@ func validateSessionToken(pubKey ed25519.PublicKey, tokenStr, issuer string) (*t
|
||||
|
||||
// issueToken is a convenience method for issuing a signed JWT.
|
||||
func (u *UIServer) issueToken(subject string, roles []string, expiry time.Duration) (string, *token.Claims, error) {
|
||||
return token.IssueToken(u.privKey, u.cfg.Tokens.Issuer, subject, roles, expiry)
|
||||
privKey, err := u.vault.PrivKey()
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("vault sealed: %w", err)
|
||||
}
|
||||
return token.IssueToken(privKey, u.cfg.Tokens.Issuer, subject, roles, expiry)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ package ui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -33,6 +32,7 @@ import (
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||
"git.wntrmute.dev/kyle/mcias/web"
|
||||
)
|
||||
|
||||
@@ -62,9 +62,7 @@ type UIServer struct {
|
||||
cfg *config.Config
|
||||
logger *slog.Logger
|
||||
csrf *CSRFManager
|
||||
pubKey ed25519.PublicKey
|
||||
privKey ed25519.PrivateKey
|
||||
masterKey []byte
|
||||
vault *vault.Vault
|
||||
}
|
||||
|
||||
// issueTOTPNonce creates a random single-use nonce for the TOTP step and
|
||||
@@ -108,8 +106,12 @@ func (u *UIServer) dummyHash() string {
|
||||
|
||||
// New constructs a UIServer, parses all templates, and returns it.
|
||||
// Returns an error if template parsing fails.
|
||||
func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed25519.PublicKey, masterKey []byte, logger *slog.Logger) (*UIServer, error) {
|
||||
csrf := newCSRFManager(masterKey)
|
||||
//
|
||||
// The CSRFManager is created lazily from vault key material when the vault
|
||||
// is unsealed. When sealed, CSRF operations fail, but the sealed middleware
|
||||
// prevents reaching CSRF-protected routes (chicken-and-egg resolution).
|
||||
func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logger) (*UIServer, error) {
|
||||
csrf := newCSRFManagerFromVault(v)
|
||||
|
||||
funcMap := template.FuncMap{
|
||||
"formatTime": func(t time.Time) string {
|
||||
@@ -212,6 +214,7 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
|
||||
"policies": "templates/policies.html",
|
||||
"pgcreds": "templates/pgcreds.html",
|
||||
"profile": "templates/profile.html",
|
||||
"unseal": "templates/unseal.html",
|
||||
}
|
||||
tmpls := make(map[string]*template.Template, len(pageFiles))
|
||||
for name, file := range pageFiles {
|
||||
@@ -226,14 +229,12 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
|
||||
}
|
||||
|
||||
srv := &UIServer{
|
||||
db: database,
|
||||
cfg: cfg,
|
||||
pubKey: pub,
|
||||
privKey: priv,
|
||||
masterKey: masterKey,
|
||||
logger: logger,
|
||||
csrf: csrf,
|
||||
tmpls: tmpls,
|
||||
db: database,
|
||||
cfg: cfg,
|
||||
vault: v,
|
||||
logger: logger,
|
||||
csrf: csrf,
|
||||
tmpls: tmpls,
|
||||
}
|
||||
|
||||
// Security (DEF-02): launch a background goroutine to evict expired TOTP
|
||||
@@ -299,6 +300,11 @@ func (u *UIServer) Register(mux *http.ServeMux) {
|
||||
}
|
||||
loginRateLimit := middleware.RateLimit(10, 10, trustedProxy)
|
||||
|
||||
// Vault unseal routes (no session required, no CSRF — vault is sealed).
|
||||
unsealRateLimit := middleware.RateLimit(3, 5, trustedProxy)
|
||||
uiMux.HandleFunc("GET /unseal", u.handleUnsealPage)
|
||||
uiMux.Handle("POST /unseal", unsealRateLimit(http.HandlerFunc(u.handleUnsealPost)))
|
||||
|
||||
// Auth routes (no session required).
|
||||
uiMux.HandleFunc("GET /login", u.handleLoginPage)
|
||||
uiMux.Handle("POST /login", loginRateLimit(http.HandlerFunc(u.handleLoginPost)))
|
||||
@@ -365,7 +371,12 @@ func (u *UIServer) requireCookieAuth(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := validateSessionToken(u.pubKey, cookie.Value, u.cfg.Tokens.Issuer)
|
||||
pubKey, err := u.vault.PubKey()
|
||||
if err != nil {
|
||||
u.redirectToLogin(w, r)
|
||||
return
|
||||
}
|
||||
claims, err := validateSessionToken(pubKey, cookie.Value, u.cfg.Tokens.Issuer)
|
||||
if err != nil {
|
||||
u.clearSessionCookie(w)
|
||||
u.redirectToLogin(w, r)
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/vault"
|
||||
)
|
||||
|
||||
const testIssuer = "https://auth.example.com"
|
||||
@@ -48,7 +48,8 @@ func newTestUIServer(t *testing.T) *UIServer {
|
||||
cfg := config.NewTestConfig(testIssuer)
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
uiSrv, err := New(database, cfg, priv, pub, masterKey, logger)
|
||||
v := vault.NewUnsealed(masterKey, priv, pub)
|
||||
uiSrv, err := New(database, cfg, v, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("new UIServer: %v", err)
|
||||
}
|
||||
@@ -319,7 +320,7 @@ func issueAdminSession(t *testing.T, u *UIServer) (tokenStr, accountUUID string,
|
||||
if err := u.db.SetRoles(acct.ID, []string{"admin"}, nil); err != nil {
|
||||
t.Fatalf("SetRoles: %v", err)
|
||||
}
|
||||
tok, claims, err := token.IssueToken(u.privKey, testIssuer, acct.UUID, []string{"admin"}, time.Hour)
|
||||
tok, claims, err := u.issueToken(acct.UUID, []string{"admin"}, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueToken: %v", err)
|
||||
}
|
||||
@@ -645,7 +646,7 @@ func issueUserSession(t *testing.T, u *UIServer) string {
|
||||
if err := u.db.SetRoles(acct.ID, []string{"user"}, nil); err != nil {
|
||||
t.Fatalf("SetRoles: %v", err)
|
||||
}
|
||||
tok, claims, err := token.IssueToken(u.privKey, testIssuer, acct.UUID, []string{"user"}, time.Hour)
|
||||
tok, claims, err := u.issueToken(acct.UUID, []string{"user"}, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueToken: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user