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>
This commit is contained in:
2026-03-16 22:02:06 -07:00
parent 128f5abc4d
commit a80323e320
29 changed files with 5061 additions and 647 deletions

View File

@@ -38,6 +38,7 @@ func (ws *WebServer) registerRoutes(r chi.Router) {
r.HandleFunc("/login", ws.handleLogin)
r.Get("/dashboard", ws.requireAuth(ws.handleDashboard))
r.Post("/dashboard/mount-ca", ws.requireAuth(ws.handleDashboardMountCA))
r.Post("/dashboard/mount-engine", ws.requireAuth(ws.handleDashboardMountEngine))
r.Route("/policy", func(r chi.Router) {
r.Get("/", ws.requireAuth(ws.handlePolicy))
@@ -45,6 +46,47 @@ func (ws *WebServer) registerRoutes(r chi.Router) {
r.Post("/delete", ws.requireAuth(ws.handlePolicyDelete))
})
r.Route("/sshca", func(r chi.Router) {
r.Get("/", ws.requireAuth(ws.handleSSHCA))
r.Post("/sign-user", ws.requireAuth(ws.handleSSHCASignUser))
r.Post("/sign-host", ws.requireAuth(ws.handleSSHCASignHost))
r.Get("/cert/{serial}", ws.requireAuth(ws.handleSSHCACertDetail))
r.Post("/cert/{serial}/revoke", ws.requireAuth(ws.handleSSHCACertRevoke))
r.Post("/cert/{serial}/delete", ws.requireAuth(ws.handleSSHCACertDelete))
r.Post("/profile/create", ws.requireAuth(ws.handleSSHCACreateProfile))
r.Get("/profile/{name}", ws.requireAuth(ws.handleSSHCAProfileDetail))
r.Post("/profile/{name}/update", ws.requireAuth(ws.handleSSHCAUpdateProfile))
r.Post("/profile/{name}/delete", ws.requireAuth(ws.handleSSHCADeleteProfile))
})
r.Route("/transit", func(r chi.Router) {
r.Get("/", ws.requireAuth(ws.handleTransit))
r.Get("/key/{name}", ws.requireAuth(ws.handleTransitKeyDetail))
r.Post("/key/create", ws.requireAuth(ws.handleTransitCreateKey))
r.Post("/key/{name}/rotate", ws.requireAuth(ws.handleTransitRotateKey))
r.Post("/key/{name}/config", ws.requireAuth(ws.handleTransitUpdateConfig))
r.Post("/key/{name}/trim", ws.requireAuth(ws.handleTransitTrimKey))
r.Post("/key/{name}/delete", ws.requireAuth(ws.handleTransitDeleteKey))
r.Post("/encrypt", ws.requireAuth(ws.handleTransitEncrypt))
r.Post("/decrypt", ws.requireAuth(ws.handleTransitDecrypt))
r.Post("/rewrap", ws.requireAuth(ws.handleTransitRewrap))
r.Post("/sign", ws.requireAuth(ws.handleTransitSign))
r.Post("/verify", ws.requireAuth(ws.handleTransitVerify))
r.Post("/hmac", ws.requireAuth(ws.handleTransitHMAC))
})
r.Route("/user", func(r chi.Router) {
r.Get("/", ws.requireAuth(ws.handleUser))
r.Post("/register", ws.requireAuth(ws.handleUserRegister))
r.Post("/provision", ws.requireAuth(ws.handleUserProvision))
r.Get("/key/{username}", ws.requireAuth(ws.handleUserKeyDetail))
r.Post("/encrypt", ws.requireAuth(ws.handleUserEncrypt))
r.Post("/decrypt", ws.requireAuth(ws.handleUserDecrypt))
r.Post("/re-encrypt", ws.requireAuth(ws.handleUserReEncrypt))
r.Post("/rotate", ws.requireAuth(ws.handleUserRotateKey))
r.Post("/delete/{username}", ws.requireAuth(ws.handleUserDeleteUser))
})
r.Route("/pki", func(r chi.Router) {
r.Get("/", ws.requireAuth(ws.handlePKI))
r.Post("/import-root", ws.requireAuth(ws.handleImportRoot))
@@ -201,13 +243,11 @@ func (ws *WebServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
token := extractCookie(r)
mounts, _ := ws.vault.ListMounts(r.Context(), token)
state, _ := ws.vault.Status(r.Context())
ws.renderTemplate(w, "dashboard.html", map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"Roles": info.Roles,
"Mounts": mounts,
"State": state,
})
data := ws.baseData(r, info)
data["Roles"] = info.Roles
data["Mounts"] = mounts
data["State"] = state
ws.renderTemplate(w, "dashboard.html", data)
}
func (ws *WebServer) handleDashboardMountCA(w http.ResponseWriter, r *http.Request) {
@@ -258,18 +298,57 @@ func (ws *WebServer) handleDashboardMountCA(w http.ResponseWriter, r *http.Reque
http.Redirect(w, r, "/pki", http.StatusFound)
}
func (ws *WebServer) handleDashboardMountEngine(w http.ResponseWriter, r *http.Request) {
info := tokenInfoFromContext(r.Context())
if !info.IsAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
_ = r.ParseForm()
mountName := r.FormValue("name")
engineType := r.FormValue("type")
if mountName == "" || engineType == "" {
ws.renderDashboardWithError(w, r, info, "Mount name and engine type are required")
return
}
cfg := map[string]interface{}{}
if v := r.FormValue("key_algorithm"); v != "" {
cfg["key_algorithm"] = v
}
token := extractCookie(r)
if err := ws.vault.Mount(r.Context(), token, mountName, engineType, cfg); err != nil {
ws.renderDashboardWithError(w, r, info, grpcMessage(err))
return
}
// Redirect to the appropriate engine page.
switch engineType {
case "sshca":
http.Redirect(w, r, "/sshca", http.StatusFound)
case "transit":
http.Redirect(w, r, "/transit", http.StatusFound)
case "user":
http.Redirect(w, r, "/user", http.StatusFound)
default:
http.Redirect(w, r, "/dashboard", http.StatusFound)
}
}
func (ws *WebServer) renderDashboardWithError(w http.ResponseWriter, r *http.Request, info *TokenInfo, errMsg string) {
token := extractCookie(r)
mounts, _ := ws.vault.ListMounts(r.Context(), token)
state, _ := ws.vault.Status(r.Context())
ws.renderTemplate(w, "dashboard.html", map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"Roles": info.Roles,
"Mounts": mounts,
"State": state,
"MountError": errMsg,
})
data := ws.baseData(r, info)
data["Roles"] = info.Roles
data["Mounts"] = mounts
data["State"] = state
data["MountError"] = errMsg
ws.renderTemplate(w, "dashboard.html", data)
}
func (ws *WebServer) handlePKI(w http.ResponseWriter, r *http.Request) {
@@ -282,11 +361,8 @@ func (ws *WebServer) handlePKI(w http.ResponseWriter, r *http.Request) {
return
}
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
}
data := ws.baseData(r, info)
data["MountName"] = mountName
if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 {
if cert, err := parsePEMCert(rootPEM); err == nil {
@@ -482,15 +558,12 @@ func (ws *WebServer) handleIssuerDetail(w http.ResponseWriter, r *http.Request)
certs[i].IssuedBy = ws.resolveUser(certs[i].IssuedBy)
}
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
"IssuerName": issuerName,
"Certs": certs,
"NameFilter": r.URL.Query().Get("name"),
"SortBy": sortBy,
}
data := ws.baseData(r, info)
data["MountName"] = mountName
data["IssuerName"] = issuerName
data["Certs"] = certs
data["NameFilter"] = r.URL.Query().Get("name")
data["SortBy"] = sortBy
ws.renderTemplate(w, "issuer_detail.html", data)
}
@@ -640,12 +713,10 @@ func (ws *WebServer) handleCertDetail(w http.ResponseWriter, r *http.Request) {
cert.IssuedBy = ws.resolveUser(cert.IssuedBy)
cert.RevokedBy = ws.resolveUser(cert.RevokedBy)
ws.renderTemplate(w, "cert_detail.html", map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
"Cert": cert,
})
data := ws.baseData(r, info)
data["MountName"] = mountName
data["Cert"] = cert
ws.renderTemplate(w, "cert_detail.html", data)
}
func (ws *WebServer) handleCertDownload(w http.ResponseWriter, r *http.Request) {
@@ -776,12 +847,9 @@ func (ws *WebServer) handleSignCSR(w http.ResponseWriter, r *http.Request) {
return
}
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
"SignedCert": signed,
}
data := ws.baseData(r, info)
data["MountName"] = mountName
data["SignedCert"] = signed
if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 {
if cert, err := parsePEMCert(rootPEM); err == nil {
data["RootCN"] = cert.Subject.CommonName
@@ -800,12 +868,9 @@ func (ws *WebServer) handleSignCSR(w http.ResponseWriter, r *http.Request) {
func (ws *WebServer) renderPKIWithError(w http.ResponseWriter, r *http.Request, mountName string, info *TokenInfo, errMsg string) {
token := extractCookie(r)
data := map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"MountName": mountName,
"Error": errMsg,
}
data := ws.baseData(r, info)
data["MountName"] = mountName
data["Error"] = errMsg
if rootPEM, err := ws.vault.GetRootCert(r.Context(), mountName); err == nil && len(rootPEM) > 0 {
if cert, err := parsePEMCert(rootPEM); err == nil {
@@ -825,16 +890,45 @@ func (ws *WebServer) renderPKIWithError(w http.ResponseWriter, r *http.Request,
}
func (ws *WebServer) findCAMount(r *http.Request, token string) (string, error) {
return ws.findMount(r, token, "ca")
}
func (ws *WebServer) findSSHCAMount(r *http.Request, token string) (string, error) {
return ws.findMount(r, token, "sshca")
}
func (ws *WebServer) findTransitMount(r *http.Request, token string) (string, error) {
return ws.findMount(r, token, "transit")
}
func (ws *WebServer) findUserMount(r *http.Request, token string) (string, error) {
return ws.findMount(r, token, "user")
}
func (ws *WebServer) findMount(r *http.Request, token, engineType string) (string, error) {
mounts, err := ws.vault.ListMounts(r.Context(), token)
if err != nil {
return "", err
}
for _, m := range mounts {
if m.Type == "ca" {
if m.Type == engineType {
return m.Name, nil
}
}
return "", fmt.Errorf("no CA engine mounted")
return "", fmt.Errorf("no %s engine mounted", engineType)
}
// mountTypes returns a set of engine types that are currently mounted.
func (ws *WebServer) mountTypes(r *http.Request, token string) map[string]bool {
mounts, err := ws.vault.ListMounts(r.Context(), token)
if err != nil {
return nil
}
types := make(map[string]bool, len(mounts))
for _, m := range mounts {
types[m.Type] = true
}
return types
}
func (ws *WebServer) handlePolicy(w http.ResponseWriter, r *http.Request) {
@@ -848,11 +942,9 @@ func (ws *WebServer) handlePolicy(w http.ResponseWriter, r *http.Request) {
if err != nil {
rules = []PolicyRule{}
}
ws.renderTemplate(w, "policy.html", map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"Rules": rules,
})
data := ws.baseData(r, info)
data["Rules"] = rules
ws.renderTemplate(w, "policy.html", data)
}
func (ws *WebServer) handlePolicyCreate(w http.ResponseWriter, r *http.Request) {
@@ -927,12 +1019,23 @@ func (ws *WebServer) handlePolicyDelete(w http.ResponseWriter, r *http.Request)
func (ws *WebServer) renderPolicyWithError(w http.ResponseWriter, r *http.Request, info *TokenInfo, token, errMsg string) {
rules, _ := ws.vault.ListPolicies(r.Context(), token)
ws.renderTemplate(w, "policy.html", map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"Rules": rules,
"Error": errMsg,
})
data := ws.baseData(r, info)
data["Rules"] = rules
data["Error"] = errMsg
ws.renderTemplate(w, "policy.html", data)
}
// baseData returns a template data map pre-populated with user info and nav flags.
func (ws *WebServer) baseData(r *http.Request, info *TokenInfo) map[string]interface{} {
token := extractCookie(r)
types := ws.mountTypes(r, token)
return map[string]interface{}{
"Username": info.Username,
"IsAdmin": info.IsAdmin,
"HasSSHCA": types["sshca"],
"HasTransit": types["transit"],
"HasUser": types["user"],
}
}
// grpcMessage extracts a human-readable message from a gRPC error.