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>
265 lines
8.5 KiB
Go
265 lines
8.5 KiB
Go
// Package webserver implements the standalone web UI server for Metacrypt.
|
|
// It communicates with the vault over gRPC and renders server-side HTML.
|
|
package webserver
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
mcias "git.wntrmute.dev/kyle/mcias/clients/go"
|
|
"git.wntrmute.dev/kyle/metacrypt/internal/config"
|
|
webui "git.wntrmute.dev/kyle/metacrypt/web"
|
|
)
|
|
|
|
// vaultBackend is the interface used by WebServer to communicate with the vault.
|
|
// It is satisfied by *VaultClient and can be replaced with a mock in tests.
|
|
type vaultBackend interface {
|
|
Status(ctx context.Context) (string, error)
|
|
Init(ctx context.Context, password string) error
|
|
Unseal(ctx context.Context, password string) error
|
|
Login(ctx context.Context, username, password, totpCode string) (string, error)
|
|
ValidateToken(ctx context.Context, token string) (*TokenInfo, error)
|
|
ListMounts(ctx context.Context, token string) ([]MountInfo, error)
|
|
Mount(ctx context.Context, token, name, engineType string, config map[string]interface{}) error
|
|
GetRootCert(ctx context.Context, mount string) ([]byte, error)
|
|
GetIssuerCert(ctx context.Context, mount, issuer string) ([]byte, error)
|
|
ImportRoot(ctx context.Context, token, mount, certPEM, keyPEM string) error
|
|
CreateIssuer(ctx context.Context, token string, req CreateIssuerRequest) error
|
|
ListIssuers(ctx context.Context, token, mount string) ([]string, error)
|
|
IssueCert(ctx context.Context, token string, req IssueCertRequest) (*IssuedCert, error)
|
|
SignCSR(ctx context.Context, token string, req SignCSRRequest) (*SignedCert, error)
|
|
GetCert(ctx context.Context, token, mount, serial string) (*CertDetail, error)
|
|
ListCerts(ctx context.Context, token, mount string) ([]CertSummary, error)
|
|
RevokeCert(ctx context.Context, token, mount, serial string) error
|
|
DeleteCert(ctx context.Context, token, mount, serial string) error
|
|
ListPolicies(ctx context.Context, token string) ([]PolicyRule, error)
|
|
GetPolicy(ctx context.Context, token, id string) (*PolicyRule, error)
|
|
CreatePolicy(ctx context.Context, token string, rule PolicyRule) (*PolicyRule, error)
|
|
DeletePolicy(ctx context.Context, token, id string) error
|
|
Close() error
|
|
}
|
|
|
|
const userCacheTTL = 5 * time.Minute
|
|
|
|
// tgzEntry holds a cached tgz archive pending download.
|
|
type tgzEntry struct {
|
|
filename string
|
|
data []byte
|
|
}
|
|
|
|
// cachedUsername holds a resolved UUID→username entry with an expiry.
|
|
type cachedUsername struct {
|
|
username string
|
|
expiresAt time.Time
|
|
}
|
|
|
|
// WebServer is the standalone web UI server.
|
|
type WebServer struct {
|
|
cfg *config.Config
|
|
vault vaultBackend
|
|
mcias *mcias.Client // optional; nil when no service_token is configured
|
|
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
|
|
}
|
|
|
|
// resolveUser returns the display name for a user ID. If the ID is already a
|
|
// human-readable username (i.e. not a UUID), it is returned unchanged. When the
|
|
// webserver has an MCIAS client configured it will look up unknown IDs and cache
|
|
// the result; otherwise the raw ID is returned as a fallback.
|
|
func (ws *WebServer) resolveUser(id string) string {
|
|
if id == "" {
|
|
return id
|
|
}
|
|
if v, ok := ws.userCache.Load(id); ok {
|
|
if entry := v.(*cachedUsername); time.Now().Before(entry.expiresAt) {
|
|
ws.logger.Info("webserver: resolved user ID from cache", "id", id, "username", entry.username)
|
|
return entry.username
|
|
}
|
|
}
|
|
if ws.mcias == nil {
|
|
ws.logger.Warn("webserver: no MCIAS client available, cannot resolve user ID", "id", id)
|
|
return id
|
|
}
|
|
ws.logger.Info("webserver: looking up user ID via MCIAS", "id", id)
|
|
acct, err := ws.mcias.GetAccount(id)
|
|
if err != nil {
|
|
ws.logger.Warn("webserver: failed to resolve user ID", "id", id, "error", err)
|
|
return id
|
|
}
|
|
ws.logger.Info("webserver: resolved user ID", "id", id, "username", acct.Username)
|
|
ws.userCache.Store(id, &cachedUsername{
|
|
username: acct.Username,
|
|
expiresAt: time.Now().Add(userCacheTTL),
|
|
})
|
|
return acct.Username
|
|
}
|
|
|
|
// New creates a new WebServer. It dials the vault gRPC endpoint.
|
|
func New(cfg *config.Config, logger *slog.Logger) (*WebServer, error) {
|
|
logger.Info("connecting to vault", "addr", cfg.Web.VaultGRPC, "ca_cert", cfg.Web.VaultCACert)
|
|
vault, err := NewVaultClient(cfg.Web.VaultGRPC, cfg.Web.VaultCACert, logger)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("webserver: connect to vault: %w", err)
|
|
}
|
|
logger.Info("vault connection ready", "addr", cfg.Web.VaultGRPC)
|
|
|
|
staticFS, err := fs.Sub(webui.FS, "static")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("webserver: static FS: %w", err)
|
|
}
|
|
|
|
ws := &WebServer{
|
|
cfg: cfg,
|
|
vault: vault,
|
|
logger: logger,
|
|
staticFS: staticFS,
|
|
csrf: newCSRFProtect(),
|
|
}
|
|
|
|
if tok := cfg.MCIAS.ServiceToken; tok != "" {
|
|
mc, err := mcias.New(cfg.MCIAS.ServerURL, mcias.Options{
|
|
CACertPath: cfg.MCIAS.CACert,
|
|
Token: tok,
|
|
})
|
|
if err != nil {
|
|
logger.Warn("webserver: failed to create MCIAS client for user resolution", "error", err)
|
|
} else {
|
|
claims, err := mc.ValidateToken(tok)
|
|
switch {
|
|
case err != nil:
|
|
logger.Warn("webserver: MCIAS service token validation failed", "error", err)
|
|
case !claims.Valid:
|
|
logger.Warn("webserver: MCIAS service token is invalid or expired")
|
|
default:
|
|
logger.Info("webserver: MCIAS service token valid", "sub", claims.Sub, "roles", claims.Roles)
|
|
ws.mcias = mc
|
|
}
|
|
}
|
|
}
|
|
|
|
return ws, nil
|
|
}
|
|
|
|
// loggingMiddleware logs each incoming HTTP request.
|
|
func (ws *WebServer) loggingMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
lw := &loggingResponseWriter{ResponseWriter: w, status: http.StatusOK}
|
|
next.ServeHTTP(lw, r)
|
|
ws.logger.Info("request",
|
|
"method", r.Method,
|
|
"path", r.URL.Path,
|
|
"status", lw.status,
|
|
"duration", time.Since(start),
|
|
"remote_addr", r.RemoteAddr,
|
|
)
|
|
})
|
|
}
|
|
|
|
// loggingResponseWriter wraps http.ResponseWriter to capture the status code.
|
|
type loggingResponseWriter struct {
|
|
http.ResponseWriter
|
|
status int
|
|
}
|
|
|
|
func (lw *loggingResponseWriter) WriteHeader(code int) {
|
|
lw.status = code
|
|
lw.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
// Unwrap returns the underlying ResponseWriter so that http.ResponseController
|
|
// can reach it to set deadlines and perform other extended operations.
|
|
func (lw *loggingResponseWriter) Unwrap() http.ResponseWriter {
|
|
return lw.ResponseWriter
|
|
}
|
|
|
|
// Start starts the web server. It blocks until the server is closed.
|
|
func (ws *WebServer) Start() error {
|
|
r := chi.NewRouter()
|
|
r.Use(ws.loggingMiddleware)
|
|
r.Use(ws.csrf.middleware)
|
|
ws.registerRoutes(r)
|
|
|
|
ws.httpSrv = &http.Server{
|
|
Addr: ws.cfg.Web.ListenAddr,
|
|
Handler: r,
|
|
ReadTimeout: 30 * time.Second,
|
|
WriteTimeout: 30 * time.Second,
|
|
IdleTimeout: 120 * time.Second,
|
|
}
|
|
|
|
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.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)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
err := ws.httpSrv.ListenAndServe()
|
|
if err != nil && err != http.ErrServerClosed {
|
|
return fmt.Errorf("webserver: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Shutdown gracefully shuts down the web server.
|
|
func (ws *WebServer) Shutdown(ctx context.Context) error {
|
|
_ = ws.vault.Close()
|
|
if ws.httpSrv != nil {
|
|
return ws.httpSrv.Shutdown(ctx)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (ws *WebServer) renderTemplate(w http.ResponseWriter, name string, data interface{}) {
|
|
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,
|
|
)
|
|
if err != nil {
|
|
ws.logger.Error("parse template", "name", name, "error", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil {
|
|
ws.logger.Error("execute template", "name", name, "error", err)
|
|
}
|
|
}
|
|
|
|
func extractCookie(r *http.Request) string {
|
|
c, err := r.Cookie("metacrypt_token")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return c.Value
|
|
}
|