How to generate keys for CloudFront URL signing with Terraform
Use a Lambda function to store the private key in an SSM Parameter
CloudFront URL signing
CloudFront URL signing relies on a public/private key pair where the public part is added to the CloudFront distribution as a trusted key while the private part is used by the backend to calculate the signature that will be included in the URL. As a best practice, this private key is stored in a suitable store, which is either SSM Parameter Store or Secrets Manager, and only referenced by the backend. This way it is protected by IAM permissions.
But then the pressing question is: how to generate the key pair?
It seems a rather straightforward question, but the easy answers have subtle problems.
Generate it manually before the stack is deployed so that the parameter is only referenced? Of course, it solves all the problems with confidentiality but it's not really a good solution as it adds a rather unnecessary step to the deployment process. Why this manual step is necessary for CloudFront signed URLs but not for S3 signed URLs? The signer key pair is a secret but it is tied to the lifecycle of the stack. It should be generated automatically.
Generate it during the deployment? That's an easy and automated solution but then the state file could contain the private key so that anybody who has access to that can access any paths.
The good solution is to deploy a Lambda function and call it during the course of the deployment. In this case the private key never leaves the cloud and there is no trace of it anywhere.
Implementation
In this article we'll use the Terraform module that was introduced in this article. This takes care of all the boilerplate of configuring and invoking the Lambda function as well as the lifecycle of the stored parameter.
To generate a key pair with the module:
module "cf_key" {
source = "sashee/ssm-generated-value/aws"
parameter_name = "/cfkey-${random_id.id.hex}"
code = <<EOF
import crypto from "node:crypto";
import {promisify} from "node:util";
export const generate = async (event) => {
const {publicKey, privateKey} = await promisify(crypto.generateKeyPair)(
"rsa",
{
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem',
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
},
},
);
return {
value: privateKey,
outputs: {
publicKey,
}
};
}
export const cleanup = () => {};
EOF
}
Then add the public key (the publicKey
in the outputs
):
resource "aws_cloudfront_public_key" "cf_key" {
encoded_key = jsondecode(module.cf_key.outputs).publicKey
}
Then configure the backend so that it knows where to read the private key from:
resource "aws_lambda_function" "lambda" {
# ...
environment {
variables = {
CF_PRIVATE_KEY_PARAMETER = module.cf_key.parameter_name
KEYPAIR_ID = aws_cloudfront_public_key.cf_key.id
}
}
}
data "aws_iam_policy_document" "backend" {
statement {
actions = [
"ssm:GetParameter",
]
resources = [
module.cf_key.parameter_arn
]
}
}
And that's it, the backend can then read the value and sign the URLs when needed:
const getCfPrivateKey = cacheSsmGetParameter({Name: process.env.CF_PRIVATE_KEY_PARAMETER, WithDecryption: true}, 15 * 1000);
const signUrl = async (key) => {
const roundTo = 5 * 60 * 1000; // 5 minutes
return getSignedUrl({
url: `https://${process.env.DISTRIBUTION_DOMAIN}/images/${key}`,
keyPairId: process.env.KEYPAIR_ID,
dateLessThan: new Date(Math.floor(new Date().getTime() / roundTo) * roundTo + 15 * 60 * 1000),
privateKey: (await getCfPrivateKey()).Parameter.Value,
});
};
The above code uses caching so that it does go over the SSM limits.