Files
mcias/internal/ui/csrf.go
Kyle Isom cb661bb8f5 Checkpoint: fix all lint warnings
- errorlint: use errors.Is for ErrSealed comparisons in vault_test.go
- gofmt: reformat config, config_test, middleware_test with goimports
- govet/fieldalignment: reorder struct fields in vault.go, csrf.go,
  detail_test.go, middleware_test.go for optimal alignment
- unused: remove unused newCSRFManager in csrf.go (superseded by
  newCSRFManagerFromVault)
- revive/early-return: invert sealed-vault condition in main.go

Security: no auth/crypto logic changed; struct reordering and error
comparison fixes only. newCSRFManager removal is safe — it was never
called; all CSRF construction goes through newCSRFManagerFromVault.

Co-authored-by: Junie <junie@jetbrains.com>
2026-03-15 16:40:11 -07:00

121 lines
3.7 KiB
Go

// 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 {
vault *vault.Vault
key []byte
mu sync.Mutex
}
// 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
}