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:
2026-03-15 13:21:13 -07:00
parent 65c92fe5ec
commit b4dbc088cb
12 changed files with 785 additions and 82 deletions

View File

@@ -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 {