From 12f5461072fe97ecac402573f34ed1b5e36001fc Mon Sep 17 00:00:00 2001 From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:07:30 +0000 Subject: [PATCH 1/5] chore: Update integration-manifest.json (#16) * Update integration-manifest.json * Update generated docs --------- Co-authored-by: Keyfactor From a9383642d226148742f274b11b1400a6eb10358d Mon Sep 17 00:00:00 2001 From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Date: Tue, 3 Mar 2026 19:25:59 +0000 Subject: [PATCH 2/5] release: 1.3.0 --------- 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 c27dce9bd1aa614a4787a40460341164bd185011 Mon Sep 17 00:00:00 2001 From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Date: Wed, 1 Apr 2026 21:45:35 +0000 Subject: [PATCH 3/5] 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> --- 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 b1f8463ce84e9ce404d54fcf3f9d0af3273ae223 Mon Sep 17 00:00:00 2001 From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:30:51 -0400 Subject: [PATCH 4/5] 200dayfixes (#25) * 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 * 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 * 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 * 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. * Add net10.0 to target frameworks * 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. * update changelog --------- Co-authored-by: Sean <1661003+spbsoluble@users.noreply.github.com> Co-authored-by: Morgan Gangwere <470584+indrora@users.noreply.github.com> Co-authored-by: Keyfactor --- CHANGELOG.md | 2 + GCPCAS/Client/GCPCASClient.cs | 100 +++++++++++++- GCPCAS/FlowLogger.cs | 244 ++++++++++++++++++++++++++++++++++ GCPCAS/GCPCAS.csproj | 2 +- GCPCAS/GCPCASCAPlugin.cs | 74 +++++++++-- 5 files changed, 405 insertions(+), 17 deletions(-) create mode 100644 GCPCAS/FlowLogger.cs 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 diff --git a/GCPCAS/Client/GCPCASClient.cs b/GCPCAS/Client/GCPCASClient.cs index 0197836..455a733 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; @@ -247,6 +248,7 @@ public async Task DownloadAllIssuedCertificates(BlockingCollection DownloadAllIssuedCertificates(BlockingCollection 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}"); + } + } + + /// + /// 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. /// 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/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 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 c372b8fe88d179e2aa9d463fb7604cbd56556528 Mon Sep 17 00:00:00 2001 From: spb <1661003+spbsoluble@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:53:25 -0700 Subject: [PATCH 5/5] fix: skip subjects with odd-length backslash runs in sync guard (#30) (#31) * Merge 1.3.3 to main (#26) * 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> * 200dayfixes (#25) * 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 * 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 * 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 * 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. * Add net10.0 to target frameworks * 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. * update changelog --------- Co-authored-by: Sean <1661003+spbsoluble@users.noreply.github.com> Co-authored-by: Morgan Gangwere <470584+indrora@users.noreply.github.com> Co-authored-by: Keyfactor --------- Co-authored-by: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Co-authored-by: Keyfactor Co-authored-by: Sean <1661003+spbsoluble@users.noreply.github.com> * fix: resolve merge conflict markers committed in 1.3.3 merge (#27) PR #26 squash-merged release-1.3 commit f4fbf10, which had been committed with unresolved conflict markers in CHANGELOG.md and GCPCASClient.cs, leaving main uncompilable (CS8300 x27). The intended resolution of every conflict block is the release-1.3 side, whose content is byte-identical to the 1.3.3 tag. Restore both files from the 1.3.3 tag. * fix: skip subjects the AnyCA Gateway cannot parse in sync guard (#30) GCP CAS will issue certificates whose subject is not valid RFC 4514 (e.g. a CN ending in a dangling backslash). The AnyCA Gateway parses the subject with BouncyCastle's X509Name on its /v2/certificate/search response; on such a subject that call throws 'badly formatted directory string', the search page 500s, and Command's Full Scan aborts. The 1.3.3 guard tried to pre-empt this by running the same X509Name parse locally, but the plugin bundled BouncyCastle 2.0.0, whose parser is lenient and never throws on these shapes - so they were admitted and still broke the downstream sync. Fix: pin BouncyCastle.Cryptography to 2.6.2, the same strict build the gateway uses (verified: it throws on exactly the shapes the gateway rejects, with the identical message spelling, and accepts valid escapes such as \HH hex and \, that the gateway accepts). The guard now faithfully reproduces the gateway's accept/reject decision - no structural heuristic and no assumptions about .NET escaping - so it skips only certs the gateway genuinely cannot parse and stops dropping valid ones. Skips are logged at error level and sync continues. The upgrade also clears the known BouncyCastle 2.0.0 security advisories. Tests: pure unit regression tests for the guard predicate, plus a sync-loop test (SyncSkipContinuationTests) that streams good/bad/good pages through DownloadAllIssuedCertificates via a fake CertificateAuthorityServiceClient and asserts the bad cert is skipped while the rest are buffered and the sync completes. Adds an internal test-only GCPCASClient constructor to inject the fake client, and a GCPCAS.Tests/README.md describing every test. This branch is based on release-1.3 and also drops the committed merge-conflict markers that release-1.3 carried in CHANGELOG.md and GCPCAS/Client/GCPCASClient.cs (same resolution as #27). --------- 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 | 5 +- GCPCAS.Tests/README.md | 201 ++++++++++++++++++++++ GCPCAS.Tests/SubjectGuardTests.cs | 111 ++++++++++++ GCPCAS.Tests/SyncSkipContinuationTests.cs | 199 +++++++++++++++++++++ GCPCAS/Client/GCPCASClient.cs | 77 +++++++-- GCPCAS/GCPCAS.csproj | 7 + 6 files changed, 578 insertions(+), 22 deletions(-) create mode 100644 GCPCAS.Tests/README.md create mode 100644 GCPCAS.Tests/SubjectGuardTests.cs create mode 100644 GCPCAS.Tests/SyncSkipContinuationTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index f6dd435..19fd897 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,7 @@ -<<<<<<< release-1.3 +- 1.3.4 + - Upgraded BouncyCastle.Cryptography from 2.0.0 to 2.6.2. The plugin now pins the same BouncyCastle build the AnyCA Gateway uses, so the sync subject guard parses subjects exactly as the gateway does and skips only certificates the gateway genuinely cannot parse (e.g. a CN ending in a dangling `\`, which GCP CAS will issue but RFC 4514 forbids). The 1.3.3 guard ran against the lenient 2.0.0 parser, which never threw on these shapes, so they were admitted and later aborted Command's Full Scan with "badly formatted directory string" (issue #30). Each skipped certificate is logged at error level (`[SYNC-SKIP]`) with its request ID, subject, and reason, and sync continues so a full sync can complete. The upgrade also clears the known BouncyCastle 2.0.0 security advisories. - 1.3.3 - Sync now skips bad/unparseable certificates returned from Google CAS instead of failing the sync. -======= ->>>>>>> main - 1.3.2 - Fixed Sans Being passed through Extensions Data, Google does not like this. - 1.3.1 diff --git a/GCPCAS.Tests/README.md b/GCPCAS.Tests/README.md new file mode 100644 index 0000000..9a3eabf --- /dev/null +++ b/GCPCAS.Tests/README.md @@ -0,0 +1,201 @@ +# GCP CAS CA Plugin β€” Test Suite Reference + +## Overview + +The `GCPCAS.Tests` project contains the tests for the GCP CAS AnyCA Gateway REST plugin. It holds +two kinds of tests: + +- **Pure unit tests** β€” no external services. They exercise the sync subject guard and the sync + download loop in-process, using self-signed certificates generated at runtime and a fake + `CertificateAuthorityServiceClient`. These run under a plain `dotnet test`. +- **Integration tests** β€” gated by `[IntegrationTestingFact]`. They hit a **real** GCP CAS project + using Application Default Credentials and auto-skip unless the required environment variables are + set (see below). + +| Class | Layer under test | Isolation technique | +|---|---|---| +| `SubjectGuardTests` | `GCPCASClient.SubjectSurvivesGatewayRoundTrip` subject parse guard | Pure unit (in-memory strings + self-signed certs) | +| `SyncSkipContinuationTests` | `GCPCASClient.DownloadAllIssuedCertificates` skip-and-continue loop | Fake `CertificateAuthorityServiceClient` (no GCP) | +| `ClientTests` | `GCPCASClient` end-to-end against GCP CAS | `[IntegrationTestingFact]` (real GCP) | + +If a test fails in `SubjectGuardTests`, the bug is in the subject-parse decision. If it fails in +`SyncSkipContinuationTests`, the bug is in how the download loop handles a bad certificate. If it +fails in `ClientTests`, the bug is in the real GCP interaction (or the environment). + +--- + +## Running the Tests + +**Prerequisites:** +- .NET 8 SDK (test project targets `net8.0`; the plugin targets net6.0/net8.0/net10.0) +- NuGet packages restored (`dotnet restore`) +- No external services required for the unit tests + +**Run all tests** (integration tests skip automatically without credentials): +```bash +dotnet test GCPCAS.sln +``` + +**Run only the unit tests / a single class:** +```bash +dotnet test --filter "FullyQualifiedName~SubjectGuardTests" +dotnet test --filter "FullyQualifiedName~SyncSkipContinuationTests" +``` + +**Run a specific test by name:** +```bash +dotnet test --filter "DisplayName~DownloadAllIssuedCertificates_SkipsBadSubject_AndContinues" +``` + +The unit tests reach the plugin's `internal` members via `InternalsVisibleTo("GCPCAS.Tests")` +declared in `GCPCAS/GCPCAS.csproj`. + +--- + +## Integration test gating + +`ClientTests` are decorated with `[IntegrationTestingFact]` (`IntegrationTestingFact.cs`), which +skips the test unless **all** of these environment variables are set: + +- `GCP_PROJECT_ID` +- `GCP_LOCATION_ID` +- `GCP_CAS_CAPOOL` +- `GCP_CAS_CAID` + +When set, the tests authenticate with Application Default Credentials and operate against that real +Enterprise-tier CA pool. With no variables set, `dotnet test` passes trivially (everything skips). + +--- + +## SubjectGuardTests + +Regression tests for the sync subject guard (issue #30). GCP CAS will issue certificates whose +subject is not valid RFC 4514 (e.g. a CN ending in a dangling `\`). The AnyCA Gateway re-parses the +subject with BouncyCastle's `X509Name` on its `/v2/certificate/search` response; on such a subject +that parse throws `badly formatted directory string`, the response 500s, and Command's Full Scan +aborts. The 1.3.3 guard shipped against the lenient BouncyCastle 2.0.0, whose parse never threw on +these shapes, so they were admitted. The plugin now pins the same BouncyCastle build the gateway +uses, so `SubjectSurvivesGatewayRoundTrip` is a faithful reproduction of the gateway's accept/reject +decision β€” it rejects only what the gateway rejects and accepts everything it accepts. + +Subject strings are in .NET's `X509Certificate2.Subject` form. + +### SubjectSurvivesGatewayRoundTrip_ClassifiesSubjects (`[Theory]`) + +| Subject | Expected | Why | +|---|---|---| +| `CN=baseline-app-01.lab.test` | accepted | well-formed | +| `CN=wellformed.lab.test` | accepted | well-formed | +| `CN=host.lab.test, OU=PKI, O=Keyfactor Labs, C=US` | accepted | well-formed multi-RDN | +| `CN=shape1.lab.test\` | **rejected** | CN ends in a dangling backslash β€” the regression shape | +| `CN=host.lab.test\, OU=PKI` | **rejected** | dangling backslash right before an RDN separator | +| `CN=shape2.lab.test\\` | accepted | two backslashes are a valid escaped pair | +| `CN=shape3.lab.test\\\\, OU=PKI, O=Keyfactor Labs` | accepted | four backslashes are valid escaped pairs | +| `CN=a\bc` | accepted | `\bc` is a valid RFC 4514 hex escape β€” **not** a false positive | +| `CN=a\,b` | accepted | escaped comma is a valid escaped special β€” **not** a false positive | +| `CN=a,b` | **rejected** | bare unescaped separator yields a malformed second RDN | + +Rejected cases assert a non-empty `failureReason`; accepted cases assert `failureReason == null`. +The `CN=shape1.lab.test\` β†’ rejected case also serves as a **BouncyCastle-version guard**: if a +future transitive change reverted to a lenient BouncyCastle, this case would flip and fail. + +### RealCertificate_WithTrailingBackslashCn_IsRejected (`[Fact]`) + +Builds a real self-signed certificate whose CN ends in a literal backslash byte (the exact shape +GCP CAS issues), then feeds its `.Subject` through the guard. Asserts .NET renders the backslash +verbatim (single, not doubled) and that the guard rejects it with a reason. + +### RealCertificate_WithWellFormedCn_IsAccepted (`[Fact]`) + +Same, with a well-formed CN β€” asserts the guard accepts it and `failureReason` is null. + +### Helper + +- `SelfSignedPemWithCommonName(cn)` β€” builds an in-memory self-signed RSA-2048 certificate with the + given CN (via `X500DistinguishedNameBuilder`, so any raw byte including a backslash is accepted) + and returns its PEM. + +--- + +## SyncSkipContinuationTests + +Regression test for the sync loop's skip-and-continue behaviour (issue #30). It verifies the +user-facing requirement directly: the download reads the certificates **before** an unparseable one, +logs and **skips** the bad one, and keeps reading the certificates **after** it, so a full sync +completes rather than aborting. A fake `CertificateAuthorityServiceClient` streams hand-built pages, +so no GCP access is required. The `GCPCASClient` is created through an `internal` test-only +constructor that injects the fake client and starts enabled. + +### DownloadAllIssuedCertificates_SkipsBadSubject_AndContinues (`[Fact]`) + +| Setup | Assertion | +|---|---| +| Page 1 = `good-1`, `good-2`, `bad-1` (CN `broken.lab.test\`); Page 2 = `good-3`, `good-4` | Returns `4`; buffer count `4`; buffered `CARequestID`s are exactly `good-1..good-4` in order; `bad-1` is absent; `buffer.IsAddingCompleted == true` (the `finally`'s `CompleteAdding()` ran) | + +This proves the bad certificate is skipped mid-stream (not at the start or end), the surrounding +good certificates on both pages are still buffered, paging is honoured, and the sync terminates +cleanly. + +### DownloadAllIssuedCertificates_TicketFixture_SkipsOnlyShape1 (`[Fact]`) + +Reproduces the exact fixture from the escalation: four well-formed baselines + +`wellformed.lab.test` + four malformation shapes issued directly on GCP CAS, split across two pages +(shape1 lands mid-page-2 to prove the loop skips it and keeps reading the shapes after it). + +| Certificate | Subject shape | Outcome | +|---|---|---| +| `baseline-app-01/02`, `baseline-mq-01`, `baseline-web-01` | well-formed CN | buffered | +| `wellformed` | well-formed CN | buffered | +| `shape1` | CN ending in one literal backslash (customer row-301651 shape) | **skipped** | +| `shape2` | CN ending in two literal backslashes | buffered | +| `shape3` | four literal backslashes ending the CN value, then `OU=PKI, O=Keyfactor Labs` | buffered | +| `shape4` | nested DN in the outer CN (`CN=CN=…:oracle_wallet`), then `O`, `C`, `C` | buffered | + +| Assertion | Meaning | +|---|---| +| Returns `8`; buffered ids are exactly the eight parseable certs in order | shape1 is the only one dropped | +| `shape1` absent from the buffer | the sole gateway-unparseable subject is skipped | +| `buffer.IsAddingCompleted == true` | the full sync completed rather than aborting | + +This is the regression the fix targets: pre-fix, all nine were admitted and Command's Full Scan then +aborted on shape1; post-fix, shape1 never enters the buffer and the other eight sync. It also +confirms the guard is not over-eager β€” shape2/shape3/shape4 (which the gateway accepts) are **not** +skipped. + +### Helpers and fakes + +- `FakeCert(certId, commonName)` β€” a `Certificate` proto with a resource `CertificateName` and a + real self-signed PEM carrying the given CN. +- `Page(certs…)` β€” wraps certificates in a `ListCertificatesResponse`. +- `SelfSignedPem(cn)` β€” same generator as `SubjectGuardTests`. +- `FakeCasClient` β€” subclasses `CertificateAuthorityServiceClient` and overrides + `ListCertificatesAsync` to return the supplied pages. +- `FakePagedAsyncEnumerable` β€” subclasses `PagedAsyncEnumerable` + and streams the in-memory pages via `AsRawResponses()` (the method the loop consumes). + +--- + +## ClientTests (integration) + +End-to-end tests against a real GCP CAS project. All are `[IntegrationTestingFact]` and skip without +the four `GCP_*` environment variables. + +| Test | What it exercises | +|---|---| +| `GCPCASClient_Integration_GetTemplates_ReturnSuccess` | Lists certificate templates (product IDs) from the pool | +| `GCPCASClient_Integration_DownloadAllCertificates_ReturnSuccess` | Full download of issued certificates into a `BlockingCollection` | +| `GCPCASClient_Integration_DownloadAllCertificatesAfter_ReturnSuccess` | Incremental download with an `issuedAfter` filter | +| `GCPCASClient_Integration_EnrollGetRevoke_ReturnSuccess` | Enroll a certificate, fetch it, then revoke it | + +--- + +## Adding New Tests + +- **`SubjectGuardTests`** β€” when changing which subjects are considered parseable/skippable. Prefer + a `[Theory]` `[InlineData]` row over a new method. Remember the assertions encode the pinned + BouncyCastle's behaviour; if you bump BouncyCastle, re-verify the expected values. +- **`SyncSkipContinuationTests`** β€” when changing the download loop's filtering, counting, paging, or + buffer-completion behaviour. Build pages with `Page(...)` and certificates with `FakeCert(...)`; + do not call `buffer.CompleteAdding()` yourself (the loop does it in a `finally`). +- **`ClientTests`** β€” when adding real GCP behaviour that only a live project can validate. Gate it + with `[IntegrationTestingFact]` so it skips cleanly in CI without credentials. diff --git a/GCPCAS.Tests/SubjectGuardTests.cs b/GCPCAS.Tests/SubjectGuardTests.cs new file mode 100644 index 0000000..6dfa9f2 --- /dev/null +++ b/GCPCAS.Tests/SubjectGuardTests.cs @@ -0,0 +1,111 @@ +// 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.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Keyfactor.Extensions.CAPlugin.GCPCAS.Client; + +namespace Keyfactor.Extensions.CAPlugin.GCPCASTests; + +/// +/// Regression tests for the sync subject guard (issue #30). The 1.3.3 guard shipped against an older, lenient +/// BouncyCastle whose X509Name parse never threw on a CN ending in a dangling backslash, so such certs +/// were admitted, persisted, and then aborted Command's Full Scan with "badly formatted directory string" when +/// the gateway (on a stricter BouncyCastle) re-parsed the subject on its search response. The plugin now pins +/// the same BouncyCastle build as the gateway, so is +/// a faithful reproduction of the gateway's accept/reject decision - it rejects only what the gateway rejects +/// and accepts everything it accepts (no structural heuristic). +/// +/// Subject strings below are in .NET's form. These are pure unit tests +/// and run under a plain dotnet test. +/// +public class SubjectGuardTests +{ + [Theory] + // Well-formed subjects parse. + [InlineData("CN=baseline-app-01.lab.test", true)] + [InlineData("CN=wellformed.lab.test", true)] + [InlineData("CN=host.lab.test, OU=PKI, O=Keyfactor Labs, C=US", true)] + // shape1: CN ends in ONE dangling backslash -> BouncyCastle throws. The regression: must be rejected now + // (the lenient 1.3.3 BouncyCastle admitted it). + [InlineData(@"CN=shape1.lab.test\", false)] + // A dangling backslash right before a real RDN separator is rejected the same way. + [InlineData(@"CN=host.lab.test\, OU=PKI", false)] + // shape2 / shape3: TWO and FOUR backslashes are valid escaped pairs -> accepted, matching the lab + // reproduction, which saw only shape1 abort the scan. + [InlineData(@"CN=shape2.lab.test\\", true)] + [InlineData(@"CN=shape3.lab.test\\\\, OU=PKI, O=Keyfactor Labs", true)] + // NOT false positives: a backslash+two-hex is a valid RFC 4514 hex escape, and an escaped comma is a valid + // escaped special. These synced fine before and must keep syncing - the old odd-run heuristic wrongly + // skipped them. + [InlineData(@"CN=a\bc", true)] + [InlineData(@"CN=a\,b", true)] + // A bare unescaped separator yields a malformed second RDN, which BouncyCastle rejects. (.NET quotes such + // values rather than emitting this, but the guard rejects it regardless.) + [InlineData("CN=a,b", false)] + public void SubjectSurvivesGatewayRoundTrip_ClassifiesSubjects(string dotNetSubject, bool expectedSurvives) + { + bool survives = GCPCASClient.SubjectSurvivesGatewayRoundTrip(dotNetSubject, out string failureReason); + + Assert.Equal(expectedSurvives, survives); + if (expectedSurvives) + { + Assert.Null(failureReason); + } + else + { + Assert.False(string.IsNullOrEmpty(failureReason)); + } + } + + [Fact] + public void RealCertificate_WithTrailingBackslashCn_IsRejected() + { + // End-to-end shape check: build a real cert whose CN ends in a literal backslash byte (as GCP CAS + // would accept and issue), then feed the .NET subject string through the guard. This is the exact + // shape from the lab reproduction (shape1). + string pem = SelfSignedPemWithCommonName(@"shape1.lab.test\"); + using X509Certificate2 cert = X509Certificate2.CreateFromPem(pem); + + // Sanity: .NET renders the single literal backslash verbatim (not doubled). + Assert.EndsWith(@"\", cert.Subject); + Assert.DoesNotContain(@"\\", cert.Subject); + + Assert.False(GCPCASClient.SubjectSurvivesGatewayRoundTrip(cert.Subject, out string failureReason)); + Assert.False(string.IsNullOrEmpty(failureReason)); + } + + [Fact] + public void RealCertificate_WithWellFormedCn_IsAccepted() + { + string pem = SelfSignedPemWithCommonName("baseline-app-01.lab.test"); + using X509Certificate2 cert = X509Certificate2.CreateFromPem(pem); + + Assert.True(GCPCASClient.SubjectSurvivesGatewayRoundTrip(cert.Subject, out string failureReason)); + Assert.Null(failureReason); + } + + private static string SelfSignedPemWithCommonName(string commonNameValue) + { + var builder = new X500DistinguishedNameBuilder(); + builder.AddCommonName(commonNameValue); + X500DistinguishedName subject = builder.Build(); + + using RSA rsa = RSA.Create(2048); + var request = new CertificateRequest(subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using X509Certificate2 cert = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); + return cert.ExportCertificatePem(); + } +} diff --git a/GCPCAS.Tests/SyncSkipContinuationTests.cs b/GCPCAS.Tests/SyncSkipContinuationTests.cs new file mode 100644 index 0000000..cbce247 --- /dev/null +++ b/GCPCAS.Tests/SyncSkipContinuationTests.cs @@ -0,0 +1,199 @@ +// 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.Collections.Concurrent; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Google.Api.Gax; +using Google.Api.Gax.Grpc; +using Google.Cloud.Security.PrivateCA.V1; +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Extensions.CAPlugin.GCPCAS.Client; + +namespace Keyfactor.Extensions.CAPlugin.GCPCASTests; + +/// +/// Regression tests for the sync loop's skip-and-continue behaviour (issue #30): the download must read the +/// certificates before an unparseable one, log-and-skip the bad one, and keep reading the certificates after +/// it, so a full sync completes instead of aborting. Uses a fake +/// that streams hand-built pages, so no GCP access is needed. +/// +public class SyncSkipContinuationTests +{ + private const string Project = "test-project"; + private const string Location = "europe-west3"; + private const string Pool = "test-pool"; + + [Fact] + public async Task DownloadAllIssuedCertificates_SkipsBadSubject_AndContinues() + { + // Page 1: two good, then the bad (dangling-backslash CN). Page 2: two more good. + // Expected: 4 good certs buffered, the bad one skipped, sync completes (does not throw). + var client = new FakeCasClient(new[] + { + Page(CnCert("good-1", "app-01.lab.test"), CnCert("good-2", "app-02.lab.test"), CnCert("bad-1", @"broken.lab.test\")), + Page(CnCert("good-3", "mq-01.lab.test"), CnCert("good-4", "web-01.lab.test")), + }); + + var gcpClient = new GCPCASClient(client, Project, Location, Pool); + var buffer = new BlockingCollection(); + + int added = await gcpClient.DownloadAllIssuedCertificates(buffer, CancellationToken.None); + + Assert.Equal(4, added); + Assert.Equal(4, buffer.Count); + Assert.True(buffer.IsAddingCompleted); // CompleteAdding() ran in the finally block + + var bufferedIds = buffer.Select(c => c.CARequestID).ToList(); + Assert.Equal(new[] { "good-1", "good-2", "good-3", "good-4" }, bufferedIds); + Assert.DoesNotContain("bad-1", bufferedIds); // the unparseable cert was skipped, not buffered + } + + /// + /// Reproduces the exact fixture from the escalation (ZD 180923): four well-formed baselines + + /// wellformed.lab.test + four malformation shapes issued directly on GCP CAS. Under the fixed guard the + /// only certificate whose subject the AnyCA Gateway cannot parse - shape1 (CN ending in one literal + /// backslash, the customer's row-301651 shape) - is skipped, while the eight parseable certificates + /// (including shape2/shape3/shape4, which the gateway accepts) are handed to the buffer and the sync + /// completes. Before the fix all nine were admitted and Command's Full Scan then aborted on shape1. + /// + [Fact] + public async Task DownloadAllIssuedCertificates_TicketFixture_SkipsOnlyShape1() + { + (string Id, Certificate Cert, bool ExpectBuffered)[] fixture = + { + // Four well-formed baselines. + ("baseline-app-01", CnCert("baseline-app-01", "baseline-app-01.lab.test"), true), + ("baseline-app-02", CnCert("baseline-app-02", "baseline-app-02.lab.test"), true), + ("baseline-mq-01", CnCert("baseline-mq-01", "baseline-mq-01.lab.test"), true), + ("baseline-web-01", CnCert("baseline-web-01", "baseline-web-01.lab.test"), true), + // Plus one more well-formed. + ("wellformed", CnCert("wellformed", "wellformed.lab.test"), true), + // shape1: CN ending in ONE literal backslash -> gateway cannot parse -> the ONLY one skipped. + ("shape1", CnCert("shape1", "shape1.lab.test\\"), false), + // shape2: CN ending in TWO literal backslashes (valid escaped pair) -> accepted. + ("shape2", CnCert("shape2", "shape2.lab.test\\\\"), true), + // shape3: FOUR literal backslashes at the end of the CN value, ahead of OU/O RDNs -> accepted. + ("shape3", Cert("shape3", Dn(b => + { + b.AddCommonName("shape3.lab.test\\\\\\\\"); + b.AddOrganizationalUnitName("PKI"); + b.AddOrganizationName("Keyfactor Labs"); + })), true), + // shape4: nested DN embedded in the outer CN, plus O and two C RDNs -> accepted (.NET quotes the CN). + ("shape4", Cert("shape4", Dn(b => + { + b.AddCommonName("CN=shape4.lab.test:oracle_wallet"); + b.AddOrganizationName("Keyfactor Labs"); + b.AddCountryOrRegion("US"); + b.AddCountryOrRegion("US"); + })), true), + }; + + // Split across two pages, with shape1 in the middle of page 2 to prove the loop skips it and keeps + // reading the shapes after it. + var client = new FakeCasClient(new[] + { + Page(fixture.Take(5).Select(f => f.Cert).ToArray()), + Page(fixture.Skip(5).Select(f => f.Cert).ToArray()), + }); + + var gcpClient = new GCPCASClient(client, Project, Location, Pool); + var buffer = new BlockingCollection(); + + int added = await gcpClient.DownloadAllIssuedCertificates(buffer, CancellationToken.None); + + string[] expectedBuffered = fixture.Where(f => f.ExpectBuffered).Select(f => f.Id).ToArray(); + var bufferedIds = buffer.Select(c => c.CARequestID).ToList(); + + Assert.Equal(8, added); // 9 issued, 1 unparseable skipped + Assert.Equal(expectedBuffered, bufferedIds); // exactly the 8 parseable, in order + Assert.DoesNotContain("shape1", bufferedIds); // the only skipped shape + Assert.True(buffer.IsAddingCompleted); // sync completed cleanly + } + + private static ListCertificatesResponse Page(params Certificate[] certs) + { + var response = new ListCertificatesResponse(); + response.Certificates.AddRange(certs); + return response; + } + + private static Certificate CnCert(string certId, string commonName) => + Cert(certId, Dn(b => b.AddCommonName(commonName))); + + private static Certificate Cert(string certId, X500DistinguishedName subject) + { + return new Certificate + { + CertificateName = new CertificateName(Project, Location, Pool, certId), + PemCertificate = SelfSignedPem(subject), + }; + } + + private static X500DistinguishedName Dn(Action configure) + { + var builder = new X500DistinguishedNameBuilder(); + configure(builder); + return builder.Build(); + } + + private static string SelfSignedPem(X500DistinguishedName subject) + { + using RSA rsa = RSA.Create(2048); + var request = new CertificateRequest(subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using X509Certificate2 cert = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); + return cert.ExportCertificatePem(); + } + + /// Fake gRPC client that returns the supplied pages from ListCertificatesAsync. + private sealed class FakeCasClient : CertificateAuthorityServiceClient + { + private readonly IReadOnlyList _pages; + public FakeCasClient(IReadOnlyList pages) => _pages = pages; + + public override PagedAsyncEnumerable ListCertificatesAsync( + ListCertificatesRequest request, CallSettings callSettings = null!) + => new FakePagedAsyncEnumerable(_pages); + } + + private sealed class FakePagedAsyncEnumerable : PagedAsyncEnumerable + { + private readonly IReadOnlyList _pages; + public FakePagedAsyncEnumerable(IReadOnlyList pages) => _pages = pages; + +#pragma warning disable CS1998 // async method without await - the fake streams in-memory pages + public override async IAsyncEnumerable AsRawResponses() + { + foreach (ListCertificatesResponse page in _pages) + { + yield return page; + } + } + + public override async IAsyncEnumerator GetAsyncEnumerator( + CancellationToken cancellationToken = default) + { + foreach (ListCertificatesResponse page in _pages) + { + foreach (Certificate cert in page.Certificates) + { + yield return cert; + } + } + } +#pragma warning restore CS1998 + } +} diff --git a/GCPCAS/Client/GCPCASClient.cs b/GCPCAS/Client/GCPCASClient.cs index 01320c0..428aeac 100644 --- a/GCPCAS/Client/GCPCASClient.cs +++ b/GCPCAS/Client/GCPCASClient.cs @@ -86,6 +86,22 @@ public GCPCASClient(string locationId, string projectId, string caPool, string c _logger.MethodExit(); } + /// + /// Test-only constructor that injects a pre-built (real or + /// fake) instead of building a credentialed one, and starts enabled. Lets the sync loop be exercised + /// without GCP credentials. Not for production use. + /// + internal GCPCASClient(CertificateAuthorityServiceClient client, string projectId, string locationId, string caPool, string caId = "") + { + _logger = LogHandler.GetClassLogger(); + _client = client; + _projectId = projectId; + _locationId = locationId; + _caPool = caPool; + _caId = caId; + _clientIsEnabled = true; + } + public override string ToString() { return $"[locationId={_locationId} projectId={_projectId} caPool={_caPool} caId={_caId}]"; @@ -271,7 +287,6 @@ public async Task DownloadAllIssuedCertificates(BlockingCollection DownloadAllIssuedCertificates(BlockingCollection>>>>>> main numberOfCertificates++; _logger.LogDebug($"Found Certificate with name {certificate.CertificateName.CertificateId} {this.ToString()}"); } @@ -317,10 +329,7 @@ public async Task DownloadAllIssuedCertificates(BlockingCollection>>>>>> main } _logger.MethodExit(); return numberOfCertificates; @@ -380,7 +389,6 @@ private AnyCAPluginCertificate AnyCAPluginCertificateFromGCPCertificate(Certific status = EndEntityStatus.REVOKED; revocationReason = (int)certificate.RevocationDetails.RevocationState; } -<<<<<<< release-1.3 string caRequestId = certificate.CertificateName.CertificateId; string pem = certificate.PemCertificate; @@ -391,8 +399,6 @@ private AnyCAPluginCertificate AnyCAPluginCertificateFromGCPCertificate(Certific // compare against what the Gateway stores / returns to Command on the /v2/certificate/search response. LogCertificateContentDiagnostics(caRequestId, pem, status, revocationDate, revocationReason); -======= ->>>>>>> main _logger.MethodExit(); return new AnyCAPluginCertificate { @@ -443,15 +449,25 @@ private void LogCertificateContentDiagnostics(string caRequestId, string pem, En } /// - /// 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. + /// Reproduces the subject parse the AnyCA Gateway performs on its /v2/certificate/search response and + /// returns for subjects that would abort Command's CA sync, so the plugin can + /// skip the certificate before it ever enters the gateway database. /// + /// + /// The gateway builds the search response by handing the certificate subject to + /// new Org.BouncyCastle.Asn1.X509.X509Name(true, ...). On a subject BouncyCastle cannot parse + /// (e.g. a CN ending in a dangling \, as GCP CAS will issue but RFC 4514 forbids) that call throws + /// "badly formatted directory string", the search page 500s, and Command's sync aborts. This plugin pins + /// the same BouncyCastle build the gateway uses (see the BouncyCastle.Cryptography reference in + /// GCPCAS.csproj), so running the identical parse here throws on exactly the subjects the gateway rejects + /// and accepts everything it accepts - no heuristic and no guesswork about .NET's escaping. The 1.3.3 + /// guard shipped against an older, lenient BouncyCastle whose parse never threw on these shapes, which is + /// why they were admitted. + /// /// 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. + /// The parse failure message when the subject (or PEM) cannot be parsed; + /// when the subject is safe. /// if the gateway can parse the subject; otherwise . private bool GatewayCanParseSubject(string pem, out string subject, out string failureReason) { @@ -461,8 +477,31 @@ private bool GatewayCanParseSubject(string pem, out string subject, out string f { 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); + } + catch (Exception ex) + { + failureReason = ex.Message; + return false; + } + + return SubjectSurvivesGatewayRoundTrip(subject, out failureReason); + } + + /// + /// Returns whether a subject string as produced by can be parsed by + /// the BouncyCastle X509Name constructor the AnyCA Gateway uses on its search response. Because the + /// plugin pins the same BouncyCastle build as the gateway, this is a faithful reproduction of the + /// gateway's accept/reject decision rather than an approximation. See . + /// + /// The subject string in .NET's RFC 4514 form. + /// The parse exception message when parsing fails; otherwise . + /// if the subject parses; otherwise . + internal static bool SubjectSurvivesGatewayRoundTrip(string dotNetSubject, out string failureReason) + { + failureReason = null; + try + { + _ = new Org.BouncyCastle.Asn1.X509.X509Name(true, dotNetSubject); return true; } catch (Exception ex) diff --git a/GCPCAS/GCPCAS.csproj b/GCPCAS/GCPCAS.csproj index 366c8df..6e3f30a 100644 --- a/GCPCAS/GCPCAS.csproj +++ b/GCPCAS/GCPCAS.csproj @@ -7,6 +7,13 @@ GCPCASCAPlugin + + + + +