// Package ui provides the HTMX-based management web interface for MCIAS. package ui import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/hex" "fmt" "sync" "git.wntrmute.dev/kyle/mcias/internal/vault" ) // CSRFManager implements HMAC-signed Double-Submit Cookie CSRF protection. // // Security design: // - The CSRF key is derived from the server master key via SHA-256 with a // domain-separation prefix, so it is unique to the UI CSRF function. // - The cookie value is 32 bytes of cryptographic random (non-HttpOnly so // HTMX can read it via JavaScript-free double-submit; SameSite=Strict // provides the primary CSRF defence for browser-initiated requests). // - 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 { mu sync.Mutex key []byte vault *vault.Vault } // 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 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. // // Returns: // - 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, key) mac.Write([]byte(cookieVal)) headerVal = hex.EncodeToString(mac.Sum(nil)) return cookieVal, headerVal, nil } // Validate verifies that headerVal is the correct HMAC of cookieVal. // Returns false on any mismatch or decoding error. func (c *CSRFManager) Validate(cookieVal, headerVal string) bool { if cookieVal == "" || headerVal == "" { return false } 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. return subtle.ConstantTimeCompare([]byte(expected), []byte(headerVal)) == 1 }