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:
- Use the KMS getPublicKey API to retrieve the public key
- Create a certificate signing request that uses the public key
- 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 introducedRSA.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:
Create a new keyring:
gcloud kms keyrings create my-keyring --location global
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
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:
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.