mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 19:38:51 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a53b648b1 |
@@ -85,8 +85,9 @@ For the full capability breakdown — revocation infrastructure (CRL + OCSP), po
|
|||||||
| Vault PKI | Beta | `VaultPKI` |
|
| Vault PKI | Beta | `VaultPKI` |
|
||||||
| DigiCert CertCentral | Beta | `DigiCert` |
|
| DigiCert CertCentral | Beta | `DigiCert` |
|
||||||
| Sectigo SCM | Beta | `Sectigo` |
|
| Sectigo SCM | Beta | `Sectigo` |
|
||||||
|
| Google CAS | Beta | `GoogleCAS` |
|
||||||
|
|
||||||
**Vault PKI, DigiCert, and Sectigo connectors are in beta.** If you hit any bugs or unexpected behavior, please [open a GitHub issue](https://github.com/shankar0123/certctl/issues) -- we're actively testing these and want to hear from real users.
|
**Vault PKI, DigiCert, Sectigo, and Google CAS 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.
|
**Note:** ADCS integration is handled via the Local CA's sub-CA mode — certctl operates as a subordinate CA with its signing certificate issued by ADCS. Any CA with a shell-accessible signing interface can be integrated today via the OpenSSL/Custom CA connector.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2643,7 +2643,7 @@ components:
|
|||||||
# ─── Issuers ─────────────────────────────────────────────────────
|
# ─── Issuers ─────────────────────────────────────────────────────
|
||||||
IssuerType:
|
IssuerType:
|
||||||
type: string
|
type: string
|
||||||
enum: [ACME, GenericCA, StepCA, VaultPKI, DigiCert, Sectigo]
|
enum: [ACME, GenericCA, StepCA, VaultPKI, DigiCert, Sectigo, GoogleCAS]
|
||||||
|
|
||||||
Issuer:
|
Issuer:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
digicertissuer "github.com/shankar0123/certctl/internal/connector/issuer/digicert"
|
digicertissuer "github.com/shankar0123/certctl/internal/connector/issuer/digicert"
|
||||||
opensslissuer "github.com/shankar0123/certctl/internal/connector/issuer/openssl"
|
opensslissuer "github.com/shankar0123/certctl/internal/connector/issuer/openssl"
|
||||||
stepcaissuer "github.com/shankar0123/certctl/internal/connector/issuer/stepca"
|
stepcaissuer "github.com/shankar0123/certctl/internal/connector/issuer/stepca"
|
||||||
|
googlecasissuer "github.com/shankar0123/certctl/internal/connector/issuer/googlecas"
|
||||||
sectigoissuer "github.com/shankar0123/certctl/internal/connector/issuer/sectigo"
|
sectigoissuer "github.com/shankar0123/certctl/internal/connector/issuer/sectigo"
|
||||||
vaultissuer "github.com/shankar0123/certctl/internal/connector/issuer/vault"
|
vaultissuer "github.com/shankar0123/certctl/internal/connector/issuer/vault"
|
||||||
notifyemail "github.com/shankar0123/certctl/internal/connector/notifier/email"
|
notifyemail "github.com/shankar0123/certctl/internal/connector/notifier/email"
|
||||||
@@ -172,6 +173,17 @@ func main() {
|
|||||||
}, logger)
|
}, logger)
|
||||||
logger.Info("initialized Sectigo SCM issuer connector")
|
logger.Info("initialized Sectigo SCM issuer connector")
|
||||||
|
|
||||||
|
// Initialize Google CAS issuer connector (for GCP private CA).
|
||||||
|
// Uses the Google CAS REST API with OAuth2 service account auth.
|
||||||
|
googlecasConnector := googlecasissuer.New(&googlecasissuer.Config{
|
||||||
|
Project: cfg.GoogleCAS.Project,
|
||||||
|
Location: cfg.GoogleCAS.Location,
|
||||||
|
CAPool: cfg.GoogleCAS.CAPool,
|
||||||
|
Credentials: cfg.GoogleCAS.Credentials,
|
||||||
|
TTL: cfg.GoogleCAS.TTL,
|
||||||
|
}, logger)
|
||||||
|
logger.Info("initialized Google CAS issuer connector")
|
||||||
|
|
||||||
// Build issuer registry: maps issuer IDs (from database) to connector implementations.
|
// Build issuer registry: maps issuer IDs (from database) to connector implementations.
|
||||||
// "iss-local" matches the seed data issuer ID for the Local CA.
|
// "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.
|
// "iss-acme-staging" and "iss-acme-prod" are conventional IDs for ACME issuers.
|
||||||
@@ -203,6 +215,12 @@ func main() {
|
|||||||
logger.Info("Sectigo SCM issuer registered", "id", "iss-sectigo")
|
logger.Info("Sectigo SCM issuer registered", "id", "iss-sectigo")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Conditionally register Google CAS (only if project and credentials are set)
|
||||||
|
if cfg.GoogleCAS.Project != "" && cfg.GoogleCAS.Credentials != "" {
|
||||||
|
issuerRegistry["iss-googlecas"] = service.NewIssuerConnectorAdapter(googlecasConnector)
|
||||||
|
logger.Info("Google CAS issuer registered", "id", "iss-googlecas")
|
||||||
|
}
|
||||||
|
|
||||||
logger.Info("issuer registry configured", "issuers", len(issuerRegistry))
|
logger.Info("issuer registry configured", "issuers", len(issuerRegistry))
|
||||||
|
|
||||||
// Initialize revocation repository
|
// Initialize revocation repository
|
||||||
|
|||||||
@@ -514,6 +514,7 @@ flowchart TB
|
|||||||
II --> VP["Vault PKI"]
|
II --> VP["Vault PKI"]
|
||||||
II --> DC["DigiCert CertCentral"]
|
II --> DC["DigiCert CertCentral"]
|
||||||
II --> SG["Sectigo SCM"]
|
II --> SG["Sectigo SCM"]
|
||||||
|
II --> GC["Google CAS"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "Target Connectors"
|
subgraph "Target Connectors"
|
||||||
|
|||||||
+18
-1
@@ -379,12 +379,29 @@ The connector submits certificate enrollments to Sectigo's `/ssl/v1/enroll` API.
|
|||||||
|
|
||||||
Location: `internal/connector/issuer/sectigo/sectigo.go`
|
Location: `internal/connector/issuer/sectigo/sectigo.go`
|
||||||
|
|
||||||
|
### Built-in: Google CAS
|
||||||
|
|
||||||
|
Google Cloud Certificate Authority Service — managed private CA on GCP. Synchronous issuance via CAS REST API with OAuth2 service account auth.
|
||||||
|
|
||||||
|
| Setting | Required | Default | Description |
|
||||||
|
|---------|----------|---------|-------------|
|
||||||
|
| `CERTCTL_GOOGLE_CAS_PROJECT` | Yes | — | GCP project ID |
|
||||||
|
| `CERTCTL_GOOGLE_CAS_LOCATION` | Yes | — | GCP region (e.g., `us-central1`) |
|
||||||
|
| `CERTCTL_GOOGLE_CAS_CA_POOL` | Yes | — | CA pool name |
|
||||||
|
| `CERTCTL_GOOGLE_CAS_CREDENTIALS` | Yes | — | Path to service account JSON |
|
||||||
|
| `CERTCTL_GOOGLE_CAS_TTL` | No | `8760h` | Default certificate TTL |
|
||||||
|
|
||||||
|
**Authentication:** OAuth2 service account. The connector reads a service account JSON file, signs a JWT with the private key, and exchanges it for an access token at Google's token endpoint. Tokens are cached and refreshed automatically (5 min before expiry).
|
||||||
|
|
||||||
|
**Note:** CRL and OCSP are managed by Google CAS directly. certctl records revocations locally and notifies Google CAS via the revoke endpoint.
|
||||||
|
|
||||||
|
Location: `internal/connector/issuer/googlecas/googlecas.go`
|
||||||
|
|
||||||
### Coming in V2.2+
|
### Coming in V2.2+
|
||||||
|
|
||||||
The following issuer connectors are planned for future releases:
|
The following issuer connectors are planned for future releases:
|
||||||
|
|
||||||
- **Entrust** — Enterprise CA via Entrust API
|
- **Entrust** — Enterprise CA via Entrust API
|
||||||
- **Google CAS** — Google Cloud Certificate Authority Service
|
|
||||||
- **AWS ACM Private CA** — AWS-managed private CA
|
- **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.
|
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.
|
||||||
|
|||||||
+71
-3
@@ -6385,15 +6385,83 @@ These must be green before starting manual QA:
|
|||||||
|
|
||||||
**PASS if** correct 3-header auth on all requests.
|
**PASS if** correct 3-header auth on all requests.
|
||||||
|
|
||||||
|
### Part 44: Google CAS Issuer Connector (M44)
|
||||||
|
|
||||||
|
**Prerequisites:** GCP project with Certificate Authority Service enabled, CA pool created, service account with `roles/privateca.certificateManager`, service account JSON key file.
|
||||||
|
|
||||||
|
#### Automated Tests
|
||||||
|
|
||||||
|
| Test | Description | Method | Pass? | Date | Notes |
|
||||||
|
|------|-------------|--------|-------|------|-------|
|
||||||
|
| 44.s1 | `IssuerTypeGoogleCAS` constant exists in domain | Auto | ☐ | | `grep 'GoogleCAS' internal/domain/connector.go` |
|
||||||
|
| 44.s2 | `GoogleCASConfig` struct exists in config | Auto | ☐ | | `grep 'GoogleCASConfig' internal/config/config.go` |
|
||||||
|
| 44.s3 | `iss-googlecas` in seed_demo.sql | Auto | ☐ | | `grep 'iss-googlecas' migrations/seed_demo.sql` |
|
||||||
|
| 44.s4 | GoogleCAS in OpenAPI IssuerType enum | Auto | ☐ | | `grep 'GoogleCAS' api/openapi.yaml` |
|
||||||
|
| 44.s5 | Google CAS connector tests pass | Auto | ☐ | | `go test ./internal/connector/issuer/googlecas/... -v` |
|
||||||
|
| 44.s6 | GoogleCAS in issuerTypes.ts | Auto | ☐ | | `grep 'GoogleCAS' web/src/config/issuerTypes.ts` |
|
||||||
|
| 44.s7 | Frontend build succeeds | Auto | ☐ | | `cd web && npm run build` |
|
||||||
|
| 44.s8 | Full Go build succeeds | Auto | ☐ | | `go build ./cmd/server/... ./cmd/agent/... ./cmd/cli/... ./cmd/mcp-server/...` |
|
||||||
|
|
||||||
|
#### Manual Tests
|
||||||
|
|
||||||
|
**44.M1: Validate Google CAS Credentials**
|
||||||
|
|
||||||
|
1. Configure env vars: `CERTCTL_GOOGLE_CAS_PROJECT`, `CERTCTL_GOOGLE_CAS_LOCATION`, `CERTCTL_GOOGLE_CAS_CA_POOL`, `CERTCTL_GOOGLE_CAS_CREDENTIALS`
|
||||||
|
2. Start certctl server — verify log line: `Google CAS issuer registered`
|
||||||
|
3. Call `GET /api/v1/issuers` — verify `iss-googlecas` appears in the list
|
||||||
|
|
||||||
|
**PASS if** `iss-googlecas` registered and visible in API.
|
||||||
|
|
||||||
|
**44.M2: Issue Certificate via Google CAS**
|
||||||
|
|
||||||
|
1. Create a certificate with `issuer_id: iss-googlecas`
|
||||||
|
2. Trigger issuance — verify synchronous issuance (no async polling needed)
|
||||||
|
3. Verify PEM cert returned with correct CN and SANs
|
||||||
|
4. Verify certificate resource name stored in order_id field
|
||||||
|
|
||||||
|
**PASS if** certificate issued synchronously, PEM valid, resource name tracked.
|
||||||
|
|
||||||
|
**44.M3: Renewal via Google CAS**
|
||||||
|
|
||||||
|
1. Trigger renewal on a Google CAS-issued certificate
|
||||||
|
2. Verify new certificate issued (delegates to IssueCertificate)
|
||||||
|
3. Verify new serial number, updated validity dates
|
||||||
|
|
||||||
|
**PASS if** renewal produces new cert with new serial.
|
||||||
|
|
||||||
|
**44.M4: Revocation via Google CAS**
|
||||||
|
|
||||||
|
1. Revoke a Google CAS-issued certificate via `POST /api/v1/certificates/{id}/revoke`
|
||||||
|
2. Verify Google CAS revoke endpoint called (`POST {name}:revoke`)
|
||||||
|
3. Verify revocation reason mapped correctly (RFC 5280 → Google CAS enum)
|
||||||
|
4. Verify audit trail records revocation
|
||||||
|
|
||||||
|
**PASS if** revocation recorded in certctl and sent to Google CAS.
|
||||||
|
|
||||||
|
**44.M5: OAuth2 Token Caching**
|
||||||
|
|
||||||
|
1. Issue multiple certificates in quick succession
|
||||||
|
2. Verify token is cached (not re-fetched for every request)
|
||||||
|
3. Verify token refresh after expiry
|
||||||
|
|
||||||
|
**PASS if** token reuse observed, refresh works after expiry.
|
||||||
|
|
||||||
|
**44.M6: CA Certificate Retrieval**
|
||||||
|
|
||||||
|
1. Call EST cacerts endpoint with Google CAS as issuer
|
||||||
|
2. Verify CA certificate chain returned from Google CAS fetchCaCerts API
|
||||||
|
|
||||||
|
**PASS if** CA cert PEM returned successfully.
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
|
|
||||||
| Category | Count |
|
| Category | Count |
|
||||||
|----------|-------|
|
|----------|-------|
|
||||||
| ☑ Auto (passed in `qa-smoke-test.sh`) | 144 |
|
| ☑ Auto (passed in `qa-smoke-test.sh`) | 144 |
|
||||||
| ☐ Auto (not yet run) | 20 |
|
| ☐ Auto (not yet run) | 28 |
|
||||||
| — Skipped (preconditions not met in demo) | 5 |
|
| — Skipped (preconditions not met in demo) | 5 |
|
||||||
| ☐ Manual (requires hands-on verification) | 247 |
|
| ☐ Manual (requires hands-on verification) | 253 |
|
||||||
| **Total** | **416** |
|
| **Total** | **430** |
|
||||||
|
|
||||||
**Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss.
|
**Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss.
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type Config struct {
|
|||||||
Vault VaultConfig
|
Vault VaultConfig
|
||||||
DigiCert DigiCertConfig
|
DigiCert DigiCertConfig
|
||||||
Sectigo SectigoConfig
|
Sectigo SectigoConfig
|
||||||
|
GoogleCAS GoogleCASConfig
|
||||||
Digest DigestConfig
|
Digest DigestConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +233,34 @@ type SectigoConfig struct {
|
|||||||
BaseURL string
|
BaseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GoogleCASConfig contains Google Cloud Certificate Authority Service configuration.
|
||||||
|
type GoogleCASConfig struct {
|
||||||
|
// Project is the GCP project ID.
|
||||||
|
// Required for Google CAS integration.
|
||||||
|
// Setting: CERTCTL_GOOGLE_CAS_PROJECT environment variable.
|
||||||
|
Project string
|
||||||
|
|
||||||
|
// Location is the GCP region (e.g., "us-central1").
|
||||||
|
// Required for Google CAS integration.
|
||||||
|
// Setting: CERTCTL_GOOGLE_CAS_LOCATION environment variable.
|
||||||
|
Location string
|
||||||
|
|
||||||
|
// CAPool is the Certificate Authority pool name.
|
||||||
|
// Required for Google CAS integration.
|
||||||
|
// Setting: CERTCTL_GOOGLE_CAS_CA_POOL environment variable.
|
||||||
|
CAPool string
|
||||||
|
|
||||||
|
// Credentials is the path to the service account JSON credentials file.
|
||||||
|
// Required for Google CAS integration.
|
||||||
|
// Setting: CERTCTL_GOOGLE_CAS_CREDENTIALS environment variable.
|
||||||
|
Credentials string
|
||||||
|
|
||||||
|
// TTL is the default certificate time-to-live.
|
||||||
|
// Default: "8760h" (1 year).
|
||||||
|
// Setting: CERTCTL_GOOGLE_CAS_TTL environment variable.
|
||||||
|
TTL string
|
||||||
|
}
|
||||||
|
|
||||||
// DigestConfig controls the scheduled certificate digest email feature.
|
// DigestConfig controls the scheduled certificate digest email feature.
|
||||||
type DigestConfig struct {
|
type DigestConfig struct {
|
||||||
// Enabled controls whether periodic digest emails are generated and sent.
|
// Enabled controls whether periodic digest emails are generated and sent.
|
||||||
@@ -547,6 +576,13 @@ func Load() (*Config, error) {
|
|||||||
Term: getEnvInt("CERTCTL_SECTIGO_TERM", 365),
|
Term: getEnvInt("CERTCTL_SECTIGO_TERM", 365),
|
||||||
BaseURL: getEnv("CERTCTL_SECTIGO_BASE_URL", "https://cert-manager.com/api"),
|
BaseURL: getEnv("CERTCTL_SECTIGO_BASE_URL", "https://cert-manager.com/api"),
|
||||||
},
|
},
|
||||||
|
GoogleCAS: GoogleCASConfig{
|
||||||
|
Project: getEnv("CERTCTL_GOOGLE_CAS_PROJECT", ""),
|
||||||
|
Location: getEnv("CERTCTL_GOOGLE_CAS_LOCATION", ""),
|
||||||
|
CAPool: getEnv("CERTCTL_GOOGLE_CAS_CA_POOL", ""),
|
||||||
|
Credentials: getEnv("CERTCTL_GOOGLE_CAS_CREDENTIALS", ""),
|
||||||
|
TTL: getEnv("CERTCTL_GOOGLE_CAS_TTL", "8760h"),
|
||||||
|
},
|
||||||
ACME: ACMEConfig{
|
ACME: ACMEConfig{
|
||||||
DirectoryURL: getEnv("CERTCTL_ACME_DIRECTORY_URL", ""),
|
DirectoryURL: getEnv("CERTCTL_ACME_DIRECTORY_URL", ""),
|
||||||
Email: getEnv("CERTCTL_ACME_EMAIL", ""),
|
Email: getEnv("CERTCTL_ACME_EMAIL", ""),
|
||||||
|
|||||||
@@ -0,0 +1,619 @@
|
|||||||
|
// Package googlecas implements the issuer.Connector interface for
|
||||||
|
// Google Cloud Certificate Authority Service (CAS).
|
||||||
|
//
|
||||||
|
// Google CAS is a managed private CA service on GCP. This connector
|
||||||
|
// uses the CAS REST API (privateca.googleapis.com/v1) with OAuth2
|
||||||
|
// service account authentication. Certificates are issued synchronously.
|
||||||
|
//
|
||||||
|
// Authentication: OAuth2 service account via JWT → access token exchange.
|
||||||
|
// No Google SDK dependency — uses stdlib crypto/rsa + net/http.
|
||||||
|
//
|
||||||
|
// API endpoints used:
|
||||||
|
//
|
||||||
|
// POST /v1/{parent}/certificates - Issue certificate
|
||||||
|
// POST /v1/{name}:revoke - Revoke certificate
|
||||||
|
// POST /v1/{caPool}:fetchCaCerts - Get CA certificate chain
|
||||||
|
package googlecas
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config represents the Google CAS issuer connector configuration.
|
||||||
|
type Config struct {
|
||||||
|
// Project is the GCP project ID.
|
||||||
|
// Required. Set via CERTCTL_GOOGLE_CAS_PROJECT environment variable.
|
||||||
|
Project string `json:"project"`
|
||||||
|
|
||||||
|
// Location is the GCP region (e.g., "us-central1").
|
||||||
|
// Required. Set via CERTCTL_GOOGLE_CAS_LOCATION environment variable.
|
||||||
|
Location string `json:"location"`
|
||||||
|
|
||||||
|
// CAPool is the Certificate Authority pool name.
|
||||||
|
// Required. Set via CERTCTL_GOOGLE_CAS_CA_POOL environment variable.
|
||||||
|
CAPool string `json:"ca_pool"`
|
||||||
|
|
||||||
|
// Credentials is the path to the service account JSON credentials file.
|
||||||
|
// Required. Set via CERTCTL_GOOGLE_CAS_CREDENTIALS environment variable.
|
||||||
|
Credentials string `json:"credentials"`
|
||||||
|
|
||||||
|
// TTL is the requested certificate TTL (e.g., "8760h" for 1 year).
|
||||||
|
// Default: "8760h". Set via CERTCTL_GOOGLE_CAS_TTL environment variable.
|
||||||
|
TTL string `json:"ttl"`
|
||||||
|
|
||||||
|
// BaseURL overrides the Google CAS API base URL (for testing).
|
||||||
|
// Default: "https://privateca.googleapis.com/v1".
|
||||||
|
BaseURL string `json:"base_url,omitempty"`
|
||||||
|
|
||||||
|
// TokenURL overrides the OAuth2 token endpoint (for testing).
|
||||||
|
// Default: "https://oauth2.googleapis.com/token".
|
||||||
|
TokenURL string `json:"token_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceAccountKey represents the relevant fields from a Google service account JSON file.
|
||||||
|
type serviceAccountKey struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ProjectID string `json:"project_id"`
|
||||||
|
PrivateKey string `json:"private_key"`
|
||||||
|
ClientEmail string `json:"client_email"`
|
||||||
|
TokenURI string `json:"token_uri"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// cachedToken holds an OAuth2 access token and its expiry.
|
||||||
|
type cachedToken struct {
|
||||||
|
token string
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connector implements the issuer.Connector interface for Google CAS.
|
||||||
|
type Connector struct {
|
||||||
|
config *Config
|
||||||
|
logger *slog.Logger
|
||||||
|
httpClient *http.Client
|
||||||
|
|
||||||
|
// OAuth2 token caching
|
||||||
|
mu sync.Mutex
|
||||||
|
tokenCache *cachedToken
|
||||||
|
saKey *serviceAccountKey
|
||||||
|
rsaKey *rsa.PrivateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Google CAS connector with the given configuration and logger.
|
||||||
|
func New(config *Config, logger *slog.Logger) *Connector {
|
||||||
|
if config != nil {
|
||||||
|
if config.TTL == "" {
|
||||||
|
config.TTL = "8760h"
|
||||||
|
}
|
||||||
|
if config.BaseURL == "" {
|
||||||
|
config.BaseURL = "https://privateca.googleapis.com/v1"
|
||||||
|
}
|
||||||
|
if config.TokenURL == "" {
|
||||||
|
config.TokenURL = "https://oauth2.googleapis.com/token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Connector{
|
||||||
|
config: config,
|
||||||
|
logger: logger,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parentPath returns the CAS resource parent path.
|
||||||
|
func (c *Connector) parentPath() string {
|
||||||
|
return fmt.Sprintf("projects/%s/locations/%s/caPools/%s",
|
||||||
|
c.config.Project, c.config.Location, c.config.CAPool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// certificateCreateResponse represents the Google CAS create certificate response.
|
||||||
|
type certificateCreateResponse struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
PEMCertificate string `json:"pemCertificate"`
|
||||||
|
PEMCertificateChain []string `json:"pemCertificateChain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchCACertsResponse represents the Google CAS fetchCaCerts response.
|
||||||
|
type fetchCACertsResponse struct {
|
||||||
|
CACerts []caCertChain `json:"caCerts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type caCertChain struct {
|
||||||
|
Certificates []string `json:"certificates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// googleAPIError represents a Google API error response.
|
||||||
|
type googleAPIError struct {
|
||||||
|
Error struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateConfig checks that the Google CAS configuration is valid.
|
||||||
|
// Verifies required fields and that the credentials file is parseable.
|
||||||
|
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 Google CAS config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Project == "" {
|
||||||
|
return fmt.Errorf("Google CAS project is required")
|
||||||
|
}
|
||||||
|
if cfg.Location == "" {
|
||||||
|
return fmt.Errorf("Google CAS location is required")
|
||||||
|
}
|
||||||
|
if cfg.CAPool == "" {
|
||||||
|
return fmt.Errorf("Google CAS CA pool is required")
|
||||||
|
}
|
||||||
|
if cfg.Credentials == "" {
|
||||||
|
return fmt.Errorf("Google CAS credentials path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify credentials file exists and is valid
|
||||||
|
saKey, _, err := loadServiceAccountKey(cfg.Credentials)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Google CAS credentials invalid: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if saKey.ClientEmail == "" {
|
||||||
|
return fmt.Errorf("Google CAS credentials missing client_email")
|
||||||
|
}
|
||||||
|
if saKey.PrivateKey == "" {
|
||||||
|
return fmt.Errorf("Google CAS credentials missing private_key")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.TTL == "" {
|
||||||
|
cfg.TTL = "8760h"
|
||||||
|
}
|
||||||
|
if cfg.BaseURL == "" {
|
||||||
|
cfg.BaseURL = "https://privateca.googleapis.com/v1"
|
||||||
|
}
|
||||||
|
if cfg.TokenURL == "" {
|
||||||
|
cfg.TokenURL = "https://oauth2.googleapis.com/token"
|
||||||
|
}
|
||||||
|
|
||||||
|
c.config = &cfg
|
||||||
|
c.logger.Info("Google CAS configuration validated",
|
||||||
|
"project", cfg.Project,
|
||||||
|
"location", cfg.Location,
|
||||||
|
"ca_pool", cfg.CAPool)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadServiceAccountKey reads and parses a service account JSON file.
|
||||||
|
func loadServiceAccountKey(path string) (*serviceAccountKey, *rsa.PrivateKey, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("cannot read credentials file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var saKey serviceAccountKey
|
||||||
|
if err := json.Unmarshal(data, &saKey); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("cannot parse credentials JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if saKey.PrivateKey == "" {
|
||||||
|
return &saKey, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the RSA private key
|
||||||
|
block, _ := pem.Decode([]byte(saKey.PrivateKey))
|
||||||
|
if block == nil {
|
||||||
|
return nil, nil, fmt.Errorf("cannot decode private key PEM")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try PKCS#8 first, then PKCS#1
|
||||||
|
var rsaKey *rsa.PrivateKey
|
||||||
|
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
|
||||||
|
var ok bool
|
||||||
|
rsaKey, ok = key.(*rsa.PrivateKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, fmt.Errorf("private key is not RSA")
|
||||||
|
}
|
||||||
|
} else if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
||||||
|
rsaKey = key
|
||||||
|
} else {
|
||||||
|
return nil, nil, fmt.Errorf("cannot parse private key: not PKCS#8 or PKCS#1")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &saKey, rsaKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAccessToken returns a valid OAuth2 access token, refreshing if needed.
|
||||||
|
func (c *Connector) getAccessToken(ctx context.Context) (string, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
// Return cached token if still valid (5 min buffer)
|
||||||
|
if c.tokenCache != nil && time.Now().Add(5*time.Minute).Before(c.tokenCache.expiresAt) {
|
||||||
|
return c.tokenCache.token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load credentials if not cached
|
||||||
|
if c.saKey == nil || c.rsaKey == nil {
|
||||||
|
saKey, rsaKey, err := loadServiceAccountKey(c.config.Credentials)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to load credentials: %w", err)
|
||||||
|
}
|
||||||
|
c.saKey = saKey
|
||||||
|
c.rsaKey = rsaKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build JWT
|
||||||
|
now := time.Now()
|
||||||
|
header := base64URLEncode([]byte(`{"alg":"RS256","typ":"JWT"}`))
|
||||||
|
|
||||||
|
claims, err := json.Marshal(map[string]interface{}{
|
||||||
|
"iss": c.saKey.ClientEmail,
|
||||||
|
"scope": "https://www.googleapis.com/auth/cloud-platform",
|
||||||
|
"aud": c.config.TokenURL,
|
||||||
|
"iat": now.Unix(),
|
||||||
|
"exp": now.Add(time.Hour).Unix(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal JWT claims: %w", err)
|
||||||
|
}
|
||||||
|
payload := base64URLEncode(claims)
|
||||||
|
|
||||||
|
// Sign
|
||||||
|
signingInput := header + "." + payload
|
||||||
|
hash := sha256.Sum256([]byte(signingInput))
|
||||||
|
sig, err := rsa.SignPKCS1v15(rand.Reader, c.rsaKey, crypto.SHA256, hash[:])
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to sign JWT: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jwt := signingInput + "." + base64URLEncode(sig)
|
||||||
|
|
||||||
|
// Exchange JWT for access token
|
||||||
|
form := url.Values{
|
||||||
|
"grant_type": {"urn:ietf:params:oauth:grant-type:jwt-bearer"},
|
||||||
|
"assertion": {jwt},
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.config.TokenURL,
|
||||||
|
strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create token request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("token exchange failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read token response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("token exchange returned status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResp struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse token response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenResp.AccessToken == "" {
|
||||||
|
return "", fmt.Errorf("empty access token in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache token
|
||||||
|
c.tokenCache = &cachedToken{
|
||||||
|
token: tokenResp.AccessToken,
|
||||||
|
expiresAt: now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second),
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenResp.AccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// doAuthenticatedRequest performs an HTTP request with OAuth2 bearer token.
|
||||||
|
func (c *Connector) doAuthenticatedRequest(ctx context.Context, method, urlStr string, body interface{}) ([]byte, int, error) {
|
||||||
|
token, err := c.getAccessToken(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("failed to get access token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if body != nil {
|
||||||
|
bodyBytes, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("failed to marshal request body: %w", err)
|
||||||
|
}
|
||||||
|
bodyReader = bytes.NewReader(bodyBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, urlStr, bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, resp.StatusCode, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return respBody, resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractAPIError extracts an error message from a Google API error response.
|
||||||
|
func extractAPIError(body []byte) string {
|
||||||
|
var apiErr googleAPIError
|
||||||
|
if err := json.Unmarshal(body, &apiErr); err == nil && apiErr.Error.Message != "" {
|
||||||
|
return fmt.Sprintf("%s (%s)", apiErr.Error.Message, apiErr.Error.Status)
|
||||||
|
}
|
||||||
|
return string(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueCertificate issues a new certificate via Google CAS.
|
||||||
|
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
|
||||||
|
c.logger.Info("processing Google CAS issuance request",
|
||||||
|
"common_name", request.CommonName,
|
||||||
|
"san_count", len(request.SANs))
|
||||||
|
|
||||||
|
// Convert TTL to seconds string
|
||||||
|
ttlDuration, err := time.ParseDuration(c.config.TTL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid TTL %q: %w", c.config.TTL, err)
|
||||||
|
}
|
||||||
|
lifetimeSeconds := fmt.Sprintf("%ds", int(ttlDuration.Seconds()))
|
||||||
|
|
||||||
|
// Generate unique certificate ID
|
||||||
|
certID := fmt.Sprintf("certctl-%d-%s", time.Now().Unix(), randomHex(4))
|
||||||
|
|
||||||
|
// Build request
|
||||||
|
createURL := fmt.Sprintf("%s/%s/certificates?certificateId=%s",
|
||||||
|
c.config.BaseURL, c.parentPath(), certID)
|
||||||
|
|
||||||
|
createBody := map[string]interface{}{
|
||||||
|
"lifetime": lifetimeSeconds,
|
||||||
|
"pemCsr": request.CSRPEM,
|
||||||
|
}
|
||||||
|
|
||||||
|
respBody, statusCode, err := c.doAuthenticatedRequest(ctx, http.MethodPost, createURL, createBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Google CAS create certificate failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if statusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("Google CAS create certificate returned status %d: %s",
|
||||||
|
statusCode, extractAPIError(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse response
|
||||||
|
var certResp certificateCreateResponse
|
||||||
|
if err := json.Unmarshal(respBody, &certResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse Google CAS response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if certResp.PEMCertificate == "" {
|
||||||
|
return nil, fmt.Errorf("no certificate in Google CAS response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse leaf cert to extract metadata
|
||||||
|
block, _ := pem.Decode([]byte(certResp.PEMCertificate))
|
||||||
|
if block == nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode certificate PEM from Google CAS")
|
||||||
|
}
|
||||||
|
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse certificate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build chain PEM
|
||||||
|
chainPEM := strings.Join(certResp.PEMCertificateChain, "\n")
|
||||||
|
|
||||||
|
serial := formatSerial(cert.SerialNumber)
|
||||||
|
|
||||||
|
// Store full resource name as OrderID for revocation lookup
|
||||||
|
orderID := certResp.Name
|
||||||
|
|
||||||
|
c.logger.Info("Google CAS certificate issued",
|
||||||
|
"common_name", request.CommonName,
|
||||||
|
"serial", serial,
|
||||||
|
"name", certResp.Name,
|
||||||
|
"not_after", cert.NotAfter)
|
||||||
|
|
||||||
|
return &issuer.IssuanceResult{
|
||||||
|
CertPEM: certResp.PEMCertificate,
|
||||||
|
ChainPEM: chainPEM,
|
||||||
|
Serial: serial,
|
||||||
|
NotBefore: cert.NotBefore,
|
||||||
|
NotAfter: cert.NotAfter,
|
||||||
|
OrderID: orderID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenewCertificate renews a certificate by creating a new one.
|
||||||
|
// For Google CAS, renewal is functionally identical to issuance.
|
||||||
|
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
|
||||||
|
c.logger.Info("processing Google CAS 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 Google CAS.
|
||||||
|
// The serial field should contain the full certificate resource name (set as OrderID at issuance).
|
||||||
|
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
|
||||||
|
c.logger.Info("processing Google CAS revocation request", "serial", request.Serial)
|
||||||
|
|
||||||
|
// Determine the certificate resource name.
|
||||||
|
// If serial starts with "projects/", it's a full resource name (from OrderID).
|
||||||
|
// Otherwise, construct a best-effort path.
|
||||||
|
var certName string
|
||||||
|
if strings.HasPrefix(request.Serial, "projects/") {
|
||||||
|
certName = request.Serial
|
||||||
|
} else {
|
||||||
|
certName = fmt.Sprintf("%s/certificates/%s", c.parentPath(), request.Serial)
|
||||||
|
}
|
||||||
|
|
||||||
|
reason := mapRevocationReason(request.Reason)
|
||||||
|
|
||||||
|
revokeURL := fmt.Sprintf("%s/%s:revoke", c.config.BaseURL, certName)
|
||||||
|
revokeBody := map[string]interface{}{
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
respBody, statusCode, err := c.doAuthenticatedRequest(ctx, http.MethodPost, revokeURL, revokeBody)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Google CAS revoke failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if statusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("Google CAS revoke returned status %d: %s",
|
||||||
|
statusCode, extractAPIError(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.logger.Info("Google CAS certificate revoked", "name", certName, "reason", reason)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrderStatus returns the status of a Google CAS order.
|
||||||
|
// Google CAS 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 Google CAS manages CRL directly.
|
||||||
|
func (c *Connector) GenerateCRL(ctx context.Context, revokedCerts []issuer.RevokedCertEntry) ([]byte, error) {
|
||||||
|
return nil, fmt.Errorf("Google CAS manages CRL directly; not supported via certctl")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignOCSPResponse is not supported because Google CAS manages OCSP directly.
|
||||||
|
func (c *Connector) SignOCSPResponse(ctx context.Context, req issuer.OCSPSignRequest) ([]byte, error) {
|
||||||
|
return nil, fmt.Errorf("Google CAS manages OCSP directly; not supported via certctl")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCACertPEM retrieves the CA certificate chain from Google CAS.
|
||||||
|
func (c *Connector) GetCACertPEM(ctx context.Context) (string, error) {
|
||||||
|
fetchURL := fmt.Sprintf("%s/%s:fetchCaCerts", c.config.BaseURL, c.parentPath())
|
||||||
|
|
||||||
|
respBody, statusCode, err := c.doAuthenticatedRequest(ctx, http.MethodPost, fetchURL, map[string]interface{}{})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("Google CAS fetchCaCerts failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if statusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("Google CAS fetchCaCerts returned status %d: %s",
|
||||||
|
statusCode, extractAPIError(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp fetchCACertsResponse
|
||||||
|
if err := json.Unmarshal(respBody, &resp); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse fetchCaCerts response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.CACerts) == 0 || len(resp.CACerts[0].Certificates) == 0 {
|
||||||
|
return "", fmt.Errorf("no CA certificates in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join all certificates from the first CA cert chain
|
||||||
|
return strings.Join(resp.CACerts[0].Certificates, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRenewalInfo returns nil, nil as Google CAS does not support ACME Renewal Information (ARI).
|
||||||
|
func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer.RenewalInfoResult, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapRevocationReason maps certctl RFC 5280 reason strings to Google CAS enum values.
|
||||||
|
func mapRevocationReason(reason *string) string {
|
||||||
|
if reason == nil {
|
||||||
|
return "REVOCATION_REASON_UNSPECIFIED"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(*reason) {
|
||||||
|
case "keycompromise":
|
||||||
|
return "KEY_COMPROMISE"
|
||||||
|
case "cacompromise":
|
||||||
|
return "CERTIFICATE_AUTHORITY_COMPROMISE"
|
||||||
|
case "affiliationchanged":
|
||||||
|
return "AFFILIATION_CHANGED"
|
||||||
|
case "superseded":
|
||||||
|
return "SUPERSEDED"
|
||||||
|
case "cessationofoperation":
|
||||||
|
return "CESSATION_OF_OPERATION"
|
||||||
|
case "certificatehold":
|
||||||
|
return "CERTIFICATE_HOLD"
|
||||||
|
case "privilegewithdrawn":
|
||||||
|
return "PRIVILEGE_WITHDRAWN"
|
||||||
|
default:
|
||||||
|
return "REVOCATION_REASON_UNSPECIFIED"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatSerial converts a *big.Int serial number to a hex string.
|
||||||
|
func formatSerial(serial *big.Int) string {
|
||||||
|
return serial.Text(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomHex generates n random bytes and returns them as a hex string.
|
||||||
|
func randomHex(n int) string {
|
||||||
|
b := make([]byte, n)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return fmt.Sprintf("%x", b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// base64URLEncode encodes data using base64url without padding.
|
||||||
|
func base64URLEncode(data []byte) string {
|
||||||
|
return base64.RawURLEncoding.EncodeToString(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Connector implements the issuer.Connector interface.
|
||||||
|
var _ issuer.Connector = (*Connector)(nil)
|
||||||
@@ -0,0 +1,826 @@
|
|||||||
|
package googlecas_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"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/issuer/googlecas"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGoogleCASConnector(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) {
|
||||||
|
credPath := createTestCredentialsFile(t)
|
||||||
|
|
||||||
|
config := googlecas.Config{
|
||||||
|
Project: "my-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "my-pool",
|
||||||
|
Credentials: credPath,
|
||||||
|
TTL: "8760h",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := googlecas.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateConfig failed: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_MissingProject", func(t *testing.T) {
|
||||||
|
config := googlecas.Config{
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "my-pool",
|
||||||
|
Credentials: "/tmp/creds.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := googlecas.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for missing project")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "project is required") {
|
||||||
|
t.Errorf("Expected project required error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_MissingLocation", func(t *testing.T) {
|
||||||
|
config := googlecas.Config{
|
||||||
|
Project: "my-project",
|
||||||
|
CAPool: "my-pool",
|
||||||
|
Credentials: "/tmp/creds.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := googlecas.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for missing location")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "location is required") {
|
||||||
|
t.Errorf("Expected location required error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_MissingCAPool", func(t *testing.T) {
|
||||||
|
config := googlecas.Config{
|
||||||
|
Project: "my-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
Credentials: "/tmp/creds.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := googlecas.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for missing CA pool")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "CA pool is required") {
|
||||||
|
t.Errorf("Expected CA pool required error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_MissingCredentials", func(t *testing.T) {
|
||||||
|
config := googlecas.Config{
|
||||||
|
Project: "my-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "my-pool",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := googlecas.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for missing credentials")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "credentials path is required") {
|
||||||
|
t.Errorf("Expected credentials required error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_InvalidCredentialsFile", func(t *testing.T) {
|
||||||
|
config := googlecas.Config{
|
||||||
|
Project: "my-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "my-pool",
|
||||||
|
Credentials: "/nonexistent/path/credentials.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := googlecas.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for invalid credentials file")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "credentials invalid") {
|
||||||
|
t.Errorf("Expected credentials invalid error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ValidateConfig_MalformedCredentialsJSON", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
badFile := filepath.Join(tmpDir, "bad-creds.json")
|
||||||
|
if err := os.WriteFile(badFile, []byte("not json"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config := googlecas.Config{
|
||||||
|
Project: "my-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "my-pool",
|
||||||
|
Credentials: badFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
connector := googlecas.New(nil, logger)
|
||||||
|
rawConfig, _ := json.Marshal(config)
|
||||||
|
err := connector.ValidateConfig(ctx, rawConfig)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for malformed credentials JSON")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "credentials invalid") {
|
||||||
|
t.Errorf("Expected credentials invalid error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("IssueCertificate_Success", func(t *testing.T) {
|
||||||
|
testCertPEM, _ := generateTestCert(t)
|
||||||
|
credPath := createTestCredentialsFile(t)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/token":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"access_token":"test-token-12345","expires_in":3600,"token_type":"Bearer"}`))
|
||||||
|
|
||||||
|
case strings.Contains(r.URL.Path, "/certificates") && r.Method == http.MethodPost &&
|
||||||
|
!strings.Contains(r.URL.Path, ":revoke") && !strings.Contains(r.URL.Path, ":fetchCaCerts"):
|
||||||
|
// Verify auth header
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if auth != "Bearer test-token-12345" {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
w.Write([]byte(`{"error":{"code":403,"message":"Permission denied","status":"PERMISSION_DENIED"}}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Verify certificateId query param
|
||||||
|
certID := r.URL.Query().Get("certificateId")
|
||||||
|
if certID == "" {
|
||||||
|
t.Error("Missing certificateId query parameter")
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
chainCert, _ := generateTestCert(t)
|
||||||
|
resp := fmt.Sprintf(`{
|
||||||
|
"name": "projects/test-project/locations/us-central1/caPools/test-pool/certificates/%s",
|
||||||
|
"pemCertificate": %q,
|
||||||
|
"pemCertificateChain": [%q]
|
||||||
|
}`, certID, testCertPEM, chainCert)
|
||||||
|
w.Write([]byte(resp))
|
||||||
|
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &googlecas.Config{
|
||||||
|
Project: "test-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "test-pool",
|
||||||
|
Credentials: credPath,
|
||||||
|
TTL: "8760h",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
TokenURL: srv.URL + "/token",
|
||||||
|
}
|
||||||
|
connector := googlecas.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, "projects/") {
|
||||||
|
t.Errorf("Expected OrderID to be full resource name, got '%s'", result.OrderID)
|
||||||
|
}
|
||||||
|
if result.ChainPEM == "" {
|
||||||
|
t.Error("ChainPEM is empty")
|
||||||
|
}
|
||||||
|
if result.NotBefore.IsZero() {
|
||||||
|
t.Error("NotBefore is zero")
|
||||||
|
}
|
||||||
|
if result.NotAfter.IsZero() {
|
||||||
|
t.Error("NotAfter is zero")
|
||||||
|
}
|
||||||
|
t.Logf("Google CAS issued cert: serial=%s, orderID=%s", result.Serial, result.OrderID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("IssueCertificate_ServerError", func(t *testing.T) {
|
||||||
|
credPath := createTestCredentialsFile(t)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/token":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
|
||||||
|
case strings.Contains(r.URL.Path, "/certificates"):
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(`{"error":{"code":400,"message":"Invalid CSR","status":"INVALID_ARGUMENT"}}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &googlecas.Config{
|
||||||
|
Project: "test-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "test-pool",
|
||||||
|
Credentials: credPath,
|
||||||
|
TTL: "8760h",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
TokenURL: srv.URL + "/token",
|
||||||
|
}
|
||||||
|
connector := googlecas.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_InvalidResponse", func(t *testing.T) {
|
||||||
|
credPath := createTestCredentialsFile(t)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/token":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
|
||||||
|
case strings.Contains(r.URL.Path, "/certificates"):
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`not-json`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &googlecas.Config{
|
||||||
|
Project: "test-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "test-pool",
|
||||||
|
Credentials: credPath,
|
||||||
|
TTL: "8760h",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
TokenURL: srv.URL + "/token",
|
||||||
|
}
|
||||||
|
connector := googlecas.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 invalid response")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "parse") {
|
||||||
|
t.Logf("Got error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetOrderStatus_AlwaysCompleted", func(t *testing.T) {
|
||||||
|
config := &googlecas.Config{
|
||||||
|
Project: "test-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "test-pool",
|
||||||
|
TTL: "8760h",
|
||||||
|
}
|
||||||
|
connector := googlecas.New(config, logger)
|
||||||
|
|
||||||
|
status, err := connector.GetOrderStatus(ctx, "projects/p/locations/l/caPools/cp/certificates/cert-123")
|
||||||
|
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 != "projects/p/locations/l/caPools/cp/certificates/cert-123" {
|
||||||
|
t.Errorf("Expected OrderID preserved, got '%s'", status.OrderID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RenewCertificate_NewCert", func(t *testing.T) {
|
||||||
|
testCertPEM, _ := generateTestCert(t)
|
||||||
|
credPath := createTestCredentialsFile(t)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/token":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
|
||||||
|
case strings.Contains(r.URL.Path, "/certificates") && r.Method == http.MethodPost &&
|
||||||
|
!strings.Contains(r.URL.Path, ":revoke"):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
resp := fmt.Sprintf(`{
|
||||||
|
"name": "projects/test-project/locations/us-central1/caPools/test-pool/certificates/certctl-renew",
|
||||||
|
"pemCertificate": %q,
|
||||||
|
"pemCertificateChain": []
|
||||||
|
}`, testCertPEM)
|
||||||
|
w.Write([]byte(resp))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &googlecas.Config{
|
||||||
|
Project: "test-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "test-pool",
|
||||||
|
Credentials: credPath,
|
||||||
|
TTL: "8760h",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
TokenURL: srv.URL + "/token",
|
||||||
|
}
|
||||||
|
connector := googlecas.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) {
|
||||||
|
credPath := createTestCredentialsFile(t)
|
||||||
|
|
||||||
|
var receivedReason string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/token":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
|
||||||
|
case strings.Contains(r.URL.Path, ":revoke"):
|
||||||
|
var body map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
receivedReason = body["reason"].(string)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"name":"projects/p/locations/l/caPools/cp/certificates/cert-123"}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &googlecas.Config{
|
||||||
|
Project: "test-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "test-pool",
|
||||||
|
Credentials: credPath,
|
||||||
|
TTL: "8760h",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
TokenURL: srv.URL + "/token",
|
||||||
|
}
|
||||||
|
connector := googlecas.New(config, logger)
|
||||||
|
|
||||||
|
reason := "keyCompromise"
|
||||||
|
revokeReq := issuer.RevocationRequest{
|
||||||
|
Serial: "projects/test-project/locations/us-central1/caPools/test-pool/certificates/cert-123",
|
||||||
|
Reason: &reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := connector.RevokeCertificate(ctx, revokeReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RevokeCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if receivedReason != "KEY_COMPROMISE" {
|
||||||
|
t.Errorf("Expected reason 'KEY_COMPROMISE', got '%s'", receivedReason)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RevokeCertificate_Error", func(t *testing.T) {
|
||||||
|
credPath := createTestCredentialsFile(t)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/token":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
|
||||||
|
case strings.Contains(r.URL.Path, ":revoke"):
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
w.Write([]byte(`{"error":{"code":404,"message":"Certificate not found","status":"NOT_FOUND"}}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &googlecas.Config{
|
||||||
|
Project: "test-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "test-pool",
|
||||||
|
Credentials: credPath,
|
||||||
|
TTL: "8760h",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
TokenURL: srv.URL + "/token",
|
||||||
|
}
|
||||||
|
connector := googlecas.New(config, logger)
|
||||||
|
|
||||||
|
revokeReq := issuer.RevocationRequest{
|
||||||
|
Serial: "projects/test-project/locations/us-central1/caPools/test-pool/certificates/nonexistent",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := connector.RevokeCertificate(ctx, revokeReq)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for revoke of nonexistent certificate")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "Certificate not found") {
|
||||||
|
t.Logf("Got error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RevocationReasonMapping", func(t *testing.T) {
|
||||||
|
credPath := createTestCredentialsFile(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
reason string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"keyCompromise", "keyCompromise", "KEY_COMPROMISE"},
|
||||||
|
{"caCompromise", "caCompromise", "CERTIFICATE_AUTHORITY_COMPROMISE"},
|
||||||
|
{"affiliationChanged", "affiliationChanged", "AFFILIATION_CHANGED"},
|
||||||
|
{"superseded", "superseded", "SUPERSEDED"},
|
||||||
|
{"cessationOfOperation", "cessationOfOperation", "CESSATION_OF_OPERATION"},
|
||||||
|
{"certificateHold", "certificateHold", "CERTIFICATE_HOLD"},
|
||||||
|
{"privilegeWithdrawn", "privilegeWithdrawn", "PRIVILEGE_WITHDRAWN"},
|
||||||
|
{"unspecified", "unspecified", "REVOCATION_REASON_UNSPECIFIED"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var receivedReason string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/token":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
|
||||||
|
case strings.Contains(r.URL.Path, ":revoke"):
|
||||||
|
var body map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
receivedReason = body["reason"].(string)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &googlecas.Config{
|
||||||
|
Project: "test-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "test-pool",
|
||||||
|
Credentials: credPath,
|
||||||
|
TTL: "8760h",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
TokenURL: srv.URL + "/token",
|
||||||
|
}
|
||||||
|
connector := googlecas.New(config, logger)
|
||||||
|
|
||||||
|
reason := tc.reason
|
||||||
|
err := connector.RevokeCertificate(ctx, issuer.RevocationRequest{
|
||||||
|
Serial: "projects/p/locations/l/caPools/cp/certificates/cert-1",
|
||||||
|
Reason: &reason,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RevokeCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if receivedReason != tc.expected {
|
||||||
|
t.Errorf("Expected reason '%s', got '%s'", tc.expected, receivedReason)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetCACertPEM_Success", func(t *testing.T) {
|
||||||
|
credPath := createTestCredentialsFile(t)
|
||||||
|
caCertPEM, _ := generateTestCert(t)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/token":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
|
||||||
|
case strings.Contains(r.URL.Path, ":fetchCaCerts"):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
resp := fmt.Sprintf(`{"caCerts":[{"certificates":[%q]}]}`, caCertPEM)
|
||||||
|
w.Write([]byte(resp))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &googlecas.Config{
|
||||||
|
Project: "test-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "test-pool",
|
||||||
|
Credentials: credPath,
|
||||||
|
TTL: "8760h",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
TokenURL: srv.URL + "/token",
|
||||||
|
}
|
||||||
|
connector := googlecas.New(config, logger)
|
||||||
|
|
||||||
|
caPEM, err := connector.GetCACertPEM(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCACertPEM failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(caPEM, "BEGIN CERTIFICATE") {
|
||||||
|
t.Errorf("Expected CA PEM to contain certificate, got: %s", caPEM[:50])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetCACertPEM_Error", func(t *testing.T) {
|
||||||
|
credPath := createTestCredentialsFile(t)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/token":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"access_token":"test-token","expires_in":3600,"token_type":"Bearer"}`))
|
||||||
|
case strings.Contains(r.URL.Path, ":fetchCaCerts"):
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
w.Write([]byte(`{"error":{"code":403,"message":"Permission denied","status":"PERMISSION_DENIED"}}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &googlecas.Config{
|
||||||
|
Project: "test-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "test-pool",
|
||||||
|
Credentials: credPath,
|
||||||
|
TTL: "8760h",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
TokenURL: srv.URL + "/token",
|
||||||
|
}
|
||||||
|
connector := googlecas.New(config, logger)
|
||||||
|
|
||||||
|
_, err := connector.GetCACertPEM(ctx)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for permission denied")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetRenewalInfo_ReturnsNil", func(t *testing.T) {
|
||||||
|
config := &googlecas.Config{
|
||||||
|
Project: "test-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "test-pool",
|
||||||
|
}
|
||||||
|
connector := googlecas.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 Google CAS")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AuthHeader_BearerToken", func(t *testing.T) {
|
||||||
|
testCertPEM, _ := generateTestCert(t)
|
||||||
|
credPath := createTestCredentialsFile(t)
|
||||||
|
var authHeader string
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/token":
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"access_token":"verified-token-abc","expires_in":3600,"token_type":"Bearer"}`))
|
||||||
|
case strings.Contains(r.URL.Path, "/certificates") && r.Method == http.MethodPost:
|
||||||
|
authHeader = r.Header.Get("Authorization")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
resp := fmt.Sprintf(`{
|
||||||
|
"name": "projects/p/locations/l/caPools/cp/certificates/c1",
|
||||||
|
"pemCertificate": %q,
|
||||||
|
"pemCertificateChain": []
|
||||||
|
}`, testCertPEM)
|
||||||
|
w.Write([]byte(resp))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
config := &googlecas.Config{
|
||||||
|
Project: "test-project",
|
||||||
|
Location: "us-central1",
|
||||||
|
CAPool: "test-pool",
|
||||||
|
Credentials: credPath,
|
||||||
|
TTL: "8760h",
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
TokenURL: srv.URL + "/token",
|
||||||
|
}
|
||||||
|
connector := googlecas.New(config, logger)
|
||||||
|
|
||||||
|
_, csrPEM := generateTestCSR(t, "auth-test.example.com")
|
||||||
|
_, err := connector.IssueCertificate(ctx, issuer.IssuanceRequest{
|
||||||
|
CommonName: "auth-test.example.com",
|
||||||
|
CSRPEM: csrPEM,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueCertificate failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if authHeader != "Bearer verified-token-abc" {
|
||||||
|
t.Errorf("Expected 'Bearer verified-token-abc', got '%s'", authHeader)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTestCredentialsFile generates a temporary service account JSON file with a test RSA key.
|
||||||
|
func createTestCredentialsFile(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to generate RSA key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keyPEM := pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "RSA PRIVATE KEY",
|
||||||
|
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||||
|
})
|
||||||
|
|
||||||
|
creds := map[string]interface{}{
|
||||||
|
"type": "service_account",
|
||||||
|
"project_id": "test-project",
|
||||||
|
"private_key_id": "key-123",
|
||||||
|
"private_key": string(keyPEM),
|
||||||
|
"client_email": "certctl@test-project.iam.gserviceaccount.com",
|
||||||
|
"token_uri": "https://oauth2.googleapis.com/token",
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(creds)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to marshal credentials: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
credPath := filepath.Join(tmpDir, "credentials.json")
|
||||||
|
if err := os.WriteFile(credPath, data, 0600); err != nil {
|
||||||
|
t.Fatalf("Failed to write credentials file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return credPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
},
|
||||||
|
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||||
|
NotAfter: time.Now().Add(24 * time.Hour),
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -72,6 +72,7 @@ const (
|
|||||||
IssuerTypeVault IssuerType = "VaultPKI"
|
IssuerTypeVault IssuerType = "VaultPKI"
|
||||||
IssuerTypeDigiCert IssuerType = "DigiCert"
|
IssuerTypeDigiCert IssuerType = "DigiCert"
|
||||||
IssuerTypeSectigo IssuerType = "Sectigo"
|
IssuerTypeSectigo IssuerType = "Sectigo"
|
||||||
|
IssuerTypeGoogleCAS IssuerType = "GoogleCAS"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TargetType represents the type of deployment target.
|
// TargetType represents the type of deployment target.
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ INSERT INTO issuers (id, name, type, config, enabled, created_at, updated_at) VA
|
|||||||
('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-vault', 'HashiCorp Vault PKI', 'VaultPKI', '{"addr": "https://vault.internal:8200", "mount": "pki", "role": "web-certs", "ttl": "8760h"}', true, NOW() - INTERVAL '20 days', NOW() - INTERVAL '20 days'),
|
||||||
('iss-digicert', 'DigiCert CertCentral', 'DigiCert', '{"base_url": "https://www.digicert.com/services/v2", "product_type": "ssl_basic"}', true, NOW() - INTERVAL '15 days', NOW() - INTERVAL '15 days'),
|
('iss-digicert', 'DigiCert CertCentral', 'DigiCert', '{"base_url": "https://www.digicert.com/services/v2", "product_type": "ssl_basic"}', true, NOW() - INTERVAL '15 days', NOW() - INTERVAL '15 days'),
|
||||||
('iss-sectigo', 'Sectigo SCM', 'Sectigo', '{"base_url": "https://cert-manager.com/api", "cert_type": 423, "term": 365}', true, NOW() - INTERVAL '10 days', NOW() - INTERVAL '10 days')
|
('iss-sectigo', 'Sectigo SCM', 'Sectigo', '{"base_url": "https://cert-manager.com/api", "cert_type": 423, "term": 365}', true, NOW() - INTERVAL '10 days', NOW() - INTERVAL '10 days'),
|
||||||
|
('iss-googlecas','Google CAS', 'GoogleCAS', '{"project": "demo-project", "location": "us-central1", "ca_pool": "demo-pool"}', false, NOW() - INTERVAL '5 days', NOW() - INTERVAL '5 days')
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|||||||
@@ -135,6 +135,19 @@ export const issuerTypes: IssuerTypeConfig[] = [
|
|||||||
{ key: 'base_url', label: 'Base URL', required: false, placeholder: 'https://cert-manager.com/api' },
|
{ key: 'base_url', label: 'Base URL', required: false, placeholder: 'https://cert-manager.com/api' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'GoogleCAS',
|
||||||
|
name: 'Google CAS',
|
||||||
|
description: 'Google Cloud Certificate Authority Service \u2014 managed private CA on GCP',
|
||||||
|
icon: '\u2601\uFE0F',
|
||||||
|
configFields: [
|
||||||
|
{ key: 'project', label: 'GCP Project ID', required: true, placeholder: 'my-gcp-project' },
|
||||||
|
{ key: 'location', label: 'Location', required: true, placeholder: 'us-central1' },
|
||||||
|
{ key: 'ca_pool', label: 'CA Pool', required: true, placeholder: 'my-ca-pool' },
|
||||||
|
{ key: 'credentials', label: 'Service Account JSON Path', required: true, placeholder: '/path/to/credentials.json', sensitive: true },
|
||||||
|
{ key: 'ttl', label: 'Default TTL', required: false, placeholder: '8760h' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'entrust',
|
id: 'entrust',
|
||||||
name: 'Entrust',
|
name: 'Entrust',
|
||||||
|
|||||||
Reference in New Issue
Block a user