Google Cloud Using a CNG-backed key as service account key in Java

When an application that’s running on-premises needs to access Google Cloud services, it needs to authenticate somehow. Typically, it’s best to use workload identity federation for this purpose, but that doesn’t work in all environments. So there’s sometimes no way around using a service account key.

The main problem with service account keys is that they are a secret, and that they need to be stored securely. On Windows, one of the best ways to ensure that a cryptographic key can’t easily be exported or leaked is to let CryptoNG (CNG) manage it for us.

But what if the application is written in Java? Can we still use a CNG-backed key as a service account key?

CryptoNG support in Java

In JDK 13 (which was released in 2019), Oracle added support for CNG. That’s more than 12 years after CNG was first introduced in Windows Vista – but better late than never.

To use CNG, we have to load the SunMSCAPI provider by calling either KeyStore.getInstance("Windows-MY") or KeyStore.getInstance("Windows-ROOT"). Internally, the provider then uses CertOpenSystemStore to open the MY or ROOT system store for the current user.

Let’s see how we can use that to authenticate a service account by using a CNG-backed RSA key.

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
    

    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 Java

To authenticate using a service account key, we use the ServiceAccountCredentials class from the Java client library. But instead of loading a service account key from disk, we use setPrivateKey method to pass it a key that we’ve loaded from CNG:

package com.jpassing.credentials;

import com.google.auth.oauth2.ServiceAccountCredentials;

import java.security.KeyStore;
import java.security.PrivateKey;
import java.util.List;

public class Main {
 public static void main(String[] args) throws Exception {
   var serviceAccountEmail = "[email protected]";
   var certificateName = "Sample CNG service account key";

   var keyStore = KeyStore.getInstance("Windows-MY");
   keyStore.load(null, null);

   var key = (PrivateKey)keyStore.getKey(certificateName, null);

   var credentials = ServiceAccountCredentials.newBuilder()
       .setServiceAccountUser(serviceAccountEmail)
       .setClientEmail(serviceAccountEmail)
       .setScopes(List.of("https://www.googleapis.com/auth/cloud-platform"))
       .setPrivateKey(key)
       .build();

   credentials.refresh();
   System.out.println(credentials.getAccessToken());
 }
}

That’s pretty straightforward and even easier than in .NET – but there’s something curious about this line:

var key = (PrivateKey)keyStore.getKey(certificateName, null);

We’re loading a key by the friendly name of a certificate. That actually works, but why?

Certificate names versus key names

A certificate contains a public key, but not the associated private key. So strictly speaking, certificate storage and (private) key storage are two different matters – and indeed, Windows handles them differently:

Certificate storage is implemented by the Certificate and Certificate Store Functions. Curiously, these functions are exported by Crypt32.dll, but they are somehow not really considered to be part of the CryptoAPI.
The certificates themselves are managed in logical and physical stores and persisted in the registry. Crucially, this does not include the private keys – the private keys remain in the key storage and only a “link” to the private key is stored alongside the certificate. The API integrates with both CNG and CryptoAPI, so with each certificate that you add to the store, you are free to decide where the corresponding key is stored.

To see how that looks like in practice, let’s run certutil -store -user my and find at the certificate we created earlier:

================ Certificate 54 ================
Serial Number: 5a41abe25a9cbbb84c0692a5af0521e5
Issuer: CN=Sample CNG service account key
 NotBefore:...
 NotAfter: …
Subject: CN=Sample CNG service account key
Signature matches Public Key
Root Certificate: Subject matches Issuer
Cert Hash(sha1): 7560811aa24bfeff9ec293afc2d96a0b9e922d9b
  Key Container = te-38486f71-ad25-4671-9caa-6ad2cac59d46
  Unique container name: 631f3c7d46b52747ad380f791cee0022_af6e14f3-c54b-44a3-84eb-ba7e14bd5839
  Provider = Microsoft Software Key Storage Provider
Private key is NOT exportable
Encryption test passed

Notice that we don’t see any details about the key – instead, there’s a link to a key named te-38486f71-ad25-4671-9caa-6ad2cac59d46 in the Microsoft Software Key Storage Provider. To find out more about the key, we have to query that provider by running certutil -csp "Microsoft Software Key Storage Provider" -key -user:

  te-38486f71-ad25-4671-9caa-6ad2cac59d46
  631f3c7d46b52747ad380f791cee0022_af6e14f3-c54b-44a3-84eb-ba7e14bd5839
  RSA
    AT_KEYEXCHANGE

With that context in mind, it would be fair to expect that Java requires us to do perform multiple steps:

  1. Find the certificate by name
  2. Look up the associated private key
  3. Load the private key

But we don’t have to do that – Java lets us look up the key by the friendly name of a certificate. A look into the source code reveals why:

  1. When we call KeyStore.getInstance("Windows-MY"), Java loads CKeyStore.
  2. The first thing that class does is to use JNI to invoke Java_sun_security_mscapi_KeyStore_loadKeysOrCertificateChains.
  3. This native function calls CertEnumCertificatesInStore to enumerate certificates, and uses CryptAcquireCertificatePrivateKey to look up their associated private keys.
  4. For each certificate (chain) and key found, the native method calls back into Java and invokes generateKeyAndCertificateChain, which creates a KeyEntry object and puts it into a cache.

The result is a Map<String, KeyEntry>, which Keystore.getCertificate and KeyStore.getKey use to look up certificates and keys. And because the map uses the certificate name as key, we have to use the certificate name, not the key name, when loading a CNG key.

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