mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:21:37 +00:00
feat(M43): Sectigo SCM issuer connector
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+24
-1
@@ -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
|
||||
|
||||
|
||||
+74
-3
@@ -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.
|
||||
|
||||
|
||||
@@ -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", ""),
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -71,6 +71,7 @@ const (
|
||||
IssuerTypeOpenSSL IssuerType = "OpenSSL"
|
||||
IssuerTypeVault IssuerType = "VaultPKI"
|
||||
IssuerTypeDigiCert IssuerType = "DigiCert"
|
||||
IssuerTypeSectigo IssuerType = "Sectigo"
|
||||
)
|
||||
|
||||
// TargetType represents the type of deployment target.
|
||||
|
||||
@@ -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;
|
||||
|
||||
-- ============================================================
|
||||
|
||||
@@ -40,6 +40,7 @@ export const typeLabels: Record<string, string> = {
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user