Windows Importing RSA public keys in downlevel .NET and .NET Framework versions

In .NET 5 and 6, we can use RSA.ImportFromPem to import a PEM-formatted RSA public key . Older .NET Core versions and .NET Framework don’t offer that functionality – but with some extension methods and a little help from CryptoAPI, we can fill that gap.

.NET Core 3.1 and below

Although .NET Core 3.1 and older versions don’t support RSA.ImportFromPem, they do support 2 other useful methods:

Recall that the header of a PEM file indicates whether the file contains a RSAPublicKey structure, a SubjectPublicKeyInfo structure, or something else entirely. Therefore, we can reimplement ImportFromPem by:

  1. Parsing the PEM header/footer to determine whether we’re dealing with a PKCS#1 RSAPublicKey structure (identified by BEGIN RSA PUBLIC KEY) or a X.509 SubjectPublicKeyInfo structure (identified by BEGIN PUBLIC KEY).
  2. Base64-decoding the body to get the DER blob.
  3. Importing the DER blob by using RSA.ImportRSAPublicKey or RSA.ImportSubjectPublicKeyInfo.

In C#, this looks like:

public static class RSAExtensions
{
  private const string RsaPublickeyPemHeader = "-----BEGIN RSA PUBLIC KEY-----";
  private const string RsaPublickeyPemFooter = "-----END RSA PUBLIC KEY-----";
  private const string SubjectPublicKeyInfoPemHeader = "-----BEGIN PUBLIC KEY-----";
  private const string SubjectPublicKeyInfoPemFooter = "-----END PUBLIC KEY-----";
    
  #if !(NET5_0 || NET5_0_OR_GREATER)
  //
  // Add missing method.
  //
  public static void ImportFromPem(
    this RSA key,
    string source)
    => ImportFromPem(key, source, out var _);
  
  #endif
  
  public static void ImportFromPem(
    this RSA key,
    string source,
    out RsaPublicKeyFormat format)
  {
    source = source.Trim();
  
    //
    // Inspect header to determine format.
    //
    if (source.StartsWith(SubjectPublicKeyInfoPemHeader) &&
        source.EndsWith(SubjectPublicKeyInfoPemFooter))
    {
      format = RsaPublicKeyFormat.SubjectPublicKeyInfo;
    }
    else if (source.StartsWith(RsaPublickeyPemHeader) &&
             source.EndsWith(RsaPublickeyPemFooter))
    {
      format = RsaPublicKeyFormat.RsaPublicKey;
    }
    else
    {
      throw new FormatException("Missing Public key header/footer");
    }
  
    //
    // Decode body to get DER blob.
    //
    var der = Convert.FromBase64String(string.Concat(
      source
        .Split('\n')
        .Select(s => s.Trim())
        .Where(line => !line.StartsWith("-----"))));
    if (format == RsaPublicKeyFormat.RsaPublicKey)
    {
      key.ImportRSAPublicKey(der, out var _);
    }
    else
    {
      key.ImportSubjectPublicKeyInfo(der, out var _);
    }
  }
  
  ...
}

public enum RsaPublicKeyFormat
{
  RsaPublicKey,
  SubjectPublicKeyInfo
}

.NET Framework

.NET Framework not only lacks ImportFromPem, but also RSA.ImportRSAPublicKey and RSA.ImportSubjectPublicKeyInfo. So we have a little more work to do.

To compensate for these missing methods, we could turn to BouncyCastle.NET. But why add another dependency if we can live off the land by P/Invoking into CryptoAPI instead? With a bit of help from CryptoAPI, we can reimplement RSA.ImportRSAPublicKey and RSA.ImportSubjectPublicKeyInfo on .NET Framework.

Reimplementing RSA.ImportRSAPublicKey

RSA.ImportRSAPublicKey takes a DER-encoded PKCS#1 RSAPublicKey structure as input. The first thing we need to do is convert that DER blob into a CSP Public Key blob. To do this, we can call CryptDecodeObjectEx and pass the flag RSA_CSP_PUBLICKEYBLOB.

The resulting CSP public key blob is understood by both CryptoAPI and CNG, but the process to import it differs slightly. Recall that RSA is an abstract base class, so we need to check whether we’re really dealing with a CNG key (RSACng) or Crypto API key (RSACryptoServiceProvider):

  • If it’s a CryptoAPI key, we can use RSACryptoServiceProvider.ImportCspBlob to import the blob, effectively overriding the existing key.
  • RSACng doesn’t provide an equivalent method, but we can work around that by first converting the blob into an RSAParameters object, and then importing that.

In C#, this looks like:

public static class RSAExtensions
{
  ...
  
  #if NET40_OR_GREATER
  
  private static void ImportCspBlob(
    RSA key,
    byte[] cspBlob)
  {
    if (key is RSACng)
    {
      //
      // RSACng.Key is private, so we can't import into
      // an existing key directly. But we can do so
      // indirectly.
      //
      var importedKey = CngKey.Import(cspBlob, CngKeyBlobFormat.GenericPublicBlob);
      var importedKeyParameters = new RSACng(importedKey).ExportParameters(false);
      key.ImportParameters(importedKeyParameters);
    }
    else if (key is RSACryptoServiceProvider cryptoApiKey)
    {
      cryptoApiKey.ImportCspBlob(cspBlob);
    }
    else
    {
      throw new ArgumentException("Unrecognized key type");
    }
  }

  public static void ImportRSAPublicKey(
    this RSA key,
    byte[] derBlob, 
    out int bytesRead)
  {
    using (var derBlobHandle = LocalAllocHandle.Alloc(derBlob.Length))
    {
      Marshal.Copy(
        derBlob,
        0,
        derBlobHandle.DangerousGetHandle(),
        derBlob.Length);
    
      //
      // Decode RSA PublicKey DER -> CSP blob.
      //
      if (UnsafeNativeMethods.CryptDecodeObjectEx(
          UnsafeNativeMethods.X509_ASN_ENCODING |
            UnsafeNativeMethods.PKCS_7_ASN_ENCODING,
          UnsafeNativeMethods.RSA_CSP_PUBLICKEYBLOB,
          derBlobHandle.DangerousGetHandle(),
          (uint)derBlob.Length,
          UnsafeNativeMethods.CRYPT_DECODE_ALLOC_FLAG,
          IntPtr.Zero,
          out var keyBlobHandle,
          out var keyBlobSize))
      {
        using (keyBlobHandle)
        {
          var keyBlobBytes = new byte[keyBlobSize];
          Marshal.Copy(
            keyBlobHandle.DangerousGetHandle(),
            keyBlobBytes,
            0,
            (int)keyBlobSize);
          
          bytesRead = derBlob.Length;
          ImportCspBlob(key, keyBlobBytes);
        }
      }
      else
      {
        throw new CryptographicException(
          "Failed to decode DER blob",
          new Win32Exception());
      }
    }
  }
  
  #endif
  
  ...
}

Note that the code uses a custom helper class LocalAllocHandle to handle memory allocations.

Reimplementing RSA.ImportSubjectPublicKeyInfo

As its name suggests, RSA.ImportSubjectPublicKeyInfo takes a DER-encoded X.509 SubjectPublicKeyInfo structure as input. To reimplement the method, we again have to convert that DER blob into a CSP Public Key blob first:

  1. Call CryptDecodeObjectEx with flag X509_PUBLIC_KEY_INFO to decode the DER blob into a CERT_PUBLIC_KEY_INFO structure.
  2. Verify that the structure really contains an RSA public key (and not something else, like an ECC key) by verifying that CERT_PUBLIC_KEY_INFO.Algorithm.pszObjId contains the RSA OID. If it does, we know that CERT_PUBLIC_KEY_INFO.PublicKey contains a DER-encoded PKCS#1 RSAPublicKey structure.
  3. Extract the RSAPublicKey from CERT_PUBLIC_KEY_INFO.PublicKey and call CryptDecodeObjectEx with flag RSA_CSP_PUBLICKEYBLOB to decode it into a public key blob that can be imported into CNG or CryptoAPI.

Once we have the CSP Public Key blob, we can import it using the ImportCspBlob method we looked at before.

public static class RSAExtensions
{
  private const string RsaOid = "1.2.840.113549.1.1.1";
  
  ...

  #if NET40_OR_GREATER
  
  public static void ImportSubjectPublicKeyInfo(
    this RSA key,
    byte[] certKeyInfoDer, 
    out int bytesRead)
  
  {
    using (var certKeyInfoDerHandle = LocalAllocHandle.Alloc(certKeyInfoDer.Length))
    {
      Marshal.Copy(
        certKeyInfoDer,
        0,
        certKeyInfoDerHandle.DangerousGetHandle(),
        certKeyInfoDer.Length);
  
      //
      // Decode DER -> CERT_PUBLIC_KEY_INFO.
      //
      if (UnsafeNativeMethods.CryptDecodeObjectEx(
          UnsafeNativeMethods.X509_ASN_ENCODING |
            UnsafeNativeMethods.PKCS_7_ASN_ENCODING,
          UnsafeNativeMethods.X509_PUBLIC_KEY_INFO,
          certKeyInfoDerHandle.DangerousGetHandle(),
          (uint)certKeyInfoDer.Length,
          UnsafeNativeMethods.CRYPT_DECODE_ALLOC_FLAG,
          IntPtr.Zero,
          out var certKeyInfoHandle,
          out var certKeyInfoSize))
      {
        using (certKeyInfoHandle)
        {
          //
          // Check that the CERT_PUBLIC_KEY_INFO contains an RSA public key.
          //
          var certInfo = Marshal.PtrToStructure<UnsafeNativeMethods.CERT_PUBLIC_KEY_INFO>(
            certKeyInfoHandle.DangerousGetHandle());
  
          if (certInfo.Algorithm.pszObjId != RsaOid)
          {
            throw new CryptographicException("Not an RSA public key");
          }
  
          //
          // Decode the RSA public key -> CSP blob.
          //
          if (UnsafeNativeMethods.CryptDecodeObjectEx(
              UnsafeNativeMethods.X509_ASN_ENCODING |
                UnsafeNativeMethods.PKCS_7_ASN_ENCODING,
              UnsafeNativeMethods.RSA_CSP_PUBLICKEYBLOB,
              certInfo.PublicKey.pbData,
              certInfo.PublicKey.cbData,
              UnsafeNativeMethods.CRYPT_DECODE_ALLOC_FLAG,
              IntPtr.Zero,
              out var cspKeyBlob,
              out var cspKeyBlobSize))
          {
            using (cspKeyBlob)
            {
              var keyBlobBytes = new byte[cspKeyBlobSize];
              Marshal.Copy(
                cspKeyBlob.DangerousGetHandle(),
                keyBlobBytes,
                0,
                (int)cspKeyBlobSize);
  
              bytesRead = certKeyInfoDer.Length;
              ImportCspBlob(key, keyBlobBytes);
            }
          }
          else
          {
            throw new CryptographicException(
              "Failed to decode RSA public key from CERT_PUBLIC_KEY_INFO",
              new Win32Exception());
          }
        }
      }
      else
      {
        throw new CryptographicException(
          "Failed to decode DER blob into CERT_PUBLIC_KEY_INFO",
          new Win32Exception());
      }
    }
  }

  #endif
}

With these extension methods in place, we can now use RSA.ImportFromPem on downlevel .NET and .NET Framework versions.

You can find the complete source code on GitHub.

Any opinions expressed on this blog are Johannes' own. Refer to the respective vendor’s product documentation for authoritative information.
« Back to home