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

@@ -11,6 +11,7 @@ import (
mcias "git.wntrmute.dev/kyle/mcias/clients/go"
"git.wntrmute.dev/kyle/metacrypt/internal/auth"
"git.wntrmute.dev/kyle/metacrypt/internal/barrier"
"git.wntrmute.dev/kyle/metacrypt/internal/crypto"
"git.wntrmute.dev/kyle/metacrypt/internal/engine"
@@ -53,10 +54,10 @@ func (s *Server) registerRoutes(r chi.Router) {
r.Get("/v1/sshca/{mount}/profiles/{name}", s.requireAuth(s.handleSSHCAGetProfile))
r.Get("/v1/sshca/{mount}/profiles", s.requireAuth(s.handleSSHCAListProfiles))
r.Delete("/v1/sshca/{mount}/profiles/{name}", s.requireAdmin(s.handleSSHCADeleteProfile))
r.Get("/v1/sshca/{mount}/certs/{serial}", s.requireAuth(s.handleSSHCAGetCert))
r.Get("/v1/sshca/{mount}/cert/{serial}", s.requireAuth(s.handleSSHCAGetCert))
r.Get("/v1/sshca/{mount}/certs", s.requireAuth(s.handleSSHCAListCerts))
r.Post("/v1/sshca/{mount}/certs/{serial}/revoke", s.requireAdmin(s.handleSSHCARevokeCert))
r.Delete("/v1/sshca/{mount}/certs/{serial}", s.requireAdmin(s.handleSSHCADeleteCert))
r.Post("/v1/sshca/{mount}/cert/{serial}/revoke", s.requireAdmin(s.handleSSHCARevokeCert))
r.Delete("/v1/sshca/{mount}/cert/{serial}", s.requireAdmin(s.handleSSHCADeleteCert))
// Public PKI routes (no auth required, but must be unsealed).
r.Get("/v1/pki/{mount}/ca", s.requireUnseal(s.handlePKIRoot))
@@ -89,7 +90,7 @@ func (s *Server) registerRoutes(r chi.Router) {
r.Get("/v1/transit/{mount}/keys/{name}", s.requireAuth(s.handleTransitGetKey))
r.Delete("/v1/transit/{mount}/keys/{name}", s.requireAdmin(s.handleTransitDeleteKey))
r.Post("/v1/transit/{mount}/keys/{name}/rotate", s.requireAdmin(s.handleTransitRotateKey))
r.Post("/v1/transit/{mount}/keys/{name}/config", s.requireAdmin(s.handleTransitUpdateKeyConfig))
r.Patch("/v1/transit/{mount}/keys/{name}/config", s.requireAdmin(s.handleTransitUpdateKeyConfig))
r.Post("/v1/transit/{mount}/keys/{name}/trim", s.requireAdmin(s.handleTransitTrimKey))
r.Post("/v1/transit/{mount}/encrypt/{key}", s.requireAuth(s.handleTransitEncrypt))
r.Post("/v1/transit/{mount}/decrypt/{key}", s.requireAuth(s.handleTransitDecrypt))
@@ -287,7 +288,7 @@ func (s *Server) handleEngineMount(w http.ResponseWriter, r *http.Request) {
if err := s.engines.Mount(r.Context(), req.Name, engine.EngineType(req.Type), req.Config); err != nil {
s.logger.Error("mount engine", "name", req.Name, "type", req.Type, "error", err)
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusBadRequest)
writeJSONError(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
@@ -302,7 +303,7 @@ func (s *Server) handleEngineUnmount(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.engines.Unmount(r.Context(), req.Name); err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
writeJSONError(w, err.Error(), http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
@@ -434,7 +435,7 @@ func (s *Server) handleEngineRequest(w http.ResponseWriter, r *http.Request) {
case strings.Contains(err.Error(), "not found"):
status = http.StatusNotFound
}
http.Error(w, `{"error":"`+err.Error()+`"}`, status)
writeJSONError(w, err.Error(), status)
return
}
@@ -615,13 +616,14 @@ func (s *Server) handleGetCert(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
if errors.Is(err, ca.ErrCertNotFound) {
http.Error(w, `{"error":"certificate not found"}`, http.StatusNotFound)
return
}
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
writeJSONError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, resp.Data)
@@ -640,6 +642,7 @@ func (s *Server) handleRevokeCert(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
if errors.Is(err, ca.ErrCertNotFound) {
@@ -650,7 +653,7 @@ func (s *Server) handleRevokeCert(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
writeJSONError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, resp.Data)
@@ -669,6 +672,7 @@ func (s *Server) handleDeleteCert(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
if errors.Is(err, ca.ErrCertNotFound) {
@@ -679,7 +683,7 @@ func (s *Server) handleDeleteCert(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
writeJSONError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusNoContent, nil)
@@ -691,7 +695,7 @@ func (s *Server) handlePKIRoot(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
caEng, err := s.getCAEngine(mountName)
if err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
writeJSONError(w, err.Error(), http.StatusNotFound)
return
}
@@ -715,7 +719,7 @@ func (s *Server) handlePKIChain(w http.ResponseWriter, r *http.Request) {
caEng, err := s.getCAEngine(mountName)
if err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
writeJSONError(w, err.Error(), http.StatusNotFound)
return
}
@@ -739,7 +743,7 @@ func (s *Server) handlePKIIssuer(w http.ResponseWriter, r *http.Request) {
caEng, err := s.getCAEngine(mountName)
if err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
writeJSONError(w, err.Error(), http.StatusNotFound)
return
}
@@ -763,7 +767,7 @@ func (s *Server) handlePKICRL(w http.ResponseWriter, r *http.Request) {
caEng, err := s.getCAEngine(mountName)
if err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
writeJSONError(w, err.Error(), http.StatusNotFound)
return
}
@@ -1047,6 +1051,7 @@ func (s *Server) handleUserRegister(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
s.writeEngineError(w, err)
@@ -1076,7 +1081,8 @@ func (s *Server) handleUserProvision(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
Data: map[string]interface{}{"username": req.Username},
CheckPolicy: s.newPolicyChecker(r, info),
Data: map[string]interface{}{"username": req.Username},
})
if err != nil {
s.writeEngineError(w, err)
@@ -1095,6 +1101,7 @@ func (s *Server) handleUserListUsers(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
s.writeEngineError(w, err)
@@ -1114,7 +1121,8 @@ func (s *Server) handleUserGetPublicKey(w http.ResponseWriter, r *http.Request)
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
Data: map[string]interface{}{"username": username},
CheckPolicy: s.newPolicyChecker(r, info),
Data: map[string]interface{}{"username": username},
})
if err != nil {
s.writeEngineError(w, err)
@@ -1134,7 +1142,8 @@ func (s *Server) handleUserDeleteUser(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
Data: map[string]interface{}{"username": username},
CheckPolicy: s.newPolicyChecker(r, info),
Data: map[string]interface{}{"username": username},
})
if err != nil {
s.writeEngineError(w, err)
@@ -1215,7 +1224,8 @@ func (s *Server) handleUserDecrypt(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
Data: map[string]interface{}{"envelope": req.Envelope},
CheckPolicy: s.newPolicyChecker(r, info),
Data: map[string]interface{}{"envelope": req.Envelope},
})
if err != nil {
s.writeEngineError(w, err)
@@ -1241,7 +1251,8 @@ func (s *Server) handleUserReEncrypt(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
Data: map[string]interface{}{"envelope": req.Envelope},
CheckPolicy: s.newPolicyChecker(r, info),
Data: map[string]interface{}{"envelope": req.Envelope},
})
if err != nil {
s.writeEngineError(w, err)
@@ -1260,6 +1271,7 @@ func (s *Server) handleUserRotateKey(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
s.writeEngineError(w, err)
@@ -1301,6 +1313,28 @@ func writeJSON(w http.ResponseWriter, status int, v interface{}) {
_ = json.NewEncoder(w).Encode(v)
}
func writeJSONError(w http.ResponseWriter, msg string, status int) {
writeJSON(w, status, map[string]string{"error": msg})
}
// newPolicyChecker builds a PolicyChecker closure for a caller, used by typed
// REST handlers to pass service-level policy evaluation into the engine.
func (s *Server) newPolicyChecker(r *http.Request, info *auth.TokenInfo) engine.PolicyChecker {
return func(resource, action string) (string, bool) {
pReq := &policy.Request{
Username: info.Username,
Roles: info.Roles,
Resource: resource,
Action: action,
}
eff, matched, pErr := s.policy.Match(r.Context(), pReq)
if pErr != nil {
return string(policy.EffectDeny), false
}
return string(eff), matched
}
}
func readJSON(r *http.Request, v interface{}) error {
defer func() { _ = r.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
@@ -1331,7 +1365,7 @@ func (s *Server) handleSSHCAPubkey(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
eng, err := s.getSSHCAEngine(mountName)
if err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
writeJSONError(w, err.Error(), http.StatusNotFound)
return
}
pubKey, err := eng.GetCAPubkey(r.Context())
@@ -1347,7 +1381,7 @@ func (s *Server) handleSSHCAKRL(w http.ResponseWriter, r *http.Request) {
mountName := chi.URLParam(r, "mount")
eng, err := s.getSSHCAEngine(mountName)
if err != nil {
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusNotFound)
writeJSONError(w, err.Error(), http.StatusNotFound)
return
}
krlData, err := eng.GetKRL()
@@ -1386,6 +1420,7 @@ func (s *Server) handleSSHCASignHost(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
s.writeEngineError(w, err)
@@ -1502,6 +1537,7 @@ func (s *Server) handleSSHCACreateProfile(w http.ResponseWriter, r *http.Request
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
s.writeEngineError(w, err)
@@ -1557,6 +1593,7 @@ func (s *Server) handleSSHCAUpdateProfile(w http.ResponseWriter, r *http.Request
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
s.writeEngineError(w, err)
@@ -1577,6 +1614,7 @@ func (s *Server) handleSSHCAGetProfile(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
s.writeEngineError(w, err)
@@ -1595,6 +1633,7 @@ func (s *Server) handleSSHCAListProfiles(w http.ResponseWriter, r *http.Request)
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
s.writeEngineError(w, err)
@@ -1615,6 +1654,7 @@ func (s *Server) handleSSHCADeleteProfile(w http.ResponseWriter, r *http.Request
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
s.writeEngineError(w, err)
@@ -1635,6 +1675,7 @@ func (s *Server) handleSSHCAGetCert(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
s.writeEngineError(w, err)
@@ -1653,6 +1694,7 @@ func (s *Server) handleSSHCAListCerts(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
s.writeEngineError(w, err)
@@ -1673,6 +1715,7 @@ func (s *Server) handleSSHCARevokeCert(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
s.writeEngineError(w, err)
@@ -1693,6 +1736,7 @@ func (s *Server) handleSSHCADeleteCert(w http.ResponseWriter, r *http.Request) {
Roles: info.Roles,
IsAdmin: info.IsAdmin,
},
CheckPolicy: s.newPolicyChecker(r, info),
})
if err != nil {
s.writeEngineError(w, err)
@@ -1728,5 +1772,5 @@ func (s *Server) writeEngineError(w http.ResponseWriter, err error) {
strings.Contains(err.Error(), "too many"):
status = http.StatusBadRequest
}
http.Error(w, `{"error":"`+err.Error()+`"}`, status)
writeJSONError(w, err.Error(), status)
}