mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:41:30 +00:00
feat: add Vault PKI and DigiCert CertCentral issuer connectors (M32 + M37)
Vault PKI: synchronous issuance via /v1/{mount}/sign/{role}, token auth,
revocation, CA cert retrieval, 14 tests. DigiCert CertCentral: async order
model (submit → poll → download), X-DC-DEVKEY auth, OV/EV support, PEM
bundle parsing, 16 tests. Both conditionally registered based on env vars.
Includes OpenAPI enum updates, seed data, connector docs, architecture docs,
README badges, and testing guide sign-off (Parts 38 + 39, 12 automated
smoke test assertions all passing).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
+1
-1
@@ -2643,7 +2643,7 @@ components:
|
||||
# ─── Issuers ─────────────────────────────────────────────────────
|
||||
IssuerType:
|
||||
type: string
|
||||
enum: [ACME, GenericCA, StepCA]
|
||||
enum: [ACME, GenericCA, StepCA, VaultPKI, DigiCert]
|
||||
|
||||
Issuer:
|
||||
type: object
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+47
-4
@@ -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.
|
||||
|
||||
|
||||
+240
-3
@@ -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.
|
||||
|
||||
|
||||
@@ -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", ""),
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
-- ============================================================
|
||||
|
||||
Reference in New Issue
Block a user