Files
metacrypt/internal/webserver/server.go
Kyle Isom a80323e320 Add web UI for SSH CA, Transit, and User engines; full security audit and remediation
Web UI: Added browser-based management for all three remaining engines
(SSH CA, Transit, User E2E). Includes gRPC client wiring, handler files,
7 HTML templates, dashboard mount forms, and conditional navigation links.
Fixed REST API routes to match design specs (SSH CA cert singular paths,
Transit PATCH for update-key-config).

Security audit: Conducted full-system audit covering crypto core, all
engine implementations, API servers, policy engine, auth, deployment,
and documentation. Identified 42 new findings (#39-#80) across all
severity levels.

Remediation of all 8 High findings:
- #68: Replaced 14 JSON-injection-vulnerable error responses with safe
  json.Encoder via writeJSONError helper
- #48: Added two-layer path traversal defense (barrier validatePath
  rejects ".." segments; engine ValidateName enforces safe name pattern)
- #39: Extended RLock through entire crypto operations in barrier
  Get/Put/Delete/List to eliminate TOCTOU race with Seal
- #40: Unified ReWrapKeys and seal_config UPDATE into single SQLite
  transaction to prevent irrecoverable data loss on crash during MEK
  rotation
- #49: Added resolveTTL to CA engine enforcing issuer MaxTTL ceiling
  on handleIssue and handleSignCSR
- #61: Store raw ECDH private key bytes in userState for effective
  zeroization on Seal
- #62: Fixed user engine policy resource path from mountPath to
  mountName() so policy rules match correctly
- #69: Added newPolicyChecker helper and passed service-level policy
  evaluation to all 25 typed REST handler engine.Request structs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 22:02:06 -07:00

312 lines
12 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 {
// System
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
Close() error
// PKI / CA
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
// Policy
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
// SSH CA
GetSSHCAPublicKey(ctx context.Context, mount string) (*SSHCAPublicKey, error)
SSHCASignHost(ctx context.Context, token, mount string, req SSHCASignRequest) (*SSHCASignResult, error)
SSHCASignUser(ctx context.Context, token, mount string, req SSHCASignRequest) (*SSHCASignResult, error)
ListSSHCAProfiles(ctx context.Context, token, mount string) ([]SSHCAProfileSummary, error)
GetSSHCAProfile(ctx context.Context, token, mount, name string) (*SSHCAProfile, error)
CreateSSHCAProfile(ctx context.Context, token, mount string, req SSHCAProfileRequest) error
UpdateSSHCAProfile(ctx context.Context, token, mount, name string, req SSHCAProfileRequest) error
DeleteSSHCAProfile(ctx context.Context, token, mount, name string) error
ListSSHCACerts(ctx context.Context, token, mount string) ([]SSHCACertSummary, error)
GetSSHCACert(ctx context.Context, token, mount, serial string) (*SSHCACertDetail, error)
RevokeSSHCACert(ctx context.Context, token, mount, serial string) error
DeleteSSHCACert(ctx context.Context, token, mount, serial string) error
GetSSHCAKRL(ctx context.Context, mount string) ([]byte, error)
// Transit
ListTransitKeys(ctx context.Context, token, mount string) ([]TransitKeySummary, error)
GetTransitKey(ctx context.Context, token, mount, name string) (*TransitKeyDetail, error)
CreateTransitKey(ctx context.Context, token, mount, name, keyType string) error
DeleteTransitKey(ctx context.Context, token, mount, name string) error
RotateTransitKey(ctx context.Context, token, mount, name string) error
UpdateTransitKeyConfig(ctx context.Context, token, mount, name string, minDecryptVersion int, allowDeletion bool) error
TrimTransitKey(ctx context.Context, token, mount, name string) (int, error)
TransitEncrypt(ctx context.Context, token, mount, key, plaintext, transitCtx string) (string, error)
TransitDecrypt(ctx context.Context, token, mount, key, ciphertext, transitCtx string) (string, error)
TransitRewrap(ctx context.Context, token, mount, key, ciphertext, transitCtx string) (string, error)
TransitSign(ctx context.Context, token, mount, key, input string) (string, error)
TransitVerify(ctx context.Context, token, mount, key, input, signature string) (bool, error)
TransitHMAC(ctx context.Context, token, mount, key, input string) (string, error)
GetTransitPublicKey(ctx context.Context, token, mount, name string) (string, error)
// User (E2E encryption)
UserRegister(ctx context.Context, token, mount string) (*UserKeyInfo, error)
UserProvision(ctx context.Context, token, mount, username string) (*UserKeyInfo, error)
GetUserPublicKey(ctx context.Context, token, mount, username string) (*UserKeyInfo, error)
ListUsers(ctx context.Context, token, mount string) ([]string, error)
UserEncrypt(ctx context.Context, token, mount, plaintext, metadata string, recipients []string) (string, error)
UserDecrypt(ctx context.Context, token, mount, envelope string) (*UserDecryptResult, error)
UserReEncrypt(ctx context.Context, token, mount, envelope string) (string, error)
UserRotateKey(ctx context.Context, token, mount string) (*UserKeyInfo, error)
UserDeleteUser(ctx context.Context, token, mount, username string) 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
}