From 54a9ac006a75cc54f5d0f60015ac83159e4ba9a8 Mon Sep 17 00:00:00 2001 From: Morgan Gangwere <470584+indrora@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:29:28 -0800 Subject: [PATCH 1/7] chore: Update integration-manifest.json * Update integration-manifest.json * Update generated docs --------- Co-authored-by: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Co-authored-by: Keyfactor From 1f2bc149ef17064c8eb3a64f1a7324aab72f7bc6 Mon Sep 17 00:00:00 2001 From: Morgan Gangwere <470584+indrora@users.noreply.github.com> Date: Tue, 3 Mar 2026 11:52:41 -0800 Subject: [PATCH 2/7] Merge 1.3.1 to main Co-authored-by: Keyfactor --------- Co-authored-by: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Co-authored-by: Keyfactor --- .gitignore | 1 + CHANGELOG.md | 6 + GCPCAS/Client/GCPCASClient.cs | 325 ++++++++++++++++++--------------- GCPCAS/GCPCASCAPlugin.cs | 2 +- GCPCAS/GCPCASCAPluginConfig.cs | 9 + README.md | 17 +- docsource/configuration.md | 16 +- integration-manifest.json | 4 + 8 files changed, 226 insertions(+), 154 deletions(-) diff --git a/.gitignore b/.gitignore index 8932a7e..6be482b 100644 --- a/.gitignore +++ b/.gitignore @@ -352,3 +352,4 @@ healthchecksdb logs *.pem *.crt +.claude/settings.local.json diff --git a/CHANGELOG.md b/CHANGELOG.md index e2b85e7..f16f424 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +- 1.3.0 + - SaaS containerization changes with Google Credentials +- 1.2.2 + - Fixed Sync Issues at CA Level, was ignoring and always syncing at pool level +- 1.2.1 + - Doc Updates - 1.2.0 - Added Enable Flag - Dual Build Support diff --git a/GCPCAS/Client/GCPCASClient.cs b/GCPCAS/Client/GCPCASClient.cs index e3ed3e3..0197836 100644 --- a/GCPCAS/Client/GCPCASClient.cs +++ b/GCPCAS/Client/GCPCASClient.cs @@ -58,8 +58,9 @@ public class GCPCASClient : IGCPCASClient /// The GCP project ID where the target GCP CAS CA is located /// The CA Pool ID in GCP CAS to use for certificate operations. If the CA Pool has resource name projects/my-project/locations/us-central1/caPools/my-pool, this field should be set to my-pool /// The CA ID of a CA in the same CA Pool as CAPool. For example, to issue certificates from a CA with resource name projects/my-project/locations/us-central1/caPools/my-pool/certificateAuthorities/my-ca, this field should be set to my-ca. - public GCPCASClient(string locationId, string projectId, string caPool, string caId) - { + /// Optional JSON service account key. When provided, used instead of Application Default Credentials. + public GCPCASClient(string locationId, string projectId, string caPool, string caId, string serviceAccountKey = null) + { _logger = LogHandler.GetClassLogger(); _logger.MethodEntry(); _logger.LogDebug($"Creating GCP CA Services Client with Location: {locationId}, Project ID: {projectId}, CA Pool: {caPool}, CA ID: {caId}"); @@ -69,8 +70,18 @@ public GCPCASClient(string locationId, string projectId, string caPool, string c this._caPool = caPool; this._caId = caId; - _logger.LogTrace($"Setting up a {typeof(CertificateAuthorityServiceClient).ToString()} using the Default gRPC adapter"); - _client = new CertificateAuthorityServiceClientBuilder().Build(); + var builder = new CertificateAuthorityServiceClientBuilder(); + if (!string.IsNullOrEmpty(serviceAccountKey)) + { + _logger.LogTrace("Using provided service account key JSON for authentication"); + builder.JsonCredentials = serviceAccountKey; + } + else + { + _logger.LogTrace($"Setting up a {typeof(CertificateAuthorityServiceClient).ToString()} using Application Default Credentials"); + } + + _client = builder.Build(); _logger.MethodExit(); } @@ -84,13 +95,13 @@ public override string ToString() /// /// public Task Enable() - { + { _logger.MethodEntry(); if (!_clientIsEnabled) { _logger.LogDebug($"Enabling GCPCAS client {this.ToString()}"); _clientIsEnabled = true; - } + } _logger.MethodExit(); return Task.CompletedTask; } @@ -100,7 +111,7 @@ public Task Enable() /// /// public Task Disable() - { + { _logger.MethodEntry(); if (_clientIsEnabled) { @@ -118,12 +129,12 @@ public Task Disable() /// A indicating if the client is enabled. /// public bool IsEnabled() - { + { _logger.MethodEntry(); _logger.MethodExit(); return _clientIsEnabled; - } - + } + /// /// Attempts to connect to the GCP CAS service to verify connectivity. Verifies that the GCP Application Default Credentials are properly configured. /// @@ -131,53 +142,53 @@ public bool IsEnabled() /// Returns nothing if the connection is successful. /// /// Thrown if the GCP Application Default Credentials are not properly configured, if the GCP CAS CA Pool/CA is not found/is not compatible, or if the was not enabled via the method. - public async Task ValidateConnection() - { - _logger.MethodEntry(); - EnsureClientIsEnabled(); - - if (string.IsNullOrEmpty(_caId)) - { - _logger.LogTrace($"Validating CA Pool {_caPool} since no specific CA ID was provided"); - - CaPoolName poolName = new CaPoolName(_projectId, _locationId, _caPool); - CaPool pool = await _client.GetCaPoolAsync(poolName); - - if (pool.Tier != CaPool.Types.Tier.Enterprise) - { - string error = $"CA Pool {_caPool} is in Tier {pool.Tier}, expected {CaPool.Types.Tier.Enterprise}."; - _logger.LogError(error); - throw new Exception(error); - } - - _logger.LogDebug($"CA Pool {_caPool} is Enterprise tier and valid."); - _logger.MethodExit(); - return; - } - - _logger.LogTrace($"Searching for CA called {_caId} in CA Pool {_caPool}"); - CertificateAuthorityName caName = new CertificateAuthorityName(_projectId, _locationId, _caPool, _caId); - CertificateAuthority ca = await _client.GetCertificateAuthorityAsync(caName); - - _logger.LogDebug($"Found CA {ca.CertificateAuthorityName.CertificateAuthorityId} in CA Pool {ca.CertificateAuthorityName.CaPoolId}"); - - if (ca.State != CertificateAuthority.Types.State.Enabled) - { - string error = $"CA {_caId} is in state {ca.State}. Expected Enabled."; - _logger.LogError(error); - throw new Exception(error); - } - - if (ca.Tier != CaPool.Types.Tier.Enterprise) - { - string error = $"CA {_caId} is in tier {ca.Tier}. Only Enterprise tier is supported."; - _logger.LogError(error); - throw new Exception(error); - } - - _logger.LogDebug($"{nameof(GCPCASClient)} is compatible with CA {_caId} in Pool {_caPool}."); - _logger.MethodExit(); - } + public async Task ValidateConnection() + { + _logger.MethodEntry(); + EnsureClientIsEnabled(); + + if (string.IsNullOrEmpty(_caId)) + { + _logger.LogTrace($"Validating CA Pool {_caPool} since no specific CA ID was provided"); + + CaPoolName poolName = new CaPoolName(_projectId, _locationId, _caPool); + CaPool pool = await _client.GetCaPoolAsync(poolName); + + if (pool.Tier != CaPool.Types.Tier.Enterprise) + { + string error = $"CA Pool {_caPool} is in Tier {pool.Tier}, expected {CaPool.Types.Tier.Enterprise}."; + _logger.LogError(error); + throw new Exception(error); + } + + _logger.LogDebug($"CA Pool {_caPool} is Enterprise tier and valid."); + _logger.MethodExit(); + return; + } + + _logger.LogTrace($"Searching for CA called {_caId} in CA Pool {_caPool}"); + CertificateAuthorityName caName = new CertificateAuthorityName(_projectId, _locationId, _caPool, _caId); + CertificateAuthority ca = await _client.GetCertificateAuthorityAsync(caName); + + _logger.LogDebug($"Found CA {ca.CertificateAuthorityName.CertificateAuthorityId} in CA Pool {ca.CertificateAuthorityName.CaPoolId}"); + + if (ca.State != CertificateAuthority.Types.State.Enabled) + { + string error = $"CA {_caId} is in state {ca.State}. Expected Enabled."; + _logger.LogError(error); + throw new Exception(error); + } + + if (ca.Tier != CaPool.Types.Tier.Enterprise) + { + string error = $"CA {_caId} is in tier {ca.Tier}. Only Enterprise tier is supported."; + _logger.LogError(error); + throw new Exception(error); + } + + _logger.LogDebug($"{nameof(GCPCASClient)} is compatible with CA {_caId} in Pool {_caPool}."); + _logger.MethodExit(); + } /// @@ -195,84 +206,100 @@ public async Task ValidateConnection() /// /// Thrown if the is null or if the operation fails. /// - public async Task DownloadAllIssuedCertificates(BlockingCollection certificatesBuffer, CancellationToken cancelToken, DateTime? issuedAfter = null) - { - _logger.MethodEntry(); - EnsureClientIsEnabled(); - - if (certificatesBuffer == null) - { - string message = "Failed to download issued certificates - certificatesBuffer is null"; - _logger.LogError(message); - throw new ArgumentNullException(nameof(certificatesBuffer), message); - } - - _logger.LogTrace($"Setting up {typeof(ListCertificatesRequest).ToString()} with {this.ToString()}"); - - ListCertificatesRequest request = new ListCertificatesRequest - { - ParentAsCaPoolName = new CaPoolName(_projectId, _locationId, _caPool), - }; - - if (issuedAfter != null) - { - Timestamp ts = Timestamp.FromDateTime(issuedAfter.Value.ToUniversalTime()); - _logger.LogDebug($"Filtering issued certificates by update_time >= {ts}"); - request.Filter = $"update_time >= {ts}"; - } - - _logger.LogTrace($"Setting up {typeof(CallSettings).ToString()} with provided {typeof(CancellationToken).ToString()} {this.ToString()}"); - CallSettings settings = CallSettings.FromCancellationToken(cancelToken); - - _logger.LogDebug($"Downloading all issued certificates from GCP CAS {this.ToString()}"); - PagedAsyncEnumerable certificates = _client.ListCertificatesAsync(request, settings); - - int pageNumber = 0; - int numberOfCertificates = 0; - - try - { - await foreach (var response in certificates.AsRawResponses()) - { - if (response.Certificates == null) - { - _logger.LogWarning($"GCP returned null certificate list for page number {pageNumber} - continuing {this.ToString()}"); - continue; - } - - foreach (Certificate certificate in response.Certificates) - { - certificatesBuffer.Add(AnyCAPluginCertificateFromGCPCertificate(certificate)); - numberOfCertificates++; - _logger.LogDebug($"Found Certificate with name {certificate.CertificateName.CertificateId} {this.ToString()}"); - } - - _logger.LogTrace($"Fetched page {pageNumber} - Next Page Token: {response.NextPageToken}"); - pageNumber++; - } - } - catch (RpcException ex) when (ex.StatusCode == StatusCode.ResourceExhausted) - { - _logger.LogError($"Rate limit exceeded while fetching certificates: {ex.Message}"); - throw; - } - catch (OperationCanceledException) - { - _logger.LogWarning("Certificate download operation was canceled."); - throw; - } - catch (Exception ex) - { - _logger.LogError($"Unexpected error while fetching certificates: {ex.Message}"); - throw; - } - finally - { - certificatesBuffer.CompleteAdding(); - _logger.LogDebug($"Fetched {certificatesBuffer.Count} certificates from GCP over {pageNumber} pages."); - } - _logger.MethodExit(); - return numberOfCertificates; + public async Task DownloadAllIssuedCertificates(BlockingCollection certificatesBuffer, CancellationToken cancelToken, DateTime? issuedAfter = null) + { + _logger.MethodEntry(); + EnsureClientIsEnabled(); + + if (certificatesBuffer == null) + { + string message = "Failed to download issued certificates - certificatesBuffer is null"; + _logger.LogError(message); + throw new ArgumentNullException(nameof(certificatesBuffer), message); + } + + _logger.LogTrace($"Setting up {typeof(ListCertificatesRequest).ToString()} with {this.ToString()}"); + + ListCertificatesRequest request = new ListCertificatesRequest + { + ParentAsCaPoolName = new CaPoolName(_projectId, _locationId, _caPool), + }; + + string caFilter = null; + if (!string.IsNullOrEmpty(_caId)) + { + caFilter = _caId; + _logger.LogDebug($"Will filter certificates client-side by issuing CA ID: {caFilter}"); + } + + if (issuedAfter != null) + { + Timestamp ts = Timestamp.FromDateTime(issuedAfter.Value.ToUniversalTime()); + _logger.LogDebug($"Filtering issued certificates by update_time >= {ts}"); + request.Filter = $"update_time >= {ts}"; + } + + _logger.LogTrace($"Setting up {typeof(CallSettings).ToString()} with provided {typeof(CancellationToken).ToString()} {this.ToString()}"); + CallSettings settings = CallSettings.FromCancellationToken(cancelToken); + + _logger.LogDebug($"Downloading all issued certificates from GCP CAS {this.ToString()}"); + PagedAsyncEnumerable certificates = _client.ListCertificatesAsync(request, settings); + + int pageNumber = 0; + int numberOfCertificates = 0; + + try + { + await foreach (var response in certificates.AsRawResponses()) + { + if (response.Certificates == null) + { + _logger.LogWarning($"GCP returned null certificate list for page number {pageNumber} - continuing {this.ToString()}"); + continue; + } + + foreach (Certificate certificate in response.Certificates) + { + if (caFilter != null) + { + CertificateAuthorityName issuer = CertificateAuthorityName.Parse(certificate.IssuerCertificateAuthority); + if (issuer.CertificateAuthorityId != caFilter) + { + _logger.LogTrace($"Skipping certificate {certificate.CertificateName.CertificateId} - issued by {issuer.CertificateAuthorityId}, not {caFilter}"); + continue; + } + } + certificatesBuffer.Add(AnyCAPluginCertificateFromGCPCertificate(certificate)); + numberOfCertificates++; + _logger.LogDebug($"Found Certificate with name {certificate.CertificateName.CertificateId} {this.ToString()}"); + } + + _logger.LogTrace($"Fetched page {pageNumber} - Next Page Token: {response.NextPageToken}"); + pageNumber++; + } + } + catch (RpcException ex) when (ex.StatusCode == StatusCode.ResourceExhausted) + { + _logger.LogError($"Rate limit exceeded while fetching certificates: {ex.Message}"); + throw; + } + catch (OperationCanceledException) + { + _logger.LogWarning("Certificate download operation was canceled."); + throw; + } + catch (Exception ex) + { + _logger.LogError($"Unexpected error while fetching certificates: {ex.Message}"); + throw; + } + finally + { + certificatesBuffer.CompleteAdding(); + _logger.LogDebug($"Fetched {certificatesBuffer.Count} certificates from GCP over {pageNumber} pages."); + } + _logger.MethodExit(); + return numberOfCertificates; } @@ -287,7 +314,7 @@ public async Task DownloadAllIssuedCertificates(BlockingCollection and task result as a containing the downloaded certificate. /// public async Task DownloadCertificate(string certificateId) - { + { _logger.MethodEntry(); EnsureClientIsEnabled(); @@ -300,13 +327,13 @@ public async Task DownloadCertificate(string certificate }; Certificate certificate = await _client.GetCertificateAsync(request); - _logger.LogTrace("GetCertificateAsync succeeded"); + _logger.LogTrace("GetCertificateAsync succeeded"); _logger.MethodExit(); return AnyCAPluginCertificateFromGCPCertificate(certificate); } private AnyCAPluginCertificate AnyCAPluginCertificateFromGCPCertificate(Certificate certificate) - { + { _logger.MethodEntry(); string productId = ""; if (certificate.CertificateTemplateAsCertificateTemplateName == null) @@ -328,7 +355,7 @@ private AnyCAPluginCertificate AnyCAPluginCertificateFromGCPCertificate(Certific revocationDate = certificate.RevocationDetails.RevocationTime.ToDateTime(); status = EndEntityStatus.REVOKED; revocationReason = (int)certificate.RevocationDetails.RevocationState; - } + } _logger.MethodExit(); return new AnyCAPluginCertificate { @@ -355,23 +382,23 @@ private AnyCAPluginCertificate AnyCAPluginCertificateFromGCPCertificate(Certific public async Task Enroll(ICreateCertificateRequestBuilder createCertificateRequestBuilder, CancellationToken cancelToken) { try - { + { _logger.MethodEntry(); EnsureClientIsEnabled(); CreateCertificateRequest request = createCertificateRequestBuilder.Build(_locationId, _projectId, _caPool, _caId); - if (request != null) - { - _logger.LogTrace($"Request Json {JsonConvert.SerializeObject(request)}"); + if (request != null) + { + _logger.LogTrace($"Request Json {JsonConvert.SerializeObject(request)}"); } Certificate certificate = await _client.CreateCertificateAsync(request, cancelToken); - if (certificate != null) - { - _logger.LogTrace($"Response Json {JsonConvert.SerializeObject(certificate)}"); - } + if (certificate != null) + { + _logger.LogTrace($"Response Json {JsonConvert.SerializeObject(certificate)}"); + } _logger.MethodExit(); return new EnrollmentResult { @@ -421,7 +448,7 @@ public async Task Enroll(ICreateCertificateRequestBuilder crea /// /// public Task RevokeCertificate(string certificateId, RevocationReason reason) - { + { _logger.MethodEntry(); EnsureClientIsEnabled(); @@ -444,7 +471,7 @@ public Task RevokeCertificate(string certificateId, RevocationReason reason) /// A of containing the available s. /// public List GetTemplates() - { + { _logger.MethodEntry(); EnsureClientIsEnabled(); diff --git a/GCPCAS/GCPCASCAPlugin.cs b/GCPCAS/GCPCASCAPlugin.cs index 7e94658..431720b 100644 --- a/GCPCAS/GCPCASCAPlugin.cs +++ b/GCPCAS/GCPCASCAPlugin.cs @@ -200,7 +200,7 @@ private void GCPCASClientFromCAConnectionData(Dictionary connect else { _logger.LogDebug("Creating new GCPCASClient instance."); - Client = new GCPCASClient(_config.LocationId, _config.ProjectId, _config.CAPool, _config.CAId); + Client = new GCPCASClient(_config.LocationId, _config.ProjectId, _config.CAPool, _config.CAId, _config.ServiceAccountKey); } if (_config.Enabled) diff --git a/GCPCAS/GCPCASCAPluginConfig.cs b/GCPCAS/GCPCASCAPluginConfig.cs index 6d5e424..4136db0 100644 --- a/GCPCAS/GCPCASCAPluginConfig.cs +++ b/GCPCAS/GCPCASCAPluginConfig.cs @@ -33,6 +33,7 @@ public class ConfigConstants public const string CAPool = "CAPool"; public const string CAId = "CAId"; public const string Enabled = "Enabled"; + public const string ServiceAccountKey = "ServiceAccountKey"; } public class Config @@ -42,6 +43,7 @@ public class Config public string CAPool { get; set; } public string CAId { get; set; } public bool Enabled { get; set; } + public string ServiceAccountKey { get; set; } } public static class EnrollmentParametersConstants @@ -88,6 +90,13 @@ public static Dictionary GetPluginAnnotations() DefaultValue = true, Type = "Boolean" }, + [ConfigConstants.ServiceAccountKey] = new PropertyConfigInfo() + { + Comments = "Optional JSON service account key for GCP authentication. When provided, this is used instead of Application Default Credentials (ADC). This is recommended for containerized environments where mounting a credentials file is not practical. Leave empty to use ADC.", + Hidden = true, + DefaultValue = "", + Type = "Secret" + }, }; } diff --git a/README.md b/README.md index 00f1a94..2c62011 100644 --- a/README.md +++ b/README.md @@ -62,9 +62,19 @@ The GCP CAS AnyCA Gateway REST plugin is supported by Keyfactor for Keyfactor cu ## Requirements -### Application Default Credentials +### GCP Authentication -The GCP CAS AnyCA Gateway REST plugin connects to and authenticates with GCP CAS implicitly using [Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials). This means that all authentication-related configuration of the GCP CAS AnyCA Gateway REST plugin is implied by the environment where the AnyCA Gateway REST itself is running. +The GCP CAS AnyCA Gateway REST plugin supports two methods for authenticating with GCP CAS: + +#### Option 1: Service Account Key via CA Connection Configuration (Recommended for Containers) + +The plugin accepts an optional **ServiceAccountKey** field in the CA Connection configuration. When provided, the JSON service account key is used directly for authentication without requiring any credential files on the filesystem. This is the recommended approach for containerized deployments (e.g., Docker, Kubernetes) where mounting credential files is not practical. + +To use this method, paste the full JSON contents of a GCP service account key into the **ServiceAccountKey** field in the CA Connection tab. In Kubernetes, the service account key JSON can be stored as a Secret and injected via the Keyfactor configuration API. + +#### Option 2: Application Default Credentials (ADC) + +If the **ServiceAccountKey** field is left empty, the plugin falls back to [Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials). This means that all authentication-related configuration is implied by the environment where the AnyCA Gateway REST itself is running. Please refer to [Google's documentation](https://cloud.google.com/docs/authentication/provide-credentials-adc) to configure ADC on the server running the AnyCA Gateway REST. @@ -75,6 +85,8 @@ Please refer to [Google's documentation](https://cloud.google.com/docs/authentic > 1. The service account that the AnyCA Gateway REST runs under must have read permission to the GCP credential JSON file. > 2. You must set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable for the Windows Service running the AnyCA Gateway REST using the [Windows registry editor](https://learn.microsoft.com/en-us/troubleshoot/windows-server/performance/windows-registry-advanced-users). > * Refer to the [HKLM\SYSTEM\CurrentControlSet\Services Registry Tree](https://learn.microsoft.com/en-us/windows-hardware/drivers/install/hklm-system-currentcontrolset-services-registry-tree) docs +> +> For containerized environments running on GCP (e.g., GKE), [Workload Identity](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity) can be used instead, which requires no credential files or environment variables. If the selected ADC mechanism is [Service Account Key](https://cloud.google.com/docs/authentication/provide-credentials-adc#wlif-key), it's recommended that a [custom role is created](https://cloud.google.com/iam/docs/creating-custom-roles) that has the following minimum permissions: @@ -140,6 +152,7 @@ Both the Keyfactor Command and AnyCA Gateway REST servers must trust the root CA * **CAPool** - The CA Pool ID in GCP CAS to use for certificate operations. If the CA Pool has resource name `projects/my-project/locations/us-central1/caPools/my-pool`, this field should be set to `my-pool` * **CAId** - The CA ID of a CA in the same CA Pool as CAPool. For example, to issue certificates from a CA with resource name `projects/my-project/locations/us-central1/caPools/my-pool/certificateAuthorities/my-ca`, this field should be set to `my-ca`. * **Enabled** - Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available. + * **ServiceAccountKey** - Optional JSON service account key for GCP authentication. When provided, this is used instead of Application Default Credentials (ADC). This is recommended for containerized environments where mounting a credentials file is not practical. Leave empty to use ADC. 2. Define [Certificate Profiles](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCP-Gateway.htm) and [Certificate Templates](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/AddCA-Gateway.htm) for the Certificate Authority as required. One Certificate Profile must be defined per Certificate Template. It's recommended that each Certificate Profile be named after the Product ID. diff --git a/docsource/configuration.md b/docsource/configuration.md index fe02049..576c39c 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -19,9 +19,19 @@ The [Google Cloud Platform (GCP) CA Services (CAS)](https://cloud.google.com/sec ## Requirements -### Application Default Credentials +### GCP Authentication -The GCP CAS AnyCA Gateway REST plugin connects to and authenticates with GCP CAS implicitly using [Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials). This means that all authentication-related configuration of the GCP CAS AnyCA Gateway REST plugin is implied by the environment where the AnyCA Gateway REST itself is running. +The GCP CAS AnyCA Gateway REST plugin supports two methods for authenticating with GCP CAS: + +#### Option 1: Service Account Key via CA Connection Configuration (Recommended for Containers) + +The plugin accepts an optional **ServiceAccountKey** field in the CA Connection configuration. When provided, the JSON service account key is used directly for authentication without requiring any credential files on the filesystem. This is the recommended approach for containerized deployments (e.g., Docker, Kubernetes) where mounting credential files is not practical. + +To use this method, paste the full JSON contents of a GCP service account key into the **ServiceAccountKey** field in the CA Connection tab. In Kubernetes, the service account key JSON can be stored as a Secret and injected via the Keyfactor configuration API. + +#### Option 2: Application Default Credentials (ADC) + +If the **ServiceAccountKey** field is left empty, the plugin falls back to [Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials). This means that all authentication-related configuration is implied by the environment where the AnyCA Gateway REST itself is running. Please refer to [Google's documentation](https://cloud.google.com/docs/authentication/provide-credentials-adc) to configure ADC on the server running the AnyCA Gateway REST. @@ -32,6 +42,8 @@ Please refer to [Google's documentation](https://cloud.google.com/docs/authentic > 1. The service account that the AnyCA Gateway REST runs under must have read permission to the GCP credential JSON file. > 2. You must set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable for the Windows Service running the AnyCA Gateway REST using the [Windows registry editor](https://learn.microsoft.com/en-us/troubleshoot/windows-server/performance/windows-registry-advanced-users). > * Refer to the [HKLM\SYSTEM\CurrentControlSet\Services Registry Tree](https://learn.microsoft.com/en-us/windows-hardware/drivers/install/hklm-system-currentcontrolset-services-registry-tree) docs +> +> For containerized environments running on GCP (e.g., GKE), [Workload Identity](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity) can be used instead, which requires no credential files or environment variables. If the selected ADC mechanism is [Service Account Key](https://cloud.google.com/docs/authentication/provide-credentials-adc#wlif-key), it's recommended that a [custom role is created](https://cloud.google.com/iam/docs/creating-custom-roles) that has the following minimum permissions: diff --git a/integration-manifest.json b/integration-manifest.json index 10c9afd..baaf854 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -32,6 +32,10 @@ { "name": "Enabled", "description": "Flag to Enable or Disable gateway functionality. Disabling is primarily used to allow creation of the CA prior to configuration information being available." + }, + { + "name": "ServiceAccountKey", + "description": "Optional JSON service account key for GCP authentication. When provided, this is used instead of Application Default Credentials (ADC). This is recommended for containerized environments where mounting a credentials file is not practical. Leave empty to use ADC." } ], "enrollment_config": [ From ffc3923c21460f3cf9213e83634389f20729e8be Mon Sep 17 00:00:00 2001 From: Morgan Gangwere <470584+indrora@users.noreply.github.com> Date: Wed, 1 Apr 2026 18:00:28 -0700 Subject: [PATCH 3/7] Merge 1.3.2 to main (#24) * chore: Update integration-manifest.json (#16) * Update integration-manifest.json * Update generated docs --------- Co-authored-by: Keyfactor * release: 1.3.0 --------- Co-authored-by: Keyfactor * fixed sans issue passed to extension data (#23) * fixed sans issue passed to extension data * fixed change log --------- Co-authored-by: Morgan Gangwere <470584+indrora@users.noreply.github.com> --------- Co-authored-by: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Co-authored-by: Keyfactor --- CHANGELOG.md | 3 +++ GCPCAS/Client/CreateCertificateRequestBuilder.cs | 14 ++++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f16f424..8355964 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +- 1.3.2 + - Fixed Sans Being passed through Extensions Data, Google does not like this. +- 1.3.1 - 1.3.0 - SaaS containerization changes with Google Credentials - 1.2.2 diff --git a/GCPCAS/Client/CreateCertificateRequestBuilder.cs b/GCPCAS/Client/CreateCertificateRequestBuilder.cs index 86b42b8..22f587f 100644 --- a/GCPCAS/Client/CreateCertificateRequestBuilder.cs +++ b/GCPCAS/Client/CreateCertificateRequestBuilder.cs @@ -1,5 +1,5 @@ /* -Copyright © 2025 Keyfactor +Copyright � 2025 Keyfactor Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -85,12 +85,14 @@ public ICreateCertificateRequestBuilder WithEnrollmentProductInfo(EnrollmentProd string base64Value = param.Value; _logger.LogTrace($"Loggin oid and value {oid} {base64Value}"); - - var extension = CreateX509Extension(oid, base64Value); - if (extension != null) + if (oid != "2.5.29.17") //can't send Sans as an extension to google, they do not like this and you will get an error { - _logger.LogTrace($"Adding Extension"); - _additionalExtensions.Add(extension); + var extension = CreateX509Extension(oid, base64Value); + if (extension != null) + { + _logger.LogTrace($"Adding Extension"); + _additionalExtensions.Add(extension); + } } } } From 9b79805e74b2fafedda023cd9f4faa25ef98c6ba Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Thu, 18 Jun 2026 10:09:32 -0400 Subject: [PATCH 4/7] Add FlowLogger and sync diagnostics for certificate metadata troubleshooting Port the FlowLogger workflow-tracing utility from the cscglobal-caplugin 200dayfixes branch and wire it into the plugin's Synchronize, Enroll, and GetSingleRecord operations to render step-by-step, timed flow diagrams to Trace logs. Add [SYNC-DIAG] instrumentation in GCPCASClient that, for every certificate handed to the AnyCA Gateway during sync, parses the PEM content and logs the fingerprint (thumbprint), NotBefore (as epoch ms), NotAfter, serial number, and subject - i.e. the exact metadata the Gateway must surface to Command on /v2/certificate/search and that the incremental sync gates on. Records whose content is null/empty or unparseable are flagged, pinpointing whether empty fingerprint / notBefore=0 values originate in the plugin. --- GCPCAS/Client/GCPCASClient.cs | 53 +++++++- GCPCAS/FlowLogger.cs | 244 ++++++++++++++++++++++++++++++++++ GCPCAS/GCPCASCAPlugin.cs | 74 +++++++++-- 3 files changed, 356 insertions(+), 15 deletions(-) create mode 100644 GCPCAS/FlowLogger.cs diff --git a/GCPCAS/Client/GCPCASClient.cs b/GCPCAS/Client/GCPCASClient.cs index 0197836..a9af263 100644 --- a/GCPCAS/Client/GCPCASClient.cs +++ b/GCPCAS/Client/GCPCASClient.cs @@ -17,6 +17,7 @@ limitations under the License. using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; using Google.Api.Gax; @@ -297,6 +298,7 @@ public async Task DownloadAllIssuedCertificates(BlockingCollection + /// Emits detailed diagnostics about the certificate content being handed to the AnyCA Gateway. + /// The Gateway parses to populate the fingerprint and + /// notBefore fields that Command's incremental sync (IssuedDateSyncPartitionTracker) gates on. Logging + /// the raw shape and the parsed metadata here pinpoints whether the plugin is the source of empty/zero + /// values seen downstream. + /// + private void LogCertificateContentDiagnostics(string caRequestId, string pem, EndEntityStatus status, DateTime? revocationDate, int? revocationReason) + { + try + { + if (string.IsNullOrEmpty(pem)) + { + _logger.LogWarning($"[SYNC-DIAG] CARequestID={caRequestId}: PemCertificate is NULL or EMPTY - the Gateway will have no content to derive fingerprint/notBefore from. status={status} revoked={(revocationDate != null)}"); + return; + } + + bool hasPemArmor = pem.Contains("-----BEGIN"); + _logger.LogTrace($"[SYNC-DIAG] CARequestID={caRequestId}: PemCertificate length={pem.Length}, hasPemArmor={hasPemArmor}, first40='{pem.Substring(0, Math.Min(40, pem.Length)).Replace("\n", "\\n").Replace("\r", "\\r")}'"); + + // Parse exactly what the Gateway would parse to derive metadata. + using var parsed = X509Certificate2.CreateFromPem(pem); + long notBeforeEpochMs = new DateTimeOffset(parsed.NotBefore.ToUniversalTime()).ToUnixTimeMilliseconds(); + long notAfterEpochMs = new DateTimeOffset(parsed.NotAfter.ToUniversalTime()).ToUnixTimeMilliseconds(); + + _logger.LogDebug( + $"[SYNC-DIAG] CARequestID={caRequestId}: parsed OK -> Thumbprint(fingerprint)={parsed.Thumbprint}, " + + $"SerialNumber={parsed.SerialNumber}, Subject='{parsed.Subject}', " + + $"NotBefore={parsed.NotBefore:o} (epochMs={notBeforeEpochMs}), NotAfter={parsed.NotAfter:o} (epochMs={notAfterEpochMs}), " + + $"status={status}, revoked={(revocationDate != null)}, revocationReason={revocationReason}"); + } + catch (Exception ex) + { + _logger.LogWarning($"[SYNC-DIAG] CARequestID={caRequestId}: FAILED to parse PemCertificate into an X509Certificate2 - the Gateway will likely store an empty fingerprint / notBefore=0 for this record. Error: {ex.Message}"); + } + } /// /// Enrolls a certificate using a configured and returns the result. /// diff --git a/GCPCAS/FlowLogger.cs b/GCPCAS/FlowLogger.cs new file mode 100644 index 0000000..f21e613 --- /dev/null +++ b/GCPCAS/FlowLogger.cs @@ -0,0 +1,244 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Keyfactor.Extensions.CAPlugin.GCPCAS; + +public enum FlowStepStatus +{ + Success, + Failed, + Skipped, + InProgress +} + +public class FlowStep +{ + public string Name { get; set; } + public FlowStepStatus Status { get; set; } + public string Detail { get; set; } + public long ElapsedMs { get; set; } + public List Children { get; } = new(); +} + +/// +/// Tracks high-level operation flow and renders a visual step diagram to Trace logs. +/// Usage: +/// using var flow = new FlowLogger(logger, "Enroll-New"); +/// flow.Step("ParseCSR"); +/// flow.Step("ValidateCSR", () => { ... }); +/// flow.Fail("CreateOrder", "API returned 400"); +/// // flow renders automatically on Dispose +/// +public sealed class FlowLogger : IDisposable +{ + private readonly ILogger _logger; + private readonly string _flowName; + private readonly Stopwatch _totalTimer; + private readonly List _steps = new(); + private FlowStep _currentParent; + private bool _disposed; + + public FlowLogger(ILogger logger, string flowName) + { + _logger = logger; + _flowName = flowName; + _totalTimer = Stopwatch.StartNew(); + _logger.LogTrace("===== FLOW START: {FlowName} =====", _flowName); + } + + /// Record a completed step. + public FlowLogger Step(string name, string detail = null) + { + var step = new FlowStep { Name = name, Status = FlowStepStatus.Success, Detail = detail }; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... OK{Detail}", + _flowName, name, detail != null ? $" ({detail})" : ""); + return this; + } + + /// Record a step that executes an action and times it. + public FlowLogger Step(string name, Action action, string detail = null) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name, Detail = detail }; + try + { + _logger.LogTrace(" [{FlowName}] {StepName} ...", _flowName, name); + action(); + sw.Stop(); + step.Status = FlowStepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... OK ({Elapsed}ms){Detail}", + _flowName, name, sw.ElapsedMilliseconds, detail != null ? $" {detail}" : ""); + } + catch (Exception ex) + { + sw.Stop(); + step.Status = FlowStepStatus.Failed; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.Detail = ex.Message; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... FAILED ({Elapsed}ms): {Error}", + _flowName, name, sw.ElapsedMilliseconds, ex.Message); + throw; + } + return this; + } + + /// Record an async step that executes and times it. + public async Task StepAsync(string name, Func action, string detail = null) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name, Detail = detail }; + try + { + _logger.LogTrace(" [{FlowName}] {StepName} ...", _flowName, name); + await action(); + sw.Stop(); + step.Status = FlowStepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... OK ({Elapsed}ms){Detail}", + _flowName, name, sw.ElapsedMilliseconds, detail != null ? $" {detail}" : ""); + } + catch (Exception ex) + { + sw.Stop(); + step.Status = FlowStepStatus.Failed; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.Detail = ex.Message; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... FAILED ({Elapsed}ms): {Error}", + _flowName, name, sw.ElapsedMilliseconds, ex.Message); + throw; + } + return this; + } + + /// Record a failed step without throwing. + public FlowLogger Fail(string name, string reason = null) + { + var step = new FlowStep { Name = name, Status = FlowStepStatus.Failed, Detail = reason }; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... FAILED{Reason}", + _flowName, name, reason != null ? $": {reason}" : ""); + return this; + } + + /// Record a skipped step. + public FlowLogger Skip(string name, string reason = null) + { + var step = new FlowStep { Name = name, Status = FlowStepStatus.Skipped, Detail = reason }; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... SKIPPED{Reason}", + _flowName, name, reason != null ? $": {reason}" : ""); + return this; + } + + /// Start a branch (group of child steps). + public FlowLogger Branch(string name) + { + var step = new FlowStep { Name = name, Status = FlowStepStatus.InProgress }; + AddStep(step); + _currentParent = step; + _logger.LogTrace(" [{FlowName}] >> Branch: {BranchName}", _flowName, name); + return this; + } + + /// End the current branch. + public FlowLogger EndBranch() + { + _currentParent = null; + return this; + } + + private void AddStep(FlowStep step) + { + if (_currentParent != null) + _currentParent.Children.Add(step); + else + _steps.Add(step); + } + + /// Render the visual flow diagram to Trace log. + private string RenderFlow() + { + var sb = new StringBuilder(); + sb.AppendLine(); + sb.AppendLine($" ===== FLOW: {_flowName} ({_totalTimer.ElapsedMilliseconds}ms total) ====="); + sb.AppendLine(); + + for (var i = 0; i < _steps.Count; i++) + { + var step = _steps[i]; + var icon = GetStatusIcon(step.Status); + var elapsed = step.ElapsedMs > 0 ? $" ({step.ElapsedMs}ms)" : ""; + var detail = !string.IsNullOrEmpty(step.Detail) ? $" [{step.Detail}]" : ""; + + sb.AppendLine($" {icon} {step.Name}{elapsed}{detail}"); + + // Render children (branch) + if (step.Children.Count > 0) + { + for (var j = 0; j < step.Children.Count; j++) + { + var child = step.Children[j]; + var childIcon = GetStatusIcon(child.Status); + var childElapsed = child.ElapsedMs > 0 ? $" ({child.ElapsedMs}ms)" : ""; + var childDetail = !string.IsNullOrEmpty(child.Detail) ? $" [{child.Detail}]" : ""; + sb.AppendLine($" |"); + sb.AppendLine($" +-- {childIcon} {child.Name}{childElapsed}{childDetail}"); + } + } + + // Connector between top-level steps + if (i < _steps.Count - 1) + { + sb.AppendLine(" |"); + sb.AppendLine(" v"); + } + } + + sb.AppendLine(); + + // Final status line + var finalStatus = _steps.Count > 0 && _steps.Last().Status == FlowStepStatus.Failed + ? "FAILED" : _steps.Any(s => s.Status == FlowStepStatus.Failed) ? "PARTIAL FAILURE" : "SUCCESS"; + sb.AppendLine($" ===== FLOW RESULT: {finalStatus} ====="); + + return sb.ToString(); + } + + private static string GetStatusIcon(FlowStepStatus status) + { + return status switch + { + FlowStepStatus.Success => "[OK]", + FlowStepStatus.Failed => "[FAIL]", + FlowStepStatus.Skipped => "[SKIP]", + FlowStepStatus.InProgress => "[...]", + _ => "[?]" + }; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _totalTimer.Stop(); + _logger.LogTrace(RenderFlow()); + } +} diff --git a/GCPCAS/GCPCASCAPlugin.cs b/GCPCAS/GCPCASCAPlugin.cs index 431720b..1a2337e 100644 --- a/GCPCAS/GCPCASCAPlugin.cs +++ b/GCPCAS/GCPCASCAPlugin.cs @@ -113,40 +113,88 @@ public Task ValidateProductInfo(EnrollmentProductInfo productInfo, Dictionary blockingBuffer, DateTime? lastSync, bool fullSync, CancellationToken cancelToken) { _logger.MethodEntry(); + string syncType = fullSync ? "Full" : "Incremental"; + using var flow = new FlowLogger(_logger, $"Synchronize-{syncType}"); + _logger.LogTrace($"Synchronize called. fullSync={fullSync}, lastSync={lastSync?.ToString("o") ?? "(null)"}, blockingBuffer is {(blockingBuffer == null ? "NULL" : "present")}"); + + if (blockingBuffer == null) + { + flow.Fail("ValidateBuffer", "blockingBuffer is null"); + throw new ArgumentNullException(nameof(blockingBuffer), "blockingBuffer cannot be null in Synchronize"); + } + if (fullSync && lastSync != null) { _logger.LogInformation("Performing a full CA synchronization"); lastSync = null; + flow.Step("DetermineFilter", "Full sync - clearing date filter"); } else { _logger.LogInformation($"Performing an incremental CA synchronization - downloading certificates issued after {lastSync}"); + flow.Step("DetermineFilter", $"Incremental - issuedAfter={lastSync?.ToString("o") ?? "(null)"}"); + } + + int certificates = 0; + try + { + await flow.StepAsync("DownloadAllIssuedCertificates", async () => + { + certificates = await Client.DownloadAllIssuedCertificates(blockingBuffer, cancelToken, lastSync); + }, $"buffered count after download"); + flow.Step("Synchronized", $"{certificates} certificate(s)"); + _logger.LogDebug($"Synchronized {certificates} certificates"); + } + catch (OperationCanceledException) + { + flow.Fail("Cancelled", "operation was cancelled"); + throw; + } + catch (Exception e) + { + flow.Fail("SyncError", e.Message); + _logger.LogError($"GCP CAS Synchronize task failed: {e.Message}"); + throw; } - int certificates = await Client.DownloadAllIssuedCertificates(blockingBuffer, cancelToken, lastSync); - _logger.LogDebug($"Synchronized {certificates} certificates"); _logger.MethodExit(); } - public Task GetSingleRecord(string caRequestID) + public async Task GetSingleRecord(string caRequestID) { _logger.MethodEntry(); + using var flow = new FlowLogger(_logger, $"GetSingleRecord({caRequestID ?? "null"})"); + AnyCAPluginCertificate result = null; + await flow.StepAsync("DownloadCertificate", async () => + { + result = await Client.DownloadCertificate(caRequestID); + }, $"caRequestID={caRequestID ?? "(null)"}"); _logger.MethodExit(); - return Client.DownloadCertificate(caRequestID); + return result; } - public Task Enroll(string csr, string subject, Dictionary san, EnrollmentProductInfo productInfo, RequestFormat requestFormat, EnrollmentType enrollmentType) + public async Task Enroll(string csr, string subject, Dictionary san, EnrollmentProductInfo productInfo, RequestFormat requestFormat, EnrollmentType enrollmentType) { _logger.MethodEntry(); - ICreateCertificateRequestBuilder ccrBuilder = new CreateCertificateRequestBuilder() - .WithCsr(csr) - .WithSubject(subject) - .WithSans(san) - .WithEnrollmentProductInfo(productInfo) - .WithRequestFormat(requestFormat) - .WithEnrollmentType(enrollmentType); + using var flow = new FlowLogger(_logger, $"Enroll-{enrollmentType}"); + ICreateCertificateRequestBuilder ccrBuilder = null; + flow.Step("BuildRequest", () => + { + ccrBuilder = new CreateCertificateRequestBuilder() + .WithCsr(csr) + .WithSubject(subject) + .WithSans(san) + .WithEnrollmentProductInfo(productInfo) + .WithRequestFormat(requestFormat) + .WithEnrollmentType(enrollmentType); + }, $"subject='{subject}', sanCount={(san?.Count ?? 0)}"); + EnrollmentResult result = null; + await flow.StepAsync("Enroll", async () => + { + result = await Client.Enroll(ccrBuilder, CancellationToken.None); + }, $"status after enroll"); _logger.MethodExit(); - return Client.Enroll(ccrBuilder, CancellationToken.None); + return result; } public async Task Revoke(string caRequestID, string hexSerialNumber, uint revocationReason) From 977c973a350496109109ccc98c80456186517c8d Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Thu, 18 Jun 2026 10:14:54 -0400 Subject: [PATCH 5/7] Add net10.0 to target frameworks --- GCPCAS/GCPCAS.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GCPCAS/GCPCAS.csproj b/GCPCAS/GCPCAS.csproj index d27536c..366c8df 100644 --- a/GCPCAS/GCPCAS.csproj +++ b/GCPCAS/GCPCAS.csproj @@ -1,6 +1,6 @@ - net6.0;net8.0 + net6.0;net8.0;net10.0 disable true Keyfactor.Extensions.CAPlugin.GCPCAS From 457e7e258f3cde96d9212236528cdca22afd0475 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Thu, 18 Jun 2026 16:26:23 -0400 Subject: [PATCH 6/7] Skip certs with gateway-unparseable subjects during sync During Synchronize, mirror the subject parsing the AnyCA Gateway performs when building its /v2/certificate/search response (new X509Name(true, netCert.Subject)). That call throws on subjects BouncyCastle cannot re-parse from .NET's string representation, which returns a 500 for the entire search page and aborts Command's CA sync. GatewayCanParseSubject runs the same parse on each certificate before it is added to the sync buffer. Certificates that would throw are skipped with a [SYNC-SKIP] warning and counted, so a single unparseable subject never lands in the gateway database and can never break the downstream Command sync. The gateway-side fix (try/catch or reading the subject from DER) will be handled separately. --- GCPCAS/Client/GCPCASClient.cs | 49 +++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/GCPCAS/Client/GCPCASClient.cs b/GCPCAS/Client/GCPCASClient.cs index a9af263..455a733 100644 --- a/GCPCAS/Client/GCPCASClient.cs +++ b/GCPCAS/Client/GCPCASClient.cs @@ -248,6 +248,7 @@ public async Task DownloadAllIssuedCertificates(BlockingCollection DownloadAllIssuedCertificates(BlockingCollection DownloadAllIssuedCertificates(BlockingCollection + /// Mirrors the subject parsing the AnyCA Gateway performs when it builds the /v2/certificate/search + /// response: new Org.BouncyCastle.Asn1.X509.X509Name(true, netCert.Subject). That call throws on + /// subjects BouncyCastle cannot re-parse from .NET's string representation, which 500s the entire gateway + /// search page and aborts Command's CA sync. Returning lets the sync skip the + /// certificate so it never enters the gateway database and can never break the downstream Command sync. + /// + /// The PEM certificate content that will be handed to the gateway. + /// The parsed .NET subject string, when available (for logging). + /// The exception message when parsing fails. + /// if the gateway can parse the subject; otherwise . + private bool GatewayCanParseSubject(string pem, out string subject, out string failureReason) + { + subject = null; + failureReason = null; + try + { + using X509Certificate2 netCert = X509Certificate2.CreateFromPem(pem); + subject = netCert.Subject; + // This is the exact operation the gateway performs and that throws on problematic subjects. + _ = new Org.BouncyCastle.Asn1.X509.X509Name(true, subject); + return true; + } + catch (Exception ex) + { + failureReason = ex.Message; + return false; + } + } /// /// Enrolls a certificate using a configured and returns the result. /// From 9051a1e4759f9bcbcee59760da997a21080f47c4 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Fri, 19 Jun 2026 15:03:19 -0400 Subject: [PATCH 7/7] update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8355964..90e8230 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,5 @@ +- 1.3.3 + - Sync now skips bad/unparseable certificates returned from Google CAS instead of failing the sync. - 1.3.2 - Fixed Sans Being passed through Extensions Data, Google does not like this. - 1.3.1