From 363dd27476a12e9a121cc33a132ec4c89320a6df Mon Sep 17 00:00:00 2001 From: Scott Beddall <45376673+scbedd@users.noreply.github.com> Date: Mon, 8 Aug 2022 11:54:57 -0700 Subject: [PATCH] Vendor CrossPlat PemReader from azure-core (#3882) * standardize line endings for RecordingHandler.cs. Update pem to an expired certificate that should be valid crossplat. remove trial RemoveOnLinuxFact * use vendored internal-only azure-core classes for loading a X509Certificate2 from the TLS Certificate input string. --- .../RecordingHandlerTests.cs | 6 +- .../test_public-key-only_pem | 27 +- .../RecordingHandler.cs | 36 +- .../Vendored/LightweightPkcs8Decoder.cs | 359 ++++++++++++++++++ .../Vendored/PemReader.cs | 294 ++++++++++++++ 5 files changed, 683 insertions(+), 39 deletions(-) create mode 100644 tools/test-proxy/Azure.Sdk.Tools.TestProxy/Vendored/LightweightPkcs8Decoder.cs create mode 100644 tools/test-proxy/Azure.Sdk.Tools.TestProxy/Vendored/PemReader.cs diff --git a/tools/test-proxy/Azure.Sdk.Tools.TestProxy.Tests/RecordingHandlerTests.cs b/tools/test-proxy/Azure.Sdk.Tools.TestProxy.Tests/RecordingHandlerTests.cs index 164ea7b6965..26c7d1d9bc7 100644 --- a/tools/test-proxy/Azure.Sdk.Tools.TestProxy.Tests/RecordingHandlerTests.cs +++ b/tools/test-proxy/Azure.Sdk.Tools.TestProxy.Tests/RecordingHandlerTests.cs @@ -827,7 +827,7 @@ public void TestSetRecordingOptionsThrowsOnInvalidStoreTypes(string body, string Assert.StartsWith(errorText, assertion.Message); } - [IgnoreOnLinuxFact] + [Fact] public void TestSetRecordingOptionsValidTlsCert() { var certValue = TestHelpers.GetValueFromCertificateFile("test_public-key-only_pem").Replace(Environment.NewLine, ""); @@ -838,7 +838,7 @@ public void TestSetRecordingOptionsValidTlsCert() testRecordingHandler.SetRecordingOptions(inputBody, null); } - [IgnoreOnLinuxFact] + [Fact] public void TestSetRecordingOptionsMultipleCertOptions() { var certValue = TestHelpers.GetValueFromCertificateFile("test_public-key-only_pem").Replace(Environment.NewLine, ""); @@ -943,7 +943,7 @@ public void TestSetRecordingOptionsInValidTransportWithTLSCert() ); Assert.StartsWith("Unable to instantiate a valid cert from the value provided in Transport settings key", assertion.Message); - Assert.Contains("No PEM encoded data found. (Parameter 'pemData')", assertion.Message); + Assert.Contains("The certificate is missing the public key", assertion.Message); } #endregion } diff --git a/tools/test-proxy/Azure.Sdk.Tools.TestProxy.Tests/Test.Certificates/test_public-key-only_pem b/tools/test-proxy/Azure.Sdk.Tools.TestProxy.Tests/Test.Certificates/test_public-key-only_pem index eb0ca67924a..d67134c0ab3 100644 --- a/tools/test-proxy/Azure.Sdk.Tools.TestProxy.Tests/Test.Certificates/test_public-key-only_pem +++ b/tools/test-proxy/Azure.Sdk.Tools.TestProxy.Tests/Test.Certificates/test_public-key-only_pem @@ -1,19 +1,10 @@ -----BEGIN CERTIFICATE----- -MIIDJjCCAg6gAwIBAgIQVYIMVF0iHbxNx/IQwVphDDANBgkqhkiG9w0BAQsFADAm -MSQwIgYDVQQDDBt0ZXN0X3B1YmxpY19rZXlfY2VydGlmaWNhdGUwHhcNMjIwNjI5 -MDAyNTM2WhcNMjMwNjI5MDA0NTM2WjAmMSQwIgYDVQQDDBt0ZXN0X3B1YmxpY19r -ZXlfY2VydGlmaWNhdGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDA -uXMFnEObEqmo/wQVLgltJPzDasH7OHEJLUKM4bBC5LNP4z5N1DzC0pud6bVm9lx4 -tDiX9NUTrWsr+yePRByXB9/OjLjsjYMOwyRPA8s0YLppp+PCG13jWoUmGuKx/9ts -CH3FaBsnBP8aoWycoq2OaLWoiX9eYD0h3wdu3ulXByLQS9ohm6TWCy3M9FkhBNZ4 -hBA+lQwR4bkPKSIMUwg8om/0F2nryAhNaaqeaJH00GHGjheuGeSS+9kI8B0Z/Nf+ -RvjTd+pz6+/SwdopDQ1eXKBWUI3iPaU86tiqLmJiDMei+UOtYctuMEyYzelWBx2P -4qC/OMRKyBxM/6UoAJNFAgMBAAGjUDBOMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUE -FjAUBggrBgEFBQcDAgYIKwYBBQUHAwEwHQYDVR0OBBYEFNIB4+o7q0oMr2EvDhaU -s3jc4kDQMA0GCSqGSIb3DQEBCwUAA4IBAQB/CgjAJxko91GzE/Tim7MHc2bVUE4d -YoBk9gm0ktyDhPNU76QAgZPEVMAg2EDTbYlWSC/JhIA6IuwFid/TVs0nm4s697iN -BvQGi6jOUvQ/1/zmIVqxp/9rurCi1HmWoMw7e6oFhxfrvdOde8Y06/2R6ccWw3KO -c+zf8DSjWTkQ/zKPNCGPxog7UziT5CKC1sFEFRqSOrE9iPDX6FzgdaHF9VJeTz1i -RUDS0o80sFjiBwMJx4iHgMjv9a/+lgHth7enu/lghIF92m9GyYbZZ8grc55lPiwg -IDSIapCjG0IaR88dGgKQOM7jq8dHNj+DxiTsFB/fE1pFUDgaKaJSVckE ------END CERTIFICATE----- \ No newline at end of file +MIIBejCCASGgAwIBAgIRAI7Ke8Vdte6RNx8cSPY/IEgwCgYIKoZIzj0EAwIwFjEU +MBIGA1UEAwwLQ0NGIE5ldHdvcmswHhcNMjIwODAyMjAzMTIzWhcNMjIxMDMxMjAz +MTIyWjAWMRQwEgYDVQQDDAtDQ0YgTmV0d29yazBZMBMGByqGSM49AgEGCCqGSM49 +AwEHA0IABLkAPoIu2Ax1qA4mJKYSiOuSQH9UooAzeiZLlDFPgEyf6JgX6W0gKm2p +CbLJb0LdTPD+uSoO0Cvnr3vaL7JamISjUDBOMAwGA1UdEwQFMAMBAf8wHQYDVR0O +BBYEFE/DlAbm9wLGdidhNfWs5TioUiIWMB8GA1UdIwQYMBaAFE/DlAbm9wLGdidh +NfWs5TioUiIWMAoGCCqGSM49BAMCA0cAMEQCIH+q3IudPuxaeyLsTBLKJxtGn8bz +CXJP9XxwlS8zciyeAiBTPEjGJK0GOycPdV0pYoZ62EAhDisI1FDdEwFK3TKNHw== +-----END CERTIFICATE----- diff --git a/tools/test-proxy/Azure.Sdk.Tools.TestProxy/RecordingHandler.cs b/tools/test-proxy/Azure.Sdk.Tools.TestProxy/RecordingHandler.cs index 3174687711c..bbf009893d3 100644 --- a/tools/test-proxy/Azure.Sdk.Tools.TestProxy/RecordingHandler.cs +++ b/tools/test-proxy/Azure.Sdk.Tools.TestProxy/RecordingHandler.cs @@ -1,9 +1,10 @@ using Azure.Core; using Azure.Sdk.Tools.TestProxy.Common; -using Azure.Sdk.Tools.TestProxy.Common.Exceptions; +using Azure.Sdk.Tools.TestProxy.Common.Exceptions; using Azure.Sdk.Tools.TestProxy.Sanitizers; using Azure.Sdk.Tools.TestProxy.Store; using Azure.Sdk.Tools.TestProxy.Transforms; +using Azure.Sdk.Tools.TestProxy.Vendored; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Primitives; @@ -564,13 +565,13 @@ public void SetRecordingOptions(IDictionary options = null, stri try { string transportObject; - if (transportConventions is JsonElement je) - { - transportObject = je.ToString(); - } - else - { - throw new Exception("'Transport' object was not a JsonElement"); + if (transportConventions is JsonElement je) + { + transportObject = je.ToString(); + } + else + { + throw new Exception("'Transport' object was not a JsonElement"); } var serializerOptions = new JsonSerializerOptions @@ -607,9 +608,8 @@ public X509Certificate2 GetValidationCert(TransportCustomizations settings) { try { - var fields = PemEncoding.Find(settings.TLSValidationCert); - var base64Data = settings.TLSValidationCert[fields.Base64Data]; - return new X509Certificate2(Encoding.ASCII.GetBytes(base64Data)); + var span = new ReadOnlySpan(settings.TLSValidationCert.ToCharArray()); + return PemReader.LoadCertificate(span, null, PemReader.KeyType.Auto, true); } catch (Exception e) { @@ -640,8 +640,8 @@ public HttpClientHandler GetTransport(bool allowAutoRedirect, TransportCustomiza throw new HttpException(HttpStatusCode.BadRequest, $"Unable to instantiate a new X509 certificate from the provided value and key. Failure Message: \"{e.Message}\"."); } } - } - + } + if (customizations.TLSValidationCert != null && !insecure) { var ledgerCert = GetValidationCert(customizations); @@ -848,7 +848,7 @@ public void SetDefaultExtensions(string recordingId = null) sb.Append("]. "); } - throw new HttpException(HttpStatusCode.BadRequest, sb.ToString()); + throw new HttpException(HttpStatusCode.BadRequest, sb.ToString()); } Sanitizers = new List { @@ -921,10 +921,10 @@ public static Uri GetRequestUri(HttpRequest request) // Using the RawTarget PREVENTS this automatic decode. We still lean on the URI constructors // to give us some amount of safety, but note that we explicitly disable escaping in that combination. var rawTarget = request.HttpContext.Features.Get().RawTarget; - var hostValue = GetHeader(request, "x-recording-upstream-base-uri"); - - // There is an ongoing issue where some libraries send a URL with two leading // after the hostname. - // This will just handle the error explicitly rather than letting it slip through and cause random issues during record/playback sessions. + var hostValue = GetHeader(request, "x-recording-upstream-base-uri"); + + // There is an ongoing issue where some libraries send a URL with two leading // after the hostname. + // This will just handle the error explicitly rather than letting it slip through and cause random issues during record/playback sessions. if (rawTarget.StartsWith("//")) { throw new HttpException(HttpStatusCode.BadRequest, $"The URI being passed has two leading '/' in the Target, which will break URI combine with the hostname. Visible URI target: {rawTarget}."); diff --git a/tools/test-proxy/Azure.Sdk.Tools.TestProxy/Vendored/LightweightPkcs8Decoder.cs b/tools/test-proxy/Azure.Sdk.Tools.TestProxy/Vendored/LightweightPkcs8Decoder.cs new file mode 100644 index 00000000000..322eea5b820 --- /dev/null +++ b/tools/test-proxy/Azure.Sdk.Tools.TestProxy/Vendored/LightweightPkcs8Decoder.cs @@ -0,0 +1,359 @@ +using System; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; + + +namespace Azure.Sdk.Tools.TestProxy.Vendored +{ + /// This code was ripped directly from https://github.com/Azure/azure-sdk-for-net/blob/873d4dc419512f42b9c70d104bdcc1983badfd1b/sdk/core/Azure.Core/src/Shared/LightweightPkcs8Decoder.cs + + /// + /// This is a very targeted PKCS#8 decoder for use when reading a PKCS# encoded RSA private key from an + /// DER encoded ASN.1 blob. In an ideal world, we would be able to call AsymmetricAlgorithm.ImportPkcs8PrivateKey + /// off an RSA object to import the private key from a byte array, which we got from the PEM file. There + /// are a few issues with this however: + /// + /// 1. ImportPkcs8PrivateKey does not exist in the Desktop .NET Framework as of today. + /// 2. ImportPkcs8PrivateKey was added to .NET Core in 3.0, and we'd love to be able to support this + /// on older versions of .NET Core. + /// + /// This code is able to decode RSA keys (without any attributes) from well formed PKCS#8 blobs. + /// + internal static partial class LightweightPkcs8Decoder + { + private static readonly byte[] s_derIntegerZero = { 0x02, 0x01, 0x00 }; + + private static readonly byte[] s_rsaAlgorithmId = + { + 0x30, 0x0D, + 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01, + 0x05, 0x00, + }; + + internal static byte[] ReadBitString(byte[] data, ref int offset) + { + // Adapted from https://github.com/dotnet/runtime/blob/be74b4bd/src/libraries/System.Formats.Asn1/src/System/Formats/Asn1/AsnDecoder.BitString.cs#L156 + + if (data[offset++] != 0x03) + { + throw new InvalidDataException("Invalid PKCS#8 Data"); + } + + int length = ReadLength(data, ref offset); + if (length == 0) + { + throw new InvalidDataException("Invalid PKCS#8 Data"); + } + + int unusedBitCount = data[offset++]; + if (unusedBitCount > 7) + { + throw new InvalidDataException("Invalid PKCS#8 Data"); + } + + Span span = data.AsSpan(offset, length - 1); + + // Build a mask for the bits that are used so the normalized value can be computed + // + // If 3 bits are "unused" then build a mask for them to check for 0. + // -1 << 3 => 0b1111_1111 << 3 => 0b1111_1000 + int mask = -1 << unusedBitCount; + byte lastByte = span[span.Length - 1]; + byte maskedByte = (byte)(lastByte & mask); + + byte[] ret = new byte[span.Length]; + + Buffer.BlockCopy(data, offset, ret, 0, span.Length); + ret[span.Length - 1] = maskedByte; + + offset += span.Length; + + return ret; + } + + internal static string ReadObjectIdentifier(byte[] data, ref int offset) + { + // Adapted from https://github.com/dotnet/runtime/blob/be74b4bd/src/libraries/System.Formats.Asn1/src/System/Formats/Asn1/AsnDecoder.Oid.cs#L175 + + if (data[offset++] != 0x06) + { + throw new InvalidDataException("Invalid PKCS#8 Data"); + } + + int length = ReadLength(data, ref offset); + + StringBuilder ret = new StringBuilder(); + for (int i = offset; i < offset + length; i++) + { + byte val = data[i]; + + if (i == offset) + { + byte first; + if (val < 40) + { + first = 0; + } + else if (val < 80) + { + first = 1; + val -= 40; + } + else + { + throw new InvalidDataException("Unsupported PKCS#8 Data"); + } + + ret.Append(first).Append('.').Append(val); + } + else + { + if (val < 128) + { + ret.Append('.').Append(val); + } + else + { + ret.Append('.'); + + if (val == 0x80) + { + throw new InvalidDataException("Invalid PKCS#8 Data"); + } + + // See how long the segment is. + int end = -1; + int idx; + + for (idx = i; idx < offset + length; idx++) + { + if ((data[idx] & 0x80) == 0) + { + end = idx; + break; + } + } + + if (end < 0) + { + throw new InvalidDataException("Invalid PKCS#8 Data"); + } + + // 4 or fewer bytes fits into a signed integer. + int max = end + 1; + if (max <= i + 4) + { + // cspell:ignore accum + int accum = 0; + for (idx = i; idx < max; idx++) + { + val = data[idx]; + accum <<= 7; + accum |= (byte)(val & 0x7f); + } + + ret.Append(accum); + i = end; + } + else + { + throw new InvalidDataException("Unsupported PKCS#8 Data"); + } + } + } + } + + offset += length; + return ret.ToString(); + } + + internal static byte[] ReadOctetString(byte[] data, ref int offset) + { + if (data[offset++] != 0x04) + { + throw new InvalidDataException("Invalid PKCS#8 Data"); + } + + int length = ReadLength(data, ref offset); + + byte[] ret = new byte[length]; + + Buffer.BlockCopy(data, offset, ret, 0, length); + offset += length; + + return ret; + } + + private static int ReadLength(byte[] data, ref int offset) + { + byte lengthOrLengthLength = data[offset++]; + + if (lengthOrLengthLength < 0x80) + { + return lengthOrLengthLength; + } + + int lengthLength = lengthOrLengthLength & 0x7F; + int length = 0; + + for (int i = 0; i < lengthLength; i++) + { + length <<= 8; + length |= data[offset++]; + + if (length > ushort.MaxValue) + { + throw new InvalidDataException("Invalid PKCS#8 Data"); + } + } + + return length; + } + + private static byte[] ReadUnsignedInteger(byte[] data, ref int offset, int targetSize = 0) + { + if (data[offset++] != 0x02) + { + throw new InvalidDataException("Invalid PKCS#8 Data"); + } + + int length = ReadLength(data, ref offset); + + // Encoding rules say 0 is encoded as the one byte value 0x00. + // Since we expect unsigned, throw if the high bit is set. + if (length < 1 || data[offset] >= 0x80) + { + throw new InvalidDataException("Invalid PKCS#8 Data"); + } + + byte[] ret; + + if (length == 1) + { + ret = new byte[length]; + ret[0] = data[offset++]; + return ret; + } + + if (data[offset] == 0) + { + offset++; + length--; + } + + if (targetSize != 0) + { + if (length > targetSize) + { + throw new InvalidDataException("Invalid PKCS#8 Data"); + } + + ret = new byte[targetSize]; + } + else + { + ret = new byte[length]; + } + + Buffer.BlockCopy(data, offset, ret, ret.Length - length, length); + offset += length; + return ret; + } + + private static int ReadPayloadTagLength(byte[] data, ref int offset, byte tagValue) + { + if (data[offset++] != tagValue) + { + throw new InvalidDataException("Invalid PKCS#8 Data"); + } + + return ReadLength(data, ref offset); + } + + private static void ConsumeFullPayloadTag(byte[] data, ref int offset, byte tagValue) + { + if (data[offset++] != tagValue) + { + throw new InvalidDataException("Invalid PKCS#8 Data"); + } + + int length = ReadLength(data, ref offset); + + if (data.Length - offset != length) + { + throw new InvalidDataException("Invalid PKCS#8 Data"); + } + } + + private static void ConsumeMatch(byte[] data, ref int offset, byte[] toMatch) + { + if (data.Length - offset > toMatch.Length) + { + if (data.Skip(offset).Take(toMatch.Length).SequenceEqual(toMatch)) + { + offset += toMatch.Length; + return; + } + } + + throw new InvalidDataException("Invalid PKCS#8 Data"); + } + + public static RSA DecodeRSAPkcs8(byte[] pkcs8Bytes) + { + int offset = 0; + + // PrivateKeyInfo SEQUENCE + ConsumeFullPayloadTag(pkcs8Bytes, ref offset, 0x30); + // PKCS#8 PrivateKeyInfo.version == 0 + ConsumeMatch(pkcs8Bytes, ref offset, s_derIntegerZero); + // rsaEncryption AlgorithmIdentifier value + ConsumeMatch(pkcs8Bytes, ref offset, s_rsaAlgorithmId); + // PrivateKeyInfo.privateKey OCTET STRING + ConsumeFullPayloadTag(pkcs8Bytes, ref offset, 0x04); + // RSAPrivateKey SEQUENCE + ConsumeFullPayloadTag(pkcs8Bytes, ref offset, 0x30); + // RSAPrivateKey.version == 0 + ConsumeMatch(pkcs8Bytes, ref offset, s_derIntegerZero); + + RSAParameters rsaParameters = new RSAParameters(); + rsaParameters.Modulus = ReadUnsignedInteger(pkcs8Bytes, ref offset); + rsaParameters.Exponent = ReadUnsignedInteger(pkcs8Bytes, ref offset); + rsaParameters.D = ReadUnsignedInteger(pkcs8Bytes, ref offset, rsaParameters.Modulus.Length); + int halfModulus = (rsaParameters.Modulus.Length + 1) / 2; + rsaParameters.P = ReadUnsignedInteger(pkcs8Bytes, ref offset, halfModulus); + rsaParameters.Q = ReadUnsignedInteger(pkcs8Bytes, ref offset, halfModulus); + rsaParameters.DP = ReadUnsignedInteger(pkcs8Bytes, ref offset, halfModulus); + rsaParameters.DQ = ReadUnsignedInteger(pkcs8Bytes, ref offset, halfModulus); + rsaParameters.InverseQ = ReadUnsignedInteger(pkcs8Bytes, ref offset, halfModulus); + + if (offset != pkcs8Bytes.Length) + { + throw new InvalidDataException("Invalid PKCS#8 Data"); + } + + RSA rsa = RSA.Create(); + rsa.ImportParameters(rsaParameters); + return rsa; + } + + public static string DecodePrivateKeyOid(byte[] pkcs8Bytes) + { + int offset = 0; + + // PrivateKeyInfo SEQUENCE + ConsumeFullPayloadTag(pkcs8Bytes, ref offset, 0x30); + + // PKCS#8 PrivateKeyInfo.version == 0 + ConsumeMatch(pkcs8Bytes, ref offset, s_derIntegerZero); + + // PKCS#8 PrivateKeyInfo.sequence + ReadPayloadTagLength(pkcs8Bytes, ref offset, 0x30); + + // Return the AlgorithmIdentifier value + return ReadObjectIdentifier(pkcs8Bytes, ref offset); + } + } +} diff --git a/tools/test-proxy/Azure.Sdk.Tools.TestProxy/Vendored/PemReader.cs b/tools/test-proxy/Azure.Sdk.Tools.TestProxy/Vendored/PemReader.cs new file mode 100644 index 00000000000..34980005074 --- /dev/null +++ b/tools/test-proxy/Azure.Sdk.Tools.TestProxy/Vendored/PemReader.cs @@ -0,0 +1,294 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Reflection; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace Azure.Sdk.Tools.TestProxy.Vendored +{ + /// This code was ripped directly from https://github.com/Azure/azure-sdk-for-net/blob/873d4dc419512f42b9c70d104bdcc1983badfd1b/sdk/core/Azure.Core/src/Shared/PemReader.cs + + /// + /// Reads PEM streams to parse PEM fields or load certificates. + /// + /// + /// This class provides a downlevel PEM decoder since PemEncoding wasn't added until net5.0. + /// The PemEncoding class takes advantage of other implementation changes in net5.0 and, + /// based on conversations with the .NET team, runtime changes. + /// + internal static partial class PemReader + { + // The following implementation was based on PemEncoding and reviewed by @bartonjs on the .NET / cryptography team. + private delegate void ImportPrivateKeyDelegate(ReadOnlySpan blob, out int bytesRead); + + private const string Prolog = "-----BEGIN "; + private const string Epilog = "-----END "; + private const string LabelEnd = "-----"; + + private const string RSAAlgorithmId = "1.2.840.113549.1.1.1"; + private const string ECDsaAlgorithmId = "1.2.840.10045.2.1"; + + private static bool s_rsaInitializedImportPkcs8PrivateKeyMethod; + private static MethodInfo s_rsaImportPkcs8PrivateKeyMethod; + private static MethodInfo s_rsaCopyWithPrivateKeyMethod; + + /// + /// Loads an from PEM data. + /// + /// The PEM data to parse. + /// Optional public certificate data if not defined within the PEM data. + /// + /// Optional of the certificate private key. The default is to automatically detect. + /// Only support for is implemented by shared code. + /// + /// Whether to create an if no private key is read. + /// A combination of the enumeration values that control where and how to import the certificate. + /// An loaded from the PEM data. + /// A cryptographic exception occurred when trying to create the . + /// is null and no CERTIFICATE field is defined in PEM, or no PRIVATE KEY is defined in PEM. + /// The is not supported. + /// Creating a from PEM data is not supported on the current platform. + public static X509Certificate2 LoadCertificate(ReadOnlySpan data, byte[] cer = null, KeyType keyType = KeyType.Auto, bool allowCertificateOnly = false, X509KeyStorageFlags keyStorageFlags = X509KeyStorageFlags.DefaultKeySet) + { + byte[] priv = null; + + while (TryRead(data, out PemField field)) + { + // TODO: Consider building up a chain to determine the leaf certificate: https://github.com/Azure/azure-sdk-for-net/issues/19043 + if (field.Label.Equals("CERTIFICATE".AsSpan(), StringComparison.Ordinal)) + { + cer = field.FromBase64Data(); + } + else if (field.Label.Equals("PRIVATE KEY".AsSpan(), StringComparison.Ordinal)) + { + priv = field.FromBase64Data(); + } + + int offset = field.Start + field.Length; + if (offset >= data.Length) + { + break; + } + + data = data.Slice(offset); + } + + if (cer is null) + { + throw new InvalidDataException("The certificate is missing the public key"); + } + + if (priv is null) + { + if (allowCertificateOnly) + { + return new X509Certificate2(cer, (string)null, keyStorageFlags); + } + + throw new InvalidDataException("The certificate is missing the private key"); + } + + if (keyType == KeyType.Auto) + { + string oid = LightweightPkcs8Decoder.DecodePrivateKeyOid(priv); + + keyType = oid switch + { + RSAAlgorithmId => KeyType.RSA, + ECDsaAlgorithmId => KeyType.ECDsa, + _ => throw new NotSupportedException($"The private key algorithm ID {oid} is not supported"), + }; + } + + if (keyType == KeyType.ECDsa) + { + X509Certificate2 certificate = null; + CreateECDsaCertificate(cer, priv, keyStorageFlags, ref certificate); + + return certificate ?? throw new NotSupportedException("Reading an ECDsa certificate from a PEM file is not supported"); + } + + return CreateRsaCertificate(cer, priv, keyStorageFlags); + } + + static partial void CreateECDsaCertificate(byte[] cer, byte[] key, X509KeyStorageFlags keyStorageFlags, ref X509Certificate2 certificate); + + private static X509Certificate2 CreateRsaCertificate(byte[] cer, byte[] key, X509KeyStorageFlags keyStorageFlags) + { + if (!s_rsaInitializedImportPkcs8PrivateKeyMethod) + { + // ImportPkcs8PrivateKey was added in .NET Core 3.0 and is only present on Core. We will fall back to a lightweight decoder if this method is missing from the current runtime. + s_rsaImportPkcs8PrivateKeyMethod = typeof(RSA).GetMethod("ImportPkcs8PrivateKey", BindingFlags.Instance | BindingFlags.Public, null, new[] { typeof(ReadOnlySpan), typeof(int).MakeByRefType() }, null); + s_rsaInitializedImportPkcs8PrivateKeyMethod = true; + } + + if (s_rsaCopyWithPrivateKeyMethod is null) + { + s_rsaCopyWithPrivateKeyMethod = typeof(RSACertificateExtensions).GetMethod("CopyWithPrivateKey", BindingFlags.Static | BindingFlags.Public, null, new[] { typeof(X509Certificate2), typeof(RSA) }, null) + ?? throw new PlatformNotSupportedException("The current platform does not support reading a private key from a PEM file"); + } + + RSA privateKey = null; + try + { + if (s_rsaImportPkcs8PrivateKeyMethod != null) + { + privateKey = RSA.Create(); + + // Because ImportPkcs8PrivateKey declares an out parameter we cannot call it directly using MethodInfo.Invoke since all arguments are passed as an object array. + // Instead we create a delegate with the correct signature and invoke it. + ImportPrivateKeyDelegate importPkcs8PrivateKey = (ImportPrivateKeyDelegate)s_rsaImportPkcs8PrivateKeyMethod.CreateDelegate(typeof(ImportPrivateKeyDelegate), privateKey); + importPkcs8PrivateKey.Invoke(key, out int bytesRead); + + if (key.Length != bytesRead) + { + throw new InvalidDataException("Invalid PKCS#8 Data"); + } + } + else + { + privateKey = LightweightPkcs8Decoder.DecodeRSAPkcs8(key); + } + + using X509Certificate2 certificateWithoutPrivateKey = new X509Certificate2(cer, (string)null, keyStorageFlags); + + X509Certificate2 certificate = (X509Certificate2)s_rsaCopyWithPrivateKeyMethod.Invoke(null, new object[] { certificateWithoutPrivateKey, privateKey }); + // On .NET Framework the PrivateKey member is not initialized after calling CopyWithPrivateKey. + + // This class only compiles against NET6.0 in tests and never in SDK libraries suppress the warning +#pragma warning disable SYSLIB0028 // Use CopyWithPrivateKey instead + if (certificate.PrivateKey is null) + { + certificate.PrivateKey = privateKey; + } +#pragma warning restore SYSLIB0028 + + // Make sure the private key doesn't get disposed now that it's used. + privateKey = null; + + return certificate; + } + finally + { + // If we created and did not use the RSA private key, make sure it's disposed. + privateKey?.Dispose(); + } + } + + /// + /// Attempts to read the next PEM field from the given data. + /// + /// The PEM data to parse. + /// The PEM first complete PEM field that was found. + /// True if a valid PEM field was parsed; otherwise, false. + /// + /// To find subsequent fields, pass a slice of past the found . + /// + public static bool TryRead(ReadOnlySpan data, out PemField field) + { + field = default; + + int start = data.IndexOf(Prolog.AsSpan()); + if (start < 0) + { + return false; + } + + ReadOnlySpan label = data.Slice(start + Prolog.Length); + int end = label.IndexOf(LabelEnd.AsSpan()); + if (end < 0) + { + return false; + } + + // Slice the label. + label = label.Slice(0, end); + + // Slice the remaining data after the label. + int dataOffset = start + Prolog.Length + end + LabelEnd.Length; + data = data.Slice(dataOffset); + + // Find the label end. + string labelEpilog = Epilog + label.ToString() + LabelEnd; + end = data.IndexOf(labelEpilog.AsSpan()); + if (end < 0) + { + return false; + } + + int fieldLength = dataOffset + end + labelEpilog.Length - start; + field = new PemField(start, label, data.Slice(0, end), fieldLength); + + return true; + } + + /// + /// Key type of the certificate private key. + /// + public enum KeyType + { + /// + /// The key type is unknown. + /// + Unknown = -1, + + /// + /// Attempt to detect the key type. + /// + Auto, + + /// + /// RSA key type. + /// + RSA, + + /// + /// ECDsa key type. + /// + ECDsa, + } + + /// + /// A PEM field including its section header and encoded data. + /// + public ref struct PemField + { + internal PemField(int start, ReadOnlySpan label, ReadOnlySpan data, int length) + { + Start = start; + Label = label; + Data = data; + Length = length; + } + + /// + /// The offset of the section from the start of the input PEM stream. + /// + public int Start { get; } + + /// + /// A span of the section label from within the PEM stream. + /// + public ReadOnlySpan Label { get; } + + /// + /// A span of the section data from within the PEM stream. + /// + public ReadOnlySpan Data { get; } + + /// + /// The length of the section from the . + /// + public int Length { get; } + + /// + /// Decodes the base64-encoded + /// + /// + public byte[] FromBase64Data() => Convert.FromBase64String(Data.ToString()); + } + } +}