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 ;
168using Keyfactor . AnyGateway . Extensions ;
179using Keyfactor . Extensions . CAPlugin . CSCGlobal . Client ;
1810using Keyfactor . Extensions . CAPlugin . CSCGlobal . Client . Models ;
2214using Keyfactor . PKI . X509 ;
2315using Microsoft . Extensions . Logging ;
2416using 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
2723namespace 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}
0 commit comments