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>
945 lines
26 KiB
Go
945 lines
26 KiB
Go
package webserver
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
// splitLines splits a newline-delimited string into non-empty trimmed lines.
|
|
func splitLines(s string) []interface{} {
|
|
var out []interface{}
|
|
for _, line := range strings.Split(s, "\n") {
|
|
if v := strings.TrimSpace(line); v != "" {
|
|
out = append(out, v)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (ws *WebServer) registerRoutes(r chi.Router) {
|
|
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(ws.staticFS))))
|
|
|
|
r.Get("/", ws.handleRoot)
|
|
r.HandleFunc("/init", ws.handleInit)
|
|
r.HandleFunc("/unseal", ws.handleUnseal)
|
|
r.HandleFunc("/login", ws.handleLogin)
|
|
r.Get("/dashboard", ws.requireAuth(ws.handleDashboard))
|
|
r.Post("/dashboard/mount-ca", ws.requireAuth(ws.handleDashboardMountCA))
|
|
|
|
r.Route("/policy", func(r chi.Router) {
|
|
r.Get("/", ws.requireAuth(ws.handlePolicy))
|
|
r.Post("/", ws.requireAuth(ws.handlePolicyCreate))
|
|
r.Post("/delete", ws.requireAuth(ws.handlePolicyDelete))
|
|
})
|
|
|
|
r.Route("/pki", func(r chi.Router) {
|
|
r.Get("/", ws.requireAuth(ws.handlePKI))
|
|
r.Post("/import-root", ws.requireAuth(ws.handleImportRoot))
|
|
r.Post("/create-issuer", ws.requireAuth(ws.handleCreateIssuer))
|
|
r.Post("/issue", ws.requireAuth(ws.handleIssueCert))
|
|
r.Post("/sign-csr", ws.requireAuth(ws.handleSignCSR))
|
|
r.Get("/download/{token}", ws.requireAuth(ws.handleTGZDownload))
|
|
r.Get("/issuer/{issuer}", ws.requireAuth(ws.handleIssuerDetail))
|
|
r.Get("/cert/{serial}", ws.requireAuth(ws.handleCertDetail))
|
|
r.Get("/cert/{serial}/download", ws.requireAuth(ws.handleCertDownload))
|
|
r.Post("/cert/{serial}/revoke", ws.requireAuth(ws.handleCertRevoke))
|
|
r.Post("/cert/{serial}/delete", ws.requireAuth(ws.handleCertDelete))
|
|
r.Get("/{issuer}", ws.requireAuth(ws.handlePKIIssuer))
|
|
})
|
|
}
|
|
|
|
// requireAuth validates the token cookie against the vault and injects TokenInfo.
|
|
func (ws *WebServer) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
state, err := ws.vault.Status(r.Context())
|
|
if err != nil || state != "unsealed" {
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
return
|
|
}
|
|
token := extractCookie(r)
|
|
if token == "" {
|
|
http.Redirect(w, r, "/login", http.StatusFound)
|
|
return
|
|
}
|
|
info, err := ws.vault.ValidateToken(r.Context(), token)
|
|
if err != nil {
|
|
http.Redirect(w, r, "/login", http.StatusFound)
|
|
return
|
|
}
|
|
info.Username = ws.resolveUser(info.Username)
|
|
r = r.WithContext(withTokenInfo(r.Context(), info))
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
func (ws *WebServer) handleRoot(w http.ResponseWriter, r *http.Request) {
|
|
state, err := ws.vault.Status(r.Context())
|
|
if err != nil {
|
|
ws.renderTemplate(w, "unseal.html", map[string]interface{}{"Error": "Cannot reach vault"})
|
|
return
|
|
}
|
|
switch state {
|
|
case "uninitialized", "initializing":
|
|
http.Redirect(w, r, "/init", http.StatusFound)
|
|
case "sealed":
|
|
http.Redirect(w, r, "/unseal", http.StatusFound)
|
|
case "unsealed":
|
|
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
|
default:
|
|
http.Redirect(w, r, "/unseal", http.StatusFound)
|
|
}
|
|
}
|
|
|
|
func (ws *WebServer) handleInit(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
state, _ := ws.vault.Status(r.Context())
|
|
if state != "uninitialized" && state != "" {
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
return
|
|
}
|
|
ws.renderTemplate(w, "init.html", nil)
|
|
case http.MethodPost:
|
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
|
_ = r.ParseForm()
|
|
password := r.FormValue("password")
|
|
if password == "" {
|
|
ws.renderTemplate(w, "init.html", map[string]interface{}{"Error": "Password is required"})
|
|
return
|
|
}
|
|
if err := ws.vault.Init(r.Context(), password); err != nil {
|
|
ws.renderTemplate(w, "init.html", map[string]interface{}{"Error": grpcMessage(err)})
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
|
default:
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func (ws *WebServer) handleUnseal(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
state, _ := ws.vault.Status(r.Context())
|
|
if state == "uninitialized" {
|
|
http.Redirect(w, r, "/init", http.StatusFound)
|
|
return
|
|
}
|
|
if state == "unsealed" {
|
|
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
|
return
|
|
}
|
|
ws.renderTemplate(w, "unseal.html", nil)
|
|
case http.MethodPost:
|
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
|
_ = r.ParseForm()
|
|
password := r.FormValue("password")
|
|
if err := ws.vault.Unseal(r.Context(), password); err != nil {
|
|
msg := "Invalid password"
|
|
if st, ok := status.FromError(err); ok && st.Code() == codes.ResourceExhausted {
|
|
msg = "Too many attempts. Please wait 60 seconds."
|
|
}
|
|
ws.renderTemplate(w, "unseal.html", map[string]interface{}{"Error": msg})
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
|
default:
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func (ws *WebServer) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
state, _ := ws.vault.Status(r.Context())
|
|
if state != "unsealed" {
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
ws.renderTemplate(w, "login.html", nil)
|
|
case http.MethodPost:
|
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
|
_ = r.ParseForm()
|
|
token, err := ws.vault.Login(r.Context(),
|
|
r.FormValue("username"),
|
|
r.FormValue("password"),
|
|
r.FormValue("totp_code"),
|
|
)
|
|
if err != nil {
|
|
ws.renderTemplate(w, "login.html", map[string]interface{}{"Error": "Invalid credentials"})
|
|
return
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "metacrypt_token",
|
|
Value: token,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: true,
|
|
SameSite: http.SameSiteStrictMode,
|
|
})
|
|
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
|
default:
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func (ws *WebServer) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|
info := tokenInfoFromContext(r.Context())
|
|
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,
|
|
})
|
|
}
|
|
|
|
func (ws *WebServer) handleDashboardMountCA(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)
|
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
|
_ = r.ParseForm()
|
|
}
|
|
|
|
mountName := r.FormValue("name")
|
|
if mountName == "" {
|
|
ws.renderDashboardWithError(w, r, info, "Mount name is required")
|
|
return
|
|
}
|
|
|
|
cfg := map[string]interface{}{}
|
|
if org := r.FormValue("organization"); org != "" {
|
|
cfg["organization"] = org
|
|
}
|
|
|
|
var certPEM, keyPEM string
|
|
if f, _, err := r.FormFile("cert_file"); err == nil {
|
|
defer func() { _ = f.Close() }()
|
|
data, _ := io.ReadAll(io.LimitReader(f, 1<<20))
|
|
certPEM = string(data)
|
|
}
|
|
if f, _, err := r.FormFile("key_file"); err == nil {
|
|
defer func() { _ = f.Close() }()
|
|
data, _ := io.ReadAll(io.LimitReader(f, 1<<20))
|
|
keyPEM = string(data)
|
|
}
|
|
if certPEM != "" && keyPEM != "" {
|
|
cfg["root_cert_pem"] = certPEM
|
|
cfg["root_key_pem"] = keyPEM
|
|
}
|
|
|
|
token := extractCookie(r)
|
|
if err := ws.vault.Mount(r.Context(), token, mountName, "ca", cfg); err != nil {
|
|
ws.renderDashboardWithError(w, r, info, grpcMessage(err))
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/pki", 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,
|
|
})
|
|
}
|
|
|
|
func (ws *WebServer) handlePKI(w http.ResponseWriter, r *http.Request) {
|
|
info := tokenInfoFromContext(r.Context())
|
|
token := extractCookie(r)
|
|
|
|
mountName, err := ws.findCAMount(r, token)
|
|
if err != nil {
|
|
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
data := map[string]interface{}{
|
|
"Username": info.Username,
|
|
"IsAdmin": info.IsAdmin,
|
|
"MountName": mountName,
|
|
}
|
|
|
|
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
|
|
data["RootOrg"] = strings.Join(cert.Subject.Organization, ", ")
|
|
data["RootNotBefore"] = cert.NotBefore.Format(time.RFC3339)
|
|
data["RootNotAfter"] = cert.NotAfter.Format(time.RFC3339)
|
|
data["RootExpired"] = time.Now().After(cert.NotAfter)
|
|
data["HasRoot"] = true
|
|
}
|
|
}
|
|
|
|
if issuers, err := ws.vault.ListIssuers(r.Context(), token, mountName); err == nil {
|
|
data["Issuers"] = issuers
|
|
}
|
|
|
|
ws.renderTemplate(w, "pki.html", data)
|
|
}
|
|
|
|
func (ws *WebServer) handleImportRoot(w http.ResponseWriter, r *http.Request) {
|
|
info := tokenInfoFromContext(r.Context())
|
|
if !info.IsAdmin {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
token := extractCookie(r)
|
|
mountName, err := ws.findCAMount(r, token)
|
|
if err != nil {
|
|
http.Error(w, "no CA engine mounted", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
|
_ = r.ParseForm()
|
|
}
|
|
|
|
certPEM := r.FormValue("cert_pem")
|
|
keyPEM := r.FormValue("key_pem")
|
|
if certPEM == "" {
|
|
if f, _, err := r.FormFile("cert_file"); err == nil {
|
|
defer func() { _ = f.Close() }()
|
|
data, _ := io.ReadAll(io.LimitReader(f, 1<<20))
|
|
certPEM = string(data)
|
|
}
|
|
}
|
|
if keyPEM == "" {
|
|
if f, _, err := r.FormFile("key_file"); err == nil {
|
|
defer func() { _ = f.Close() }()
|
|
data, _ := io.ReadAll(io.LimitReader(f, 1<<20))
|
|
keyPEM = string(data)
|
|
}
|
|
}
|
|
|
|
if certPEM == "" || keyPEM == "" {
|
|
ws.renderPKIWithError(w, r, mountName, info, "Certificate and private key are required")
|
|
return
|
|
}
|
|
|
|
if err = ws.vault.ImportRoot(r.Context(), token, mountName, certPEM, keyPEM); err != nil {
|
|
ws.renderPKIWithError(w, r, mountName, info, grpcMessage(err))
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/pki", http.StatusFound)
|
|
}
|
|
|
|
func (ws *WebServer) handleCreateIssuer(w http.ResponseWriter, r *http.Request) {
|
|
info := tokenInfoFromContext(r.Context())
|
|
if !info.IsAdmin {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
token := extractCookie(r)
|
|
mountName, err := ws.findCAMount(r, token)
|
|
if err != nil {
|
|
http.Error(w, "no CA engine mounted", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
|
_ = r.ParseForm()
|
|
name := r.FormValue("name")
|
|
if name == "" {
|
|
ws.renderPKIWithError(w, r, mountName, info, "Issuer name is required")
|
|
return
|
|
}
|
|
|
|
issuerReq := CreateIssuerRequest{
|
|
Mount: mountName,
|
|
Name: name,
|
|
}
|
|
if v := r.FormValue("expiry"); v != "" {
|
|
issuerReq.Expiry = v
|
|
}
|
|
if v := r.FormValue("max_ttl"); v != "" {
|
|
issuerReq.MaxTTL = v
|
|
}
|
|
if v := r.FormValue("key_algorithm"); v != "" {
|
|
issuerReq.KeyAlgorithm = v
|
|
}
|
|
if v := r.FormValue("key_size"); v != "" {
|
|
var size int32
|
|
if _, err := fmt.Sscanf(v, "%d", &size); err == nil {
|
|
issuerReq.KeySize = size
|
|
}
|
|
}
|
|
|
|
if err = ws.vault.CreateIssuer(r.Context(), token, issuerReq); err != nil {
|
|
ws.renderPKIWithError(w, r, mountName, info, grpcMessage(err))
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/pki", http.StatusFound)
|
|
}
|
|
|
|
func (ws *WebServer) handlePKIIssuer(w http.ResponseWriter, r *http.Request) {
|
|
token := extractCookie(r)
|
|
mountName, err := ws.findCAMount(r, token)
|
|
if err != nil {
|
|
http.Error(w, "no CA engine mounted", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
issuerName := chi.URLParam(r, "issuer")
|
|
certPEM, err := ws.vault.GetIssuerCert(r.Context(), mountName, issuerName)
|
|
if err != nil {
|
|
http.Error(w, "issuer not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/x-pem-file")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.pem", issuerName))
|
|
_, _ = w.Write(certPEM) //nolint:gosec
|
|
}
|
|
|
|
func (ws *WebServer) handleIssuerDetail(w http.ResponseWriter, r *http.Request) {
|
|
info := tokenInfoFromContext(r.Context())
|
|
token := extractCookie(r)
|
|
|
|
mountName, err := ws.findCAMount(r, token)
|
|
if err != nil {
|
|
http.Error(w, "no CA engine mounted", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
issuerName := chi.URLParam(r, "issuer")
|
|
|
|
allCerts, err := ws.vault.ListCerts(r.Context(), token, mountName)
|
|
if err != nil {
|
|
http.Error(w, "failed to list certificates", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
nameFilter := strings.ToLower(r.URL.Query().Get("name"))
|
|
sortBy := r.URL.Query().Get("sort")
|
|
if sortBy == "" {
|
|
sortBy = "cn"
|
|
}
|
|
|
|
var certs []CertSummary
|
|
for _, cs := range allCerts {
|
|
if cs.Issuer != issuerName {
|
|
continue
|
|
}
|
|
if nameFilter != "" && !strings.Contains(strings.ToLower(cs.CommonName), strings.ToLower(nameFilter)) {
|
|
continue
|
|
}
|
|
certs = append(certs, cs)
|
|
}
|
|
|
|
// Sort: by expiry date or by common name (default).
|
|
if sortBy == "expiry" {
|
|
for i := 1; i < len(certs); i++ {
|
|
for j := i; j > 0; j-- {
|
|
if certs[j-1].ExpiresAt > certs[j].ExpiresAt {
|
|
certs[j-1], certs[j] = certs[j], certs[j-1]
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
for i := 1; i < len(certs); i++ {
|
|
for j := i; j > 0; j-- {
|
|
if strings.ToLower(certs[j-1].CommonName) > strings.ToLower(certs[j].CommonName) {
|
|
certs[j-1], certs[j] = certs[j], certs[j-1]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for i := range certs {
|
|
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,
|
|
}
|
|
|
|
ws.renderTemplate(w, "issuer_detail.html", data)
|
|
}
|
|
|
|
func (ws *WebServer) handleIssueCert(w http.ResponseWriter, r *http.Request) {
|
|
|
|
info := tokenInfoFromContext(r.Context())
|
|
token := extractCookie(r)
|
|
|
|
mountName, err := ws.findCAMount(r, token)
|
|
if err != nil {
|
|
http.Error(w, "no CA engine mounted", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
|
_ = r.ParseForm()
|
|
|
|
commonName := r.FormValue("common_name")
|
|
if commonName == "" {
|
|
ws.renderPKIWithError(w, r, mountName, info, "Common name is required")
|
|
return
|
|
}
|
|
issuer := r.FormValue("issuer")
|
|
if issuer == "" {
|
|
ws.renderPKIWithError(w, r, mountName, info, "Issuer is required")
|
|
return
|
|
}
|
|
|
|
certReq := IssueCertRequest{
|
|
Mount: mountName,
|
|
Issuer: issuer,
|
|
CommonName: commonName,
|
|
}
|
|
if v := r.FormValue("profile"); v != "" {
|
|
certReq.Profile = v
|
|
}
|
|
if v := r.FormValue("ttl"); v != "" {
|
|
certReq.TTL = v
|
|
}
|
|
if lines := splitLines(r.FormValue("dns_names")); len(lines) > 0 {
|
|
for _, l := range lines {
|
|
certReq.DNSNames = append(certReq.DNSNames, l.(string))
|
|
}
|
|
}
|
|
if lines := splitLines(r.FormValue("ip_addresses")); len(lines) > 0 {
|
|
for _, l := range lines {
|
|
certReq.IPAddresses = append(certReq.IPAddresses, l.(string))
|
|
}
|
|
}
|
|
certReq.KeyUsages = r.Form["key_usages"]
|
|
certReq.ExtKeyUsages = r.Form["ext_key_usages"]
|
|
|
|
issuedCert, err := ws.vault.IssueCert(r.Context(), token, certReq)
|
|
if err != nil {
|
|
ws.renderPKIWithError(w, r, mountName, info, grpcMessage(err))
|
|
return
|
|
}
|
|
|
|
// Build the tgz archive in memory, store it in the cache, then redirect
|
|
// the browser to the one-time download URL so the archive is only served
|
|
// once and then discarded.
|
|
var buf bytes.Buffer
|
|
gw := gzip.NewWriter(&buf)
|
|
tw := tar.NewWriter(gw)
|
|
|
|
writeTarFile := func(name string, data []byte) error {
|
|
hdr := &tar.Header{
|
|
Name: name,
|
|
Mode: 0600,
|
|
Size: int64(len(data)),
|
|
ModTime: time.Now(),
|
|
}
|
|
if err := tw.WriteHeader(hdr); err != nil {
|
|
return err
|
|
}
|
|
_, err := tw.Write(data)
|
|
return err
|
|
}
|
|
|
|
if err := writeTarFile("key.pem", []byte(issuedCert.KeyPEM)); err != nil {
|
|
ws.logger.Error("build tgz key", "error", err)
|
|
http.Error(w, "failed to build archive", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := writeTarFile("cert.pem", []byte(issuedCert.CertPEM)); err != nil {
|
|
ws.logger.Error("build tgz cert", "error", err)
|
|
http.Error(w, "failed to build archive", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
_ = tw.Close()
|
|
_ = gw.Close()
|
|
|
|
// Generate a random one-time token for the download URL.
|
|
var raw [16]byte
|
|
if _, err := rand.Read(raw[:]); err != nil {
|
|
ws.logger.Error("generate download token", "error", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
dlToken := hex.EncodeToString(raw[:])
|
|
|
|
ws.tgzCache.Store(dlToken, &tgzEntry{
|
|
filename: issuedCert.Serial + ".tgz",
|
|
data: buf.Bytes(),
|
|
})
|
|
|
|
http.Redirect(w, r, "/pki/download/"+dlToken, http.StatusSeeOther)
|
|
}
|
|
|
|
func (ws *WebServer) handleTGZDownload(w http.ResponseWriter, r *http.Request) {
|
|
dlToken := chi.URLParam(r, "token")
|
|
|
|
val, ok := ws.tgzCache.LoadAndDelete(dlToken)
|
|
if !ok {
|
|
http.Error(w, "download not found or already used", http.StatusNotFound)
|
|
return
|
|
}
|
|
entry := val.(*tgzEntry)
|
|
|
|
w.Header().Set("Content-Type", "application/gzip")
|
|
w.Header().Set("Content-Disposition", "attachment; filename=\""+entry.filename+"\"")
|
|
_, _ = w.Write(entry.data)
|
|
}
|
|
|
|
func (ws *WebServer) handleCertDetail(w http.ResponseWriter, r *http.Request) {
|
|
info := tokenInfoFromContext(r.Context())
|
|
token := extractCookie(r)
|
|
|
|
mountName, err := ws.findCAMount(r, token)
|
|
if err != nil {
|
|
http.Error(w, "no CA engine mounted", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
serial := chi.URLParam(r, "serial")
|
|
cert, err := ws.vault.GetCert(r.Context(), token, mountName, serial)
|
|
if err != nil {
|
|
st, _ := status.FromError(err)
|
|
if st.Code() == codes.NotFound {
|
|
http.Error(w, "certificate not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
func (ws *WebServer) handleCertDownload(w http.ResponseWriter, r *http.Request) {
|
|
token := extractCookie(r)
|
|
|
|
mountName, err := ws.findCAMount(r, token)
|
|
if err != nil {
|
|
http.Error(w, "no CA engine mounted", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
serial := chi.URLParam(r, "serial")
|
|
cert, err := ws.vault.GetCert(r.Context(), token, mountName, serial)
|
|
if err != nil {
|
|
st, _ := status.FromError(err)
|
|
if st.Code() == codes.NotFound {
|
|
http.Error(w, "certificate not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/x-pem-file")
|
|
w.Header().Set("Content-Disposition", "attachment; filename=\""+serial+".pem\"")
|
|
_, _ = w.Write([]byte(cert.CertPEM))
|
|
}
|
|
|
|
func (ws *WebServer) handleCertRevoke(w http.ResponseWriter, r *http.Request) {
|
|
info := tokenInfoFromContext(r.Context())
|
|
if !info.IsAdmin {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
token := extractCookie(r)
|
|
mountName, err := ws.findCAMount(r, token)
|
|
if err != nil {
|
|
http.Error(w, "no CA engine mounted", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
serial := chi.URLParam(r, "serial")
|
|
if err := ws.vault.RevokeCert(r.Context(), token, mountName, serial); err != nil {
|
|
st, _ := status.FromError(err)
|
|
if st.Code() == codes.NotFound {
|
|
http.Error(w, "certificate not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, "/pki/cert/"+serial, http.StatusSeeOther)
|
|
}
|
|
|
|
func (ws *WebServer) handleCertDelete(w http.ResponseWriter, r *http.Request) {
|
|
info := tokenInfoFromContext(r.Context())
|
|
if !info.IsAdmin {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
token := extractCookie(r)
|
|
mountName, err := ws.findCAMount(r, token)
|
|
if err != nil {
|
|
http.Error(w, "no CA engine mounted", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
serial := chi.URLParam(r, "serial")
|
|
|
|
// Fetch the cert to get the issuer for the redirect.
|
|
cert, certErr := ws.vault.GetCert(r.Context(), token, mountName, serial)
|
|
|
|
if err := ws.vault.DeleteCert(r.Context(), token, mountName, serial); err != nil {
|
|
st, _ := status.FromError(err)
|
|
if st.Code() == codes.NotFound {
|
|
http.Error(w, "certificate not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
http.Error(w, grpcMessage(err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if certErr == nil && cert != nil {
|
|
http.Redirect(w, r, "/pki/issuer/"+cert.Issuer, http.StatusSeeOther)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/pki", http.StatusSeeOther)
|
|
}
|
|
|
|
func (ws *WebServer) handleSignCSR(w http.ResponseWriter, r *http.Request) {
|
|
info := tokenInfoFromContext(r.Context())
|
|
token := extractCookie(r)
|
|
|
|
mountName, err := ws.findCAMount(r, token)
|
|
if err != nil {
|
|
http.Error(w, "no CA engine mounted", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
|
_ = r.ParseForm()
|
|
|
|
issuer := r.FormValue("issuer")
|
|
if issuer == "" {
|
|
ws.renderPKIWithError(w, r, mountName, info, "Issuer is required")
|
|
return
|
|
}
|
|
csrPEM := r.FormValue("csr_pem")
|
|
if csrPEM == "" {
|
|
ws.renderPKIWithError(w, r, mountName, info, "CSR PEM is required")
|
|
return
|
|
}
|
|
|
|
req := SignCSRRequest{
|
|
Mount: mountName,
|
|
Issuer: issuer,
|
|
CSRPEM: csrPEM,
|
|
Profile: r.FormValue("profile"),
|
|
TTL: r.FormValue("ttl"),
|
|
}
|
|
|
|
signed, err := ws.vault.SignCSR(r.Context(), token, req)
|
|
if err != nil {
|
|
ws.renderPKIWithError(w, r, mountName, info, grpcMessage(err))
|
|
return
|
|
}
|
|
|
|
data := map[string]interface{}{
|
|
"Username": info.Username,
|
|
"IsAdmin": info.IsAdmin,
|
|
"MountName": mountName,
|
|
"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
|
|
data["RootOrg"] = strings.Join(cert.Subject.Organization, ", ")
|
|
data["RootNotBefore"] = cert.NotBefore.Format(time.RFC3339)
|
|
data["RootNotAfter"] = cert.NotAfter.Format(time.RFC3339)
|
|
data["RootExpired"] = time.Now().After(cert.NotAfter)
|
|
data["HasRoot"] = true
|
|
}
|
|
}
|
|
if issuers, err := ws.vault.ListIssuers(r.Context(), token, mountName); err == nil {
|
|
data["Issuers"] = issuers
|
|
}
|
|
ws.renderTemplate(w, "pki.html", data)
|
|
}
|
|
|
|
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,
|
|
}
|
|
|
|
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
|
|
data["RootOrg"] = strings.Join(cert.Subject.Organization, ", ")
|
|
data["RootNotBefore"] = cert.NotBefore.Format(time.RFC3339)
|
|
data["RootNotAfter"] = cert.NotAfter.Format(time.RFC3339)
|
|
data["RootExpired"] = time.Now().After(cert.NotAfter)
|
|
data["HasRoot"] = true
|
|
}
|
|
}
|
|
if issuers, err := ws.vault.ListIssuers(r.Context(), token, mountName); err == nil {
|
|
data["Issuers"] = issuers
|
|
}
|
|
|
|
ws.renderTemplate(w, "pki.html", data)
|
|
}
|
|
|
|
func (ws *WebServer) findCAMount(r *http.Request, token string) (string, error) {
|
|
mounts, err := ws.vault.ListMounts(r.Context(), token)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, m := range mounts {
|
|
if m.Type == "ca" {
|
|
return m.Name, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("no CA engine mounted")
|
|
}
|
|
|
|
func (ws *WebServer) handlePolicy(w http.ResponseWriter, r *http.Request) {
|
|
info := tokenInfoFromContext(r.Context())
|
|
if !info.IsAdmin {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
token := extractCookie(r)
|
|
rules, err := ws.vault.ListPolicies(r.Context(), token)
|
|
if err != nil {
|
|
rules = []PolicyRule{}
|
|
}
|
|
ws.renderTemplate(w, "policy.html", map[string]interface{}{
|
|
"Username": info.Username,
|
|
"IsAdmin": info.IsAdmin,
|
|
"Rules": rules,
|
|
})
|
|
}
|
|
|
|
func (ws *WebServer) handlePolicyCreate(w http.ResponseWriter, r *http.Request) {
|
|
info := tokenInfoFromContext(r.Context())
|
|
if !info.IsAdmin {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
token := extractCookie(r)
|
|
_ = r.ParseForm()
|
|
|
|
priorityStr := r.FormValue("priority")
|
|
priority := 50
|
|
if priorityStr != "" {
|
|
if p, err := strconv.Atoi(priorityStr); err == nil {
|
|
priority = p
|
|
}
|
|
}
|
|
|
|
splitCSV := func(s string) []string {
|
|
var out []string
|
|
for _, v := range strings.Split(s, ",") {
|
|
v = strings.TrimSpace(v)
|
|
if v != "" {
|
|
out = append(out, v)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
rule := PolicyRule{
|
|
ID: r.FormValue("id"),
|
|
Priority: priority,
|
|
Effect: r.FormValue("effect"),
|
|
Usernames: splitCSV(r.FormValue("usernames")),
|
|
Roles: splitCSV(r.FormValue("roles")),
|
|
Resources: splitCSV(r.FormValue("resources")),
|
|
Actions: splitCSV(r.FormValue("actions")),
|
|
}
|
|
|
|
if rule.ID == "" || rule.Effect == "" {
|
|
ws.renderPolicyWithError(w, r, info, token, "ID and effect are required")
|
|
return
|
|
}
|
|
|
|
if _, err := ws.vault.CreatePolicy(r.Context(), token, rule); err != nil {
|
|
ws.renderPolicyWithError(w, r, info, token, grpcMessage(err))
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/policy", http.StatusFound)
|
|
}
|
|
|
|
func (ws *WebServer) handlePolicyDelete(w http.ResponseWriter, r *http.Request) {
|
|
info := tokenInfoFromContext(r.Context())
|
|
if !info.IsAdmin {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
token := extractCookie(r)
|
|
_ = r.ParseForm()
|
|
id := r.FormValue("id")
|
|
if id == "" {
|
|
http.Redirect(w, r, "/policy", http.StatusFound)
|
|
return
|
|
}
|
|
if err := ws.vault.DeletePolicy(r.Context(), token, id); err != nil {
|
|
ws.renderPolicyWithError(w, r, info, token, grpcMessage(err))
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/policy", http.StatusFound)
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
// grpcMessage extracts a human-readable message from a gRPC error.
|
|
func grpcMessage(err error) string {
|
|
if st, ok := status.FromError(err); ok {
|
|
return st.Message()
|
|
}
|
|
return err.Error()
|
|
}
|