Using a Cloud KMS asymmetric signing key to create an X.509 certificate

After you create an asymmetric signing key in Cloud KMS, you can download the key pair’s public key. The key is provided in PEM format – that’s pretty standard and all you need in many use cases. But especially when dealing with third party services, you sometimes need an X.509 certificate instead of a plain public key.

Before concluding that Cloud KMS simply isn’t a fit for use cases that demand a X.509 certificate, let’s remind ourselves what a X.509 certificate really is:

A certificate consists of a public key and a set of metadata, all signed by a private key. If the private key that is used for signing and the public key embedded in the certificate are part of the same keypair, then it is a self-signed certificate. If the private key is from another party, then that other party effectively acts as a certificate authority.

To get a X.509 certificate for a KMS asymmetric signing key, we essentially have to:

  1. Use the KMS getPublicKey API to retrieve the public key
  2. Create a certificate signing request that uses the public key
  3. Use the KMS asymmetricSign API to sign the certificate signing request.

Support for X.509 certificates and certificate signing requests (CSR) used to be rather poor prior to .NET 4.6. Fortunately, the situation has improved since then – there are still many certificate-related use cases that might require you to use CertEnroll or Bouncy Castle, but the current .NET Framework and .NET Core versions provide us all the APIs we need to implement the three steps above.

Let’s see how.

Implementing a X509SignatureGenerator

To create and sign a CSR using the .NET framework libraries, we have to use the CertificateRequest.Create method. There are multiple overloads of this method – to sign a CSR using a local private key, we’d typically use the overload that lets us pass a X509Certificate2 instance. But because our private key is managed by KMS, we can’t use that – instead, we use the overload that lets us pass a X509SignatureGenerator instance. And rather than using the standard X509SignatureGenerator implementation (which again only works with local private keys), we use a derived class that offloads the cryptographic operations to Cloud KMS:

class KmsSignatureGenerator : X509SignatureGenerator
{
  private readonly CloudKMSService kmsService;
  private readonly string qualifiedKeyName;

  public KmsSignatureGenerator(
    CloudKMSService kmsService,
    string qualifiedKeyName)
  {
    this.kmsService = kmsService;
    this.qualifiedKeyName = qualifiedKeyName;
  }

  protected override PublicKey BuildPublicKey()
  {
    //
    // Get the public key portion of the KMS key.
    //
    var result = this.kmsService.Projects.Locations.KeyRings.CryptoKeys.CryptoKeyVersions
      .GetPublicKey(this.qualifiedKeyName)
      .Execute();

    //
    // Load the PEM. In .NET 5, we can use RSA.ImportFromPem() for that, in
    // previous versions, we need to strip the header/footer and use 
    // ImportSubjectPublicKeyInfo instead.
    //
    var pemWithoutHeader = string.Concat(result.Pem
      .Split('\n')
      .Where(line => !line.StartsWith("-----")));

    var key = RSA.Create();
    key.ImportSubjectPublicKeyInfo(Convert.FromBase64String(pemWithoutHeader), out _);

    //
    // Use the default RSA signature provider to construct the public key.
    //
    return X509SignatureGenerator.CreateForRSA(key, RSASignaturePadding.Pkcs1).PublicKey;
  }

  public override byte[] GetSignatureAlgorithmIdentifier(HashAlgorithmName hashAlgorithmName)
  {
    if (hashAlgorithmName == HashAlgorithmName.SHA256)
    {
      // DER-encoded OID 1.2.840.113549.1.1.11
      return new byte[] { 48, 13, 6, 9, 42, 134, 72, 134, 247, 13, 1, 1, 11, 5, 0 };
    }
    else
    {
      throw new ArgumentException("Unsupported hash algorithm");
    }
  }

  public override byte[] SignData(byte[] data, HashAlgorithmName hashAlgorithmName)
  {
    if (hashAlgorithmName == HashAlgorithmName.SHA256)
    {
      using (var hashAlgorithm = SHA256.Create())
      {
        //
        // Create a SHA356 hash.
        //
        var hash = hashAlgorithm.ComputeHash(data);

        //
        // Request KMS to sign the hash.
        //
        var result = this.kmsService.Projects.Locations.KeyRings.CryptoKeys.CryptoKeyVersions
          .AsymmetricSign(
            new Google.Apis.CloudKMS.v1.Data.AsymmetricSignRequest()
            {
              Digest = new Google.Apis.CloudKMS.v1.Data.Digest()
              {
                Sha256 = Convert.ToBase64String(hash)
              }
            },
            this.qualifiedKeyName)
          .Execute();

        return Convert.FromBase64String(result.Signature);
      }
    }
    else
    {
      throw new ArgumentException("Unsupported hash algorithm");
    }
  }
}

There a few things to note about this class:

  • X509SignatureGenerator isn’t restricted to any particular signing or hashing algorithm. But to keep things simple, I’m limiting support to SHA256 and RSA.
  • To import a PEM-encoded public key in .NET Framework or .NET Core < 5.0, we have to use RSA.ImportSubjectPublicKeyInfo, which can be a bit brittle. .NET 5 introduced RSA.ImportFromPem, which makes importing a PEM-encoded key easier and more robust.
  • X509SignatureGenerator expects us to return the algorithm OID in DER-encoded format – but we don’t have a DER encoder, so we use a “magic” constant that represents the DER-encoded OID for sha256WithRSAEncryption.
  • I’m using the “classic” Google.Apis.CloudKMS.v1 NuGet package here. The code looks slightly different if you use the Google.Cloud.Kms.V1 package instead.
  • The code does only minimal error handling.

Creating and signing the certificate signing request

Equipped with our custom, KMS-based X509SignatureGenerator implementation, creating and signing a CSR becomes rather straightforward:

static X509Certificate2 CertificateFromKmsKey(
  CloudKMSService kmsService,
  string kmsKeyName)
{
  var signatureGenerator = new KmsSignatureGenerator(kmsService, kmsKeyName);


  //
  // (1) Create a certificate signing request for the KMS key's public key.
  //
  var csr = new CertificateRequest(
    new X500DistinguishedName($"CN=My KMS key"),
    signatureGenerator.PublicKey,
    HashAlgorithmName.SHA256);

  //
  // (2) Sign the certificate signing request using the corresponding
  // signing key, effectively creating a self-signed certificate.
  //
  return csr.Create(
    csr.SubjectName,
    signatureGenerator,
    DateTime.UtcNow,
    DateTime.UtcNow.AddDays(30),
    new byte[] { 1 });
}

The final missing piece is saving an X509Certificate2 object as a PEM file. .NET doesn’t provide a method for that, but a little extension method will do the trick:

public static class X509Certificate2Extensions
{
  public static string ToPem(this X509Certificate2 certificate)
  {
    var buffer = new StringBuilder();

    buffer.AppendLine("-----BEGIN CERTIFICATE-----");
    buffer.AppendLine(Convert.ToBase64String(
      certificate.RawData,
      Base64FormattingOptions.InsertLineBreaks));
    buffer.AppendLine("-----END CERTIFICATE-----");

    return buffer.ToString();
  }
}

Testing the process

To check if the code works, let’s create an asymmetric signing key using Cloud KMS:

  1. Create a new keyring:

    gcloud kms keyrings create my-keyring --location global
    
  2. Create a RSA2048 signing key with PKCS#1 padding:

    gcloud kms keys create my-key --keyring my-keyring --location global --purpose asymmetric-signing --default-algorithm rsa-sign-pkcs1-2048-sha256
    
  3. Get the fully qualified key name:

    gcloud kms keys versions describe 1 --key azure-app --keyring azure-keyring --location global  --format=value\('name'\)
    

    The output should look like this:

    projects/my-project/locations/global/keyRings/my-keyring/cryptoKeys/my-key/cryptoKeyVersions/1
    

With the key in place, we can create a little test program that creates a X.509 certificate for the key, and prints it to the console:

static void Main(string[] args)
{
  //
  // Use the (platform-provided) default credential to authenticate.
  //
  var kmsKeyName = args[1];
  var kmsService = new CloudKMSService(
    new Google.Apis.Services.BaseClientService.Initializer()
    {
      HttpClientInitializer = GoogleCredential.GetApplicationDefault()
    });

  var certificate = CertificateFromKmsKey(
    kmsService,
    kmsKeyName);

  Console.WriteLine(certificate.ToPem());
}

The output should look similar to this:

-----BEGIN CERTIFICATE-----
MIIDdzCCAl+gAwIBAgIBATANBgkqhkiG9w0BAQsFADB/MX0wewYDVQQDE3Rwcm9qZWN0cy9qcGFz
c2luZy1zZXJ2aWNlYWNjb3VudHMtMS9sb2NhdGlvbnMvZ2xvYmFsL2tleVJpbmdzL2F6dXJlLWtl
eXJpbmcvY3J5cHRvS2V5cy9henVyZS1hcHAvY3J5cHRvS2V5VmVyc2lvbnMvMTAeFw0yMTA2MTYx
MjQ1MzFaFw0yMTA3MTYxMjQ1MzFaMH8xfTB7BgNVBAMTdHByb2plY3RzL2pwYXNzaW5nLXNlcnZp
...
-----END CERTIFICATE-----

If we save the output in a file that uses the .cer file extension, we can use the Windows certificate viewer to verify that what we have is indeed a self-signed certificate:

Certificate

Certificate details

Using the process and code above, we can now use a KMS key in situations where a third-party API requires us to provide a public key as a X.509 certificate.

Thanks to Marco Ferrari for reviewing this blog post.

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