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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user