- 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>
121 lines
3.7 KiB
Go
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
|
|
}
|