diff --git a/CHANGELOG.md b/CHANGELOG.md index 90e8230..19fd897 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,5 @@ +- 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. - 1.3.2 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 455a733..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}]"; @@ -281,7 +297,7 @@ public async Task 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. + /// 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) { @@ -451,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 + + + + +