Examples

Small, self-contained programs showing typical usage. Beside the modules of the distribution (Moo, Ouch and namespace::clean, see cpanfile; with Carton, run them as carton exec ./eg/...) they only need Perl core modules (HTTP::Tiny needs IO::Socket::SSL for HTTPS), plus CryptX for the X.509 ones. Run them from a checkout (they load ../lib).

Set DRY_RUN=1 to see the signed request instead of sending it. This is handy to try them out without any AWS access, e.g. with dummy credentials:

export AWS_ACCESS_KEY_ID=AKIDEXAMPLE AWS_SECRET_ACCESS_KEY=secret DRY_RUN=1

Credentials come from AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and optionally AWS_SESSION_TOKEN; the region from AWS_REGION where it makes sense (default us-east-1).

HTTP::Tiny is used only because it is in the core: the module does not depend on it, and any other user agent can be used the same way. Mind that HTTP::Tiny wants to set the Host header itself, so the examples remove it from the headers they pass (it is still part of the signature). They also ask it to verify TLS certificates explicitly (verify_SSL => 1), because versions of HTTP::Tiny before 0.083, bundled with older Perls, do not do it by default: without it, anybody in the middle could read the signed requests and, in example 07, the temporary credentials.

01-sts-get-caller-identity.pl

The simplest case: a signed POST with credentials taken from the environment. It asks STS who the caller is, so it is also a quick way to check that the credentials work: it prints the answer either way, and exits non-zero when the request fails.

./eg/01-sts-get-caller-identity.pl
DRY_RUN=1 ./eg/01-sts-get-caller-identity.pl

02-s3-get-object.pl

Download an S3 object, printing it on the standard output or saving it in a file. S3 is special (paths are neither normalized nor double-encoded, the payload hash goes in a header) but the module knows it from the service name.

./eg/02-s3-get-object.pl my-bucket path/to/key.txt
AWS_REGION=eu-west-1 ./eg/02-s3-get-object.pl my-bucket photo%20one.jpg photo.jpg

Like 03, 04 and 05, this example puts the key in the URL as it is written, so the key must be given as it appears there: ASCII, percent-encoded (a space is %20, a percent sign %25, and so on). Handing over a raw space makes HTTP::Tiny write an invalid request line, and a raw %, ? or # truncates the key or breaks the signature.

These four examples also use the virtual-hosted URL, where the bucket is part of the host name. That host does not match the *.s3.REGION.amazonaws.com certificate when the bucket name contains a dot, and the answer is not to turn TLS verification off: use the path-style URL https://s3.REGION.amazonaws.com/BUCKET/KEY for those buckets.

Because the bucket and AWS_REGION end up in the host name, they are checked before use, as in 10: a / in either would move the host somewhere else altogether (a bucket of evil.example.com/x gives https://evil.example.com/x.s3...) and the request would be signed and sent there, session token and all. The key needs no such check, since it lands in the path: sign already refuses control characters in the URL, and the rest is the encoding caveat above.

03-s3-put-object-from-file.pl

Upload a file to S3 without loading it in memory: the payload hash is computed from a filehandle (body_fh, which is left where it was), and HTTP::Tiny then reads the same file a piece at a time when sending.

./eg/03-s3-put-object-from-file.pl backup.tar.gz my-bucket backups/backup.tar.gz
DRY_RUN=1 ./eg/03-s3-put-object-from-file.pl big.iso my-bucket isos/big.iso

04-s3-presigned-urls.pl

Presigned URLs: the signature travels in the query string, so anyone holding the URL can use it until it expires, with any client at all. It prints one URL to read the object and one to write it.

./eg/04-s3-presigned-urls.pl my-bucket path/to/key.txt
./eg/04-s3-presigned-urls.pl my-bucket path/to/key.txt 600    # valid for 10 minutes

The output suggests how to use them with curl.

05-s3-chunked-upload.pl

Streaming upload to S3 (aws-chunked): the body is sent in signed chunks, so the whole payload does not need to be hashed in advance nor held in memory. The total size must be known, because the Content-Length of the encoded body is computed with encoded_length and the chunker checks that the data matches. In dry-run mode it also encodes the file and reports how many bytes the body has, compared with the announced length.

To try a trailing checksum, set the @checksum variable in the script to (checksum => 'crc32c'): it is passed to both encoded_length and sign, because the announced length has to count the trailer too.

./eg/05-s3-chunked-upload.pl data.bin my-bucket uploads/data.bin
DRY_RUN=1 ./eg/05-s3-chunked-upload.pl data.bin my-bucket uploads/data.bin

06-dynamodb-json-api.pl

A JSON API, where the operation is chosen by a header (X-Amz-Target), signed like all the others. It lists the DynamoDB tables of a region.

./eg/06-dynamodb-json-api.pl
./eg/06-dynamodb-json-api.pl eu-west-1

07-rolesanywhere-x509.pl

The X.509 variant: instead of a secret key, the request is signed with the private key of a certificate, as IAM Roles Anywhere wants, to get temporary credentials. KEY_TYPE is RSA (default) or ECDSA; CHAIN_FILE (a PEM bundle of intermediate CAs) and KEY_PASSWORD (for encrypted keys) are optional. TRUST_ANCHOR_ARN, PROFILE_ARN and ROLE_ARN are required, except with DRY_RUN=1, where placeholders stand in just to show the shape of the request. Beside the Authorization header, the request carries the certificate in X-Amz-X509 and the chain, if any, in X-Amz-X509-Chain.

CERT_FILE=cert.pem KEY_FILE=key.pem \
   TRUST_ANCHOR_ARN=arn:aws:rolesanywhere:... PROFILE_ARN=arn:aws:rolesanywhere:... \
   ROLE_ARN=arn:aws:iam::... ./eg/07-rolesanywhere-x509.pl

DRY_RUN=1 KEY_TYPE=ECDSA CERT_FILE=cert.pem KEY_FILE=key.enc.pem \
   KEY_PASSWORD=secret CHAIN_FILE=ca-bundle.pem ./eg/07-rolesanywhere-x509.pl

The session JSON is the only thing on the standard output — the algorithm, the status line, any error body and the DRY_RUN=1 dump of the request all go to the standard error — so the program can be used as > session.json or piped into jq. A dry run obtains no session and so writes nothing there either. A failed request prints nothing at all on the standard output, rather than an error body where a session was expected: HTTP::Tiny reports a request that never reached AWS as status 599 with the reason, as plain text, in the body, and that saved under the name of a session would be a failure kept as if it were credentials. The exit status is non-zero either way. Mind that what does get saved holds temporary credentials, so the file deserves the same care as a private key.

AWS_REGION is checked here too, for the same reason as in 02 to 05 and 10: it lands in the host name, and a / would send the certificate and its signature somewhere else, with whatever answered read back as a session.

Check the shape of the CreateSession request (path and body) against the current IAM Roles Anywhere API reference before relying on it: it was written from memory and has not been tried against AWS.

08-x509-custom-signer.pl

With the X.509 variant the private key does not have to be given to the module: a signer function receives the bytes to sign and returns the signature. That is how to use keys that cannot leave an HSM or a KMS. Here the function just uses CryptX by itself and logs what it does, to show where the hook is.

CERT_FILE=cert.pem KEY_FILE=key.pem ./eg/08-x509-custom-signer.pl

09-inspect-a-signature.pl

No network, no secrets: it reproduces the example of the AWS documentation and prints all the intermediate values (canonical request, string to sign, authorization header), which is what helps when AWS answers SignatureDoesNotMatch. The time is fixed, so the output is always the same, and the program tells whether the signature matches the one in the documentation.

./eg/09-inspect-a-signature.pl

10-s3-content-encoding-probe.pl

Not a usage example but a diagnostic probe, kept here because it is built out of the same pieces as 05. With streaming, sign adds aws-chunked to whatever Content-Encoding the caller already set, and the authorities disagree on the order: this uploads a gzipped object and reports whether S3 takes the one the module sends (see the note in TODO.md).

It writes two objects to the bucket it is given, a control without any Content-Encoding and the real case with gzip, so that a rejection can be told apart from a wrong bucket, region or set of credentials. It reads both back, checks the bytes round-trip, and deletes them again unless KEEP=1. The key prefix defaults to aws-sigv4-probe/.

Because it deletes what it uploads, it first checks that both keys hold nothing and refuses to run otherwise, treating anything but a clear "not there" as occupied. That check needs s3:ListBucket on the bucket: without it S3 answers a HEAD on a key that does not exist with 403 rather than 404, a free key cannot be told from a forbidden one, and the program stops and says so. The bucket, the region and the key prefix are also checked before use: they are pasted into the URL, and a / in the bucket or the region would move the host elsewhere and send the signed request, session token included, to whatever is there.

./eg/10-s3-content-encoding-probe.pl my-bucket
AWS_REGION=eu-west-1 ./eg/10-s3-content-encoding-probe.pl my-bucket scratch/probe-
KEEP=1 ./eg/10-s3-content-encoding-probe.pl my-bucket
DRY_RUN=1 ./eg/10-s3-content-encoding-probe.pl my-bucket

A verdict is only reached when the run earns it. "Accepted" needs more than a 200: S3 must also have stored the object as Content-Encoding: gzip, having taken the aws-chunked token off, and have given the bytes back unchanged. "Rejected", which tells you to change the order in the signing code, needs S3 itself to have turned the upload down — a 4xx carrying an S3 error code, and not one of the 401 and 403 that talk about the credentials rather than the header. Everything else, a 503, a dropped connection, a key that expired halfway through, is reported as inconclusive, because the control having gone through says nothing about the upload after it. It exits non-zero when the probe reaches no verdict, or when the order is refused, and then says which line to change. The report it prints is meant to be pasted into a bug report or a chat: it is built from a fixed list of fields, so it holds no credentials, no Authorization header, no session token, and neither the bucket name nor the key.