diff --git a/README.md b/README.md index 64a80d7..cafef52 100644 --- a/README.md +++ b/README.md @@ -85,8 +85,9 @@ For the full capability breakdown — revocation infrastructure (CRL + OCSP), po | Vault PKI | Beta | `VaultPKI` | | DigiCert CertCentral | Beta | `DigiCert` | | 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. diff --git a/api/openapi.yaml b/api/openapi.yaml index 55afa65..fe2cd1a 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2643,7 +2643,7 @@ components: # ─── Issuers ───────────────────────────────────────────────────── IssuerType: type: string - enum: [ACME, GenericCA, StepCA, VaultPKI, DigiCert, Sectigo] + enum: [ACME, GenericCA, StepCA, VaultPKI, DigiCert, Sectigo, GoogleCAS] Issuer: type: object diff --git a/cmd/server/main.go b/cmd/server/main.go index d478c97..1c76c51 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -22,6 +22,7 @@ import ( digicertissuer "github.com/shankar0123/certctl/internal/connector/issuer/digicert" opensslissuer "github.com/shankar0123/certctl/internal/connector/issuer/openssl" stepcaissuer "github.com/shankar0123/certctl/internal/connector/issuer/stepca" + googlecasissuer "github.com/shankar0123/certctl/internal/connector/issuer/googlecas" sectigoissuer "github.com/shankar0123/certctl/internal/connector/issuer/sectigo" vaultissuer "github.com/shankar0123/certctl/internal/connector/issuer/vault" notifyemail "github.com/shankar0123/certctl/internal/connector/notifier/email" @@ -172,6 +173,17 @@ func main() { }, logger) 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. // "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. @@ -203,6 +215,12 @@ func main() { 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)) // Initialize revocation repository diff --git a/docs/architecture.md b/docs/architecture.md index f22b872..4a9e39f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -514,6 +514,7 @@ flowchart TB II --> VP["Vault PKI"] II --> DC["DigiCert CertCentral"] II --> SG["Sectigo SCM"] + II --> GC["Google CAS"] end subgraph "Target Connectors" diff --git a/docs/connectors.md b/docs/connectors.md index 04328c6..03f96fb 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -379,12 +379,29 @@ The connector submits certificate enrollments to Sectigo's `/ssl/v1/enroll` API. 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+ The following issuer connectors are planned for future releases: - **Entrust** — Enterprise CA via Entrust API -- **Google CAS** — Google Cloud Certificate Authority Service - **AWS ACM Private CA** — AWS-managed private CA Note: ADCS (Active Directory Certificate Services) integration is handled via the **sub-CA mode** of the Local CA issuer, not as a separate connector. certctl operates as a subordinate CA with its signing certificate issued by ADCS, so all certctl-issued certs chain to the enterprise ADCS root. See the Local CA section above. diff --git a/docs/testing-guide.md b/docs/testing-guide.md index ce7738d..65960ea 100644 --- a/docs/testing-guide.md +++ b/docs/testing-guide.md @@ -6385,15 +6385,83 @@ These must be green before starting manual QA: **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 | Category | Count | |----------|-------| | ☑ 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 | -| ☐ Manual (requires hands-on verification) | 247 | -| **Total** | **416** | +| ☐ Manual (requires hands-on verification) | 253 | +| **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. diff --git a/internal/config/config.go b/internal/config/config.go index 11a34b4..2a6406f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,6 +28,7 @@ type Config struct { Vault VaultConfig DigiCert DigiCertConfig Sectigo SectigoConfig + GoogleCAS GoogleCASConfig Digest DigestConfig } @@ -232,6 +233,34 @@ type SectigoConfig struct { 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. type DigestConfig struct { // Enabled controls whether periodic digest emails are generated and sent. @@ -547,6 +576,13 @@ func Load() (*Config, error) { Term: getEnvInt("CERTCTL_SECTIGO_TERM", 365), 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{ DirectoryURL: getEnv("CERTCTL_ACME_DIRECTORY_URL", ""), Email: getEnv("CERTCTL_ACME_EMAIL", ""), diff --git a/internal/connector/issuer/googlecas/googlecas.go b/internal/connector/issuer/googlecas/googlecas.go new file mode 100644 index 0000000..341b263 --- /dev/null +++ b/internal/connector/issuer/googlecas/googlecas.go @@ -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) diff --git a/internal/connector/issuer/googlecas/googlecas_test.go b/internal/connector/issuer/googlecas/googlecas_test.go new file mode 100644 index 0000000..7dc18e6 --- /dev/null +++ b/internal/connector/issuer/googlecas/googlecas_test.go @@ -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 +} diff --git a/internal/domain/connector.go b/internal/domain/connector.go index bddd92d..43bbeca 100644 --- a/internal/domain/connector.go +++ b/internal/domain/connector.go @@ -72,6 +72,7 @@ const ( IssuerTypeVault IssuerType = "VaultPKI" IssuerTypeDigiCert IssuerType = "DigiCert" IssuerTypeSectigo IssuerType = "Sectigo" + IssuerTypeGoogleCAS IssuerType = "GoogleCAS" ) // TargetType represents the type of deployment target. diff --git a/migrations/seed_demo.sql b/migrations/seed_demo.sql index e1d8747..6218e6c 100644 --- a/migrations/seed_demo.sql +++ b/migrations/seed_demo.sql @@ -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-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-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; -- ============================================================ diff --git a/web/src/config/issuerTypes.ts b/web/src/config/issuerTypes.ts index 9005ec4..b0c244b 100644 --- a/web/src/config/issuerTypes.ts +++ b/web/src/config/issuerTypes.ts @@ -135,6 +135,19 @@ export const issuerTypes: IssuerTypeConfig[] = [ { 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', name: 'Entrust',