From 3a11e447cfa8ba904a99becbe2132b46240d5c3e Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Fri, 3 Apr 2026 21:01:14 -0400 Subject: [PATCH] feat(M43): Sectigo SCM issuer connector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement Sectigo Certificate Manager REST API connector with async order model (enroll → poll → collect PEM), 3-header auth, DV/OV/EV support, collect-not-ready (400/-183) graceful handling, and RFC 5280 revocation reason mapping. 20 tests with httptest mock API. Co-Authored-By: Claude Opus 4.6 --- README.md | 3 +- api/openapi.yaml | 2 +- cmd/server/main.go | 20 + docs/architecture.md | 1 + docs/connectors.md | 25 +- docs/testing-guide.md | 77 +- internal/config/config.go | 47 + internal/connector/issuer/sectigo/sectigo.go | 618 +++++++++++++ .../connector/issuer/sectigo/sectigo_test.go | 843 ++++++++++++++++++ internal/domain/connector.go | 1 + migrations/seed_demo.sql | 3 +- web/src/config/issuerTypes.ts | 20 +- 12 files changed, 1647 insertions(+), 13 deletions(-) create mode 100644 internal/connector/issuer/sectigo/sectigo.go create mode 100644 internal/connector/issuer/sectigo/sectigo_test.go diff --git a/README.md b/README.md index df5d078..64a80d7 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,9 @@ For the full capability breakdown — revocation infrastructure (CRL + OCSP), po | OpenSSL / Custom CA | Implemented | `OpenSSL` | | Vault PKI | Beta | `VaultPKI` | | DigiCert CertCentral | Beta | `DigiCert` | +| Sectigo SCM | Beta | `Sectigo` | -**Vault PKI and DigiCert connectors are in beta.** If you hit any bugs or unexpected behavior, please [open a GitHub issue](https://github.com/shankar0123/certctl/issues) -- we're actively testing these and want to hear from real users. +**Vault PKI, DigiCert, and Sectigo connectors are in beta.** If you hit any bugs or unexpected behavior, please [open a GitHub issue](https://github.com/shankar0123/certctl/issues) -- we're actively testing these and want to hear from real users. **Note:** ADCS integration is handled via the Local CA's sub-CA mode — certctl operates as a subordinate CA with its signing certificate issued by ADCS. Any CA with a shell-accessible signing interface can be integrated today via the OpenSSL/Custom CA connector. diff --git a/api/openapi.yaml b/api/openapi.yaml index 74eb831..55afa65 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2643,7 +2643,7 @@ components: # ─── Issuers ───────────────────────────────────────────────────── IssuerType: type: string - enum: [ACME, GenericCA, StepCA, VaultPKI, DigiCert] + enum: [ACME, GenericCA, StepCA, VaultPKI, DigiCert, Sectigo] Issuer: type: object diff --git a/cmd/server/main.go b/cmd/server/main.go index 18ba773..d478c97 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -22,6 +22,7 @@ import ( digicertissuer "github.com/shankar0123/certctl/internal/connector/issuer/digicert" opensslissuer "github.com/shankar0123/certctl/internal/connector/issuer/openssl" stepcaissuer "github.com/shankar0123/certctl/internal/connector/issuer/stepca" + sectigoissuer "github.com/shankar0123/certctl/internal/connector/issuer/sectigo" vaultissuer "github.com/shankar0123/certctl/internal/connector/issuer/vault" notifyemail "github.com/shankar0123/certctl/internal/connector/notifier/email" notifyopsgenie "github.com/shankar0123/certctl/internal/connector/notifier/opsgenie" @@ -158,6 +159,19 @@ func main() { }, logger) logger.Info("initialized DigiCert CertCentral issuer connector") + // Initialize Sectigo SCM issuer connector (for enterprise public CA). + // Uses the Sectigo SCM REST API with async order model. + sectigoConnector := sectigoissuer.New(§igoissuer.Config{ + CustomerURI: cfg.Sectigo.CustomerURI, + Login: cfg.Sectigo.Login, + Password: cfg.Sectigo.Password, + OrgID: cfg.Sectigo.OrgID, + CertType: cfg.Sectigo.CertType, + Term: cfg.Sectigo.Term, + BaseURL: cfg.Sectigo.BaseURL, + }, logger) + logger.Info("initialized Sectigo SCM issuer connector") + // Build issuer registry: maps issuer IDs (from database) to connector implementations. // "iss-local" matches the seed data issuer ID for the Local CA. // "iss-acme-staging" and "iss-acme-prod" are conventional IDs for ACME issuers. @@ -183,6 +197,12 @@ func main() { logger.Info("DigiCert CertCentral issuer registered", "id", "iss-digicert") } + // Conditionally register Sectigo SCM (only if all 3 auth credentials are set) + if cfg.Sectigo.CustomerURI != "" && cfg.Sectigo.Login != "" && cfg.Sectigo.Password != "" { + issuerRegistry["iss-sectigo"] = service.NewIssuerConnectorAdapter(sectigoConnector) + logger.Info("Sectigo SCM issuer registered", "id", "iss-sectigo") + } + logger.Info("issuer registry configured", "issuers", len(issuerRegistry)) // Initialize revocation repository diff --git a/docs/architecture.md b/docs/architecture.md index 322f53d..f22b872 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -513,6 +513,7 @@ flowchart TB II --> OC["OpenSSL / Custom CA"] II --> VP["Vault PKI"] II --> DC["DigiCert CertCentral"] + II --> SG["Sectigo SCM"] end subgraph "Target Connectors" diff --git a/docs/connectors.md b/docs/connectors.md index 2d6dab8..04328c6 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -355,12 +355,35 @@ The connector submits certificate orders to DigiCert's `/order/certificate/creat Location: `internal/connector/issuer/digicert/digicert.go` +### Built-in: Sectigo SCM + +The Sectigo connector integrates with Sectigo Certificate Manager's REST API for ordering and managing DV, OV, and EV certificates. Like DigiCert, it uses an async order model: submit an enrollment, receive an sslId, then poll for completion. + +**Configuration:** + +| Variable | Default | Description | +|----------|---------|-------------| +| `CERTCTL_SECTIGO_CUSTOMER_URI` | — | Sectigo customer URI (organization identifier) | +| `CERTCTL_SECTIGO_LOGIN` | — | API account login | +| `CERTCTL_SECTIGO_PASSWORD` | — | API account password | +| `CERTCTL_SECTIGO_ORG_ID` | — | Organization ID (integer) | +| `CERTCTL_SECTIGO_CERT_TYPE` | — | Certificate type ID (integer, from `/ssl/v1/types`) | +| `CERTCTL_SECTIGO_TERM` | `365` | Certificate validity in days | +| `CERTCTL_SECTIGO_BASE_URL` | `https://cert-manager.com/api` | Sectigo API base URL | + +The connector submits certificate enrollments to Sectigo's `/ssl/v1/enroll` API. DV certificates may issue immediately; OV/EV certificates require validation (handled by Sectigo) and poll-based completion. The connector periodically checks enrollment status via `/ssl/v1/{sslId}` and downloads the PEM bundle via `/ssl/v1/collect/{sslId}/pem` when issued. + +**Authentication:** Three custom headers on every request — `customerUri`, `login`, and `password`. + +**Note:** CRL and OCSP are managed by Sectigo. certctl records revocations locally and notifies Sectigo via `/ssl/v1/revoke/{sslId}`. + +Location: `internal/connector/issuer/sectigo/sectigo.go` + ### Coming in V2.2+ The following issuer connectors are planned for future releases: - **Entrust** — Enterprise CA via Entrust API -- **Sectigo** — Commercial CA integration via Sectigo REST API - **Google CAS** — Google Cloud Certificate Authority Service - **AWS ACM Private CA** — AWS-managed private CA diff --git a/docs/testing-guide.md b/docs/testing-guide.md index 72d6fce..ce7738d 100644 --- a/docs/testing-guide.md +++ b/docs/testing-guide.md @@ -6314,15 +6314,86 @@ These must be green before starting manual QA: | 41.m8 | Discovery table — CA badge | Manual | ☐ | | | | 41.m9 | Fleet overview — macOS display | Manual | ☐ | | | +### Part 43: Sectigo SCM Connector (M43) + +**Prerequisites:** Sectigo SCM account with API access, valid customerUri + login + password credentials, at least one cert type available in `/ssl/v1/types`. + +#### Automated Tests + +| Test | Description | Method | Pass? | Date | Notes | +|------|-------------|--------|-------|------|-------| +| 43.s1 | `IssuerTypeSectigo` constant exists in domain | Auto | ☐ | | `grep 'Sectigo' internal/domain/connector.go` | +| 43.s2 | `SectigoConfig` struct exists in config | Auto | ☐ | | `grep 'SectigoConfig' internal/config/config.go` | +| 43.s3 | `iss-sectigo` in seed_demo.sql | Auto | ☐ | | `grep 'iss-sectigo' migrations/seed_demo.sql` | +| 43.s4 | Sectigo in OpenAPI IssuerType enum | Auto | ☐ | | `grep 'Sectigo' api/openapi.yaml` | +| 43.s5 | Sectigo connector tests pass | Auto | ☐ | | `go test ./internal/connector/issuer/sectigo/... -v` | +| 43.s6 | Sectigo in issuerTypes.ts | Auto | ☐ | | `grep 'Sectigo' web/src/config/issuerTypes.ts` | +| 43.s7 | Frontend build succeeds | Auto | ☐ | | `cd web && npm run build` | +| 43.s8 | Full Go build succeeds | Auto | ☐ | | `go build ./cmd/server/... ./cmd/agent/... ./cmd/cli/... ./cmd/mcp-server/...` | + +#### Manual Tests + +**43.M1: Validate Sectigo Credentials** + +1. Configure env vars: `CERTCTL_SECTIGO_CUSTOMER_URI`, `CERTCTL_SECTIGO_LOGIN`, `CERTCTL_SECTIGO_PASSWORD`, `CERTCTL_SECTIGO_ORG_ID` +2. Start certctl server — verify log line: `Sectigo SCM issuer registered` +3. Call `GET /api/v1/issuers` — verify `iss-sectigo` appears in the list + +**PASS if** `iss-sectigo` registered and visible in API. + +**43.M2: Enroll DV Certificate** + +1. Create a certificate with `issuer_id: iss-sectigo` +2. Trigger issuance — verify enrollment submitted (job enters Pending or AwaitingCSR) +3. If DV, check for immediate issuance or poll via GetOrderStatus +4. Verify `sslId` tracked in job's order_id field + +**PASS if** enrollment submits successfully, sslId returned, job state machine progresses. + +**43.M3: Async Polling — OV Certificate** + +1. Submit OV certificate enrollment (requires org validation) +2. Verify job enters Pending state with sslId in order_id +3. Wait for Sectigo to process (or mock status check) +4. Verify GetOrderStatus returns "pending" → "completed" transition +5. Verify PEM bundle downloaded and parsed (leaf + chain) + +**PASS if** async flow works end-to-end with correct status transitions. + +**43.M4: Collect Not Ready (400/-183 Handling)** + +1. If possible, catch the window where status is "Issued" but cert not yet generated +2. Verify collect endpoint returns 400 with code -183 +3. Verify GetOrderStatus treats this as "pending" (not error) +4. Verify next poll succeeds when cert is generated + +**PASS if** 400/-183 handled gracefully as pending, not as error. + +**43.M5: Revocation** + +1. Revoke an issued Sectigo certificate via `POST /api/v1/certificates/{id}/revoke` +2. Verify Sectigo revoke endpoint called (`POST /ssl/v1/revoke/{sslId}`) +3. Verify audit trail records revocation + +**PASS if** revocation recorded in certctl and sent to Sectigo. + +**43.M6: Auth Header Verification** + +1. Inspect network requests to Sectigo API (via proxy or logs) +2. Verify all 3 headers present: `customerUri`, `login`, `password` +3. Verify no `X-DC-DEVKEY` header (DigiCert auth should not leak) + +**PASS if** correct 3-header auth on all requests. + ### Summary | Category | Count | |----------|-------| | ☑ Auto (passed in `qa-smoke-test.sh`) | 144 | -| ☐ Auto (not yet run) | 12 | +| ☐ Auto (not yet run) | 20 | | — Skipped (preconditions not met in demo) | 5 | -| ☐ Manual (requires hands-on verification) | 241 | -| **Total** | **402** | +| ☐ Manual (requires hands-on verification) | 247 | +| **Total** | **416** | **Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss. diff --git a/internal/config/config.go b/internal/config/config.go index 96352ef..11a34b4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,6 +27,7 @@ type Config struct { ACME ACMEConfig Vault VaultConfig DigiCert DigiCertConfig + Sectigo SectigoConfig Digest DigestConfig } @@ -194,6 +195,43 @@ type DigiCertConfig struct { BaseURL string } +// SectigoConfig contains Sectigo Certificate Manager issuer connector configuration. +type SectigoConfig struct { + // CustomerURI is the Sectigo customer URI (organization identifier). + // Required for Sectigo integration. + // Setting: CERTCTL_SECTIGO_CUSTOMER_URI environment variable. + CustomerURI string + + // Login is the Sectigo API account login. + // Required for Sectigo integration. + // Setting: CERTCTL_SECTIGO_LOGIN environment variable. + Login string + + // Password is the Sectigo API account password or API key. + // Required for Sectigo integration. + // Setting: CERTCTL_SECTIGO_PASSWORD environment variable. + Password string + + // OrgID is the Sectigo organization ID for certificate enrollments. + // Required for Sectigo integration. + // Setting: CERTCTL_SECTIGO_ORG_ID environment variable. + OrgID int + + // CertType is the Sectigo certificate type ID (from GET /ssl/v1/types). + // Required for enrollment. Set via CERTCTL_SECTIGO_CERT_TYPE environment variable. + CertType int + + // Term is the certificate validity in days (e.g., 365, 730). + // Default: 365. + // Setting: CERTCTL_SECTIGO_TERM environment variable. + Term int + + // BaseURL is the Sectigo SCM API base URL. + // Default: "https://cert-manager.com/api". + // Setting: CERTCTL_SECTIGO_BASE_URL environment variable. + BaseURL string +} + // DigestConfig controls the scheduled certificate digest email feature. type DigestConfig struct { // Enabled controls whether periodic digest emails are generated and sent. @@ -500,6 +538,15 @@ func Load() (*Config, error) { ProductType: getEnv("CERTCTL_DIGICERT_PRODUCT_TYPE", "ssl_basic"), BaseURL: getEnv("CERTCTL_DIGICERT_BASE_URL", "https://www.digicert.com/services/v2"), }, + Sectigo: SectigoConfig{ + CustomerURI: getEnv("CERTCTL_SECTIGO_CUSTOMER_URI", ""), + Login: getEnv("CERTCTL_SECTIGO_LOGIN", ""), + Password: getEnv("CERTCTL_SECTIGO_PASSWORD", ""), + OrgID: getEnvInt("CERTCTL_SECTIGO_ORG_ID", 0), + CertType: getEnvInt("CERTCTL_SECTIGO_CERT_TYPE", 0), + Term: getEnvInt("CERTCTL_SECTIGO_TERM", 365), + BaseURL: getEnv("CERTCTL_SECTIGO_BASE_URL", "https://cert-manager.com/api"), + }, ACME: ACMEConfig{ DirectoryURL: getEnv("CERTCTL_ACME_DIRECTORY_URL", ""), Email: getEnv("CERTCTL_ACME_EMAIL", ""), diff --git a/internal/connector/issuer/sectigo/sectigo.go b/internal/connector/issuer/sectigo/sectigo.go new file mode 100644 index 0000000..677a0a7 --- /dev/null +++ b/internal/connector/issuer/sectigo/sectigo.go @@ -0,0 +1,618 @@ +// Package sectigo implements the issuer.Connector interface for Sectigo Certificate Manager (SCM). +// +// Sectigo Certificate Manager is an enterprise certificate authority offering DV, OV, and EV +// certificates. Like DigiCert, Sectigo uses an asynchronous order model: submit an enrollment, +// receive an sslId, then poll for completion. OV/EV certificates require organization validation +// which may take hours or days; DV certificates may be issued immediately. +// +// This connector maps to certctl's existing job state machine: +// - IssueCertificate submits the enrollment; if status is "Issued", returns cert immediately. +// If status is "Applied" or "Pending", returns OrderID with empty CertPEM — the job system +// polls via GetOrderStatus. +// - GetOrderStatus polls the order; when status becomes "Issued", downloads and parses the +// PEM bundle via the collect endpoint. +// +// Authentication: Three custom headers on every request — customerUri, login, password. +// +// Sectigo SCM REST API used: +// +// POST /ssl/v1/enroll - Submit certificate enrollment +// GET /ssl/v1/{sslId} - Check enrollment status +// GET /ssl/v1/collect/{sslId}/pem - Download PEM bundle when issued +// POST /ssl/v1/revoke/{sslId} - Revoke certificate +// GET /ssl/v1/types - List available cert types (used for health check) +package sectigo + +import ( + "bytes" + "context" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/shankar0123/certctl/internal/connector/issuer" +) + +// Config represents the Sectigo Certificate Manager issuer connector configuration. +type Config struct { + // CustomerURI is the Sectigo customer URI (organization identifier). + // Required. Set via CERTCTL_SECTIGO_CUSTOMER_URI environment variable. + CustomerURI string `json:"customer_uri"` + + // Login is the Sectigo API account login. + // Required. Set via CERTCTL_SECTIGO_LOGIN environment variable. + Login string `json:"login"` + + // Password is the Sectigo API account password or API key. + // Required. Set via CERTCTL_SECTIGO_PASSWORD environment variable. + Password string `json:"password"` + + // OrgID is the Sectigo organization ID for certificate enrollments. + // Required. Set via CERTCTL_SECTIGO_ORG_ID environment variable. + OrgID int `json:"org_id"` + + // CertType is the Sectigo certificate type ID (from GET /ssl/v1/types). + // Required for enrollment. Set via CERTCTL_SECTIGO_CERT_TYPE environment variable. + CertType int `json:"cert_type"` + + // Term is the certificate validity in days (e.g., 365, 730). + // Default: 365. Set via CERTCTL_SECTIGO_TERM environment variable. + Term int `json:"term"` + + // BaseURL is the Sectigo SCM API base URL. + // Default: "https://cert-manager.com/api". + // Set via CERTCTL_SECTIGO_BASE_URL environment variable. + BaseURL string `json:"base_url"` +} + +// Connector implements the issuer.Connector interface for Sectigo Certificate Manager. +type Connector struct { + config *Config + logger *slog.Logger + httpClient *http.Client +} + +// New creates a new Sectigo SCM connector with the given configuration and logger. +func New(config *Config, logger *slog.Logger) *Connector { + if config != nil { + if config.Term == 0 { + config.Term = 365 + } + if config.BaseURL == "" { + config.BaseURL = "https://cert-manager.com/api" + } + } + + return &Connector{ + config: config, + logger: logger, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// enrollRequest is the JSON body for Sectigo certificate enrollment. +type enrollRequest struct { + OrgID int `json:"orgId"` + CSR string `json:"csr"` + CertType int `json:"certType"` + Term int `json:"term"` + SubjAltNames string `json:"subjAltNames,omitempty"` + Comments string `json:"comments,omitempty"` + ExternalRequester string `json:"externalRequester,omitempty"` +} + +// enrollResponse is the JSON response from a certificate enrollment. +type enrollResponse struct { + SSLId int `json:"sslId"` + RenewId string `json:"renewId,omitempty"` +} + +// statusResponse is the JSON response from an enrollment status check. +type statusResponse struct { + SSLId int `json:"sslId"` + Status string `json:"status"` + CommonName string `json:"commonName,omitempty"` + SerialNumber string `json:"serialNumber,omitempty"` +} + +// setAuthHeaders sets the three Sectigo authentication headers on a request. +func (c *Connector) setAuthHeaders(req *http.Request) { + req.Header.Set("customerUri", c.config.CustomerURI) + req.Header.Set("login", c.config.Login) + req.Header.Set("password", c.config.Password) + req.Header.Set("Content-Type", "application/json") +} + +// ValidateConfig checks that the Sectigo configuration is valid and API access works. +func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error { + var cfg Config + if err := json.Unmarshal(rawConfig, &cfg); err != nil { + return fmt.Errorf("invalid Sectigo config: %w", err) + } + + if cfg.CustomerURI == "" { + return fmt.Errorf("Sectigo customer_uri is required") + } + + if cfg.Login == "" { + return fmt.Errorf("Sectigo login is required") + } + + if cfg.Password == "" { + return fmt.Errorf("Sectigo password is required") + } + + if cfg.OrgID == 0 { + return fmt.Errorf("Sectigo org_id is required") + } + + if cfg.Term == 0 { + cfg.Term = 365 + } + if cfg.BaseURL == "" { + cfg.BaseURL = "https://cert-manager.com/api" + } + + // Test API access via GET /ssl/v1/types (health check) + typesURL := cfg.BaseURL + "/ssl/v1/types" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, typesURL, nil) + if err != nil { + return fmt.Errorf("failed to create API test request: %w", err) + } + req.Header.Set("customerUri", cfg.CustomerURI) + req.Header.Set("login", cfg.Login) + req.Header.Set("password", cfg.Password) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("Sectigo API not reachable at %s: %w", cfg.BaseURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized { + return fmt.Errorf("Sectigo API credentials are invalid (status %d)", resp.StatusCode) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("Sectigo API returned status %d", resp.StatusCode) + } + + c.config = &cfg + c.logger.Info("Sectigo Certificate Manager configuration validated", + "base_url", cfg.BaseURL, + "org_id", cfg.OrgID) + + return nil +} + +// IssueCertificate submits a certificate enrollment to Sectigo SCM. +// If the certificate is issued immediately (DV certs), returns the cert. +// If pending (OV/EV certs), returns OrderID with empty CertPEM for polling. +func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) { + c.logger.Info("processing Sectigo enrollment request", + "common_name", request.CommonName, + "san_count", len(request.SANs), + "cert_type", c.config.CertType) + + enrollReq := enrollRequest{ + OrgID: c.config.OrgID, + CSR: request.CSRPEM, + CertType: c.config.CertType, + Term: c.config.Term, + Comments: "Issued by certctl", + } + + if len(request.SANs) > 0 { + enrollReq.SubjAltNames = strings.Join(request.SANs, ",") + } + + body, err := json.Marshal(enrollReq) + if err != nil { + return nil, fmt.Errorf("failed to marshal enrollment request: %w", err) + } + + enrollURL := c.config.BaseURL + "/ssl/v1/enroll" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, enrollURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create enrollment request: %w", err) + } + c.setAuthHeaders(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("Sectigo enrollment request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read enrollment response: %w", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("Sectigo enrollment returned status %d: %s", resp.StatusCode, string(respBody)) + } + + var enrollResp enrollResponse + if err := json.Unmarshal(respBody, &enrollResp); err != nil { + return nil, fmt.Errorf("failed to parse enrollment response: %w", err) + } + + orderID := fmt.Sprintf("%d", enrollResp.SSLId) + + c.logger.Info("Sectigo enrollment submitted", "ssl_id", orderID) + + // Check status immediately to see if cert was issued right away + status, err := c.checkStatus(ctx, enrollResp.SSLId) + if err != nil { + // Status check failed but enrollment succeeded — return as pending + c.logger.Warn("Sectigo status check after enrollment failed, treating as pending", + "ssl_id", orderID, "error", err) + return &issuer.IssuanceResult{ + OrderID: orderID, + }, nil + } + + if status.Status == "Issued" { + certPEM, chainPEM, serial, notBefore, notAfter, collectErr := c.collectCertificate(ctx, enrollResp.SSLId) + if collectErr != nil { + // Cert is issued but collect failed — might not be generated yet + c.logger.Warn("Sectigo certificate issued but collect failed, treating as pending", + "ssl_id", orderID, "error", collectErr) + return &issuer.IssuanceResult{ + OrderID: orderID, + }, nil + } + + c.logger.Info("Sectigo certificate issued immediately", + "ssl_id", orderID, + "serial", serial) + + return &issuer.IssuanceResult{ + CertPEM: certPEM, + ChainPEM: chainPEM, + Serial: serial, + NotBefore: notBefore, + NotAfter: notAfter, + OrderID: orderID, + }, nil + } + + // Pending — return OrderID for polling via GetOrderStatus + c.logger.Info("Sectigo enrollment pending validation", + "ssl_id", orderID, + "status", status.Status) + + return &issuer.IssuanceResult{ + OrderID: orderID, + }, nil +} + +// RenewCertificate renews a certificate by submitting a new enrollment. +// Sectigo supports POST /ssl/renewById/{sslId} but for simplicity we submit +// a new enrollment (same pattern as DigiCert). +func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) { + c.logger.Info("processing Sectigo renewal request", + "common_name", request.CommonName, + "san_count", len(request.SANs)) + + return c.IssueCertificate(ctx, issuer.IssuanceRequest{ + CommonName: request.CommonName, + SANs: request.SANs, + CSRPEM: request.CSRPEM, + EKUs: request.EKUs, + }) +} + +// RevokeCertificate revokes a certificate at Sectigo SCM. +func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error { + c.logger.Info("processing Sectigo revocation request", "serial", request.Serial) + + reason := "Unspecified" + if request.Reason != nil { + reason = mapRevocationReason(*request.Reason) + } + + revokeBody := map[string]interface{}{ + "reason": reason, + } + + body, err := json.Marshal(revokeBody) + if err != nil { + return fmt.Errorf("failed to marshal revoke request: %w", err) + } + + // Sectigo uses sslId in the URL path for revocation + revokeURL := fmt.Sprintf("%s/ssl/v1/revoke/%s", c.config.BaseURL, request.Serial) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, revokeURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create revoke request: %w", err) + } + c.setAuthHeaders(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("Sectigo revoke request failed: %w", err) + } + defer resp.Body.Close() + + // Sectigo returns 204 No Content on successful revocation + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("Sectigo revoke returned status %d: %s", resp.StatusCode, string(respBody)) + } + + c.logger.Info("Sectigo certificate revoked", "serial", request.Serial, "reason", reason) + return nil +} + +// GetOrderStatus checks the status of a Sectigo certificate enrollment. +// If the enrollment is "Issued", downloads the certificate and returns it. +// If still pending, returns pending status for continued polling. +func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) { + c.logger.Debug("checking Sectigo enrollment status", "ssl_id", orderID) + + // Parse sslId from string + var sslId int + if _, err := fmt.Sscanf(orderID, "%d", &sslId); err != nil { + return nil, fmt.Errorf("invalid Sectigo ssl_id: %s", orderID) + } + + status, err := c.checkStatus(ctx, sslId) + if err != nil { + return nil, err + } + + now := time.Now() + + switch status.Status { + case "Issued": + certPEM, chainPEM, serial, notBefore, notAfter, collectErr := c.collectCertificate(ctx, sslId) + if collectErr != nil { + // Cert approved but not yet generated — treat as pending + if isCollectNotReady(collectErr) { + msg := fmt.Sprintf("enrollment %s is issued but certificate not yet generated", orderID) + return &issuer.OrderStatus{ + OrderID: orderID, + Status: "pending", + Message: &msg, + UpdatedAt: now, + }, nil + } + return nil, fmt.Errorf("failed to collect certificate: %w", collectErr) + } + + c.logger.Info("Sectigo enrollment completed", + "ssl_id", orderID, + "serial", serial) + + return &issuer.OrderStatus{ + OrderID: orderID, + Status: "completed", + CertPEM: &certPEM, + ChainPEM: &chainPEM, + Serial: &serial, + NotBefore: ¬Before, + NotAfter: ¬After, + UpdatedAt: now, + }, nil + + case "Applied", "Pending": + msg := fmt.Sprintf("enrollment %s is %s", orderID, status.Status) + return &issuer.OrderStatus{ + OrderID: orderID, + Status: "pending", + Message: &msg, + UpdatedAt: now, + }, nil + + case "Rejected": + msg := fmt.Sprintf("enrollment %s was rejected", orderID) + return &issuer.OrderStatus{ + OrderID: orderID, + Status: "failed", + Message: &msg, + UpdatedAt: now, + }, nil + + case "Revoked", "Expired", "Not Enrolled": + msg := fmt.Sprintf("enrollment %s has status: %s", orderID, status.Status) + return &issuer.OrderStatus{ + OrderID: orderID, + Status: "failed", + Message: &msg, + UpdatedAt: now, + }, nil + + default: + msg := fmt.Sprintf("unknown enrollment status: %s", status.Status) + return &issuer.OrderStatus{ + OrderID: orderID, + Status: "pending", + Message: &msg, + UpdatedAt: now, + }, nil + } +} + +// checkStatus retrieves the enrollment status from Sectigo. +func (c *Connector) checkStatus(ctx context.Context, sslId int) (*statusResponse, error) { + statusURL := fmt.Sprintf("%s/ssl/v1/%d", c.config.BaseURL, sslId) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create status request: %w", err) + } + c.setAuthHeaders(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("Sectigo status request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read status response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Sectigo status returned %d: %s", resp.StatusCode, string(respBody)) + } + + var statusResp statusResponse + if err := json.Unmarshal(respBody, &statusResp); err != nil { + return nil, fmt.Errorf("failed to parse status response: %w", err) + } + + return &statusResp, nil +} + +// collectCertificate downloads the PEM bundle for a Sectigo certificate. +func (c *Connector) collectCertificate(ctx context.Context, sslId int) (certPEM string, chainPEM string, serial string, notBefore time.Time, notAfter time.Time, err error) { + collectURL := fmt.Sprintf("%s/ssl/v1/collect/%d/pem", c.config.BaseURL, sslId) + req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, collectURL, nil) + if reqErr != nil { + err = fmt.Errorf("failed to create collect request: %w", reqErr) + return + } + c.setAuthHeaders(req) + + resp, doErr := c.httpClient.Do(req) + if doErr != nil { + err = fmt.Errorf("Sectigo collect request failed: %w", doErr) + return + } + defer resp.Body.Close() + + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + err = fmt.Errorf("failed to read collect response: %w", readErr) + return + } + + // Sectigo returns 400 with code -183 when cert is approved but not yet generated + if resp.StatusCode == http.StatusBadRequest { + err = &collectNotReadyError{statusCode: resp.StatusCode, body: string(body)} + return + } + + if resp.StatusCode != http.StatusOK { + err = fmt.Errorf("Sectigo collect returned status %d: %s", resp.StatusCode, string(body)) + return + } + + // Parse the PEM bundle: first cert is the leaf, rest are intermediates + certPEM, chainPEM, serial, notBefore, notAfter, err = parsePEMBundle(string(body)) + return +} + +// collectNotReadyError indicates the certificate is not yet generated. +type collectNotReadyError struct { + statusCode int + body string +} + +func (e *collectNotReadyError) Error() string { + return fmt.Sprintf("certificate not yet available (status %d): %s", e.statusCode, e.body) +} + +// isCollectNotReady checks if an error indicates the cert is not yet generated. +func isCollectNotReady(err error) bool { + _, ok := err.(*collectNotReadyError) + return ok +} + +// parsePEMBundle splits a PEM bundle into leaf cert and chain, extracting metadata. +func parsePEMBundle(bundle string) (certPEM string, chainPEM string, serial string, notBefore time.Time, notAfter time.Time, err error) { + var certs []string + remaining := bundle + + for { + var block *pem.Block + block, rest := pem.Decode([]byte(remaining)) + if block == nil { + break + } + if block.Type == "CERTIFICATE" { + certs = append(certs, string(pem.EncodeToMemory(block))) + } + remaining = string(rest) + } + + if len(certs) == 0 { + err = fmt.Errorf("no certificates found in PEM bundle") + return + } + + certPEM = certs[0] + if len(certs) > 1 { + chainPEM = strings.Join(certs[1:], "") + } + + // Parse leaf cert for metadata + block, _ := pem.Decode([]byte(certPEM)) + if block == nil { + err = fmt.Errorf("failed to decode leaf certificate PEM") + return + } + + cert, parseErr := x509.ParseCertificate(block.Bytes) + if parseErr != nil { + err = fmt.Errorf("failed to parse leaf certificate: %w", parseErr) + return + } + + serial = cert.SerialNumber.String() + notBefore = cert.NotBefore + notAfter = cert.NotAfter + return +} + +// mapRevocationReason maps RFC 5280 / certctl reason strings to Sectigo reason strings. +func mapRevocationReason(reason string) string { + switch strings.ToLower(reason) { + case "keycompromise", "key_compromise": + return "Compromised" + case "cessationofoperation", "cessation_of_operation": + return "Cessation of Operation" + case "affiliationchanged", "affiliation_changed": + return "Affiliation Changed" + case "superseded": + return "Superseded" + default: + return "Unspecified" + } +} + +// GenerateCRL is not supported because Sectigo manages CRL distribution. +func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) { + return nil, fmt.Errorf("Sectigo manages CRL distribution; use Sectigo's CRL endpoints") +} + +// SignOCSPResponse is not supported because Sectigo manages OCSP. +func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) { + return nil, fmt.Errorf("Sectigo manages OCSP; use Sectigo's OCSP responder") +} + +// GetCACertPEM is not directly supported. Sectigo intermediate certificates +// come with each certificate issuance as part of the PEM bundle. +func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) { + return "", fmt.Errorf("Sectigo intermediate certificates are included with each issued certificate") +} + +// GetRenewalInfo returns nil, nil as Sectigo does not support ACME Renewal Information (ARI). +func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) { + return nil, nil +} + +// Ensure Connector implements the issuer.Connector interface. +var _ issuer.Connector = (*Connector)(nil) diff --git a/internal/connector/issuer/sectigo/sectigo_test.go b/internal/connector/issuer/sectigo/sectigo_test.go new file mode 100644 index 0000000..9b390e8 --- /dev/null +++ b/internal/connector/issuer/sectigo/sectigo_test.go @@ -0,0 +1,843 @@ +package sectigo_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "log/slog" + "math/big" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/shankar0123/certctl/internal/connector/issuer" + "github.com/shankar0123/certctl/internal/connector/issuer/sectigo" +) + +func TestSectigoConnector(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + ctx := context.Background() + + t.Run("ValidateConfig_Success", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/ssl/v1/types" { + // Verify all 3 auth headers are present + if r.Header.Get("customerUri") != "test-org" { + t.Errorf("Expected customerUri 'test-org', got '%s'", r.Header.Get("customerUri")) + } + if r.Header.Get("login") != "api-user" { + t.Errorf("Expected login 'api-user', got '%s'", r.Header.Get("login")) + } + if r.Header.Get("password") != "api-pass" { + t.Errorf("Expected password 'api-pass', got '%s'", r.Header.Get("password")) + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`[{"id":423,"name":"Sectigo OV SSL","term":[365,730]}]`)) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + config := sectigo.Config{ + CustomerURI: "test-org", + Login: "api-user", + Password: "api-pass", + OrgID: 12345, + CertType: 423, + Term: 365, + BaseURL: srv.URL, + } + + connector := sectigo.New(nil, logger) + rawConfig, _ := json.Marshal(config) + err := connector.ValidateConfig(ctx, rawConfig) + if err != nil { + t.Fatalf("ValidateConfig failed: %v", err) + } + }) + + t.Run("ValidateConfig_MissingCustomerURI", func(t *testing.T) { + config := sectigo.Config{ + Login: "api-user", + Password: "api-pass", + OrgID: 12345, + } + + connector := sectigo.New(nil, logger) + rawConfig, _ := json.Marshal(config) + err := connector.ValidateConfig(ctx, rawConfig) + if err == nil { + t.Fatal("Expected error for missing customer_uri") + } + if !strings.Contains(err.Error(), "customer_uri is required") { + t.Errorf("Expected customer_uri required error, got: %v", err) + } + }) + + t.Run("ValidateConfig_MissingLogin", func(t *testing.T) { + config := sectigo.Config{ + CustomerURI: "test-org", + Password: "api-pass", + OrgID: 12345, + } + + connector := sectigo.New(nil, logger) + rawConfig, _ := json.Marshal(config) + err := connector.ValidateConfig(ctx, rawConfig) + if err == nil { + t.Fatal("Expected error for missing login") + } + if !strings.Contains(err.Error(), "login is required") { + t.Errorf("Expected login required error, got: %v", err) + } + }) + + t.Run("ValidateConfig_MissingPassword", func(t *testing.T) { + config := sectigo.Config{ + CustomerURI: "test-org", + Login: "api-user", + OrgID: 12345, + } + + connector := sectigo.New(nil, logger) + rawConfig, _ := json.Marshal(config) + err := connector.ValidateConfig(ctx, rawConfig) + if err == nil { + t.Fatal("Expected error for missing password") + } + if !strings.Contains(err.Error(), "password is required") { + t.Errorf("Expected password required error, got: %v", err) + } + }) + + t.Run("ValidateConfig_MissingOrgID", func(t *testing.T) { + config := sectigo.Config{ + CustomerURI: "test-org", + Login: "api-user", + Password: "api-pass", + } + + connector := sectigo.New(nil, logger) + rawConfig, _ := json.Marshal(config) + err := connector.ValidateConfig(ctx, rawConfig) + if err == nil { + t.Fatal("Expected error for missing org_id") + } + if !strings.Contains(err.Error(), "org_id is required") { + t.Errorf("Expected org_id required error, got: %v", err) + } + }) + + t.Run("ValidateConfig_InvalidCredentials", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/ssl/v1/types" { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"code":0,"description":"Invalid credentials"}`)) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + config := sectigo.Config{ + CustomerURI: "bad-org", + Login: "bad-user", + Password: "bad-pass", + OrgID: 12345, + BaseURL: srv.URL, + } + + connector := sectigo.New(nil, logger) + rawConfig, _ := json.Marshal(config) + err := connector.ValidateConfig(ctx, rawConfig) + if err == nil { + t.Fatal("Expected error for invalid credentials") + } + if !strings.Contains(err.Error(), "invalid") { + t.Logf("Got error: %v", err) + } + }) + + t.Run("IssueCertificate_ImmediateSuccess", func(t *testing.T) { + testCertPEM, _ := generateTestCert(t) + testChainPEM, _ := generateTestCert(t) + pemBundle := testCertPEM + testChainPEM + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify auth headers on every request + if r.Header.Get("customerUri") == "" || r.Header.Get("login") == "" || r.Header.Get("password") == "" { + t.Error("Missing auth headers on request") + } + + switch { + case r.URL.Path == "/ssl/v1/enroll" && r.Method == http.MethodPost: + // Verify request body structure + body, _ := io.ReadAll(r.Body) + var req map[string]interface{} + json.Unmarshal(body, &req) + if req["orgId"] == nil { + t.Error("Expected orgId in enrollment request") + } + if req["certType"] == nil { + t.Error("Expected certType in enrollment request") + } + // SANs should be comma-separated string, not array + if sans, ok := req["subjAltNames"].(string); ok { + if !strings.Contains(sans, ",") && len(sans) > 0 { + // Single SAN is fine + } + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"sslId":55001,"renewId":"ren-abc"}`)) + + case r.URL.Path == "/ssl/v1/55001" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"sslId":55001,"status":"Issued","commonName":"app.example.com"}`)) + + case r.URL.Path == "/ssl/v1/collect/55001/pem" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + w.Write([]byte(pemBundle)) + + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + config := §igo.Config{ + CustomerURI: "test-org", + Login: "api-user", + Password: "api-pass", + OrgID: 12345, + CertType: 423, + Term: 365, + BaseURL: srv.URL, + } + connector := sectigo.New(config, logger) + + _, csrPEM := generateTestCSR(t, "app.example.com") + req := issuer.IssuanceRequest{ + CommonName: "app.example.com", + SANs: []string{"app.example.com", "www.example.com"}, + CSRPEM: csrPEM, + } + + result, err := connector.IssueCertificate(ctx, req) + if err != nil { + t.Fatalf("IssueCertificate failed: %v", err) + } + + if result.CertPEM == "" { + t.Error("CertPEM should not be empty for immediate issuance") + } + if result.Serial == "" { + t.Error("Serial should not be empty for immediate issuance") + } + if result.OrderID != "55001" { + t.Errorf("Expected OrderID '55001', got '%s'", result.OrderID) + } + t.Logf("Sectigo issued cert: serial=%s, orderID=%s", result.Serial, result.OrderID) + }) + + t.Run("IssueCertificate_Pending", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/ssl/v1/enroll": + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"sslId":55002}`)) + case r.URL.Path == "/ssl/v1/55002": + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"sslId":55002,"status":"Applied","commonName":"secure.example.com"}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + config := §igo.Config{ + CustomerURI: "test-org", + Login: "api-user", + Password: "api-pass", + OrgID: 12345, + CertType: 423, + Term: 365, + BaseURL: srv.URL, + } + connector := sectigo.New(config, logger) + + _, csrPEM := generateTestCSR(t, "secure.example.com") + req := issuer.IssuanceRequest{ + CommonName: "secure.example.com", + CSRPEM: csrPEM, + } + + result, err := connector.IssueCertificate(ctx, req) + if err != nil { + t.Fatalf("IssueCertificate failed: %v", err) + } + + if result.OrderID != "55002" { + t.Errorf("Expected OrderID '55002', got '%s'", result.OrderID) + } + if result.CertPEM != "" { + t.Error("CertPEM should be empty for pending order") + } + if result.Serial != "" { + t.Error("Serial should be empty for pending order") + } + }) + + t.Run("IssueCertificate_ServerError", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"code":-14,"description":"Invalid CSR"}`)) + })) + defer srv.Close() + + config := §igo.Config{ + CustomerURI: "test-org", + Login: "api-user", + Password: "api-pass", + OrgID: 12345, + CertType: 423, + Term: 365, + BaseURL: srv.URL, + } + connector := sectigo.New(config, logger) + + req := issuer.IssuanceRequest{ + CommonName: "test.example.com", + CSRPEM: "invalid-csr", + } + + _, err := connector.IssueCertificate(ctx, req) + if err == nil { + t.Fatal("Expected error for server error response") + } + }) + + t.Run("GetOrderStatus_Issued", func(t *testing.T) { + testCertPEM, _ := generateTestCert(t) + testChainPEM, _ := generateTestCert(t) + pemBundle := testCertPEM + testChainPEM + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/ssl/v1/55001": + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"sslId":55001,"status":"Issued","commonName":"app.example.com"}`)) + case "/ssl/v1/collect/55001/pem": + w.WriteHeader(http.StatusOK) + w.Write([]byte(pemBundle)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + config := §igo.Config{ + CustomerURI: "test-org", + Login: "api-user", + Password: "api-pass", + OrgID: 12345, + BaseURL: srv.URL, + } + connector := sectigo.New(config, logger) + + status, err := connector.GetOrderStatus(ctx, "55001") + if err != nil { + t.Fatalf("GetOrderStatus failed: %v", err) + } + + if status.Status != "completed" { + t.Errorf("Expected status 'completed', got '%s'", status.Status) + } + if status.CertPEM == nil || *status.CertPEM == "" { + t.Error("CertPEM should not be empty for issued order") + } + if status.Serial == nil || *status.Serial == "" { + t.Error("Serial should not be empty for issued order") + } + }) + + t.Run("GetOrderStatus_Pending", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/ssl/v1/55002" { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"sslId":55002,"status":"Applied"}`)) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + config := §igo.Config{ + CustomerURI: "test-org", + Login: "api-user", + Password: "api-pass", + OrgID: 12345, + BaseURL: srv.URL, + } + connector := sectigo.New(config, logger) + + status, err := connector.GetOrderStatus(ctx, "55002") + if err != nil { + t.Fatalf("GetOrderStatus failed: %v", err) + } + + if status.Status != "pending" { + t.Errorf("Expected status 'pending', got '%s'", status.Status) + } + if status.CertPEM != nil { + t.Error("CertPEM should be nil for pending order") + } + }) + + t.Run("GetOrderStatus_Rejected", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/ssl/v1/55003" { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"sslId":55003,"status":"Rejected"}`)) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + config := §igo.Config{ + CustomerURI: "test-org", + Login: "api-user", + Password: "api-pass", + OrgID: 12345, + BaseURL: srv.URL, + } + connector := sectigo.New(config, logger) + + status, err := connector.GetOrderStatus(ctx, "55003") + if err != nil { + t.Fatalf("GetOrderStatus failed: %v", err) + } + + if status.Status != "failed" { + t.Errorf("Expected status 'failed', got '%s'", status.Status) + } + }) + + t.Run("GetOrderStatus_CollectNotReady", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/ssl/v1/55004": + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"sslId":55004,"status":"Issued","commonName":"pending-collect.example.com"}`)) + case "/ssl/v1/collect/55004/pem": + // Sectigo returns 400 with code -183 when cert not yet generated + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"code":-183,"description":"Certificate is not available"}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + config := §igo.Config{ + CustomerURI: "test-org", + Login: "api-user", + Password: "api-pass", + OrgID: 12345, + BaseURL: srv.URL, + } + connector := sectigo.New(config, logger) + + status, err := connector.GetOrderStatus(ctx, "55004") + if err != nil { + t.Fatalf("GetOrderStatus failed: %v", err) + } + + // Should be treated as pending (cert approved but not yet generated) + if status.Status != "pending" { + t.Errorf("Expected status 'pending' for collect-not-ready, got '%s'", status.Status) + } + }) + + t.Run("RenewCertificate_NewOrder", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/ssl/v1/enroll": + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"sslId":55010}`)) + case r.URL.Path == "/ssl/v1/55010": + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"sslId":55010,"status":"Applied"}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + config := §igo.Config{ + CustomerURI: "test-org", + Login: "api-user", + Password: "api-pass", + OrgID: 12345, + CertType: 423, + Term: 365, + BaseURL: srv.URL, + } + connector := sectigo.New(config, logger) + + _, csrPEM := generateTestCSR(t, "renew.example.com") + renewReq := issuer.RenewalRequest{ + CommonName: "renew.example.com", + CSRPEM: csrPEM, + } + + result, err := connector.RenewCertificate(ctx, renewReq) + if err != nil { + t.Fatalf("RenewCertificate failed: %v", err) + } + + if result.OrderID == "" { + t.Error("OrderID should not be empty") + } + }) + + t.Run("RevokeCertificate_Success", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/ssl/v1/revoke/") && r.Method == http.MethodPost { + // Verify auth headers + if r.Header.Get("customerUri") == "" { + t.Error("Missing customerUri header on revoke request") + } + if r.Header.Get("login") == "" { + t.Error("Missing login header on revoke request") + } + if r.Header.Get("password") == "" { + t.Error("Missing password header on revoke request") + } + + // Verify reason in body + body, _ := io.ReadAll(r.Body) + var req map[string]interface{} + json.Unmarshal(body, &req) + if req["reason"] == nil { + t.Error("Expected reason in revoke request body") + } + + w.WriteHeader(http.StatusNoContent) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + config := §igo.Config{ + CustomerURI: "test-org", + Login: "api-user", + Password: "api-pass", + OrgID: 12345, + BaseURL: srv.URL, + } + connector := sectigo.New(config, logger) + + reason := "keyCompromise" + revokeReq := issuer.RevocationRequest{ + Serial: "55001", + Reason: &reason, + } + + err := connector.RevokeCertificate(ctx, revokeReq) + if err != nil { + t.Fatalf("RevokeCertificate failed: %v", err) + } + }) + + t.Run("RevokeCertificate_Error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"code":-1,"description":"Certificate not found"}`)) + })) + defer srv.Close() + + config := §igo.Config{ + CustomerURI: "test-org", + Login: "api-user", + Password: "api-pass", + OrgID: 12345, + BaseURL: srv.URL, + } + connector := sectigo.New(config, logger) + + revokeReq := issuer.RevocationRequest{ + Serial: "00000", + } + + err := connector.RevokeCertificate(ctx, revokeReq) + if err == nil { + t.Fatal("Expected error for revocation of nonexistent cert") + } + }) + + t.Run("GetRenewalInfo_ReturnsNil", func(t *testing.T) { + config := §igo.Config{ + CustomerURI: "test-org", + Login: "api-user", + Password: "api-pass", + OrgID: 12345, + BaseURL: "https://cert-manager.com/api", + } + connector := sectigo.New(config, logger) + + result, err := connector.GetRenewalInfo(ctx, "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----") + if err != nil { + t.Fatalf("GetRenewalInfo should not return error, got: %v", err) + } + if result != nil { + t.Fatal("GetRenewalInfo should return nil for Sectigo") + } + }) + + t.Run("DefaultTerm", func(t *testing.T) { + config := §igo.Config{ + CustomerURI: "test-org", + Login: "api-user", + Password: "api-pass", + OrgID: 12345, + CertType: 423, + // Term intentionally left as 0 + } + connector := sectigo.New(config, logger) + + // Verify the connector was created (the default is set in New()) + if connector == nil { + t.Fatal("Connector should not be nil") + } + + // Verify via a request that uses the term + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/ssl/v1/enroll" { + body, _ := io.ReadAll(r.Body) + var req map[string]interface{} + json.Unmarshal(body, &req) + // Default term should be 365 + if term, ok := req["term"].(float64); ok { + if int(term) != 365 { + t.Errorf("Expected default term 365, got %d", int(term)) + } + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"sslId":55099}`)) + return + } + if r.URL.Path == "/ssl/v1/55099" { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"sslId":55099,"status":"Applied"}`)) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + // Reconfigure with test server URL + config.BaseURL = srv.URL + connector = sectigo.New(config, logger) + + _, csrPEM := generateTestCSR(t, "test.example.com") + req := issuer.IssuanceRequest{ + CommonName: "test.example.com", + CSRPEM: csrPEM, + } + + result, err := connector.IssueCertificate(ctx, req) + if err != nil { + t.Fatalf("IssueCertificate with default term failed: %v", err) + } + if result.OrderID == "" { + t.Error("OrderID should not be empty") + } + }) + + t.Run("AuthHeaders_PresentOnAllRequests", func(t *testing.T) { + requestCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + // Every single request must have all 3 auth headers + if r.Header.Get("customerUri") != "verify-org" { + t.Errorf("Request %d: expected customerUri 'verify-org', got '%s'", requestCount, r.Header.Get("customerUri")) + } + if r.Header.Get("login") != "verify-user" { + t.Errorf("Request %d: expected login 'verify-user', got '%s'", requestCount, r.Header.Get("login")) + } + if r.Header.Get("password") != "verify-pass" { + t.Errorf("Request %d: expected password 'verify-pass', got '%s'", requestCount, r.Header.Get("password")) + } + + switch { + case r.URL.Path == "/ssl/v1/enroll": + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"sslId":55050}`)) + case r.URL.Path == "/ssl/v1/55050": + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"sslId":55050,"status":"Applied"}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + config := §igo.Config{ + CustomerURI: "verify-org", + Login: "verify-user", + Password: "verify-pass", + OrgID: 12345, + CertType: 423, + Term: 365, + BaseURL: srv.URL, + } + connector := sectigo.New(config, logger) + + _, csrPEM := generateTestCSR(t, "auth-check.example.com") + req := issuer.IssuanceRequest{ + CommonName: "auth-check.example.com", + CSRPEM: csrPEM, + } + + _, err := connector.IssueCertificate(ctx, req) + if err != nil { + t.Fatalf("IssueCertificate failed: %v", err) + } + + if requestCount < 2 { + t.Errorf("Expected at least 2 requests (enroll + status), got %d", requestCount) + } + }) + + t.Run("RevocationReasonMapping", func(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"keyCompromise", "Compromised"}, + {"cessationOfOperation", "Cessation of Operation"}, + {"affiliationChanged", "Affiliation Changed"}, + {"superseded", "Superseded"}, + {"unspecified", "Unspecified"}, + {"unknown_reason", "Unspecified"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + var receivedReason string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/ssl/v1/revoke/") { + body, _ := io.ReadAll(r.Body) + var req map[string]interface{} + json.Unmarshal(body, &req) + receivedReason = req["reason"].(string) + w.WriteHeader(http.StatusNoContent) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + config := §igo.Config{ + CustomerURI: "test-org", + Login: "api-user", + Password: "api-pass", + OrgID: 12345, + BaseURL: srv.URL, + } + connector := sectigo.New(config, logger) + + reason := tt.input + err := connector.RevokeCertificate(ctx, issuer.RevocationRequest{ + Serial: "12345", + Reason: &reason, + }) + if err != nil { + t.Fatalf("RevokeCertificate failed: %v", err) + } + + if receivedReason != tt.expected { + t.Errorf("Expected reason '%s', got '%s'", tt.expected, receivedReason) + } + }) + } + }) +} + +// generateTestCert creates a self-signed test certificate and returns the PEM strings. +func generateTestCert(t *testing.T) (certPEM string, keyPEM string) { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("Failed to generate key: %v", err) + } + + serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: fmt.Sprintf("Test Certificate %s", serial.String()[:8]), + }, + DNSNames: []string{"test.example.com"}, + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatalf("Failed to create certificate: %v", err) + } + + certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certBytes})) + keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})) + + return certPEM, keyPEM +} + +// generateTestCSR creates a test CSR for the given common name. +func generateTestCSR(t *testing.T, commonName string) (*x509.CertificateRequest, string) { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("Failed to generate key: %v", err) + } + + csrTemplate := x509.CertificateRequest{ + Subject: pkix.Name{ + CommonName: commonName, + }, + DNSNames: []string{commonName}, + SignatureAlgorithm: x509.SHA256WithRSA, + } + + csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, key) + if err != nil { + t.Fatalf("Failed to create CSR: %v", err) + } + + csrPEM := string(pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE REQUEST", + Bytes: csrBytes, + })) + + csr, err := x509.ParseCertificateRequest(csrBytes) + if err != nil { + t.Fatalf("Failed to parse CSR: %v", err) + } + + return csr, csrPEM +} diff --git a/internal/domain/connector.go b/internal/domain/connector.go index ede064f..bddd92d 100644 --- a/internal/domain/connector.go +++ b/internal/domain/connector.go @@ -71,6 +71,7 @@ const ( IssuerTypeOpenSSL IssuerType = "OpenSSL" IssuerTypeVault IssuerType = "VaultPKI" IssuerTypeDigiCert IssuerType = "DigiCert" + IssuerTypeSectigo IssuerType = "Sectigo" ) // TargetType represents the type of deployment target. diff --git a/migrations/seed_demo.sql b/migrations/seed_demo.sql index 344a4f2..e1d8747 100644 --- a/migrations/seed_demo.sql +++ b/migrations/seed_demo.sql @@ -45,7 +45,8 @@ INSERT INTO issuers (id, name, type, config, enabled, created_at, updated_at) VA ('iss-acme-zs', 'ZeroSSL (EAB)', 'ACME', '{"directory_url": "https://acme.zerossl.com/v2/DV90", "email": "admin@example.com", "challenge_type": "http-01"}', true, NOW() - INTERVAL '60 days', NOW() - INTERVAL '60 days'), ('iss-openssl', 'Custom OpenSSL CA', 'OpenSSL', '{"sign_script": "/opt/ca/sign.sh", "timeout_seconds": 30}', false, NOW() - INTERVAL '30 days', NOW() - INTERVAL '30 days'), ('iss-vault', 'HashiCorp Vault PKI', 'VaultPKI', '{"addr": "https://vault.internal:8200", "mount": "pki", "role": "web-certs", "ttl": "8760h"}', true, NOW() - INTERVAL '20 days', NOW() - INTERVAL '20 days'), - ('iss-digicert', 'DigiCert CertCentral', 'DigiCert', '{"base_url": "https://www.digicert.com/services/v2", "product_type": "ssl_basic"}', true, NOW() - INTERVAL '15 days', NOW() - INTERVAL '15 days') + ('iss-digicert', 'DigiCert CertCentral', 'DigiCert', '{"base_url": "https://www.digicert.com/services/v2", "product_type": "ssl_basic"}', true, NOW() - INTERVAL '15 days', NOW() - INTERVAL '15 days'), + ('iss-sectigo', 'Sectigo SCM', 'Sectigo', '{"base_url": "https://cert-manager.com/api", "cert_type": 423, "term": 365}', true, NOW() - INTERVAL '10 days', NOW() - INTERVAL '10 days') ON CONFLICT (id) DO NOTHING; -- ============================================================ diff --git a/web/src/config/issuerTypes.ts b/web/src/config/issuerTypes.ts index 6760a3b..9005ec4 100644 --- a/web/src/config/issuerTypes.ts +++ b/web/src/config/issuerTypes.ts @@ -40,6 +40,7 @@ export const typeLabels: Record = { openssl: 'OpenSSL/Custom', VaultPKI: 'Vault PKI', DigiCert: 'DigiCert', + Sectigo: 'Sectigo SCM', manual: 'Manual', }; @@ -120,12 +121,19 @@ export const issuerTypes: IssuerTypeConfig[] = [ ], }, { - id: 'sectigo', - name: 'Sectigo', - description: 'Sectigo Certificate Manager \u2014 coming soon', - icon: '\uD83D\uDCE6', - configFields: [], - comingSoon: true, + id: 'Sectigo', + name: 'Sectigo SCM', + description: 'Sectigo Certificate Manager for DV, OV, and EV certificates', + icon: '\uD83D\uDD10', + configFields: [ + { key: 'customer_uri', label: 'Customer URI', required: true, placeholder: 'your-org-uri' }, + { key: 'login', label: 'API Login', required: true, placeholder: 'api-account-name' }, + { key: 'password', label: 'API Password', required: true, sensitive: true, type: 'password' }, + { key: 'org_id', label: 'Organization ID', required: true, placeholder: '12345', type: 'number' }, + { key: 'cert_type', label: 'Certificate Type ID', required: false, placeholder: '423', type: 'number' }, + { key: 'term', label: 'Validity (days)', required: false, placeholder: '365', type: 'number' }, + { key: 'base_url', label: 'Base URL', required: false, placeholder: 'https://cert-manager.com/api' }, + ], }, { id: 'entrust',