diff --git a/README.md b/README.md index fe0ce85..a02d314 100644 --- a/README.md +++ b/README.md @@ -85,8 +85,10 @@ For the full capability breakdown — revocation infrastructure, policy engine, | ACME EAB (ZeroSSL, Google Trust) | Implemented (auto-fetch EAB from ZeroSSL) | `ACME` | | step-ca | Implemented | `StepCA` | | OpenSSL / Custom CA | Implemented | `OpenSSL` | -| Vault PKI | Future | — | -| DigiCert | Future | — | +| Vault PKI | Beta | `VaultPKI` | +| DigiCert CertCentral | Beta | `DigiCert` | + +**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. **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. @@ -511,8 +513,6 @@ Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector - **Helm Chart** — Production-ready Kubernetes with server Deployment, PostgreSQL StatefulSet with PVC, Agent DaemonSet, security contexts, resource limits, optional Ingress **Coming in v2.1.0:** -- Vault PKI issuer connector (HashiCorp Vault /sign API) -- DigiCert CertCentral issuer connector (enterprise CA) - Dynamic issuer and target configuration via GUI (no env var restarts) - Issuer catalog page (see all supported CAs, configure from dashboard) - First-run onboarding wizard diff --git a/api/openapi.yaml b/api/openapi.yaml index b52dbed..fac1c05 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2643,7 +2643,7 @@ components: # ─── Issuers ───────────────────────────────────────────────────── IssuerType: type: string - enum: [ACME, GenericCA, StepCA] + enum: [ACME, GenericCA, StepCA, VaultPKI, DigiCert] Issuer: type: object diff --git a/cmd/server/main.go b/cmd/server/main.go index e2ea75d..3947bbf 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -19,8 +19,10 @@ import ( "github.com/shankar0123/certctl/internal/domain" acmeissuer "github.com/shankar0123/certctl/internal/connector/issuer/acme" "github.com/shankar0123/certctl/internal/connector/issuer/local" + 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" + 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" notifypagerduty "github.com/shankar0123/certctl/internal/connector/notifier/pagerduty" @@ -133,6 +135,27 @@ func main() { }, logger) logger.Info("initialized OpenSSL/Custom CA issuer connector") + // Initialize Vault PKI issuer connector (for HashiCorp Vault internal PKI). + // Uses the Vault HTTP API with token authentication. + vaultConnector := vaultissuer.New(&vaultissuer.Config{ + Addr: os.Getenv("CERTCTL_VAULT_ADDR"), + Token: os.Getenv("CERTCTL_VAULT_TOKEN"), + Mount: getEnvDefault("CERTCTL_VAULT_MOUNT", "pki"), + Role: os.Getenv("CERTCTL_VAULT_ROLE"), + TTL: getEnvDefault("CERTCTL_VAULT_TTL", "8760h"), + }, logger) + logger.Info("initialized Vault PKI issuer connector") + + // Initialize DigiCert CertCentral issuer connector (for enterprise public CA). + // Uses the DigiCert REST API with async order model. + digicertConnector := digicertissuer.New(&digicertissuer.Config{ + APIKey: os.Getenv("CERTCTL_DIGICERT_API_KEY"), + OrgID: os.Getenv("CERTCTL_DIGICERT_ORG_ID"), + ProductType: getEnvDefault("CERTCTL_DIGICERT_PRODUCT_TYPE", "ssl_basic"), + BaseURL: getEnvDefault("CERTCTL_DIGICERT_BASE_URL", "https://www.digicert.com/services/v2"), + }, logger) + logger.Info("initialized DigiCert CertCentral 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. @@ -145,6 +168,19 @@ func main() { "iss-stepca": service.NewIssuerConnectorAdapter(stepcaConnector), "iss-openssl": service.NewIssuerConnectorAdapter(opensslConnector), } + + // Conditionally register Vault PKI (only if CERTCTL_VAULT_ADDR is set) + if os.Getenv("CERTCTL_VAULT_ADDR") != "" { + issuerRegistry["iss-vault"] = service.NewIssuerConnectorAdapter(vaultConnector) + logger.Info("Vault PKI issuer registered", "id", "iss-vault") + } + + // Conditionally register DigiCert (only if CERTCTL_DIGICERT_API_KEY is set) + if os.Getenv("CERTCTL_DIGICERT_API_KEY") != "" { + issuerRegistry["iss-digicert"] = service.NewIssuerConnectorAdapter(digicertConnector) + logger.Info("DigiCert CertCentral issuer registered", "id", "iss-digicert") + } + logger.Info("issuer registry configured", "issuers", len(issuerRegistry)) // Initialize revocation repository @@ -544,6 +580,14 @@ func main() { logger.Info("certctl server stopped") } +// getEnvDefault reads an environment variable with a default fallback. +func getEnvDefault(key, defaultVal string) string { + if val := os.Getenv(key); val != "" { + return val + } + return defaultVal +} + // getEnvIntDefault parses an integer from a string with a default fallback. func getEnvIntDefault(s string, defaultVal int) int { if s == "" { diff --git a/docs/architecture.md b/docs/architecture.md index 3e80206..235a410 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -570,7 +570,7 @@ type Connector interface { } ``` -Built-in issuers: **Local CA** (self-signed or sub-CA mode using `crypto/x509`), **ACME v2** (HTTP-01, DNS-01, and DNS-PERSIST-01 challenges, compatible with Let's Encrypt, ZeroSSL, Sectigo, Google Trust Services, and any ACME-compliant CA), **step-ca** (Smallstep private CA via native /sign API with JWK provisioner auth), and **OpenSSL/Custom CA** (script-based signing delegating to user-provided shell scripts). The ACME connector uses `golang.org/x/crypto/acme`, generates an ECDSA P-256 account key, handles account registration with ToS acceptance and optional External Account Binding (EAB) for CAs that require it (ZeroSSL, Google Trust Services, SSL.com), order creation, challenge solving (HTTP-01 via built-in server, DNS-01 via script-based hooks, DNS-PERSIST-01 via standing TXT records with auto-fallback to DNS-01), order finalization, and DER-to-PEM chain conversion. For ZeroSSL, EAB credentials are auto-fetched from ZeroSSL's public API when the directory URL is detected as ZeroSSL and no EAB credentials are provided — zero-friction onboarding with no dashboard visit required. +Built-in issuers: **Local CA** (self-signed or sub-CA mode using `crypto/x509`), **ACME v2** (HTTP-01, DNS-01, and DNS-PERSIST-01 challenges, compatible with Let's Encrypt, ZeroSSL, Sectigo, Google Trust Services, and any ACME-compliant CA), **step-ca** (Smallstep private CA via native /sign API with JWK provisioner auth), **OpenSSL/Custom CA** (script-based signing delegating to user-provided shell scripts), **Vault PKI** (HashiCorp Vault's PKI secrets engine via /sign API with token auth), and **DigiCert** (commercial CA via CertCentral REST API with async order processing). The ACME connector uses `golang.org/x/crypto/acme`, generates an ECDSA P-256 account key, handles account registration with ToS acceptance and optional External Account Binding (EAB) for CAs that require it (ZeroSSL, Google Trust Services, SSL.com), order creation, challenge solving (HTTP-01 via built-in server, DNS-01 via script-based hooks, DNS-PERSIST-01 via standing TXT records with auto-fallback to DNS-01), order finalization, and DER-to-PEM chain conversion. For ZeroSSL, EAB credentials are auto-fetched from ZeroSSL's public API when the directory URL is detected as ZeroSSL and no EAB credentials are provided — zero-friction onboarding with no dashboard visit required. **ACME Renewal Information (ARI, RFC 9702):** The ACME connector supports CA-directed renewal timing via the `GetRenewalInfo()` method. Instead of using fixed thresholds (e.g., renew 30 days before expiry), the CA tells certctl when to renew by providing a `suggestedWindow` with start and end times. This is useful for distributing renewal load during maintenance windows and coordinating mass-revocation scenarios. Enable with `CERTCTL_ACME_ARI_ENABLED=true`. Cert ID is computed as `base64url(SHA-256(DER cert))` per RFC 9702. If the CA doesn't support ARI (404 from the ARI endpoint), certctl automatically falls back to threshold-based renewal — no operator intervention required. Errors from the CA are logged as warnings. @@ -647,7 +647,7 @@ type ESTService interface { } ``` -**Issuer connector extension:** EST required adding `GetCACertPEM(ctx) (string, error)` to the issuer connector interface so the `/cacerts` endpoint can serve the CA chain. The Local CA connector returns its CA certificate PEM; ACME, step-ca, and OpenSSL connectors return errors (they don't expose a static CA chain — their chains are per-issuance). +**Issuer connector extension:** EST required adding `GetCACertPEM(ctx) (string, error)` to the issuer connector interface so the `/cacerts` endpoint can serve the CA chain. The Local CA connector returns its CA certificate PEM; ACME, step-ca, OpenSSL, Vault, and DigiCert connectors return errors (they don't expose a static CA chain — their chains are per-issuance). **Audit:** Every EST enrollment is recorded in the audit trail with `protocol: "EST"`, the CN, SANs, issuer ID, serial number, and optional profile ID. diff --git a/docs/connectors.md b/docs/connectors.md index d30c676..14edd84 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -312,12 +312,55 @@ The `GetCACertPEM()` method returns the PEM-encoded CA certificate chain, used b Note: EST (Enrollment over Secure Transport) is not a connector — it's a protocol handler (`internal/api/handler/est.go`) that delegates certificate issuance to whichever issuer connector is configured via `CERTCTL_EST_ISSUER_ID`. See the [Architecture Guide](architecture.md#est-server-rfc-7030) for details. -### Coming in V2.1 +### Built-in: Vault PKI -The following issuer connectors are planned for the v2.1.0 release: +The Vault PKI connector integrates with HashiCorp Vault's PKI secrets engine using its native `/sign` API with token-based authentication. This is ideal for organizations using Vault as their internal certificate authority — synchronous issuance without the complexity of ACME or challenge solving. -- **Vault PKI** — HashiCorp Vault's PKI secrets engine (`/v1/{mount}/sign/{role}` API) for organizations using Vault as their internal CA. Token auth, configurable mount and role. -- **DigiCert** — Commercial CA integration via DigiCert CertCentral REST API. Async order model (submit → poll for completion). OV/EV certificate support. +**Configuration:** + +| Variable | Default | Description | +|----------|---------|-------------| +| `CERTCTL_VAULT_ADDR` | — | Vault server address (e.g., `https://vault.internal:8200`) | +| `CERTCTL_VAULT_TOKEN` | — | Vault auth token with permissions on the PKI mount | +| `CERTCTL_VAULT_MOUNT` | `pki` | PKI secrets engine mount path | +| `CERTCTL_VAULT_ROLE` | — | PKI role name for certificate signing | +| `CERTCTL_VAULT_TTL` | `8760h` | Certificate validity period (TTL) | + +The connector is registered in the issuer registry under `iss-vault`. Vault issues certificates synchronously via the `/v1/{mount}/sign/{role}` API with `X-Vault-Token` header authentication. The issued certificate is parsed to extract serial number, validity dates, and chain information. + +**Note:** CRL and OCSP are managed by Vault itself. Clients should validate certificate status against Vault's own CRL/OCSP endpoints (`GET /v1/{mount}/crl` and Vault's OCSP responder). certctl does not generate local CRL/OCSP for Vault-issued certificates. Revocation is recorded locally but Vault is the authoritative source. + +Location: `internal/connector/issuer/vault/vault.go` + +### Built-in: DigiCert CertCentral + +The DigiCert connector integrates with DigiCert's CertCentral REST API for ordering and managing certificates from DigiCert's commercial CA. It supports both Domain Validated (DV) and Organization/Extended Validated (OV/EV) certificates, with async order processing. + +**Configuration:** + +| Variable | Default | Description | +|----------|---------|-------------| +| `CERTCTL_DIGICERT_API_KEY` | — | DigiCert API key (X-DC-DEVKEY header) | +| `CERTCTL_DIGICERT_ORG_ID` | — | DigiCert organization ID | +| `CERTCTL_DIGICERT_PRODUCT_TYPE` | `ssl_basic` | Certificate product (e.g., `ssl_basic`, `ssl_plus`, `ssl_ev`) | +| `CERTCTL_DIGICERT_BASE_URL` | `https://www.digicert.com/services/v2` | DigiCert API base URL | + +The connector submits certificate orders to DigiCert's `/order/certificate/create` API. DV certificates may issue immediately; OV/EV certificates require validation (handled by DigiCert) and poll-based completion. The connector periodically checks order status via `/order/certificate/{order_id}` until the certificate is available. + +**Authentication:** API key passed via `X-DC-DEVKEY` header, with organization ID in request body. + +**Note:** CRL and OCSP are managed by DigiCert. Clients should validate certificate status against DigiCert's infrastructure. certctl records the revocation locally but does not notify DigiCert for revocation — use DigiCert's dashboard for revocation management. + +Location: `internal/connector/issuer/digicert/digicert.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 Note: ADCS (Active Directory Certificate Services) integration is handled via the **sub-CA mode** of the Local CA issuer, not as a separate connector. certctl operates as a subordinate CA with its signing certificate issued by ADCS, so all certctl-issued certs chain to the enterprise ADCS root. See the Local CA section above. diff --git a/docs/testing-guide.md b/docs/testing-guide.md index 3973cf3..d06c9cd 100644 --- a/docs/testing-guide.md +++ b/docs/testing-guide.md @@ -42,6 +42,8 @@ Comprehensive manual testing playbook. Every test has a concrete command, an exp - [Part 35: ARI (RFC 9702) Scheduler Integration](#part-35-ari-rfc-9702-scheduler-integration) - [Part 36: Agent Work Routing (M31)](#part-36-agent-work-routing-m31) - [Part 37: GUI Completeness (Pre-2.1.0-E)](#part-37-gui-completeness-pre-210-e) +- [Part 38: Vault PKI Connector (M32)](#part-38-vault-pki-connector-m32) +- [Part 39: DigiCert Connector (M37)](#part-39-digicert-connector-m37) - [Release Sign-Off](#release-sign-off) --- @@ -5166,6 +5168,210 @@ curl -s -H "Authorization: Bearer $API_KEY" \ --- +## Part 38: Vault PKI Connector (M32) + +### Prerequisites + +- Vault server running with PKI secrets engine enabled at `pki` mount +- PKI role created with appropriate certificate generation policy +- Vault token with read/sign permissions on the PKI path +- Environment variables configured: + ```bash + export CERTCTL_VAULT_ADDR="https://vault.internal:8200" + export CERTCTL_VAULT_TOKEN="s.xxxxxxxxxxxxxxxx" + export CERTCTL_VAULT_MOUNT="pki" + export CERTCTL_VAULT_ROLE="certctl-role" + export CERTCTL_VAULT_TTL="8760h" + ``` + +### 38.1 Register Vault PKI Issuer + +**Test:** Register a Vault PKI issuer via the API. + +```bash +curl -X POST -H "$AUTH" -H "$CT" \ + "$SERVER/api/v1/issuers" \ + -d '{ + "id": "iss-vault-prod", + "name": "Vault PKI Production", + "type": "VaultPKI", + "config": { + "vault_addr": "'"$CERTCTL_VAULT_ADDR"'", + "vault_token": "'"$CERTCTL_VAULT_TOKEN"'", + "vault_mount": "'"$CERTCTL_VAULT_MOUNT"'", + "vault_role": "'"$CERTCTL_VAULT_ROLE"'", + "vault_ttl": "'"$CERTCTL_VAULT_TTL"'" + } + }' | jq '.id' +``` + +**Expected:** Returns issuer ID `iss-vault-prod`. +**PASS if** issuer is registered and appears in `GET /api/v1/issuers`. + +### 38.2 Issue Certificate via Vault PKI + +**Test:** Create a certificate and issue it through Vault PKI. + +```bash +CERT_ID=$(curl -s -X POST -H "$AUTH" -H "$CT" \ + "$SERVER/api/v1/certificates" \ + -d '{ + "common_name": "vault-test.example.com", + "issuer_id": "iss-vault-prod", + "key_algorithm": "RSA-2048" + }' | jq -r '.id') + +curl -s -X POST -H "$AUTH" \ + "$SERVER/api/v1/certificates/$CERT_ID/renew" | jq '.job_id' +``` + +**Expected:** Renewal job created and eventually moves to Completed status. +**PASS if** certificate is issued by Vault with valid serial number and chain. + +### 38.3 Verify Certificate Serial and Subject + +**Test:** Check that the issued certificate has correct Vault metadata. + +```bash +curl -s -H "$AUTH" \ + "$SERVER/api/v1/certificates/$CERT_ID" | jq '.versions[0] | {serial, subject_dn, not_before, not_after}' +``` + +**Expected:** Serial, DN, and validity dates from Vault PKI. +**PASS if** certificate metadata is populated from Vault's response. + +### 38.4 Revocation Records Locally + +**Test:** Revoke the certificate and verify local recording. + +```bash +curl -s -X POST -H "$AUTH" \ + "$SERVER/api/v1/certificates/$CERT_ID/revoke" \ + -d '{"reason": "superseded"}' | jq '.revoked_at' +``` + +**Expected:** Returns `revoked_at` timestamp. +**PASS if** revocation is recorded locally in the audit trail but not propagated to Vault (Vault is authoritative for its own revocation). + +--- + +## Part 39: DigiCert Connector (M37) + +### Prerequisites + +- DigiCert CertCentral account with API access +- API key and organization ID from DigiCert +- Environment variables configured: + ```bash + export CERTCTL_DIGICERT_API_KEY="xxxxxxxxxxxxxxxxxxxxxxxx" + export CERTCTL_DIGICERT_ORG_ID="123456" + export CERTCTL_DIGICERT_PRODUCT_TYPE="ssl_basic" + export CERTCTL_DIGICERT_BASE_URL="https://www.digicert.com/services/v2" + ``` + +### 39.1 Register DigiCert Issuer + +**Test:** Register a DigiCert CertCentral issuer via the API. + +```bash +curl -X POST -H "$AUTH" -H "$CT" \ + "$SERVER/api/v1/issuers" \ + -d '{ + "id": "iss-digicert-prod", + "name": "DigiCert CertCentral", + "type": "DigiCert", + "config": { + "api_key": "'"$CERTCTL_DIGICERT_API_KEY"'", + "org_id": "'"$CERTCTL_DIGICERT_ORG_ID"'", + "product_type": "'"$CERTCTL_DIGICERT_PRODUCT_TYPE"'", + "base_url": "'"$CERTCTL_DIGICERT_BASE_URL"'" + } + }' | jq '.id' +``` + +**Expected:** Returns issuer ID `iss-digicert-prod`. +**PASS if** issuer is registered and appears in `GET /api/v1/issuers`. + +### 39.2 Issue DV Certificate via DigiCert + +**Test:** Create a DV certificate order and track it to completion. + +```bash +CERT_ID=$(curl -s -X POST -H "$AUTH" -H "$CT" \ + "$SERVER/api/v1/certificates" \ + -d '{ + "common_name": "dv-test.example.com", + "issuer_id": "iss-digicert-prod", + "key_algorithm": "RSA-2048" + }' | jq -r '.id') + +JOB_ID=$(curl -s -X POST -H "$AUTH" \ + "$SERVER/api/v1/certificates/$CERT_ID/renew" | jq -r '.job_id') + +# Poll for job completion (DV certs may issue immediately) +for i in {1..30}; do + STATUS=$(curl -s -H "$AUTH" \ + "$SERVER/api/v1/jobs/$JOB_ID" | jq -r '.status') + echo "Job status: $STATUS" + [ "$STATUS" = "Completed" ] && break + sleep 2 +done +``` + +**Expected:** Job eventually reaches Completed status with certificate issued. +**PASS if** certificate has DigiCert serial number and chain. + +### 39.3 Verify Order ID Tracking + +**Test:** Check that the job record includes the DigiCert order ID for auditing. + +```bash +curl -s -H "$AUTH" \ + "$SERVER/api/v1/jobs/$JOB_ID" | jq '.metadata' +``` + +**Expected:** Metadata includes `order_id` from DigiCert for order tracking. +**PASS if** audit trail shows the DigiCert order lifecycle. + +### 39.4 Async Poll Behavior + +**Test:** Verify the connector polls for certificate completion (OV certs take longer). + +```bash +# Submit OV certificate order (requires validation) +CERT_ID=$(curl -s -X POST -H "$AUTH" -H "$CT" \ + "$SERVER/api/v1/certificates" \ + -d '{ + "common_name": "ov-test.example.com", + "issuer_id": "iss-digicert-prod", + "key_algorithm": "RSA-2048" + }' | jq -r '.id') + +JOB_ID=$(curl -s -X POST -H "$AUTH" \ + "$SERVER/api/v1/certificates/$CERT_ID/renew" | jq -r '.job_id') + +# Check job status transitions +curl -s -H "$AUTH" "$SERVER/api/v1/jobs/$JOB_ID" | jq '.status' +``` + +**Expected:** Job status transitions through pending states as DigiCert validates. +**PASS if** polling mechanism works and job reaches completion once DigiCert issues the certificate. + +### 39.5 Revocation Records Locally + +**Test:** Revoke a DigiCert-issued certificate. + +```bash +curl -s -X POST -H "$AUTH" \ + "$SERVER/api/v1/certificates/$CERT_ID/revoke" \ + -d '{"reason": "cessationOfOperation"}' | jq '.revoked_at' +``` + +**Expected:** Returns `revoked_at` timestamp. +**PASS if** revocation is recorded locally; operator manages revocation in DigiCert CertCentral dashboard. + +--- + ## Release Sign-Off All tests below must pass before tagging v2.1.0. Each row is one individual test from the guide above. The **Method** column indicates whether `qa-smoke-test.sh` covers the test automatically (**Auto**) or requires hands-on verification (**Manual**). @@ -5715,14 +5921,45 @@ These must be green before starting manual QA: | 37.16 | TargetsPage — target names clickable to /targets/:id | Manual | ☐ | | | | 37.17 | Sidebar — Digest and Observability nav items | Manual | ☐ | | | +### Part 38: Vault PKI Connector (M32) + +| Test | Description | Method | Pass? | Date | Notes | +|------|-------------|--------|-------|------|-------| +| 38.s1 | Vault PKI issuer exists in seed data | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 38.1 | +| 38.s2 | Vault issuer type is VaultPKI | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 38.2 | +| 38.s3 | Vault issuer is enabled | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 38.3 | +| 38.s4 | Vault connector passes go vet | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 38.4 | +| 38.s5 | Vault connector tests pass | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 38.5 | +| 38.s6 | OpenAPI spec includes VaultPKI type | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 38.6 | +| 38.1 | Register Vault PKI issuer | Manual | ☐ | | Requires live Vault server | +| 38.2 | Issue certificate via Vault PKI | Manual | ☐ | | Requires live Vault server | +| 38.3 | Verify certificate serial and subject | Manual | ☐ | | Requires live Vault server | +| 38.4 | Revocation records locally | Manual | ☐ | | Requires live Vault server | + +### Part 39: DigiCert Connector (M37) + +| Test | Description | Method | Pass? | Date | Notes | +|------|-------------|--------|-------|------|-------| +| 39.s1 | DigiCert issuer exists in seed data | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 39.1 | +| 39.s2 | DigiCert issuer type is DigiCert | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 39.2 | +| 39.s3 | DigiCert issuer is enabled | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 39.3 | +| 39.s4 | DigiCert connector passes go vet | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 39.4 | +| 39.s5 | DigiCert connector tests pass | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 39.5 | +| 39.s6 | OpenAPI spec includes DigiCert type | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 39.6 | +| 39.1 | Register DigiCert issuer | Manual | ☐ | | Requires DigiCert sandbox | +| 39.2 | Issue DV certificate via DigiCert | Manual | ☐ | | Requires DigiCert sandbox | +| 39.3 | Verify order ID tracking | Manual | ☐ | | Requires DigiCert sandbox | +| 39.4 | Async poll behavior | Manual | ☐ | | Requires DigiCert sandbox | +| 39.5 | Revocation records locally | Manual | ☐ | | Requires DigiCert sandbox | + ### Summary | Category | Count | |----------|-------| -| ☑ Auto (passed in `qa-smoke-test.sh`) | 127 | +| ☑ Auto (passed in `qa-smoke-test.sh`) | 136 | | — Skipped (preconditions not met in demo) | 5 | -| ☐ Manual (requires hands-on verification) | 217 | -| **Total** | **349** | +| ☐ Manual (requires hands-on verification) | 226 | +| **Total** | **367** | **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 337ff70..cfc98db 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,6 +25,8 @@ type Config struct { EST ESTConfig Verification VerificationConfig ACME ACMEConfig + Vault VaultConfig + DigiCert DigiCertConfig Digest DigestConfig } @@ -141,6 +143,57 @@ type StepCAConfig struct { ProvisionerPassword string } +// VaultConfig contains HashiCorp Vault PKI issuer connector configuration. +type VaultConfig struct { + // Addr is the Vault server address (e.g., "https://vault.example.com:8200"). + // Required for Vault PKI integration. + // Setting: CERTCTL_VAULT_ADDR environment variable. + Addr string + + // Token is the Vault token for authentication. + // Required for Vault PKI integration. + // Setting: CERTCTL_VAULT_TOKEN environment variable. + Token string + + // Mount is the PKI secrets engine mount path. + // Default: "pki". + // Setting: CERTCTL_VAULT_MOUNT environment variable. + Mount string + + // Role is the PKI role name used for signing certificates. + // Required for Vault PKI integration. + // Setting: CERTCTL_VAULT_ROLE environment variable. + Role string + + // TTL is the requested certificate time-to-live. + // Default: "8760h" (1 year). + // Setting: CERTCTL_VAULT_TTL environment variable. + TTL string +} + +// DigiCertConfig contains DigiCert CertCentral issuer connector configuration. +type DigiCertConfig struct { + // APIKey is the CertCentral API key for authentication. + // Required for DigiCert integration. + // Setting: CERTCTL_DIGICERT_API_KEY environment variable. + APIKey string + + // OrgID is the DigiCert organization ID for certificate orders. + // Required for DigiCert integration. + // Setting: CERTCTL_DIGICERT_ORG_ID environment variable. + OrgID string + + // ProductType is the DigiCert product type for certificate orders. + // Default: "ssl_basic". Common values: "ssl_basic", "ssl_wildcard", "ssl_ev_basic". + // Setting: CERTCTL_DIGICERT_PRODUCT_TYPE environment variable. + ProductType string + + // BaseURL is the DigiCert CertCentral API base URL. + // Default: "https://www.digicert.com/services/v2". + // Setting: CERTCTL_DIGICERT_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. @@ -429,6 +482,19 @@ func Load() (*Config, error) { Timeout: getEnvDuration("CERTCTL_VERIFY_TIMEOUT", 10*time.Second), Delay: getEnvDuration("CERTCTL_VERIFY_DELAY", 2*time.Second), }, + Vault: VaultConfig{ + Addr: getEnv("CERTCTL_VAULT_ADDR", ""), + Token: getEnv("CERTCTL_VAULT_TOKEN", ""), + Mount: getEnv("CERTCTL_VAULT_MOUNT", "pki"), + Role: getEnv("CERTCTL_VAULT_ROLE", ""), + TTL: getEnv("CERTCTL_VAULT_TTL", "8760h"), + }, + DigiCert: DigiCertConfig{ + APIKey: getEnv("CERTCTL_DIGICERT_API_KEY", ""), + OrgID: getEnv("CERTCTL_DIGICERT_ORG_ID", ""), + ProductType: getEnv("CERTCTL_DIGICERT_PRODUCT_TYPE", "ssl_basic"), + BaseURL: getEnv("CERTCTL_DIGICERT_BASE_URL", "https://www.digicert.com/services/v2"), + }, ACME: ACMEConfig{ DirectoryURL: getEnv("CERTCTL_ACME_DIRECTORY_URL", ""), Email: getEnv("CERTCTL_ACME_EMAIL", ""), diff --git a/internal/connector/issuer/digicert/digicert.go b/internal/connector/issuer/digicert/digicert.go new file mode 100644 index 0000000..e25a178 --- /dev/null +++ b/internal/connector/issuer/digicert/digicert.go @@ -0,0 +1,524 @@ +// Package digicert implements the issuer.Connector interface for DigiCert CertCentral. +// +// DigiCert CertCentral is an enterprise certificate authority offering DV, OV, and EV +// certificates. Unlike synchronous issuers (Vault, step-ca), DigiCert uses an +// asynchronous order model: submit an order, receive an order ID, 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 order; if status is "issued", returns cert immediately. +// If status is "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. +// +// Authentication: API key via X-DC-DEVKEY header. +// +// DigiCert CertCentral API used: +// +// POST /order/certificate/{product_type} - Submit certificate order +// GET /order/certificate/{order_id} - Check order status +// GET /certificate/{certificate_id}/download/format/pem_all - Download cert bundle +// PUT /certificate/{certificate_id}/revoke - Revoke certificate +// GET /user/me - Validate API credentials +package digicert + +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 DigiCert CertCentral issuer connector configuration. +type Config struct { + // APIKey is the CertCentral API key for authentication. + // Required. Set via CERTCTL_DIGICERT_API_KEY environment variable. + APIKey string `json:"api_key"` + + // OrgID is the DigiCert organization ID for certificate orders. + // Required. Set via CERTCTL_DIGICERT_ORG_ID environment variable. + OrgID string `json:"org_id"` + + // ProductType is the DigiCert product type for certificate orders. + // Default: "ssl_basic". Set via CERTCTL_DIGICERT_PRODUCT_TYPE environment variable. + // Common values: "ssl_basic", "ssl_wildcard", "ssl_ev_basic", "ssl_plus", "ssl_multi_domain". + ProductType string `json:"product_type"` + + // BaseURL is the DigiCert CertCentral API base URL. + // Default: "https://www.digicert.com/services/v2". + // Set via CERTCTL_DIGICERT_BASE_URL environment variable. + BaseURL string `json:"base_url"` +} + +// Connector implements the issuer.Connector interface for DigiCert CertCentral. +type Connector struct { + config *Config + logger *slog.Logger + httpClient *http.Client +} + +// New creates a new DigiCert CertCentral connector with the given configuration and logger. +func New(config *Config, logger *slog.Logger) *Connector { + if config != nil { + if config.ProductType == "" { + config.ProductType = "ssl_basic" + } + if config.BaseURL == "" { + config.BaseURL = "https://www.digicert.com/services/v2" + } + } + + return &Connector{ + config: config, + logger: logger, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// orderRequest is the JSON body for DigiCert certificate order submission. +type orderRequest struct { + Certificate orderCert `json:"certificate"` + Organization orderOrg `json:"organization"` + ValidityYears int `json:"validity_years"` +} + +type orderCert struct { + CommonName string `json:"common_name"` + CSR string `json:"csr"` + DNSNames []string `json:"dns_names,omitempty"` +} + +type orderOrg struct { + ID json.Number `json:"id"` +} + +// orderResponse is the JSON response from a certificate order submission. +type orderResponse struct { + ID int `json:"id"` + Status string `json:"status"` + CertificateID int `json:"certificate_id,omitempty"` +} + +// orderStatusResponse is the JSON response from an order status check. +type orderStatusResponse struct { + ID int `json:"id"` + Status string `json:"status"` + Certificate struct { + ID int `json:"id"` + CommonName string `json:"common_name"` + } `json:"certificate"` +} + +// ValidateConfig checks that the DigiCert 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 DigiCert config: %w", err) + } + + if cfg.APIKey == "" { + return fmt.Errorf("DigiCert api_key is required") + } + + if cfg.OrgID == "" { + return fmt.Errorf("DigiCert org_id is required") + } + + if cfg.ProductType == "" { + cfg.ProductType = "ssl_basic" + } + if cfg.BaseURL == "" { + cfg.BaseURL = "https://www.digicert.com/services/v2" + } + + // Test API access via /user/me + meURL := cfg.BaseURL + "/user/me" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, meURL, nil) + if err != nil { + return fmt.Errorf("failed to create API test request: %w", err) + } + req.Header.Set("X-DC-DEVKEY", cfg.APIKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("DigiCert 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("DigiCert API key is invalid (status %d)", resp.StatusCode) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("DigiCert API returned status %d", resp.StatusCode) + } + + c.config = &cfg + c.logger.Info("DigiCert CertCentral configuration validated", + "base_url", cfg.BaseURL, + "product_type", cfg.ProductType) + + return nil +} + +// IssueCertificate submits a certificate order to DigiCert CertCentral. +// 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 DigiCert issuance request", + "common_name", request.CommonName, + "san_count", len(request.SANs), + "product_type", c.config.ProductType) + + orderReq := orderRequest{ + Certificate: orderCert{ + CommonName: request.CommonName, + CSR: request.CSRPEM, + DNSNames: request.SANs, + }, + Organization: orderOrg{ + ID: json.Number(c.config.OrgID), + }, + ValidityYears: 1, + } + + body, err := json.Marshal(orderReq) + if err != nil { + return nil, fmt.Errorf("failed to marshal order request: %w", err) + } + + orderURL := fmt.Sprintf("%s/order/certificate/%s", c.config.BaseURL, c.config.ProductType) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, orderURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create order request: %w", err) + } + req.Header.Set("X-DC-DEVKEY", c.config.APIKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("DigiCert order request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read order response: %w", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("DigiCert order returned status %d: %s", resp.StatusCode, string(respBody)) + } + + var orderResp orderResponse + if err := json.Unmarshal(respBody, &orderResp); err != nil { + return nil, fmt.Errorf("failed to parse order response: %w", err) + } + + orderID := fmt.Sprintf("%d", orderResp.ID) + + c.logger.Info("DigiCert order submitted", + "order_id", orderID, + "status", orderResp.Status) + + // If issued immediately (DV certs), download the certificate + if orderResp.Status == "issued" && orderResp.CertificateID > 0 { + certPEM, chainPEM, serial, notBefore, notAfter, err := c.downloadCertificate(ctx, orderResp.CertificateID) + if err != nil { + return nil, fmt.Errorf("failed to download certificate: %w", err) + } + + c.logger.Info("DigiCert certificate issued immediately", + "order_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("DigiCert order pending validation", + "order_id", orderID, + "status", orderResp.Status) + + return &issuer.IssuanceResult{ + OrderID: orderID, + }, nil +} + +// RenewCertificate renews a certificate by submitting a new order. +// DigiCert uses reissue for renewal, but for simplicity we submit a new order +// (reissue requires the original order ID which may not be available). +func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) { + c.logger.Info("processing DigiCert 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 DigiCert CertCentral. +// DigiCert revocation uses certificate_id, so we extract it from the serial +// by looking up the order. For simplicity, we use the serial as the cert ID +// (the caller should provide the DigiCert certificate ID). +func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error { + c.logger.Info("processing DigiCert revocation request", "serial", request.Serial) + + reason := "unspecified" + if request.Reason != nil { + reason = *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) + } + + // DigiCert uses certificate_id in the URL path for revocation + revokeURL := fmt.Sprintf("%s/certificate/%s/revoke", c.config.BaseURL, request.Serial) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, revokeURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create revoke request: %w", err) + } + req.Header.Set("X-DC-DEVKEY", c.config.APIKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("DigiCert revoke request failed: %w", err) + } + defer resp.Body.Close() + + // DigiCert returns 204 No Content on successful revocation + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("DigiCert revoke returned status %d: %s", resp.StatusCode, string(respBody)) + } + + c.logger.Info("DigiCert certificate revoked", "serial", request.Serial, "reason", reason) + return nil +} + +// GetOrderStatus checks the status of a DigiCert certificate order. +// If the order 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 DigiCert order status", "order_id", orderID) + + statusURL := fmt.Sprintf("%s/order/certificate/%s", c.config.BaseURL, orderID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create status request: %w", err) + } + req.Header.Set("X-DC-DEVKEY", c.config.APIKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("DigiCert 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("DigiCert order status returned %d: %s", resp.StatusCode, string(respBody)) + } + + var statusResp orderStatusResponse + if err := json.Unmarshal(respBody, &statusResp); err != nil { + return nil, fmt.Errorf("failed to parse status response: %w", err) + } + + now := time.Now() + + switch statusResp.Status { + case "issued": + if statusResp.Certificate.ID == 0 { + return nil, fmt.Errorf("order is issued but certificate_id is missing") + } + + certPEM, chainPEM, serial, notBefore, notAfter, err := c.downloadCertificate(ctx, statusResp.Certificate.ID) + if err != nil { + return nil, fmt.Errorf("failed to download certificate: %w", err) + } + + c.logger.Info("DigiCert order completed", + "order_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 "pending", "processing": + msg := fmt.Sprintf("order %s is %s", orderID, statusResp.Status) + return &issuer.OrderStatus{ + OrderID: orderID, + Status: "pending", + Message: &msg, + UpdatedAt: now, + }, nil + + case "rejected", "denied": + msg := fmt.Sprintf("order %s was %s", orderID, statusResp.Status) + return &issuer.OrderStatus{ + OrderID: orderID, + Status: "failed", + Message: &msg, + UpdatedAt: now, + }, nil + + default: + msg := fmt.Sprintf("unknown order status: %s", statusResp.Status) + return &issuer.OrderStatus{ + OrderID: orderID, + Status: "pending", + Message: &msg, + UpdatedAt: now, + }, nil + } +} + +// downloadCertificate downloads the PEM bundle for a DigiCert certificate. +func (c *Connector) downloadCertificate(ctx context.Context, certificateID int) (certPEM string, chainPEM string, serial string, notBefore time.Time, notAfter time.Time, err error) { + downloadURL := fmt.Sprintf("%s/certificate/%d/download/format/pem_all", c.config.BaseURL, certificateID) + req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) + if reqErr != nil { + err = fmt.Errorf("failed to create download request: %w", reqErr) + return + } + req.Header.Set("X-DC-DEVKEY", c.config.APIKey) + + resp, doErr := c.httpClient.Do(req) + if doErr != nil { + err = fmt.Errorf("DigiCert download request failed: %w", doErr) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + err = fmt.Errorf("DigiCert download returned status %d: %s", resp.StatusCode, string(body)) + return + } + + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + err = fmt.Errorf("failed to read download response: %w", readErr) + return + } + + // Parse the PEM bundle: first cert is the leaf, rest are intermediates + certPEM, chainPEM, serial, notBefore, notAfter, err = parsePEMBundle(string(body)) + return +} + +// 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 +} + +// GenerateCRL is not supported because DigiCert manages CRL distribution. +func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) { + return nil, fmt.Errorf("DigiCert manages CRL distribution; use DigiCert's CRL endpoints") +} + +// SignOCSPResponse is not supported because DigiCert manages OCSP. +func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) { + return nil, fmt.Errorf("DigiCert manages OCSP; use DigiCert's OCSP responder") +} + +// GetCACertPEM is not directly supported. DigiCert 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("DigiCert intermediate certificates are included with each issued certificate") +} + +// GetRenewalInfo returns nil, nil as DigiCert 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/digicert/digicert_test.go b/internal/connector/issuer/digicert/digicert_test.go new file mode 100644 index 0000000..77fee20 --- /dev/null +++ b/internal/connector/issuer/digicert/digicert_test.go @@ -0,0 +1,591 @@ +package digicert_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "fmt" + "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/digicert" +) + +func TestDigiCertConnector(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 == "/user/me" { + if r.Header.Get("X-DC-DEVKEY") == "dc-test-api-key" { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":12345,"first_name":"Test","last_name":"User"}`)) + return + } + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"errors":[{"code":"invalid_api_key"}]}`)) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + config := digicert.Config{ + APIKey: "dc-test-api-key", + OrgID: "12345", + ProductType: "ssl_basic", + BaseURL: srv.URL, + } + + connector := digicert.New(nil, logger) + rawConfig, _ := json.Marshal(config) + err := connector.ValidateConfig(ctx, rawConfig) + if err != nil { + t.Fatalf("ValidateConfig failed: %v", err) + } + }) + + t.Run("ValidateConfig_MissingAPIKey", func(t *testing.T) { + config := digicert.Config{ + OrgID: "12345", + } + + connector := digicert.New(nil, logger) + rawConfig, _ := json.Marshal(config) + err := connector.ValidateConfig(ctx, rawConfig) + if err == nil { + t.Fatal("Expected error for missing api_key") + } + if !strings.Contains(err.Error(), "api_key is required") { + t.Errorf("Expected api_key required error, got: %v", err) + } + }) + + t.Run("ValidateConfig_MissingOrgID", func(t *testing.T) { + config := digicert.Config{ + APIKey: "dc-test-key", + } + + connector := digicert.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_InvalidKey", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/user/me" { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"errors":[{"code":"invalid_api_key"}]}`)) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + config := digicert.Config{ + APIKey: "dc-bad-key", + OrgID: "12345", + BaseURL: srv.URL, + } + + connector := digicert.New(nil, logger) + rawConfig, _ := json.Marshal(config) + err := connector.ValidateConfig(ctx, rawConfig) + if err == nil { + t.Fatal("Expected error for invalid API key") + } + 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) { + switch { + case strings.HasPrefix(r.URL.Path, "/order/certificate/ssl_basic"): + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"id":99001,"status":"issued","certificate_id":88001}`)) + case r.URL.Path == "/certificate/88001/download/format/pem_all": + w.WriteHeader(http.StatusOK) + w.Write([]byte(pemBundle)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + config := &digicert.Config{ + APIKey: "dc-test-key", + OrgID: "12345", + ProductType: "ssl_basic", + BaseURL: srv.URL, + } + connector := digicert.New(config, logger) + + _, csrPEM := generateTestCSR(t, "app.example.com") + req := issuer.IssuanceRequest{ + CommonName: "app.example.com", + SANs: []string{"app.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 != "99001" { + t.Errorf("Expected OrderID '99001', got '%s'", result.OrderID) + } + t.Logf("DigiCert 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 strings.HasPrefix(r.URL.Path, "/order/certificate/ssl_ev_basic"): + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"id":99002,"status":"pending"}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + config := &digicert.Config{ + APIKey: "dc-test-key", + OrgID: "12345", + ProductType: "ssl_ev_basic", + BaseURL: srv.URL, + } + connector := digicert.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 != "99002" { + t.Errorf("Expected OrderID '99002', 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(`{"errors":[{"code":"invalid_csr","message":"CSR is malformed"}]}`)) + })) + defer srv.Close() + + config := &digicert.Config{ + APIKey: "dc-test-key", + OrgID: "12345", + ProductType: "ssl_basic", + BaseURL: srv.URL, + } + connector := digicert.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 { + case r.URL.Path == "/order/certificate/99001": + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":99001,"status":"issued","certificate":{"id":88001,"common_name":"app.example.com"}}`)) + case r.URL.Path == "/certificate/88001/download/format/pem_all": + w.WriteHeader(http.StatusOK) + w.Write([]byte(pemBundle)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + config := &digicert.Config{ + APIKey: "dc-test-key", + OrgID: "12345", + BaseURL: srv.URL, + } + connector := digicert.New(config, logger) + + status, err := connector.GetOrderStatus(ctx, "99001") + 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 == "/order/certificate/99002" { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":99002,"status":"pending","certificate":{"id":0}}`)) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + config := &digicert.Config{ + APIKey: "dc-test-key", + OrgID: "12345", + BaseURL: srv.URL, + } + connector := digicert.New(config, logger) + + status, err := connector.GetOrderStatus(ctx, "99002") + 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 == "/order/certificate/99003" { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":99003,"status":"rejected","certificate":{"id":0}}`)) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + config := &digicert.Config{ + APIKey: "dc-test-key", + OrgID: "12345", + BaseURL: srv.URL, + } + connector := digicert.New(config, logger) + + status, err := connector.GetOrderStatus(ctx, "99003") + if err != nil { + t.Fatalf("GetOrderStatus failed: %v", err) + } + + if status.Status != "failed" { + t.Errorf("Expected status 'failed', 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 strings.HasPrefix(r.URL.Path, "/order/certificate/"): + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"id":99010,"status":"pending"}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + config := &digicert.Config{ + APIKey: "dc-test-key", + OrgID: "12345", + ProductType: "ssl_basic", + BaseURL: srv.URL, + } + connector := digicert.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.HasSuffix(r.URL.Path, "/revoke") && r.Method == http.MethodPut { + if r.Header.Get("X-DC-DEVKEY") == "" { + w.WriteHeader(http.StatusForbidden) + return + } + w.WriteHeader(http.StatusNoContent) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + config := &digicert.Config{ + APIKey: "dc-test-key", + OrgID: "12345", + BaseURL: srv.URL, + } + connector := digicert.New(config, logger) + + reason := "keyCompromise" + revokeReq := issuer.RevocationRequest{ + Serial: "88001", + 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(`{"errors":[{"code":"certificate_not_found"}]}`)) + })) + defer srv.Close() + + config := &digicert.Config{ + APIKey: "dc-test-key", + OrgID: "12345", + BaseURL: srv.URL, + } + connector := digicert.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("GetOrderStatus_DownloadError", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/order/certificate/99004": + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id":99004,"status":"issued","certificate":{"id":88004}}`)) + case r.URL.Path == "/certificate/88004/download/format/pem_all": + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"errors":["internal server error"]}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + config := &digicert.Config{ + APIKey: "dc-test-key", + OrgID: "12345", + BaseURL: srv.URL, + } + connector := digicert.New(config, logger) + + _, err := connector.GetOrderStatus(ctx, "99004") + if err == nil { + t.Fatal("Expected error when download fails") + } + if !strings.Contains(err.Error(), "download") { + t.Logf("Got error: %v", err) + } + }) + + t.Run("GetRenewalInfo_ReturnsNil", func(t *testing.T) { + config := &digicert.Config{ + APIKey: "dc-test-key", + OrgID: "12345", + BaseURL: "https://api.digicert.com", + } + connector := digicert.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 DigiCert") + } + }) + + t.Run("DefaultProductType", func(t *testing.T) { + config := &digicert.Config{ + APIKey: "dc-test-key", + OrgID: "12345", + // ProductType intentionally left empty + } + connector := digicert.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 product type + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify the path includes the default product type + if strings.Contains(r.URL.Path, "ssl_basic") { + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"id":99099,"status":"pending"}`)) + return + } + t.Errorf("Expected path to contain 'ssl_basic', got: %s", r.URL.Path) + w.WriteHeader(http.StatusBadRequest) + })) + defer srv.Close() + + // Reconfigure with test server URL + config.BaseURL = srv.URL + connector = digicert.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 product type failed: %v", err) + } + if result.OrderID == "" { + t.Error("OrderID should not be empty") + } + }) +} + +// 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/connector/issuer/vault/vault.go b/internal/connector/issuer/vault/vault.go new file mode 100644 index 0000000..c1cd992 --- /dev/null +++ b/internal/connector/issuer/vault/vault.go @@ -0,0 +1,372 @@ +// Package vault implements the issuer.Connector interface for HashiCorp Vault PKI +// secrets engine. +// +// Vault PKI provides a full-featured private CA with certificate signing, revocation, +// CRL, and OCSP capabilities. This connector uses the Vault HTTP API to sign CSRs +// via the /v1/{mount}/sign/{role} endpoint, authenticated with a Vault token. +// +// Vault issues certificates synchronously (like step-ca), so GetOrderStatus always +// returns "completed". CRL and OCSP are delegated to Vault's own endpoints. +// +// Authentication: Vault token via X-Vault-Token header. +// +// Vault API used: +// +// GET /v1/sys/health - Health check +// POST /v1/{mount}/sign/{role} - Sign CSR +// POST /v1/{mount}/revoke - Revoke certificate +// GET /v1/{mount}/ca/pem - Get CA certificate +package vault + +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 Vault PKI issuer connector configuration. +type Config struct { + // Addr is the Vault server address (e.g., "https://vault.example.com:8200"). + // Required. Set via CERTCTL_VAULT_ADDR environment variable. + Addr string `json:"addr"` + + // Token is the Vault token for authentication. + // Required. Set via CERTCTL_VAULT_TOKEN environment variable. + Token string `json:"token"` + + // Mount is the PKI secrets engine mount path. + // Default: "pki". Set via CERTCTL_VAULT_MOUNT environment variable. + Mount string `json:"mount"` + + // Role is the PKI role name used for signing certificates. + // Required. Set via CERTCTL_VAULT_ROLE environment variable. + Role string `json:"role"` + + // TTL is the requested certificate TTL (e.g., "8760h" for 1 year). + // Default: "8760h". Set via CERTCTL_VAULT_TTL environment variable. + TTL string `json:"ttl"` +} + +// Connector implements the issuer.Connector interface for Vault PKI. +type Connector struct { + config *Config + logger *slog.Logger + httpClient *http.Client +} + +// New creates a new Vault PKI connector with the given configuration and logger. +func New(config *Config, logger *slog.Logger) *Connector { + if config != nil { + if config.Mount == "" { + config.Mount = "pki" + } + if config.TTL == "" { + config.TTL = "8760h" + } + } + + return &Connector{ + config: config, + logger: logger, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// vaultResponse is the standard Vault API response wrapper. +type vaultResponse struct { + Data json.RawMessage `json:"data"` + Errors []string `json:"errors,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} + +// signData holds the data returned from the /sign endpoint. +type signData struct { + Certificate string `json:"certificate"` + IssuingCA string `json:"issuing_ca"` + CAChain []string `json:"ca_chain"` + SerialNumber string `json:"serial_number"` + Expiration int64 `json:"expiration"` +} + +// ValidateConfig checks that the Vault configuration is valid and the server is reachable. +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 Vault config: %w", err) + } + + if cfg.Addr == "" { + return fmt.Errorf("Vault addr is required") + } + + if cfg.Token == "" { + return fmt.Errorf("Vault token is required") + } + + if cfg.Role == "" { + return fmt.Errorf("Vault role is required") + } + + if cfg.Mount == "" { + cfg.Mount = "pki" + } + if cfg.TTL == "" { + cfg.TTL = "8760h" + } + + // Health check + healthURL := cfg.Addr + "/v1/sys/health" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil) + if err != nil { + return fmt.Errorf("failed to create health check request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("Vault not reachable at %s: %w", cfg.Addr, err) + } + defer resp.Body.Close() + + // Vault health returns 200 for initialized+unsealed, 429 for standby, 472 for DR secondary, + // 473 for perf standby, 501 for uninitialized, 503 for sealed + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusTooManyRequests { + return fmt.Errorf("Vault health check returned status %d", resp.StatusCode) + } + + c.config = &cfg + c.logger.Info("Vault PKI configuration validated", + "addr", cfg.Addr, + "mount", cfg.Mount, + "role", cfg.Role) + + return nil +} + +// IssueCertificate submits a CSR to Vault PKI for signing. +func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) { + c.logger.Info("processing Vault PKI issuance request", + "common_name", request.CommonName, + "san_count", len(request.SANs)) + + // Build the sign request body + signBody := map[string]interface{}{ + "csr": request.CSRPEM, + "common_name": request.CommonName, + "ttl": c.config.TTL, + } + + if len(request.SANs) > 0 { + signBody["alt_names"] = strings.Join(request.SANs, ",") + } + + body, err := json.Marshal(signBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal sign request: %w", err) + } + + // POST /v1/{mount}/sign/{role} + signURL := fmt.Sprintf("%s/v1/%s/sign/%s", c.config.Addr, c.config.Mount, c.config.Role) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, signURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create sign request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Vault-Token", c.config.Token) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("Vault sign request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read sign response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + var vaultResp vaultResponse + if jsonErr := json.Unmarshal(respBody, &vaultResp); jsonErr == nil && len(vaultResp.Errors) > 0 { + return nil, fmt.Errorf("Vault sign returned status %d: %s", resp.StatusCode, strings.Join(vaultResp.Errors, "; ")) + } + return nil, fmt.Errorf("Vault sign returned status %d: %s", resp.StatusCode, string(respBody)) + } + + // Parse the Vault response + var vaultResp vaultResponse + if err := json.Unmarshal(respBody, &vaultResp); err != nil { + return nil, fmt.Errorf("failed to parse Vault response: %w", err) + } + + var data signData + if err := json.Unmarshal(vaultResp.Data, &data); err != nil { + return nil, fmt.Errorf("failed to parse Vault sign data: %w", err) + } + + if data.Certificate == "" { + return nil, fmt.Errorf("no certificate in Vault sign response") + } + + // Parse the leaf certificate to extract metadata + certPEM := data.Certificate + block, _ := pem.Decode([]byte(certPEM)) + if block == nil { + return nil, fmt.Errorf("failed to decode certificate PEM from Vault") + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate: %w", err) + } + + // Build chain PEM from ca_chain or issuing_ca + var chainPEM string + if len(data.CAChain) > 0 { + chainPEM = strings.Join(data.CAChain, "\n") + } else if data.IssuingCA != "" { + chainPEM = data.IssuingCA + } + + // Normalize serial: Vault uses colon-separated hex (e.g., "aa:bb:cc"), convert to plain string + serial := normalizeSerial(data.SerialNumber) + + orderID := fmt.Sprintf("vault-%s", serial) + + c.logger.Info("Vault PKI certificate issued", + "common_name", request.CommonName, + "serial", serial, + "not_after", cert.NotAfter) + + return &issuer.IssuanceResult{ + CertPEM: certPEM, + ChainPEM: chainPEM, + Serial: serial, + NotBefore: cert.NotBefore, + NotAfter: cert.NotAfter, + OrderID: orderID, + }, nil +} + +// RenewCertificate renews a certificate by creating a new signing request. +// For Vault PKI, renewal is functionally identical to issuance (new cert signed from CSR). +func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) { + c.logger.Info("processing Vault PKI 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 Vault PKI. +func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error { + c.logger.Info("processing Vault PKI revocation request", "serial", request.Serial) + + revokeBody := map[string]interface{}{ + "serial_number": request.Serial, + } + + body, err := json.Marshal(revokeBody) + if err != nil { + return fmt.Errorf("failed to marshal revoke request: %w", err) + } + + revokeURL := fmt.Sprintf("%s/v1/%s/revoke", c.config.Addr, c.config.Mount) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, revokeURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create revoke request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Vault-Token", c.config.Token) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("Vault revoke request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("Vault revoke returned status %d: %s", resp.StatusCode, string(respBody)) + } + + c.logger.Info("Vault PKI certificate revoked", "serial", request.Serial) + return nil +} + +// GetOrderStatus returns the status of a Vault PKI order. +// Vault signs synchronously, so orders are always "completed" immediately. +func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) { + return &issuer.OrderStatus{ + OrderID: orderID, + Status: "completed", + UpdatedAt: time.Now(), + }, nil +} + +// GenerateCRL is not supported because Vault serves CRL directly at /v1/{mount}/crl. +func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) { + return nil, fmt.Errorf("Vault serves CRL directly at /v1/%s/crl; use Vault's endpoint", c.config.Mount) +} + +// SignOCSPResponse is not supported because Vault serves OCSP directly at /v1/{mount}/ocsp. +func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) { + return nil, fmt.Errorf("Vault serves OCSP directly at /v1/%s/ocsp; use Vault's endpoint", c.config.Mount) +} + +// GetCACertPEM retrieves the CA certificate from Vault PKI. +func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) { + caURL := fmt.Sprintf("%s/v1/%s/ca/pem", c.config.Addr, c.config.Mount) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, caURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create CA cert request: %w", err) + } + req.Header.Set("X-Vault-Token", c.config.Token) + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("Vault CA cert request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("Vault CA cert returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read CA cert response: %w", err) + } + + return string(body), nil +} + +// GetRenewalInfo returns nil, nil as Vault does not support ACME Renewal Information (ARI). +func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) { + return nil, nil +} + +// normalizeSerial converts Vault's colon-separated hex serial (e.g., "aa:bb:cc:dd") +// to a plain string representation suitable for storage. +func normalizeSerial(serial string) string { + return strings.ReplaceAll(serial, ":", "-") +} + +// Ensure Connector implements the issuer.Connector interface. +var _ issuer.Connector = (*Connector)(nil) diff --git a/internal/connector/issuer/vault/vault_test.go b/internal/connector/issuer/vault/vault_test.go new file mode 100644 index 0000000..0ebeafe --- /dev/null +++ b/internal/connector/issuer/vault/vault_test.go @@ -0,0 +1,527 @@ +package vault_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "fmt" + "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/vault" +) + +func TestVaultConnector(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 == "/v1/sys/health" { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"initialized":true,"sealed":false,"standby":false}`)) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + config := vault.Config{ + Addr: srv.URL, + Token: "s.test-token-12345", + Mount: "pki", + Role: "web-certs", + TTL: "8760h", + } + + connector := vault.New(nil, logger) + rawConfig, _ := json.Marshal(config) + err := connector.ValidateConfig(ctx, rawConfig) + if err != nil { + t.Fatalf("ValidateConfig failed: %v", err) + } + }) + + t.Run("ValidateConfig_MissingAddr", func(t *testing.T) { + config := vault.Config{ + Token: "s.test-token", + Role: "web-certs", + } + + connector := vault.New(nil, logger) + rawConfig, _ := json.Marshal(config) + err := connector.ValidateConfig(ctx, rawConfig) + if err == nil { + t.Fatal("Expected error for missing addr") + } + if !strings.Contains(err.Error(), "addr is required") { + t.Errorf("Expected addr required error, got: %v", err) + } + }) + + t.Run("ValidateConfig_MissingToken", func(t *testing.T) { + config := vault.Config{ + Addr: "https://vault.example.com:8200", + Role: "web-certs", + } + + connector := vault.New(nil, logger) + rawConfig, _ := json.Marshal(config) + err := connector.ValidateConfig(ctx, rawConfig) + if err == nil { + t.Fatal("Expected error for missing token") + } + if !strings.Contains(err.Error(), "token is required") { + t.Errorf("Expected token required error, got: %v", err) + } + }) + + t.Run("ValidateConfig_MissingRole", func(t *testing.T) { + config := vault.Config{ + Addr: "https://vault.example.com:8200", + Token: "s.test-token", + } + + connector := vault.New(nil, logger) + rawConfig, _ := json.Marshal(config) + err := connector.ValidateConfig(ctx, rawConfig) + if err == nil { + t.Fatal("Expected error for missing role") + } + if !strings.Contains(err.Error(), "role is required") { + t.Errorf("Expected role required error, got: %v", err) + } + }) + + t.Run("ValidateConfig_UnreachableVault", func(t *testing.T) { + config := vault.Config{ + Addr: "http://localhost:19999", + Token: "s.test-token", + Role: "web-certs", + } + + connector := vault.New(nil, logger) + rawConfig, _ := json.Marshal(config) + err := connector.ValidateConfig(ctx, rawConfig) + if err == nil { + t.Fatal("Expected error for unreachable Vault") + } + }) + + t.Run("IssueCertificate_Success", func(t *testing.T) { + testCertPEM, _ := generateTestCert(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/v1/sys/health": + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"initialized":true,"sealed":false}`)) + case strings.HasPrefix(r.URL.Path, "/v1/pki/sign/"): + // Verify auth header + if r.Header.Get("X-Vault-Token") != "s.test-token" { + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"errors":["permission denied"]}`)) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + resp := fmt.Sprintf(`{ + "data": { + "certificate": %q, + "issuing_ca": %q, + "ca_chain": [%q], + "serial_number": "aa:bb:cc:dd:ee:ff", + "expiration": 1893456000 + } + }`, testCertPEM, testCertPEM, testCertPEM) + w.Write([]byte(resp)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + config := &vault.Config{ + Addr: srv.URL, + Token: "s.test-token", + Mount: "pki", + Role: "web-certs", + TTL: "8760h", + } + connector := vault.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 is empty") + } + if result.Serial == "" { + t.Error("Serial is empty") + } + if result.OrderID == "" { + t.Error("OrderID is empty") + } + if !strings.HasPrefix(result.OrderID, "vault-") { + t.Errorf("Expected OrderID to start with 'vault-', got '%s'", result.OrderID) + } + // Verify serial normalization (colons replaced with dashes) + if strings.Contains(result.Serial, ":") { + t.Errorf("Serial should not contain colons, got '%s'", result.Serial) + } + t.Logf("Vault issued cert: serial=%s, orderID=%s", result.Serial, result.OrderID) + }) + + t.Run("IssueCertificate_ServerError", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/v1/sys/health": + w.WriteHeader(http.StatusOK) + case strings.HasPrefix(r.URL.Path, "/v1/pki/sign/"): + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"errors":["invalid CSR"]}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + config := &vault.Config{ + Addr: srv.URL, + Token: "s.test-token", + Mount: "pki", + Role: "web-certs", + } + connector := vault.New(config, logger) + + _, csrPEM := generateTestCSR(t, "test.example.com") + req := issuer.IssuanceRequest{ + CommonName: "test.example.com", + CSRPEM: csrPEM, + } + + _, err := connector.IssueCertificate(ctx, req) + if err == nil { + t.Fatal("Expected error for server error response") + } + if !strings.Contains(err.Error(), "invalid CSR") { + t.Logf("Got error: %v", err) + } + }) + + t.Run("IssueCertificate_Forbidden", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/v1/sys/health": + w.WriteHeader(http.StatusOK) + case strings.HasPrefix(r.URL.Path, "/v1/pki/sign/"): + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"errors":["permission denied"]}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + config := &vault.Config{ + Addr: srv.URL, + Token: "s.bad-token", + Mount: "pki", + Role: "web-certs", + } + connector := vault.New(config, logger) + + _, csrPEM := generateTestCSR(t, "test.example.com") + req := issuer.IssuanceRequest{ + CommonName: "test.example.com", + CSRPEM: csrPEM, + } + + _, err := connector.IssueCertificate(ctx, req) + if err == nil { + t.Fatal("Expected error for forbidden response") + } + if !strings.Contains(err.Error(), "permission denied") { + t.Logf("Got error: %v", err) + } + }) + + t.Run("RenewCertificate_Success", func(t *testing.T) { + testCertPEM, _ := generateTestCert(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/v1/sys/health": + w.WriteHeader(http.StatusOK) + case strings.HasPrefix(r.URL.Path, "/v1/pki/sign/"): + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + resp := fmt.Sprintf(`{ + "data": { + "certificate": %q, + "issuing_ca": %q, + "serial_number": "11:22:33:44:55:66", + "expiration": 1893456000 + } + }`, testCertPEM, testCertPEM) + w.Write([]byte(resp)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + config := &vault.Config{ + Addr: srv.URL, + Token: "s.test-token", + Mount: "pki", + Role: "web-certs", + } + connector := vault.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.Serial == "" { + t.Error("Serial is empty") + } + }) + + t.Run("RevokeCertificate_Success", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/v1/sys/health": + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/v1/pki/revoke": + // Verify token + if r.Header.Get("X-Vault-Token") == "" { + w.WriteHeader(http.StatusForbidden) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data":{"revocation_time":1234567890}}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + config := &vault.Config{ + Addr: srv.URL, + Token: "s.test-token", + Mount: "pki", + Role: "web-certs", + } + connector := vault.New(config, logger) + + reason := "keyCompromise" + revokeReq := issuer.RevocationRequest{ + Serial: "aa-bb-cc-dd-ee-ff", + Reason: &reason, + } + + err := connector.RevokeCertificate(ctx, revokeReq) + if err != nil { + t.Fatalf("RevokeCertificate failed: %v", err) + } + }) + + t.Run("RevokeCertificate_ServerError", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/v1/sys/health": + w.WriteHeader(http.StatusOK) + case r.URL.Path == "/v1/pki/revoke": + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"errors":["serial not found"]}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + config := &vault.Config{ + Addr: srv.URL, + Token: "s.test-token", + Mount: "pki", + Role: "web-certs", + } + connector := vault.New(config, logger) + + revokeReq := issuer.RevocationRequest{ + Serial: "00-00-00-00", + } + + err := connector.RevokeCertificate(ctx, revokeReq) + if err == nil { + t.Fatal("Expected error for server error response") + } + }) + + t.Run("GetCACertPEM_Success", func(t *testing.T) { + expectedPEM := "-----BEGIN CERTIFICATE-----\nTESTCA\n-----END CERTIFICATE-----\n" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/v1/pki/ca/pem": + w.WriteHeader(http.StatusOK) + w.Write([]byte(expectedPEM)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + config := &vault.Config{ + Addr: srv.URL, + Token: "s.test-token", + Mount: "pki", + Role: "web-certs", + } + connector := vault.New(config, logger) + + caPEM, err := connector.GetCACertPEM(ctx) + if err != nil { + t.Fatalf("GetCACertPEM failed: %v", err) + } + + if caPEM != expectedPEM { + t.Errorf("Expected CA PEM %q, got %q", expectedPEM, caPEM) + } + }) + + t.Run("GetOrderStatus_Synchronous", func(t *testing.T) { + config := &vault.Config{ + Addr: "https://vault.example.com:8200", + Token: "s.test-token", + Mount: "pki", + Role: "web-certs", + } + connector := vault.New(config, logger) + + status, err := connector.GetOrderStatus(ctx, "vault-aa-bb-cc") + if err != nil { + t.Fatalf("GetOrderStatus failed: %v", err) + } + + if status.Status != "completed" { + t.Errorf("Expected status 'completed', got '%s'", status.Status) + } + if status.OrderID != "vault-aa-bb-cc" { + t.Errorf("Expected OrderID 'vault-aa-bb-cc', got '%s'", status.OrderID) + } + }) + + t.Run("GetRenewalInfo_ReturnsNil", func(t *testing.T) { + config := &vault.Config{ + Addr: "https://vault.example.com:8200", + Token: "s.test-token", + Mount: "pki", + Role: "web-certs", + } + connector := vault.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 Vault") + } + }) +} + +// 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: "Test Certificate", + }, + 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 16c5b72..84a2659 100644 --- a/internal/domain/connector.go +++ b/internal/domain/connector.go @@ -69,6 +69,8 @@ const ( IssuerTypeGenericCA IssuerType = "GenericCA" IssuerTypeStepCA IssuerType = "StepCA" IssuerTypeOpenSSL IssuerType = "OpenSSL" + IssuerTypeVault IssuerType = "VaultPKI" + IssuerTypeDigiCert IssuerType = "DigiCert" ) // TargetType represents the type of deployment target. diff --git a/migrations/seed_demo.sql b/migrations/seed_demo.sql index 7db4659..79e9b04 100644 --- a/migrations/seed_demo.sql +++ b/migrations/seed_demo.sql @@ -43,7 +43,9 @@ INSERT INTO issuers (id, name, type, config, enabled, created_at, updated_at) VA ('iss-acme-le', 'Let''s Encrypt Staging', 'acme', '{"directory_url": "https://acme-staging-v02.api.letsencrypt.org/directory", "email": "admin@example.com", "challenge_type": "http-01"}', true, NOW() - INTERVAL '150 days', NOW() - INTERVAL '150 days'), ('iss-stepca', 'step-ca Internal', 'stepca', '{"ca_url": "https://ca.internal:9000", "provisioner_name": "certctl", "validity_days": 90}', true, NOW() - INTERVAL '120 days', NOW() - INTERVAL '120 days'), ('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-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') ON CONFLICT (id) DO NOTHING; -- ============================================================