Skip to content

Commit ca82b77

Browse files
authored
Merge 91a70cf into c90a7a8
2 parents c90a7a8 + 91a70cf commit ca82b77

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+321
-105
lines changed

cscglobal-caplugin.sln

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ VisualStudioVersion = 17.11.35327.3
55
MinimumVisualStudioVersion = 10.0.40219.1
66
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSCGlobalCAPlugin", "cscglobal-caplugin\CSCGlobalCAPlugin.csproj", "{01DDFD6F-275D-46E7-B522-E0C965D1BF9C}"
77
EndProject
8+
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}"
9+
ProjectSection(SolutionItems) = preProject
10+
CHANGELOG.md = CHANGELOG.md
11+
integration-manifest.json = integration-manifest.json
12+
EndProjectSection
13+
EndProject
814
Global
915
GlobalSection(SolutionConfigurationPlatforms) = preSolution
1016
Debug|Any CPU = Debug|Any CPU

cscglobal-caplugin/CSCGlobalCAPlugin.cs

Lines changed: 215 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,6 @@
55
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions
66
// and limitations under the License.
77

8-
using System;
9-
using System.Collections.Concurrent;
10-
using System.Collections.Generic;
11-
using System.Net.Http;
12-
using System.Security.Cryptography.X509Certificates;
13-
using System.Text;
14-
using System.Threading;
15-
using System.Threading.Tasks;
168
using Keyfactor.AnyGateway.Extensions;
179
using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client;
1810
using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client.Models;
@@ -22,7 +14,11 @@
2214
using Keyfactor.PKI.X509;
2315
using Microsoft.Extensions.Logging;
2416
using Newtonsoft.Json;
25-
using Org.BouncyCastle.Pqc.Crypto.Lms;
17+
using System.Collections.Concurrent;
18+
using System.Security.Cryptography;
19+
using System.Security.Cryptography.X509Certificates;
20+
using System.Text;
21+
using System.Text.RegularExpressions;
2622

2723
namespace Keyfactor.Extensions.CAPlugin.CSCGlobal;
2824

@@ -64,7 +60,7 @@ public async Task<AnyCAPluginCertificate> GetSingleRecord(string caRequestID)
6460
var certificateResponse =
6561
Task.Run(async () => await CscGlobalClient.SubmitGetCertificateAsync(keyfactorCaId))
6662
.Result;
67-
63+
6864
Logger.LogTrace($"Single Cert JSON: {JsonConvert.SerializeObject(certificateResponse)}");
6965

7066
var fileContent =
@@ -120,8 +116,8 @@ public async Task Synchronize(BlockingCollection<AnyCAPluginCertificate> blockin
120116
if (EnableTemplateSync) productId = currentResponseItem?.CertificateType;
121117

122118
var fileContent =
123-
Encoding.ASCII.GetString(
124-
Convert.FromBase64String(currentResponseItem?.Certificate ?? string.Empty));
119+
PreparePemTextFromApi(
120+
currentResponseItem?.Certificate ?? string.Empty);
125121

126122
if (fileContent.Length > 0)
127123
{
@@ -176,7 +172,8 @@ await CscGlobalClient.SubmitRevokeCertificateAsync(caRequestID.Substring(0, 36))
176172

177173
if (revokeResult == (int)EndEntityStatus.FAILED)
178174
if (!string.IsNullOrEmpty(revokeResponse?.RegistrationError?.Description))
179-
throw new HttpRequestException($"Revoke Failed with message {revokeResponse?.RegistrationError?.Description}");
175+
throw new HttpRequestException(
176+
$"Revoke Failed with message {revokeResponse?.RegistrationError?.Description}");
180177

181178
return revokeResult;
182179
}
@@ -203,9 +200,9 @@ public async Task<EnrollmentResult> Enroll(string csr, string subject, Dictionar
203200
}
204201

205202
string uUId;
206-
var customFields = await CscGlobalClient.SubmitGetCustomFields();
203+
var customFields = await CscGlobalClient.SubmitGetCustomFields();
207204

208-
switch (enrollmentType)
205+
switch (enrollmentType)
209206
{
210207
case EnrollmentType.New:
211208
Logger.LogTrace("Entering New Enrollment");
@@ -396,37 +393,177 @@ public List<string> GetProductIds()
396393

397394
#region PRIVATE
398395

399-
//potential issues
400-
private string GetEndEntityCertificate(string certData)
396+
//Trying to fix leaf extraction
397+
private static readonly Regex PemBlock = new(
398+
"-----BEGIN CERTIFICATE-----\\s*(?<b64>[A-Za-z0-9+/=\\r\\n]+?)\\s*-----END CERTIFICATE-----",
399+
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Singleline);
400+
401+
private static readonly Regex Ws = new("\\s+", RegexOptions.Compiled);
402+
403+
/// <summary>
404+
/// Returns the end-entity certificate as Base64 DER (no PEM headers), or "" if none could be found.
405+
/// </summary>
406+
public string GetEndEntityCertificate(string pemChain)
407+
{
408+
if (string.IsNullOrWhiteSpace(pemChain))
409+
{
410+
Logger.LogWarning("Empty PEM input.");
411+
return string.Empty;
412+
}
413+
414+
// 1) Extract certs block-by-block, ignoring any garbage outside of valid fences.
415+
var certs = ExtractCertificates(pemChain);
416+
if (certs.Count == 0)
417+
{
418+
Logger.LogWarning("No valid certificate blocks found in input.");
419+
return string.Empty;
420+
}
421+
422+
// 2) Pick the leaf (end-entity).
423+
var leaf = FindLeaf(certs);
424+
if (leaf is null)
425+
{
426+
Logger.LogWarning("Could not determine end-entity certificate from the provided chain.");
427+
return string.Empty;
428+
}
429+
430+
try
431+
{
432+
// 3) Export to DER and Base64 (no headers).
433+
byte[] der = leaf.Export(X509ContentType.Cert);
434+
string b64 = Convert.ToBase64String(der);
435+
Logger.LogTrace("End-entity certificate exported successfully.");
436+
return b64;
437+
}
438+
catch (Exception ex)
439+
{
440+
Logger.LogError(ex, "Failed to export end-entity certificate.");
441+
return string.Empty;
442+
}
443+
finally
444+
{
445+
// Dispose everything we created.
446+
foreach (var c in certs) c.Dispose();
447+
}
448+
}
449+
450+
private List<X509Certificate2> ExtractCertificates(string pem)
401451
{
402-
var splitCerts =
403-
certData.Split(new[] { "-----END CERTIFICATE-----", "-----BEGIN CERTIFICATE-----" },
404-
StringSplitOptions.RemoveEmptyEntries);
452+
var results = new List<X509Certificate2>();
405453

406-
X509Certificate2Collection col = new X509Certificate2Collection();
407-
foreach (var cert in splitCerts)
454+
foreach (Match m in PemBlock.Matches(pem))
408455
{
409-
Logger.LogTrace($"Split Cert Value: {cert}");
410-
//skip these headers that came with the split function
411-
if (!cert.Contains(".crt"))
456+
string b64 = m.Groups["b64"].Value;
457+
if (string.IsNullOrWhiteSpace(b64))
412458
{
413-
col.Import(Encoding.UTF8.GetBytes(cert));
459+
Logger.LogTrace("Skipping empty PEM block.");
460+
continue;
461+
}
462+
463+
// Normalize: remove all whitespace and non-base64 spacers that sometimes creep in
464+
b64 = Ws.Replace(b64, string.Empty);
465+
466+
// Strict Base64 decode with validation.
467+
try
468+
{
469+
// Convert.TryFromBase64String is fast and avoids temporary arrays when possible
470+
if (!Convert.TryFromBase64String(b64, new Span<byte>(new byte[GetDecodedLength(b64)]), out int bytesWritten))
471+
{
472+
// Fallback to FromBase64String to trigger a clear exception path
473+
var discard = Convert.FromBase64String(b64);
474+
bytesWritten = discard.Length; // unreachable if invalid
475+
}
476+
477+
byte[] der = Convert.FromBase64String(b64);
478+
var cert = new X509Certificate2(der);
479+
results.Add(cert);
480+
Logger.LogTrace($"Imported certificate: Subject='{cert.Subject}', Issuer='{cert.Issuer}'");
481+
}
482+
catch (FormatException fex)
483+
{
484+
Logger.LogWarning(fex, "Invalid Base64 inside a PEM block; skipping this block.");
485+
}
486+
catch (CryptographicException cex)
487+
{
488+
Logger.LogWarning(cex, "DER payload failed to parse as X509; skipping this block.");
489+
}
490+
catch (Exception ex)
491+
{
492+
Logger.LogWarning(ex, "Unexpected error while parsing a PEM block; skipping this block.");
414493
}
415494
}
416-
Logger.LogTrace("Getting End Entity Certificate");
417-
var currentCert = X509Utilities.ExtractEndEntityCertificateContents(ExportCollectionToPem(col), "");
418-
Logger.LogTrace("Converting to Byte Array");
419-
var byteArray = currentCert?.Export(X509ContentType.Cert);
420-
Logger.LogTrace("Initializing empty string");
421-
var certString = string.Empty;
422-
if (byteArray != null)
495+
496+
return results;
497+
}
498+
499+
// Heuristic leaf selection:
500+
// - Prefer a certificate with CA=false (BasicConstraints) and whose Subject is not an Issuer of any other cert.
501+
// - If multiple, prefer the one whose Subject does not appear as any Issuer at all.
502+
// - As a last resort, pick the one with the longest chain distance (i.e., not issuing others).
503+
private X509Certificate2? FindLeaf(IReadOnlyList<X509Certificate2> certs)
504+
{
505+
// Build sets for quick lookups
506+
var issuers = new HashSet<string>(certs.Select(c => c.Issuer), StringComparer.OrdinalIgnoreCase);
507+
var subjects = new HashSet<string>(certs.Select(c => c.Subject), StringComparer.OrdinalIgnoreCase);
508+
509+
bool IsCa(X509Certificate2 c)
423510
{
424-
certString = Convert.ToBase64String(byteArray);
511+
try
512+
{
513+
var bc = c.Extensions["2.5.29.19"]; // Basic Constraints
514+
if (bc is X509BasicConstraintsExtension bce)
515+
return bce.CertificateAuthority;
516+
}
517+
catch { /* ignore and treat as unknown */ }
518+
return false; // if unknown, bias towards non-CA for end-entity picking
425519
}
426-
Logger.LogTrace($"Got certificate {certString}");
427520

428-
return certString;
521+
// Candidates that do not issue others (their Subject is not an Issuer of any other).
522+
var nonIssuers = certs.Where(c =>
523+
!certs.Any(o => !ReferenceEquals(o, c) && string.Equals(o.Issuer, c.Subject, StringComparison.OrdinalIgnoreCase))
524+
).ToList();
525+
526+
// Prefer non-CA among non-issuers
527+
var nonIssuerNonCa = nonIssuers.Where(c => !IsCa(c)).ToList();
528+
if (nonIssuerNonCa.Count == 1) return nonIssuerNonCa[0];
529+
if (nonIssuerNonCa.Count > 1)
530+
{
531+
// If multiple, pick the one whose subject appears least as an issuer (tie-breaker unnecessary here since nonIssuers already exclude issuers).
532+
return nonIssuerNonCa[0];
533+
}
534+
535+
// If that failed, pick any non-CA that is not an issuer in the set of all issuers
536+
var anyNonCa = certs.Where(c => !IsCa(c)).ToList();
537+
if (anyNonCa.Count == 1) return anyNonCa[0];
538+
if (anyNonCa.Count > 1)
539+
{
540+
// Prefer one whose subject is not equal to any issuer (a stricter non-issuer check across entire set)
541+
var strict = anyNonCa.FirstOrDefault(c => !issuers.Contains(c.Subject));
542+
if (strict != null) return strict;
543+
544+
return anyNonCa[0];
545+
}
546+
547+
// Last resort: pick the cert that issues nobody else (even if CA=true)
548+
if (nonIssuers.Count > 0) return nonIssuers[0];
549+
550+
// Give up
551+
return null;
552+
}
553+
554+
private static int GetDecodedLength(string b64)
555+
{
556+
// Approximate decoded length: 3/4 of input, minus padding effect
557+
int len = b64.Length;
558+
int padding = 0;
559+
if (len >= 2)
560+
{
561+
if (b64[^1] == '=') padding++;
562+
if (b64[^2] == '=') padding++;
563+
}
564+
return Math.Max(0, (len / 4) * 3 - padding);
429565
}
566+
430567
private string ExportCollectionToPem(X509Certificate2Collection collection)
431568
{
432569
var pemBuilder = new StringBuilder();
@@ -440,10 +577,50 @@ private string ExportCollectionToPem(X509Certificate2Collection collection)
440577

441578
return pemBuilder.ToString();
442579
}
580+
private static readonly Encoding Utf8Strict = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
581+
private static readonly Encoding Latin1 = Encoding.GetEncoding("ISO-8859-1");
443582

444-
#endregion
583+
private string PreparePemTextFromApi(string? base64)
584+
{
585+
if (string.IsNullOrWhiteSpace(base64))
586+
return string.Empty;
587+
588+
byte[] raw;
589+
try
590+
{
591+
raw = Convert.FromBase64String(base64);
592+
}
593+
catch (FormatException)
594+
{
595+
// Not even Base64; nothing we can do.
596+
return string.Empty;
597+
}
598+
599+
// Try UTF-8 first (strict); if it fails, decode as Latin-1 to avoid loss.
600+
string text;
601+
try
602+
{
603+
text = Utf8Strict.GetString(raw);
604+
}
605+
catch (DecoderFallbackException)
606+
{
607+
text = Latin1.GetString(raw);
608+
}
609+
610+
// Drop UTF-8/UTF-16 BOMs if present
611+
if (text.Length > 0 && text[0] == '\uFEFF') text = text[1..];
612+
613+
// Normalize line endings to '\n' (keep line structure!)
614+
text = text.Replace("\r\n", "\n").Replace("\r", "\n");
615+
616+
// Remove NUL and non-printable control chars, but keep \n and \t
617+
text = new string(text.Where(ch =>
618+
ch == '\n' || ch == '\t' || (ch >= ' ' && ch != '\u007F')
619+
).ToArray());
620+
621+
return text;
622+
}
445623

446-
#region PUBLIC
447624

448625
#endregion
449626
}

cscglobal-caplugin/CSCGlobalCAPlugin.csproj

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,27 +12,22 @@
1212
</PropertyGroup>
1313

1414

15-
<ItemGroup>
16-
<PackageReference Include="BouncyCastle.Cryptography" Version="2.5.0" />
17-
<PackageReference Include="Keyfactor.AnyGateway.IAnyCAPlugin" Version="3.0.0" />
18-
<PackageReference Include="Keyfactor.Common" Version="2.5.0" />
19-
<PackageReference Include="Keyfactor.Logging" Version="1.1.2" />
20-
<PackageReference Include="Keyfactor.Orchestrators.Common" Version="3.2.0" />
21-
<PackageReference Include="Keyfactor.PKI" Version="5.5.0" />
22-
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
23-
<PackageReference Include="System.Formats.Asn1" Version="8.0.1" />
24-
<PackageReference Include="System.Net.Http" Version="4.3.4" />
25-
</ItemGroup>
26-
27-
2815
<Target Name="CustomPostBuild" AfterTargets="PostBuildEvent">
29-
<Exec Condition="'$(Configuration)'=='DebugAndPush'" Command="PowerShell -ExecutionPolicy Bypass -File &quot;C:\Users\mkachkaev\source\repos\scripts\SyncScriptCSC.ps1&quot;&#xA;" />
16+
<Exec Condition="'$(Configuration)'=='DebugAndPush'"
17+
Command="PowerShell -ExecutionPolicy Bypass -File &quot;C:\Users\mkachkaev\source\repos\scripts\SyncScriptCSC.ps1&quot;&#xA;" />
3018
</Target>
3119

32-
<ItemGroup>
33-
<None Update="manifest.json">
34-
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
35-
</None>
36-
</ItemGroup>
3720

38-
</Project>
21+
<ItemGroup>
22+
<PackageReference Include="Keyfactor.AnyGateway.IAnyCAPlugin" Version="3.1.0" />
23+
<PackageReference Include="Keyfactor.PKI" Version="6.3.0" />
24+
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
25+
</ItemGroup>
26+
27+
<ItemGroup>
28+
<None Update="manifest.json">
29+
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
30+
</None>
31+
</ItemGroup>
32+
33+
</Project>

0 commit comments

Comments
 (0)