Using a CNG-backed key as service account key
Last time, we looked at how you can use a CryptoAPI-backed key as a service account key and use it to authenticate. Now let’s see how you can do the same with CNG.
The Cryptography API: Next Generation (CNG for short) has been around since Windows Vista and is arguably better than the CryptoAPI, but there is still a lot of code and applications that rely on the CryptoAPI instead of CNG. Part of the reason for that is that .NET Framework was very slow to add support for CNG: It wasn’t until .NET 4.6 that .NET introduced proper support for CNG.
And that’s still hurting us today, as we’ll see in a bit.
Creating and uploading a certificate
First, let’s create a certificate that uses a CNG-based key and store it in the Windows certificate store:
Create a self-signed certificate:
$Certificate = New-SelfSignedCertificate ` -KeyUsage DigitalSignature ` -FriendlyName "Sample CNG service account key" ` -Subject "Sample CNG service account key" ` -KeyExportPolicy NonExportable ` -CertStoreLocation "cert:\CurrentUser\My" ` -Provider "Microsoft Software Key Storage Provider" ` -KeyAlgorithm RSA ` -KeyLength 2048
This time, we’re using the
Microsoft Software Key Storage Provider
to store the private key. This is the default CNG key storage provider.We’re not ever planning to export the private key from CNG, so we can set the export-policy to
NonExportable.
Next, we export the certificate (but not the private key!) as a PEM file. This step is necessary because
gcloud
can’t read certificates directly from the certificate store.Windows does not (to my knowledge, anyway) any cmdlet to export a certificate as PEM, but it’s not difficult to do:
"-----BEGIN CERTIFICATE-----" | Out-File -Encoding ASCII cng-cert.cer [Convert]::ToBase64String($Certificate.RawData, [Base64FormattingOptions]::InsertLineBreaks) | Out-File -Encoding ASCII cng-cert.cer -Append "-----END CERTIFICATE-----" | Out-File -Encoding ASCII cng-cert.cer -Append
The PEM file only contains public information, so it’s safe to copy it to another machine, or to hand it to a colleague so that they can associate it with a service account.
Now create a service account:
gcloud iam service-accounts create sa-with-cng-cert
Upload the certificate and associate it with the service account:
gcloud iam service-accounts keys upload cng-cert.cer ` --iam-account sa-with-cng-cert@[PROJECT-ID].iam.gserviceaccount.com
The uploaded key now shows up in the Cloud Console:
Authenticating using the certificate in .NET Core
To authenticate using a service account key, we typically use the ServiceAccountCredential
class from the client library. But before we do, let’s take a look at its
source code,
which contains the following piece of code near the top of the file:
#if NETSTANDARD1\_3 || NETSTANDARD2\_0
using RsaKey = System.Security.Cryptography.RSA;
#elif NET45
using RsaKey = System.Security.Cryptography.RSACryptoServiceProvider;
#else
#error Unsupported target
#endif
What this piece of code means is:
- If you’re using .NET Core,
ServiceAccountCredential
usesSystem.Security.Cryptography.RSA
to refer to keys.RSA
is a base class for bothRSACryptoServiceProvider
(a CryptoNG key) andRSACng
(a CNG key). As a result,ServiceAccountCredential
is provider-agnostic and works the same for CNG and CryptoAPI. - If you’re using .NET Framework 4.5 or later,
ServiceAccountCredential
usesRSACryptoServiceProvider
to refer to keys, which means it can only work with CryptoAPI keys.
If you’re using .NET Core, that’s good news – we can use the same code that we used in the CryptoAPI example to authenticate using a CNG key:
static void Main(string[] args)
{
var emailAddress = "sa-with-cng-cert@[PROJECT-ID].iam.gserviceaccount.com";
//
// Look up the certificate in the key store.
//
using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser))
{
store.Open(OpenFlags.ReadOnly);
using (var certificate = store.Certificates
.Cast<X509Certificate2>()
.Where(c => c.FriendlyName == "Sample CNG service account key")
.FirstOrDefault()
?? throw new Exception("Certificate not found"))
{
//
// Initialize a ServiceAccountCredential with the certificate.
//
var credential = new ServiceAccountCredential(
new ServiceAccountCredential.Initializer(emailAddress)
{
Scopes = new[] { "https://www.googleapis.com/auth/cloud-platform" }
}
.FromCertificate(certificate));
//
// Request an access token.
//
var accessToken = credential.GetAccessTokenForRequestAsync().Result;
Console.WriteLine("Authenticated as {0}", emailAddress);
Console.WriteLine("Token is: {0}", accessToken);
}
}
}
Easy. But what about .NET Framework?
Authenticating using the certificate in .NET Framework
If we run the same code on .NET Framework, this happens:
Unhandled Exception: System.Security.Cryptography.CryptographicException: Invalid provider type specified.
at System.Security.Cryptography.Utils.CreateProvHandle(CspParameters parameters, Boolean randomKeyContainer)
at System.Security.Cryptography.Utils.GetKeyPairHelper(CspAlgorithmType keyType, CspParameters parameters, Boolean randomKeyContainer, Int32 dwKeySize, SafeProvHandle& safeProvHandle, SafeKeyHandle& safeKeyHandle)
at System.Security.Cryptography.RSACryptoServiceProvider.GetKeyPair()
at System.Security.Cryptography.RSACryptoServiceProvider..ctor(Int32 dwKeySize, CspParameters parameters, Boolean useDefaultKeySize)
at System.Security.Cryptography.X509Certificates.X509Certificate2.get\_PrivateKey()
at Google.Apis.Auth.OAuth2.ServiceAccountCredential.Initializer.FromCertificate(X509Certificate2 certificate) in C:\Apiary\2021-03-18.17-02-24\Src\Support\Google.Apis.Auth\OAuth2\ServiceAccountCredential.cs:line 128
at CngNetFx.Program.Main(String[] args) in C:\...\Program.cs:line 33
ServiceAccountCredential
is trying to treat a CNG key as CryptoAPI key, which obviously doesn’t work.
If we can’t use ServiceAccountCredential
, then let’s create our own credential class. The class should…
- implement
ICredential
so that it can be used for client libraries - accept a
X509Certificate2
as input - work with both CryptoAPI and CNG-based keys
We can save ourselves a lot of work by deriving from ServiceCredential
, which already takes care of caching access
tokens and automatically requesting new tokens before they expire. All we have to implement is RequestAccessTokenAsync
.
In RequestAccessTokenAsync
, we need to:
- Create a JWT bearer token for the service account
- Sign the JWT bearer token with the certificate (or it’s private key, rather)
- Exchange the signed token against an access token.
To create and sign the JWT, we can use the System.IdentityModel.Tokens.Jwt
package. The resulting code looks like this:
public class CertificateServiceAccountCredential : ServiceCredential
{
private static readonly DateTime UnixEpoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
/// <summary>
/// Service account ID (email address).
/// </summary>
public string Id { get; }
/// <summary>
/// Signing key.
/// </summary>
public X509Certificate2 Certificate { get; }
public IEnumerable<string> Scopes { get; }
public CertificateServiceAccountCredential(
string id,
X509Certificate2 certificate,
IEnumerable<string> scopes)
: base(new Initializer(GoogleAuthConsts.OidcTokenUrl))
{
this.Id = id;
this.Certificate = certificate;
this.Scopes = scopes;
}
/// <summary>
/// Requests a new token as specified in
/// https://developers.google.com/accounts/docs/OAuth2ServiceAccount#makingrequest.
/// </summary>
/// <param name="cancellationToken">Cancellation token to cancel operation.</param>
/// <returns><c>true</c> if a new token was received successfully.</returns>
public override async Task<bool> RequestAccessTokenAsync(
CancellationToken cancellationToken)
{
//
// (1) Create a JWT bearer for the service account.
//
var issuedAt = DateTime.UtcNow;
var claims = new Hashtable
{
{ "iss", this.Id },
{ "iat", ((long)(issuedAt - UnixEpoch).TotalSeconds) },
{ "exp", ((long)(issuedAt.AddMinutes(10) - UnixEpoch).TotalSeconds) },
{ "aud", "https://oauth2.googleapis.com/token" },
{ "sub", this.Id },
{ "scope", string.Join(" ", this.Scopes) }
};
//
// (2) Sign it using the certificate.
//
var signedJwt = new JsonWebTokenHandler().CreateToken(
JsonConvert.SerializeObject(claims),
new X509SigningCredentials(this.Certificate));
//
// (3) Exchange against an access token.
//
var tokenRequest = new GoogleAssertionTokenRequest()
{
GrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer",
Assertion = signedJwt
};
this.Token = await tokenRequest.ExecuteAsync(
base.HttpClient,
base.TokenServerUrl,
cancellationToken,
base.Clock);
return true;
}
}
To use CertificateServiceAccountCredential
, we now need to adapt our sample code a bit:
static void Main(string[] args)
{
var emailAddress = "sa-with-cng-cert@[PROJECT-ID].iam.gserviceaccount.com";
//
// Lookup the certificate in the key store.
//
using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser))
{
store.Open(OpenFlags.ReadOnly);
using (var certificate = store.Certificates
.Cast<X509Certificate2>()
.Where(c => c.FriendlyName == "Sample CNG service account key")
.FirstOrDefault()
?? throw new Exception("Certificate not found"))
{
//
// Initialize a CertificateServiceAccountCredential with the certificate.
//
var credential = new CertificateServiceAccountCredential(
emailAddress,
certificate,
new[] { "https://www.googleapis.com/auth/cloud-platform" });
//
// Request an access token.
//
var accessToken = credential.GetAccessTokenForRequestAsync().Result;
Console.WriteLine("Authenticated as {0}", emailAddress);
Console.WriteLine("Token is: {0}", accessToken);
}
}
}
That’s it, we can now authenticate a service account by using a CNG-based RSA key.