Google Cloud 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:

  1. 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.

  2. 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.

  3. Now create a service account:

    gcloud iam service-accounts create sa-with-cng-cert
    
  4. 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:

Uploaded key

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 uses System.Security.Cryptography.RSA to refer to keys. RSA is a base class for both RSACryptoServiceProvider (a CryptoNG key) and RSACng (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 uses RSACryptoServiceProvider 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.

Update: This issue has been fixed in v1.52.0 of the Google.Apis libraries, so the workaround below isn't necessary anymore if you're using the latest version.

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:

  1. Create a JWT bearer token for the service account
  2. Sign the JWT bearer token with the certificate (or it’s private key, rather)
  3. 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.

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