Add certificate issuance, CSR signing, and cert listing to web UI
- Add SignCSR RPC to v2 CA proto and regenerate; implement handleSignCSR
in CA engine and caServer gRPC layer; add SignCSR client method and
POST /pki/sign-csr web route with result display in pki.html
- Fix issuer detail cert listing: template was using map-style index on
CertSummary structs; switch to struct field access and populate
IssuedBy/IssuedAt fields from proto response
- Add certificate detail view (cert_detail.html) with GET /cert/{serial}
and GET /cert/{serial}/download routes
- Update Makefile proto target to generate both v1 and v2 protos
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -300,6 +300,8 @@ func (e *CAEngine) HandleRequest(ctx context.Context, req *engine.Request) (*eng
|
||||
return e.handleListCerts(ctx, req)
|
||||
case "renew":
|
||||
return e.handleRenew(ctx, req)
|
||||
case "sign-csr":
|
||||
return e.handleSignCSR(ctx, req)
|
||||
case "import-root":
|
||||
return e.handleImportRoot(ctx, req)
|
||||
default:
|
||||
@@ -1014,6 +1016,109 @@ func (e *CAEngine) handleRenew(ctx context.Context, req *engine.Request) (*engin
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *CAEngine) handleSignCSR(ctx context.Context, req *engine.Request) (*engine.Response, error) {
|
||||
if req.CallerInfo == nil {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
|
||||
issuerName, _ := req.Data["issuer"].(string)
|
||||
if issuerName == "" {
|
||||
issuerName = req.Path
|
||||
}
|
||||
if issuerName == "" {
|
||||
return nil, fmt.Errorf("ca: issuer name is required")
|
||||
}
|
||||
|
||||
csrPEM, _ := req.Data["csr_pem"].(string)
|
||||
if csrPEM == "" {
|
||||
return nil, fmt.Errorf("ca: csr_pem is required")
|
||||
}
|
||||
|
||||
block, _ := pem.Decode([]byte(csrPEM))
|
||||
if block == nil || block.Type != "CERTIFICATE REQUEST" {
|
||||
return nil, fmt.Errorf("ca: invalid CSR PEM")
|
||||
}
|
||||
csr, err := x509.ParseCertificateRequest(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ca: parse CSR: %w", err)
|
||||
}
|
||||
if err := csr.CheckSignature(); err != nil {
|
||||
return nil, fmt.Errorf("ca: invalid CSR signature: %w", err)
|
||||
}
|
||||
|
||||
profileName, _ := req.Data["profile"].(string)
|
||||
if profileName == "" {
|
||||
profileName = "server"
|
||||
}
|
||||
profile, ok := GetProfile(profileName)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownProfile, profileName)
|
||||
}
|
||||
|
||||
if v, ok := req.Data["ttl"].(string); ok && v != "" {
|
||||
profile.Expiry = v
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
if e.rootCert == nil {
|
||||
return nil, ErrSealed
|
||||
}
|
||||
|
||||
is, ok := e.issuers[issuerName]
|
||||
if !ok {
|
||||
return nil, ErrIssuerNotFound
|
||||
}
|
||||
|
||||
leafCert, err := profile.SignRequest(is.cert, csr, is.key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ca: sign CSR: %w", err)
|
||||
}
|
||||
|
||||
leafCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafCert.Raw})
|
||||
|
||||
var chainPEM []byte
|
||||
chainPEM = append(chainPEM, leafCertPEM...)
|
||||
chainPEM = append(chainPEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: is.cert.Raw})...)
|
||||
chainPEM = append(chainPEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: e.rootCert.Raw})...)
|
||||
|
||||
serial := fmt.Sprintf("%x", leafCert.SerialNumber)
|
||||
cn := csr.Subject.CommonName
|
||||
allSANs := append(leafCert.DNSNames, ipStrings(leafCert.IPAddresses)...)
|
||||
|
||||
record := &CertRecord{
|
||||
Serial: serial,
|
||||
Issuer: issuerName,
|
||||
CN: cn,
|
||||
SANs: allSANs,
|
||||
Profile: profileName,
|
||||
CertPEM: string(leafCertPEM),
|
||||
IssuedBy: req.CallerInfo.Username,
|
||||
IssuedAt: time.Now(),
|
||||
ExpiresAt: leafCert.NotAfter,
|
||||
}
|
||||
recordData, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ca: marshal cert record: %w", err)
|
||||
}
|
||||
if err := e.barrier.Put(ctx, e.mountPath+"certs/"+serial+".json", recordData); err != nil {
|
||||
return nil, fmt.Errorf("ca: store cert record: %w", err)
|
||||
}
|
||||
|
||||
return &engine.Response{
|
||||
Data: map[string]interface{}{
|
||||
"serial": serial,
|
||||
"cert_pem": string(leafCertPEM),
|
||||
"chain_pem": string(chainPEM),
|
||||
"cn": cn,
|
||||
"sans": allSANs,
|
||||
"issued_by": req.CallerInfo.Username,
|
||||
"expires_at": leafCert.NotAfter.Format(time.RFC3339),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func defaultCAConfig() *CAConfig {
|
||||
|
||||
@@ -371,6 +371,55 @@ func (cs *caServer) RenewCert(ctx context.Context, req *pb.RenewCertRequest) (*p
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (cs *caServer) SignCSR(ctx context.Context, req *pb.SignCSRRequest) (*pb.SignCSRResponse, error) {
|
||||
if req.Mount == "" || req.Issuer == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "mount and issuer are required")
|
||||
}
|
||||
if len(req.CsrPem) == 0 {
|
||||
return nil, status.Error(codes.InvalidArgument, "csr_pem is required")
|
||||
}
|
||||
data := map[string]interface{}{
|
||||
"issuer": req.Issuer,
|
||||
"csr_pem": string(req.CsrPem),
|
||||
}
|
||||
if req.Profile != "" {
|
||||
data["profile"] = req.Profile
|
||||
}
|
||||
if req.Ttl != "" {
|
||||
data["ttl"] = req.Ttl
|
||||
}
|
||||
resp, err := cs.caHandleRequest(ctx, req.Mount, "sign-csr", &engine.Request{
|
||||
Operation: "sign-csr",
|
||||
CallerInfo: cs.callerInfo(ctx),
|
||||
Data: data,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
serial, _ := resp.Data["serial"].(string)
|
||||
cn, _ := resp.Data["cn"].(string)
|
||||
issuedBy, _ := resp.Data["issued_by"].(string)
|
||||
certPEM, _ := resp.Data["cert_pem"].(string)
|
||||
chainPEM, _ := resp.Data["chain_pem"].(string)
|
||||
sans := toStringSliceFromInterface(resp.Data["sans"])
|
||||
var expiresAt *timestamppb.Timestamp
|
||||
if s, ok := resp.Data["expires_at"].(string); ok {
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
expiresAt = timestamppb.New(t)
|
||||
}
|
||||
}
|
||||
cs.s.logger.Info("audit: CSR signed", "mount", req.Mount, "issuer", req.Issuer, "cn", cn, "serial", serial, "username", callerUsername(ctx))
|
||||
return &pb.SignCSRResponse{
|
||||
Serial: serial,
|
||||
CommonName: cn,
|
||||
Sans: sans,
|
||||
IssuedBy: issuedBy,
|
||||
ExpiresAt: expiresAt,
|
||||
CertPem: []byte(certPEM),
|
||||
ChainPem: []byte(chainPEM),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func certRecordFromData(d map[string]interface{}) *pb.CertRecord {
|
||||
|
||||
@@ -278,6 +278,87 @@ func (c *VaultClient) IssueCert(ctx context.Context, token string, req IssueCert
|
||||
return issued, nil
|
||||
}
|
||||
|
||||
// SignCSRRequest holds parameters for signing an external CSR.
|
||||
type SignCSRRequest struct {
|
||||
Mount string
|
||||
Issuer string
|
||||
CSRPEM string
|
||||
Profile string
|
||||
TTL string
|
||||
}
|
||||
|
||||
// SignedCert holds the result of signing a CSR.
|
||||
type SignedCert struct {
|
||||
Serial string
|
||||
CertPEM string
|
||||
ChainPEM string
|
||||
ExpiresAt string
|
||||
}
|
||||
|
||||
// SignCSR signs an externally generated CSR with the named issuer.
|
||||
func (c *VaultClient) SignCSR(ctx context.Context, token string, req SignCSRRequest) (*SignedCert, error) {
|
||||
resp, err := c.ca.SignCSR(withToken(ctx, token), &pb.SignCSRRequest{
|
||||
Mount: req.Mount,
|
||||
Issuer: req.Issuer,
|
||||
CsrPem: []byte(req.CSRPEM),
|
||||
Profile: req.Profile,
|
||||
Ttl: req.TTL,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sc := &SignedCert{
|
||||
Serial: resp.Serial,
|
||||
CertPEM: string(resp.CertPem),
|
||||
ChainPEM: string(resp.ChainPem),
|
||||
}
|
||||
if resp.ExpiresAt != nil {
|
||||
sc.ExpiresAt = resp.ExpiresAt.AsTime().Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
return sc, nil
|
||||
}
|
||||
|
||||
// CertDetail holds the full certificate record for the detail view.
|
||||
type CertDetail struct {
|
||||
Serial string
|
||||
Issuer string
|
||||
CommonName string
|
||||
SANs []string
|
||||
Profile string
|
||||
IssuedBy string
|
||||
IssuedAt string
|
||||
ExpiresAt string
|
||||
CertPEM string
|
||||
}
|
||||
|
||||
// GetCert retrieves a full certificate record by serial number.
|
||||
func (c *VaultClient) GetCert(ctx context.Context, token, mount, serial string) (*CertDetail, error) {
|
||||
resp, err := c.ca.GetCert(withToken(ctx, token), &pb.GetCertRequest{Mount: mount, Serial: serial})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rec := resp.GetCert()
|
||||
if rec == nil {
|
||||
return nil, fmt.Errorf("cert not found")
|
||||
}
|
||||
cd := &CertDetail{
|
||||
Serial: rec.Serial,
|
||||
Issuer: rec.Issuer,
|
||||
CommonName: rec.CommonName,
|
||||
SANs: rec.Sans,
|
||||
Profile: rec.Profile,
|
||||
IssuedBy: rec.IssuedBy,
|
||||
CertPEM: string(rec.CertPem),
|
||||
}
|
||||
if rec.IssuedAt != nil {
|
||||
cd.IssuedAt = rec.IssuedAt.AsTime().Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
if rec.ExpiresAt != nil {
|
||||
cd.ExpiresAt = rec.ExpiresAt.AsTime().Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
return cd, nil
|
||||
}
|
||||
|
||||
// CertSummary holds lightweight certificate metadata for list views.
|
||||
type CertSummary struct {
|
||||
Serial string
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package webserver
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -39,6 +41,8 @@ func (ws *WebServer) registerRoutes(r chi.Router) {
|
||||
r.Post("/create-issuer", ws.requireAuth(ws.handleCreateIssuer))
|
||||
r.Post("/issue", ws.requireAuth(ws.handleIssueCert))
|
||||
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.Get("/{issuer}", ws.requireAuth(ws.handlePKIIssuer))
|
||||
})
|
||||
}
|
||||
@@ -526,12 +530,140 @@ func (ws *WebServer) handleIssueCert(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Re-render the PKI page with the issued certificate displayed.
|
||||
// Stream a tgz archive containing the private key (PKCS8) and certificate.
|
||||
filename := issuedCert.Serial + ".tgz"
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
|
||||
|
||||
gw := gzip.NewWriter(w)
|
||||
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("write key to tgz", "error", err)
|
||||
return
|
||||
}
|
||||
if err := writeTarFile("cert.pem", []byte(issuedCert.CertPEM)); err != nil {
|
||||
ws.logger.Error("write cert to tgz", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
_ = tw.Close()
|
||||
_ = gw.Close()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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) 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,
|
||||
"IssuedCert": issuedCert,
|
||||
"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 {
|
||||
|
||||
Reference in New Issue
Block a user