Implement a two-level key hierarchy: the MEK now wraps per-engine DEKs stored in a new barrier_keys table, rather than encrypting all barrier entries directly. A v2 ciphertext format (0x02) embeds the key ID so the barrier can resolve which DEK to use on decryption. v1 ciphertext remains supported for backward compatibility. Key changes: - crypto: EncryptV2/DecryptV2/ExtractKeyID for v2 ciphertext with key IDs - barrier: key registry (CreateKey, RotateKey, ListKeys, MigrateToV2, ReWrapKeys) - seal: RotateMEK re-wraps DEKs without re-encrypting data - engine: Mount auto-creates per-engine DEK - REST + gRPC: barrier/keys, barrier/rotate-mek, barrier/rotate-key, barrier/migrate - proto: BarrierService (v1 + v2) with ListKeys, RotateMEK, RotateKey, Migrate - db: migration v2 adds barrier_keys table Also includes: security audit report, CSRF protection, engine design specs (sshca, transit, user), path-bound AAD migration tool, policy engine enhancements, and ARCHITECTURE.md updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -193,6 +193,7 @@ func newTestWebServer(t *testing.T, vault vaultBackend) *WebServer {
|
||||
vault: vault,
|
||||
logger: slog.Default(),
|
||||
staticFS: staticFS,
|
||||
csrf: newCSRFProtect(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ type VaultClient struct {
|
||||
func NewVaultClient(addr, caCertPath string, logger *slog.Logger) (*VaultClient, error) {
|
||||
logger.Debug("connecting to vault", "addr", addr, "ca_cert", caCertPath)
|
||||
|
||||
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS13}
|
||||
if caCertPath != "" {
|
||||
logger.Debug("loading vault CA certificate", "path", caCertPath)
|
||||
pemData, err := os.ReadFile(caCertPath) //nolint:gosec
|
||||
|
||||
119
internal/webserver/csrf.go
Normal file
119
internal/webserver/csrf.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package webserver
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
csrfCookieName = "metacrypt_csrf"
|
||||
csrfFieldName = "csrf_token"
|
||||
csrfTokenLen = 32
|
||||
)
|
||||
|
||||
// csrfProtect provides CSRF protection using the signed double-submit cookie
|
||||
// pattern. A random secret is generated at startup. CSRF tokens are an HMAC of
|
||||
// a random nonce, sent as both a cookie and a hidden form field. On POST the
|
||||
// middleware verifies that the form field matches the cookie's HMAC.
|
||||
type csrfProtect struct {
|
||||
secret []byte
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newCSRFProtect() *csrfProtect {
|
||||
secret := make([]byte, 32)
|
||||
if _, err := rand.Read(secret); err != nil {
|
||||
panic("csrf: failed to generate secret: " + err.Error())
|
||||
}
|
||||
return &csrfProtect{secret: secret}
|
||||
}
|
||||
|
||||
// generateToken creates a new CSRF token: base64(nonce) + "." + base64(hmac(nonce)).
|
||||
func (c *csrfProtect) generateToken() (string, error) {
|
||||
nonce := make([]byte, csrfTokenLen)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", fmt.Errorf("csrf: generate nonce: %w", err)
|
||||
}
|
||||
nonceB64 := base64.RawURLEncoding.EncodeToString(nonce)
|
||||
mac := hmac.New(sha256.New, c.secret)
|
||||
mac.Write(nonce)
|
||||
sigB64 := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
return nonceB64 + "." + sigB64, nil
|
||||
}
|
||||
|
||||
// validToken checks that a token has a valid HMAC signature.
|
||||
func (c *csrfProtect) validToken(token string) bool {
|
||||
parts := strings.SplitN(token, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
nonce, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil || len(nonce) != csrfTokenLen {
|
||||
return false
|
||||
}
|
||||
sig, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
mac := hmac.New(sha256.New, c.secret)
|
||||
mac.Write(nonce)
|
||||
return hmac.Equal(mac.Sum(nil), sig)
|
||||
}
|
||||
|
||||
// setToken generates a new CSRF token, sets it as a cookie, and returns it
|
||||
// for embedding in a form.
|
||||
func (c *csrfProtect) setToken(w http.ResponseWriter) string {
|
||||
token, err := c.generateToken()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: csrfCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
return token
|
||||
}
|
||||
|
||||
// middleware returns an HTTP middleware that enforces CSRF validation on
|
||||
// mutation requests (POST, PUT, PATCH, DELETE). GET/HEAD/OPTIONS are passed
|
||||
// through. The HTMX hx-post for /v1/seal is excluded since it hits the API
|
||||
// server directly and uses token auth, not cookies.
|
||||
func (c *csrfProtect) middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Read token from form field (works for both regular forms and
|
||||
// multipart forms since ParseForm/ParseMultipartForm will have
|
||||
// been called or the field is available via FormValue).
|
||||
formToken := r.FormValue(csrfFieldName)
|
||||
|
||||
// Read token from cookie.
|
||||
cookie, err := r.Cookie(csrfCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
http.Error(w, "CSRF validation failed", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Both tokens must be present, match each other, and be validly signed.
|
||||
if formToken == "" || formToken != cookie.Value || !c.validToken(formToken) {
|
||||
http.Error(w, "CSRF validation failed", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
105
internal/webserver/csrf_test.go
Normal file
105
internal/webserver/csrf_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package webserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCSRFTokenGenerateAndValidate(t *testing.T) {
|
||||
c := newCSRFProtect()
|
||||
token, err := c.generateToken()
|
||||
if err != nil {
|
||||
t.Fatalf("generateToken: %v", err)
|
||||
}
|
||||
if !c.validToken(token) {
|
||||
t.Fatal("valid token rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFTokenInvalidFormats(t *testing.T) {
|
||||
c := newCSRFProtect()
|
||||
for _, bad := range []string{"", "nodot", "a.b.c", "abc.def"} {
|
||||
if c.validToken(bad) {
|
||||
t.Errorf("should reject %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFTokenCrossSecret(t *testing.T) {
|
||||
c1 := newCSRFProtect()
|
||||
c2 := newCSRFProtect()
|
||||
token, _ := c1.generateToken()
|
||||
if c2.validToken(token) {
|
||||
t.Fatal("token from different secret should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFMiddlewareAllowsGET(t *testing.T) {
|
||||
c := newCSRFProtect()
|
||||
handler := c.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET should pass through, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFMiddlewareBlocksPOSTWithoutToken(t *testing.T) {
|
||||
c := newCSRFProtect()
|
||||
handler := c.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("foo=bar"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("POST without CSRF token should be forbidden, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFMiddlewareAllowsPOSTWithValidToken(t *testing.T) {
|
||||
c := newCSRFProtect()
|
||||
token, _ := c.generateToken()
|
||||
|
||||
handler := c.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
body := csrfFieldName + "=" + token
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(&http.Cookie{Name: csrfCookieName, Value: token})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("POST with valid CSRF token should pass, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFMiddlewareRejectsMismatch(t *testing.T) {
|
||||
c := newCSRFProtect()
|
||||
token1, _ := c.generateToken()
|
||||
token2, _ := c.generateToken()
|
||||
|
||||
handler := c.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
body := csrfFieldName + "=" + token1
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(&http.Cookie{Name: csrfCookieName, Value: token2})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("POST with mismatched tokens should be forbidden, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,7 @@ func (ws *WebServer) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
default:
|
||||
|
||||
@@ -70,6 +70,7 @@ type WebServer struct {
|
||||
logger *slog.Logger
|
||||
httpSrv *http.Server
|
||||
staticFS fs.FS
|
||||
csrf *csrfProtect
|
||||
tgzCache sync.Map // key: UUID string → *tgzEntry
|
||||
userCache sync.Map // key: UUID string → *cachedUsername
|
||||
}
|
||||
@@ -125,6 +126,7 @@ func New(cfg *config.Config, logger *slog.Logger) (*WebServer, error) {
|
||||
vault: vault,
|
||||
logger: logger,
|
||||
staticFS: staticFS,
|
||||
csrf: newCSRFProtect(),
|
||||
}
|
||||
|
||||
if tok := cfg.MCIAS.ServiceToken; tok != "" {
|
||||
@@ -188,6 +190,7 @@ func (lw *loggingResponseWriter) Unwrap() http.ResponseWriter {
|
||||
func (ws *WebServer) Start() error {
|
||||
r := chi.NewRouter()
|
||||
r.Use(ws.loggingMiddleware)
|
||||
r.Use(ws.csrf.middleware)
|
||||
ws.registerRoutes(r)
|
||||
|
||||
ws.httpSrv = &http.Server{
|
||||
@@ -201,7 +204,7 @@ func (ws *WebServer) Start() error {
|
||||
ws.logger.Info("starting web server", "addr", ws.cfg.Web.ListenAddr)
|
||||
|
||||
if ws.cfg.Web.TLSCert != "" && ws.cfg.Web.TLSKey != "" {
|
||||
ws.httpSrv.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
ws.httpSrv.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS13}
|
||||
err := ws.httpSrv.ListenAndServeTLS(ws.cfg.Web.TLSCert, ws.cfg.Web.TLSKey)
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
return fmt.Errorf("webserver: %w", err)
|
||||
@@ -226,7 +229,18 @@ func (ws *WebServer) Shutdown(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (ws *WebServer) renderTemplate(w http.ResponseWriter, name string, data interface{}) {
|
||||
tmpl, err := template.ParseFS(webui.FS,
|
||||
csrfToken := ws.csrf.setToken(w)
|
||||
|
||||
funcMap := template.FuncMap{
|
||||
"csrfField": func() template.HTML {
|
||||
return template.HTML(fmt.Sprintf(
|
||||
`<input type="hidden" name="%s" value="%s">`,
|
||||
csrfFieldName, template.HTMLEscapeString(csrfToken),
|
||||
))
|
||||
},
|
||||
}
|
||||
|
||||
tmpl, err := template.New("").Funcs(funcMap).ParseFS(webui.FS,
|
||||
"templates/layout.html",
|
||||
"templates/"+name,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user