package service import ( "fmt" "strings" "time" "github.com/rs/zerolog" "github.com/gosec/gsc-ops-api/internal/client" "github.com/gosec/gsc-ops-api/pkg/types" ) // CertificateService handles EJBCA certificate operations type CertificateService struct { client *client.EJBCAClient logger zerolog.Logger } // NewCertificateService creates a new certificate service func NewCertificateService(ejbcaClient *client.EJBCAClient, logger zerolog.Logger) *CertificateService { return &CertificateService{ client: ejbcaClient, logger: logger.With().Str("service", "certificate").Logger(), } } // ListCertificates searches for certificates func (s *CertificateService) ListCertificates(search string, limit int) ([]types.Certificate, error) { if limit <= 0 { limit = 50 } criteria := []client.CertSearchCriterion{} if search != "" { criteria = append(criteria, client.CertSearchCriterion{ Property: "QUERY", Value: search, Operation: "LIKE", }) } else { // EJBCA rejects an empty criteria list ("Invalid criteria value, // cannot be empty"). With no search term, default to listing active // certificates so GET /certs returns a useful result instead of 500. criteria = append(criteria, client.CertSearchCriterion{ Property: "STATUS", Value: "CERT_ACTIVE", Operation: "EQUAL", }) } certs, err := s.client.SearchCertificates(&client.CertSearchRequest{ MaxResults: limit, Criteria: criteria, }) if err != nil { return nil, err } result := make([]types.Certificate, 0, len(certs)) for _, c := range certs { cert := types.Certificate{ SerialNumber: c.SerialNumber, SubjectDN: c.SubjectDN, IssuerDN: c.IssuerDN, Status: c.Status, CAName: c.CAName, } if t, err := time.Parse(time.RFC3339, c.NotBefore); err == nil { cert.NotBefore = t } if t, err := time.Parse(time.RFC3339, c.NotAfter); err == nil { cert.NotAfter = t } result = append(result, cert) } return result, nil } // GetCertificate gets a certificate by serial number func (s *CertificateService) GetCertificate(serialNumber, issuerDN string) (*types.Certificate, error) { c, err := s.client.GetCertificate(issuerDN, serialNumber) if err != nil { return nil, err } cert := &types.Certificate{ SerialNumber: c.SerialNumber, SubjectDN: c.SubjectDN, IssuerDN: c.IssuerDN, Status: c.Status, CAName: c.CAName, } if t, err := time.Parse(time.RFC3339, c.NotBefore); err == nil { cert.NotBefore = t } if t, err := time.Parse(time.RFC3339, c.NotAfter); err == nil { cert.NotAfter = t } return cert, nil } // RequestCertificate requests a new certificate from EJBCA func (s *CertificateService) RequestCertificate(req *types.CertRequest) (*types.Certificate, error) { san := buildSANString(req.SubjectDN, req.SANs) enrollReq := &client.CertEnrollRequest{ CertificateProfileName: req.CertProfileName, EndEntityProfileName: req.EndEntityName, CAName: req.CAName, Username: req.EndEntityName, Password: "internal", IncludeChain: true, SubjectAltName: san, } c, err := s.client.EnrollCertificate(enrollReq) if err != nil { return nil, err } cert := &types.Certificate{ SerialNumber: c.SerialNumber, SubjectDN: c.SubjectDN, IssuerDN: c.IssuerDN, Status: c.Status, } return cert, nil } // buildSANString builds an EJBCA-format SAN string (e.g. "dNSName=foo,dNSName=bar"). // If sans is empty, extracts CN from subjectDN as a fallback DNS SAN. func buildSANString(subjectDN string, sans []string) string { if len(sans) > 0 { parts := make([]string, 0, len(sans)) for _, s := range sans { if s != "" { parts = append(parts, fmt.Sprintf("dNSName=%s", s)) } } return strings.Join(parts, ",") } // Fallback: extract CN from SubjectDN and use as DNS SAN cn := extractCN(subjectDN) if cn != "" { return fmt.Sprintf("dNSName=%s", cn) } return "" } // extractCN extracts the CN value from a SubjectDN string like "CN=foo.bar,O=Org" func extractCN(subjectDN string) string { for _, part := range strings.Split(subjectDN, ",") { part = strings.TrimSpace(part) if strings.HasPrefix(part, "CN=") { return strings.TrimPrefix(part, "CN=") } } return "" } // RevokeCertificate revokes a certificate func (s *CertificateService) RevokeCertificate(serialNumber string, req *types.CertRevoke) error { reason := req.Reason if reason == "" { reason = "UNSPECIFIED" } return s.client.RevokeCertificate(req.IssuerDN, serialNumber, reason) }