How to use CloudFront Trusted Key Groups parameter and the trusted_key_group Terraform resource

How to use CloudFront signed URLs without the root user

Author's image
Tamás Sallai
7 mins

Signed URLs in AWS

Recently, AWS added a powerful and well-needed function to CloudFront: Trusted Key Groups. They allow using CloudFront signed URLs without involving the account root user.

Signed URLs are a way to provide controlled access to private resources. The canonical example is giving access to ebooks or other digital goods: you want to only allow downloading them for users who bought them and not everybody. Since the users don't have AWS accounts you can't use the traditional access control mechanism of AWS to grant/deny access to the files. Instead, you keep the files private but implement a URL signing process on the backend: the code checks if the user bought the content then returns a special URL that allows temporary access to the file. It is compatible with serverless backends, in fact, it's the only way to send large files to the users in that case.

In AWS, both S3 and CloudFront implemented signed URLs. The former uses the IAM service to delegate permissions, reusing the Access Key ID/Secret Access Key mechanism that underlies all AWS API calls. This integrates well with other parts of AWS and tools. But CloudFront signed URLs use a separate way: they rely on public/private keys where the public part is added as a trusted key and the other one is used for the signing itself.

For the first 12 years, CloudFront only supported adding these keys to the account using the root user. This goes against all security best practices and made deploying solutions that relied on this signing a pain. I advocated that S3 signed URLs should be the preferred way to implement private content signing wherever possible.

But CloudFront signed URLs are superior to their S3 counterparts. You can use an S3 bucket behind CloudFront as well as any other type of backend, and its signed URL mechanism can protect all of them. On the other hand, S3 can only sign URLs for objects in S3.

That's why I see the support for Trusted Key Groups as a great feature of CloudFront: you can now use signed URLs without worrying about all those problems an account-level setting would entail.

Especially now that Terraform added support for the resource and the setting, it is ready for prime time.

So, let's see how to implement a proper CloudFront signed URLs solution!

Here's a screencast of how deploying and using a Terraform solution works:

Generate the keys

The first step is to generate the keys that we'll use for the signing process. openssl is an easy way to do it, as described in the AWS docs:

openssl genrsa -out private_key.pem 2048
openssl rsa -pubout -in private_key.pem -out public_key.pem

These create a private_key.pem and a public_key.pem, named according to their usage.

If you want to generate the keys with Terraform, you can use the tls_private_key resource:

resource "tls_private_key" "keypair" {
	algorithm = "RSA"
}

Note though that it will include the keys in the Terraform state file. It's great for quick up-and-running demos but you shouldn't do it for a production system. Instead, add the private key as an SSM parameter manually and configure the stack with a pointer to it.

CloudFront configuration

There are two new resource types in CloudFront: Public keys and Key groups. The former holds the public part of the generated keys, and the latter holds a group of such keys. As we'll discuss, the CloudFront cache behavior gets a list of the key groups to trust, so there are 2 many-to-many connections here:

This means you can add multiple keys to a group, and you can add multiple groups to a distribution's cache behavior. It makes it easy to both rotate keys and use multiple independently managed stacks to sign URLs.

On the CloudFront Console, there are two new menu entries:

In the public keys menu, you can import keys you've generated:

Then in the Key groups, you can add these keys to a group:

Finally, in the CloudFront cache behavior settings you can select the new "Trusted Key Groups" option and add the key group there:

And that's it, now CloudFront will require a signature generated using one of the private keys that are reachable from the distribution setting.

The same with Terraform:

resource "aws_cloudfront_public_key" "cf_key" {
	encoded_key = tls_private_key.keypair.public_key_pem
}

# note the random name
resource "aws_cloudfront_key_group" "cf_keygroup" {
	items = [aws_cloudfront_public_key.cf_key.id]
	name  = "${random_id.id.hex}-group"
}

resource "aws_cloudfront_distribution" "distribution" {
	# ...
	default_cache_behavior {
		# ...
		trusted_key_groups = [aws_cloudfront_key_group.cf_keygroup.id]
	}
}

Backend implementation

The backend needs the private key for the signing process, so we need a way to configure it. Environment variables are usually used for this, but they are not suitable here for two reasons. First, the private key is considered sensitive information and the environment is usually less protected. Second, the key is simply too large to fit into the limits of a variable.

A better solution that solves both of these problems is to use the Systems Manager Parameter Store. It stores values and only allows access for identities that are allowed through IAM policies, which is well-supported for AWS backends.

With a separate place to store the private key, the backend only needs two things:

  • A link to where the key is stored
  • Permissions to read the value

The former is an environment variable with the parameter name:

resource "aws_lambda_function" "lambda" {
	# ...
	environment {
		variables = {
			CLOUDFRONT_DOMAIN     = aws_cloudfront_distribution.distribution.domain_name
			KEYPAIR_ID            = aws_cloudfront_public_key.cf_key.id
			PRIVATE_KEY_PARAMETER = aws_ssm_parameter.private_key.name
		}
	}
}

The latter is a statement that allows access for the execution role of the function:

data "aws_iam_policy_document" "lambda_exec_role_policy" {
	statement {
		actions = [
			"ssm:GetParameter",
		]
		resources = [
			aws_ssm_parameter.private_key.arn
		]
	}

Signer code

To sign a CloudFront URL, the code needs 3 things:

  • The private key
  • The full URL to sign
  • The ID of the public key

The private key is stored in SSM, so the backend needs to get the current value:

const { SSMClient, GetParameterCommand } = require("@aws-sdk/client-ssm");
const ssm = new SSMClient();

const key = (
	await ssm.send(new GetParameterCommand({
		Name: process.env.PRIVATE_KEY_PARAMETER,
		WithDecryption: true
	}))
).Parameter.Value;

This makes a call to the SSM service every time it is called, which can overload the standard throughput rate. A better approach is to implement caching on the Lambda side.

To get the full URL, concatenate the scheme with the CloudFront distribution domain and the path:

const cfURL = `https://${process.env.CLOUDFRONT_DOMAIN}/secret.txt`;

Finally, the ID of the public key is available from the environment directly:

const cfKeyPairId = process.env.KEYPAIR_ID;

Library

The aws-cloudfront-sign provides an easy-to-use function to generate the signature. The v2 AWS SDK has support to generate the URL but it's currently missing from the v3. Hopefully, AWS will add an official way to generate CloudFront signed URLs eventually, but until then the community project seems to work well.

It's essentially a function that gets the 3 arguments and returns a string with the URL:

const signedUrl = cfUtil.getSignedUrl(cfURL, {
	keypairId: cfKeyPairId,
	expireTime: Date.now() + 60000,
	privateKeyString: cfPk
});

And that's it, you can send this to the client and they can access the file:

return {
	statusCode: 303,
	headers: {
		Location: signedUrl,
	}
};

Conclusion

Signed URL support in CloudFront is a powerful access control mechanism that supports all kinds of backends. Because of this, it is superior to S3 signed URLs and should be used wherever controlled direct access is needed.

With the new Trusted Key Groups setting it is now possible to deploy the whole process of URL signing without the root user.

Signed URL support in CloudFront reached 1.0.

May 4, 2021
In this article