X.509 Certificates with AWS KMS

I’ve done a custom PKI structure of X509 certificates to implement a signature service on top of it. Along the way, I found how to generate certificates and sign data without having private keys in my code.

Before we go, let me note, it is possible to use Amazon Certificate Manager to host private certificates. By the time of writing (August 2021), every certificate would cost about $400/month, excluding API calls. It is fine to use that for a root certificate, but it looks too much when multiplied by N, as in my case. There is no API for signatures for an ACM managed certificate too, so one would have to deal with private keys.

Hosted Keys

Hosted (hardware or HSM) keys or certificates give a nice advantage — we can avoid having direct access to private keys material bytes. Instead, we send a request to a service to generate a needed signature.

AWS CloudHSM or alike solutions allow ensuring no one would ever extract a private key from our service or even AWS. In the worst case, someone could get access to the service. One minimizes risks via the IAM setup and audit.

AWS Key Management Service allows managing RSA keys this way, having this in mind, I’ve started researching how to generate certificates with keys in the KMS for my solution.

OpenSSL

OpenSSL supports all necessary operations with X509 certificates. The openssl console command could help to decode a given certificate, validate, generate or sign. Ping me in the comment or on Twitter and I’ll blog more details. I test the generated certificates via openssl command to make sure it was done correctly.

Sadly, but the openssl command does not support KMS or hardware keys out of the box. One has to dig deeper into the implementation level, patches, sources, or forks.

For my further investigation, I’d use JVM and the Bouncy Castle library. I’ll blog more on how to do X509 certificates with the library soon, there are many obsolete or old examples online, and it needs to be cleared up. Let me know in the comments or on Twitter, so it would happen earlier.

Bouncy Castle on JVM

One usually generates a child X509 certificate using the JcaX509v3CertificateBuilder from Bouncy Castle. It takes the parent certificate and signs a child certificate with the parent one.

Here is a simplified example to generate a signed subordinate certificate:

val parentCa: X509CertificateHolder = TODO("Load parent certificate")
val builder = JcaX509v3CertificateBuilder(
  parentCa.subject,
  BigInteger("C0 Ff Ee"),
  Date(),
  parentCa.notAfter,
  rootSubject,
  childCaPublicKey,
)
//TODO: condifigure the builder to add extensions and params
val signer: ContentSigner = TODO("Implement the Content Signer")
val childCert: X509CertificateHolder = builder.build(signer)
//This is the child certificate, use JcaPEMWriter for PEM encoding 
return childCert

As we see, it does not require a private key to run. It uses only the RSA public key of the child certificate. No secretes so far.

The ContentSigner interface encapsulates the signature need. Usually, we use JcaContentSignerBuilder to create an instance from a private key of the parent certificate. It is not the case here.

ContentSigner implementation via KMS

We implement the ContentSigner directly, and do a remote call to the AWS service for the signature. We do not have access to the actual private key bytes. I use the following implementation for that:

private class AwsKmsContentSignerSha512WithRSA(
  private val aws: KmsClient,
  private val keyId: String,
) : ContentSigner {
  private val bytesToSign = MessageDigest.getInstance("SHA-512")
  private val wrapper = DigestOutputStream(OutputStream.nullOutputStream(), bytesToSign)
  override fun getOutputStream() = wrapper
  override fun getAlgorithmIdentifier() = AlgorithmIdentifier(PKCSObjectIdentifiers.sha512WithRSAEncryption)
  override fun getSignature(): ByteArray {
    return aws.sign { req ->
      req.keyId(keyId)
      req.message(SdkBytes.fromByteArray(bytesToSign.digest()))
      req.messageType(MessageType.DIGEST)
      req.signingAlgorithm(SigningAlgorithmSpec.RSASSA_PKCS1_V1_5_SHA_512)
    }.signature().asByteArray()
  }
}

As we see, the code computes SHA-512 digest from the actual data, that needs to be signed. No matter how big is the certificate, it will only send a short message to the AWS.

This code uses the AWS KMS client and a keyId. It sends a request to AWS to generate the signature, it does not use the actual private key directly in our code. That is enough for the BouncyCastle library to sign the new certificate or data for us.

You may also note, that there are many tricky bound constants in the code snippet:

  • SHA-512
  • PKCSObjectIdentifiers.sha512WithRSAEncryption
  • SigningAlgorithmSpec.RSASSA_PKCS1_V1_5_SHA_512

Similarly, it could be tuned to use the SHA-256 signature instead. Note, it would require changing all three parameters.

The ContentSigner interface is widely used in the BouncyCastle library, so we could use it at many other places to implement signatures that we need, for example, a CMS (S/MIME) one.

Conclusion

It appeared easy to use BouncyCastle library to implement cryptography on top of the AWS KMS. It opens the way for writing safe applications which deal with X509 certificates without direct access to private keys.

comments powered by Disqus