How to use KMS keys with CloudFront signed URLs

Generate an encrypted data key pair

Author's image
Tamás Sallai
4 mins
Photo by FLY:D on Unsplash

CloudFront signed URLs

CloudFront signed URLs rely on a private key to calculate a signature that is added to the URL. This makes them secure: only the backend knows that key so it can decide who can access which protected URLs.

But that also means the private key needs to be kept secret. In a previous article I introduced a method to generate it entirely in the cloud, store in SSM Paramter Store and protect it with IAM permissions. That is a good solution as it does not suffer from most of the drawbacks of generating or storing secrets in an insecure way.

Why KMS does not work

But then I started thinking: as the root of the problem is to protect the private key of a key pair, why not use AWS's dedicated solution for cryptographic key management? KMS, short for Key Management Service, was made for exactly this problem: manage the key in a way that the key material can not be recovered and provide the crypto operations.

Storing the key pair in KMS should be a better solution than SSM, right?

Unfortunately, it does not work. A cursory look at the documentation gives the impression that it should work: KMS supports managing RSA asymmetric keys and it can sign arbitrary messages. The devil is in the details though.

CloudFront requires that the signature uses SHA1 hashing:

OpenSSL hashes the file using SHA-1 and signs it using RSA and the private key file private_key.pem.

But KMS does not support that hash:

Valid Values: RSASSA_PSS_SHA_256 | RSASSA_PSS_SHA_384 | RSASSA_PSS_SHA_512 | RSASSA_PKCS1_V1_5_SHA_256 | RSASSA_PKCS1_V1_5_SHA_384 | RSASSA_PKCS1_V1_5_SHA_512 | ECDSA_SHA_256 | ECDSA_SHA_384 | ECDSA_SHA_512 | SM2DSA

I had to go through the implementation myself to figure this out but it is also mentioned in this ticket.

KMS keys for the private key

Using KMS keys directly to calculate the signature is only one way to use KMS. The other one is to generate a key pair and encrypt the private key with a KMS key. This way, the encrypted private key can be safely passed as an environment variable and can only be decrypted with IAM access to the KMS key. This approach is similarly strong as storing the private key in SSM: only those with the necessary IAM permissions can recover the key material.

To generate a key pair without revealing the private key, use a local-exec provisioner:

resource "aws_kms_key" "key" {
}

resource "terraform_data" "gen_keys" {
	provisioner "local-exec" {
		command = <<EOT
aws kms generate-data-key-pair-without-plaintext --key-id $KEY_ARN --key-pair-spec RSA_2048 --encryption-context "USE=CF" > $TARGET_FILE
EOT
		environment = {
			KEY_ARN = aws_kms_key.key.arn
			TARGET_FILE = "/tmp/keys-${random_id.id.hex}.json"
		}
	}
}

data "local_file" "key" {
	filename = "/tmp/keys-${random_id.id.hex}.json"
	depends_on = [
		terraform_data.gen_keys
	]
}

This is a workaround until the provider gets a native resource type for this. Also notice that it adds a {"USE": "CF"} encryption context.

The data.local_file.key then contains a JSON with the encrypted private key and the public key.

Public key

The public key is added to the CloudFront distribution as a trusted key:

resource "aws_cloudfront_public_key" "kms" {
	encoded_key = "-----BEGIN PUBLIC KEY-----\n${join("\n", regexall(".{1,64}", jsondecode(data.local_file.key.content).PublicKey))}\n-----END PUBLIC KEY-----\n"
}

resource "aws_cloudfront_key_group" "cf_keygroup" {
	items = [
		aws_cloudfront_public_key.kms.id,
	]
	name  = "${random_id.id.hex}-group"
}

The aws_cloudfront_public_key expects the key in PEM format broken to lines with length 64 while the generate-data-key-pair-without-plaintext returns it in DER format. PEM is Base64 encoded DER with a header and a footer, so simple Terraform utility functions are enough to convert one to the other.

Private key

To use the private key on the backend, add the generated and encrypted part as an environment variable:

resource "aws_lambda_function" "signer" {
	environment {
		variables = {
			KMS_KEY_ID = aws_kms_key.key.arn
			PRIVATE_KEY_CIPHERTEXT = jsondecode(data.local_file.key.content).PrivateKeyCiphertextBlob
			KMS_KEYPAIR_ID = aws_cloudfront_public_key.kms.id
		}
	}
	# ...
}

And also give kms:Decrypt access so that the backend can recover the private key:

data "aws_iam_policy_document" "signer" {
	# ...
	statement {
		actions = [
			"kms:Decrypt",
		]
		resources = [
			aws_kms_key.key.arn
		]
	}
}

In the backend code, decrypt the ciphertext to recover the private key:

import {KMSClient, DecryptCommand} from "@aws-sdk/client-kms";

new KMSClient().send(new DecryptCommand({
	CiphertextBlob: Buffer.from(process.env.PRIVATE_KEY_CIPHERTEXT, "base64"),
	EncryptionContext: {USE: "CF"},
	KeyId: process.env.KMS_KEY_ID,
}))

Notice the encryption context: {"USE": "CF"}. This is required as the key was created with this context.

The last step is to convert the result into PEM format and sign the URL:

import {getSignedUrl} from "@aws-sdk/cloudfront-signer";
import {Buffer} from "node:buffer";

const inPem = `-----BEGIN PRIVATE KEY-----\n${Buffer.from(privateKey).toString("base64")}\n-----END PRIVATE KEY-----\n`;
return getSignedUrl({
	url,
	keyPairId: process.env.KMS_KEYPAIR_ID,
	dateLessThan: expiration,
	privateKey: inPem,
});
October 3, 2023
In this article