Skip to content

[cloudfront] Upgrade sign command: ECDSA, SHA-256, wildcard policies, input validation - #10716

Open
luigi617 wants to merge 21 commits into
v2from
feat-cloudfront-sign
Open

luigi617 wants to merge 21 commits into
v2from
feat-cloudfront-sign

Conversation

@luigi617

@luigi617 luigi617 commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Issue #, if available:

Description of changes:

Description of changes

Brings aws cloudfront sign in line with the shared CloudFront URL Signer SEP.

New features

  • ECDSA (P-256) keys: sign accepts ECDSA private keys in addition to RSA. The key type is detected from the PEM. Supported formats: PKCS#1, PKCS#8, and SEC1.
  • SHA-256 signing: new --hash-algorithm SHA1|SHA256 option. RSA still defaults to SHA-1, so existing signed URLs stay byte-identical. SHA-256 adds Hash-Algorithm=SHA256 so CloudFront verifies with the right hash. ECDSA keys always use SHA-256.
  • Wildcard policies: a * in the resource now selects a custom policy, so the signature works for every matching URL. With the new --policy-resource option, you sign a wildcard resource and apply the signature to a specific --url.

Bug fixes

  • --private-key fileb://... now works. Previously it failed and printed the key in the error message.
  • Keys preceded by other PEM blocks (e.g. a certificate, or the EC PARAMETERS block from openssl ecparam) and keys with a UTF-8 byte order mark are now accepted.
  • Input that previously produced a URL CloudFront would reject now fails fast with a clear error instead:
    • a URL without an http(s):// scheme, with a #fragment, with non-ASCII characters, or already containing signing parameters
    • quotes or backslashes in the resource or IP address (policy JSON injection)
    • an invalid IPv4 address or CIDR, or any IPv6 address
    • --date-greater-than at or after --date-less-than
  • Clearer errors for invalid dates and for unsupported keys (encrypted, DSA, non-P-256, public keys).
    Refactor
  • Other cloudfront customizations (create-invalidation --paths, create-distribution/update-distribution --default-root-object, --origin-domain-name) moved into a cloudfront/ package with no behavior change.

Testing

  • SEP test suite: all SEP test cases that apply to signed URLs run against the sign command with the SEP's test keys.
  • Functional and unit tests: cover policy selection, input validation, key formats and key errors, signing failures, CloudFrontSigner Hash-Algorithm, and base64 encoding.
  • E2E comparison against v2 on a real distribution with RSA and ECDSA keys, fetching every signed URL:
    • Every URL the feature branch produces is accepted by CloudFront. Expired, IP-restricted, and unknown-key cases are denied as expected.
    • Wildcard signatures work when reused on other matching URLs.
    • Canned and custom RSA SHA-1 output is byte-identical to v2.
    • Every input the branch now rejects produced a URL that CloudFront refused in v2 (MalformedPolicy, AccessDenied, MissingKey).
    • create-invalidation, create-distribution, and update-distribution send identical requests to v2.

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@luigi617
luigi617 marked this pull request as ready for review October 1, 2026 19:55
@luigi617
luigi617 requested a review from a team as a code owner October 1, 2026 19:55
@luigi617
luigi617 added this pull request to stack #10717 October 1, 2026 19:57
'''

def __init__(self, key_id, rsa_signer):
def __init__(self, key_id, rsa_signer, hash_algorithm=None):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we still call this param rsa_signer if it can be either rsa signer or ecsda signer? is it for backward compatibility reasons?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, it is not technically for backward compatibility because even if we change the name to signer, CLI user won't see any behavioral change, I kept rsa_signer only because I wasn't sure if I can make the assumption that botocore module isn't being used by others and it is a breaking change for python team so keeping the same name can make python team port process it easier.


class ECDSASigner(_KeySigner):
_P256_COORDINATE_LENGTH = 32
_SUPPORTED_HASH_ALGORITHMS = (None, 'SHA256')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: why do we have None as valid choice here but then down below hard code to sha256?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None means the user didn't pass --hash-algorithm. _SUPPORTED_HASH_ALGORITHMS lists the inputs we accept, not the algorithms we sign with. It is used only for fast failing if parameter given is invalid

@GarrettBeatty GarrettBeatty left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The crypto checks out. I checked RSA SHA-1 and SHA-256 and ECDSA (SEC1, PKCS#8, and files with an EC PARAMETERS block first) signatures with openssl dgst -verify on canned, custom and wildcard policies. ECDSA output is DER, and 400 random P-256 keys loaded without a false "not P-256" rejection. RSA SHA-1 output is byte-identical to v2 for canned, custom, query-string, epoch and offset-date inputs.

Blocker: five generate_signed_cookies unit tests are still on this branch after the split, so CI fails on this PR alone (5 failed, 165 passed in tests/unit/botocore/test_signers.py). The other comments are smaller.

Comment thread tests/unit/botocore/test_signers.py Outdated
policy='{}',
)

def test_generate_signed_cookies_with_expire_time(self):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: test_generate_signed_cookies_with_expire_time, _with_custom_policy, _with_hash_algorithm, _signs_the_policy and _requires_one_of_date_or_policy (lines 712–773) call CloudFrontSigner.generate_signed_cookies. That method is only added in #10715, so all five fail with AttributeError on this branch. Please move them to #10715 with the method.

Comment thread awscli/botocore/signers.py Outdated

class CloudFrontSigner:
'''A signer to create a signed CloudFront URL.
'''A signer to create signed CloudFront URLs and signed cookies.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the docstring says "signed CloudFront URLs and signed cookies", but this PR doesn't add cookie support. Suggest keeping "signed CloudFront URLs" here and updating it in #10715.

{
"type": "enhancement",
"category": "``cloudfront``",
"description": "``aws cloudfront sign`` now supports ECDSA (P-256) private keys, SHA-256 signing with ``--hash-algorithm``, and wildcard policies with ``--policy-resource``, and rejects input that would produce a URL CloudFront rejects."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a behavior change, so it should be in the changelog. --url values that aren't absolute http(s) URLs used to sign with rc 0 and now fail with rc 252. Examples: rtmp-stream-name.mp4, videos/clip.mp4, https://host:99999/a.jpg. The same goes for a later --date-greater-than and URLs with spaces. RTMP distributions are gone and CloudFront would have rejected these URLs, so rejecting them is reasonable. But scripts that relied on rc 0 will break, and the PR description says "Exit codes are unchanged". Can we mention in the entry that non-http(s) resources are now rejected?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed offline, fast fail for invalid case are not breaking change. PR description updated

def _get_policy_resource(self, args):
_validate_resource(args.url, 'url', allow_wildcards=False)
signing_params = _SIGNING_QUERY_PARAMS.intersection(
name for name, _ in parse_qsl(urlsplit(args.url).query)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parse_qsl drops blank values by default, so the duplicate-parameter check misses ?Expires and ?Signature=. Both still sign and produce a URL with duplicate parameters:

aws cloudfront sign --url 'https://d1.cloudfront.net/a.jpg?Signature=' ...
# -> ...a.jpg?Signature=&Expires=...&Signature=...
Suggested change
name for name, _ in parse_qsl(urlsplit(args.url).query)
name
for name, _ in parse_qsl(
urlsplit(args.url).query, keep_blank_values=True
)

_SUPPORTED_HASH_ALGORITHMS = (None, 'SHA256')

def __init__(self, priv_key, hash_algorithm=None):
coords = priv_key.get_public_coords()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On Linux, this "does not include its public key" error is only reachable for PKCS#8 keys. A SEC1 key exported with openssl ec -no_public (BEGIN EC PRIVATE KEY) fails earlier in EC.new_key_from_der_data with AWS_ERROR_CAL_UNSUPPORTED_KEY_FORMAT. from_pem swallows that error, so the user gets the generic "Unsupported key type or invalid private key" message. Could we detect that case for the EC PRIVATE KEY label and give the specific message?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Detecting this means parsing the SEC1 DER ourselves, which seems like a lot of code for a rare case (-no_public keys). The key is still rejected with a clear error. I'd rather leave it for now, or pick it up in a follow-up if it comes up.


@skip_if_windows('The Windows CRT derives the missing EC public key.')
def test_ec_private_key_without_public_key(self):
# A P-256 key exported with ``openssl ec -no_public``.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fixture is PKCS#8 (BEGIN PRIVATE KEY), but openssl ec -no_public writes SEC1 (BEGIN EC PRIVATE KEY). Suggest something like openssl ec -no_public | openssl pkcs8 -topk8 -nocrypt, and adding a SEC1 case if the SEC1 error message is handled (see the comment on ECDSASigner.__init__).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment fixed and did not add SEC1 as I leave it for now


class _KeySigner:
def sign(self, message):
try:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AwsCrtError subclasses Exception, not RuntimeError, so a CRT error raised as AwsCrtError from ECDSASigner._sign skips this wrapper. RSASigner._sign already catches both. Also, the SHA-1 RuntimeError from RSASigner._sign gets wrapped here a second time, so the message reads "Failed to sign the CloudFront policy with the private key: Failed to sign the URL using the SHA1 hash algorithm: …". Suggest catching (AwsCrtError, RuntimeError) here and raising the SHA-1 error as ValueError directly so it isn't wrapped twice.

),
},
{
'name': 'hash-algorithm',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: --hash-algorithm sha256 fails with "Found invalid choice 'sha256'". Since this is a CLI-only argument, accepting any case would be friendlier, but keeping it as is matches other choices arguments.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CloudFront uses the uppercase form and the error already tells the user what to do, I'd leave it as is


https://d111111abcdef8.cloudfront.net/private-content/private-file.html?Expires=1577836800&Signature=nEXK7Kby47XKeZQKVc6pwkif6oZc-JWSpDkH0UH7EBGGqvgurkecCbgL5VfUAXyLQuJxFwRQWscz-owcq9KpmewCXrXQbPaJZNi9XSNwf4YKurPDQYaRQawKoeenH0GFteRf9ELK-Bs3nljTLjtbgzIUt7QJNKXcWr8AuUYikzGdJ4-qzx6WnxXfH~fxg4-GGl6l2kgCpXUB6Jx6K~Y3kpVOdzUPOIqFLHAnJojbhxqrVejomZZ2XrquDvNUCCIbePGnR3d24UPaLXG4FKOqNEaWDIBXu7jUUPwOyQCvpt-GNvjRJxqWf93uMobeMOiVYahb-e0KItiQewGcm0eLZQ__&Key-Pair-Id=APKAEIBAERJR2EXAMPLE

**To sign a CloudFront URL using SHA-256**

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The signatures in the SHA-256 and wildcard examples are 128 bytes, which means a 1024-bit key. The first example's is 256 bytes (2048-bit), and the text says all three use the same cf-signer-priv-key.pem. CloudFront key groups need 2048-bit RSA keys, so could we regenerate these with the same 2048-bit key? Also, ECDSA support is a headline feature of this PR but has no example; worth adding one.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants