Skip to content

AWS from Wyn

Call AWS with zero dependencies. No SDK, no packages, no OpenSSL bindings - just Wyn's built-in Crypto, Env, File, and Process, plus the curl that ships with your OS for TLS.

Requires wyn > 1.19.1 (upcoming release)

Crypto.hmac_sha256_hex and the native SHA-256 these patterns rely on are on main but not yet in v1.19.1. Build from source or wait for the next release.

The full working demos live in the compiler repo under demos/aws/ - sigv4.wyn (the signer), transport.wyn (the HTTPS transport), and three runnable examples. Everything on this page is lifted from them.

Whoami in 25 Lines

aws sts get-caller-identity, in Wyn. With sigv4.wyn and transport.wyn next to your file:

wyn
import sigv4
import transport

fn main() -> int {
    query = "Action=GetCallerIdentity&Version=2011-06-15"
    cfg = sigv4.curl_config("GET", "sts.amazonaws.com", "/", query, "us-east-1", "sts", "")
    body = transport.request(cfg)
    arn = transport.xml_text(body, "Arn")
    if arn.len() == 0 {
        print("request failed:")
        print(body)
        return 1
    }
    print("Account: " + transport.xml_text(body, "Account"))
    print("Arn:     " + arn)
    print("UserId:  " + transport.xml_text(body, "UserId"))
    return 0
}

Put credentials in the environment and run it:

sh
eval "$(aws configure export-credentials --profile myprofile --format env)"
wyn run whoami.wyn
Account: 123456789012
Arn:     arn:aws:iam::123456789012:user/me
UserId:  AIDAEXAMPLEEXAMPLE

That is a real, verified response shape from a live AWS account (identifiers sanitized). The rest of this page explains the two modules that make it work.

Anatomy of a Signed Request

Every AWS API call carries an Authorization header proving you hold the secret key, computed with AWS Signature Version 4. The pipeline is: canonical request, then string-to-sign, then a derived signing key, then the signature.

1. The canonical request

A byte-exact rendering of what you are about to send: method, path, query, headers, and the SHA-256 of the payload. Header names are lowercase and sorted; the query string must already be sorted and URI-encoded.

wyn
payload_hash = Crypto.sha256(payload)

canon_headers = "host:" + host + "\n"
canon_headers = canon_headers + "x-amz-content-sha256:" + payload_hash + "\n"
canon_headers = canon_headers + "x-amz-date:" + amzdate + "\n"
signed_hdrs = "host;x-amz-content-sha256;x-amz-date"

canonical = method + "\n" + uri + "\n" + query + "\n" + canon_headers + "\n" + signed_hdrs + "\n" + payload_hash

For a GET, payload is "" and its hash is the well-known constant e3b0c442.... If anything in the canonical request differs from what AWS reconstructs on its side, even one byte, you get SignatureDoesNotMatch.

2. The string-to-sign

The canonical request is hashed and wrapped with the algorithm name, the timestamp, and the credential scope (date/region/service/aws4_request):

wyn
scope = datestamp + "/" + region + "/" + service + "/aws4_request"
string_to_sign = "AWS4-HMAC-SHA256\n" + amzdate + "\n" + scope + "\n" + Crypto.sha256(canonical)

3. The four-round signing key

SigV4 never signs with your secret key directly. It derives a scoped key by chaining four HMAC-SHA256 rounds:

kDate    = HMAC("AWS4" + secret, date)
kRegion  = HMAC(kDate, region)
kService = HMAC(kRegion, service)
kSigning = HMAC(kService, "aws4_request")

The intermediate digests are raw binary, and there is the catch: Wyn strings are C strings, so a return value cannot carry arbitrary bytes (a zero byte would truncate it). The fix is to keep the digest in hex between rounds. Crypto.hmac_sha256 returns its digest as hex; Crypto.hmac_sha256_hex takes its key as hex and decodes it back to binary internally. Chain them and the binary key material flows through unharmed:

wyn
fn signature(secret: string, amzdate: string, region: string, service: string, string_to_sign: string) -> string {
    kdate = Crypto.hmac_sha256("AWS4" + secret, amzdate.substring(0, 8))
    kregion = Crypto.hmac_sha256_hex(kdate, region)
    kservice = Crypto.hmac_sha256_hex(kregion, service)
    ksigning = Crypto.hmac_sha256_hex(kservice, "aws4_request")
    return Crypto.hmac_sha256_hex(ksigning, string_to_sign)
}

Both builtins are native in-process HMAC-SHA256 (RFC 2104) over a native SHA-256 (FIPS 180-4), validated against the RFC 4231 test vectors in the compiler's test suite. No subprocess, no OpenSSL.

4. The Authorization header

The final signature plus your access key ID and the scope:

AWS4-HMAC-SHA256 Credential=AKIA.../20260722/us-east-1/sts/aws4_request,
SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=5d46d0d9...

sigv4.curl_config() does all four steps and returns the result as a curl config file body, which brings us to the transport.

Keeping Secrets Out of argv

The obvious transport is wrong:

sh
# DO NOT do this
curl -H "Authorization: AWS4-HMAC-SHA256 Credential=AKIA..." \
     -H "x-amz-security-token: FwoG..." https://sts.amazonaws.com/...

Command-line arguments are visible to every process on the machine (ps aux, /proc/<pid>/cmdline). Your session token would sit in the process list for the duration of the request, and possibly in shell history too.

Instead, write the whole request - URL and headers, session token included - into a temp file that is created with 0600 permissions before any content touches it, then hand curl only the path:

wyn
fn request(cfg: string) -> string {
    path = File.temp_file()
    // Create the file 0600 first (umask 077), then write into it.
    // File.write on an existing file keeps its permissions.
    Process.exec_status("umask 077; : > " + path)
    File.write(path, cfg)
    out = Process.exec_capture("curl --config " + path)
    Process.exec_status("rm -f " + path)
    return out
}

The ordering matters. Creating the file empty under umask 077 and then writing means there is no window where the file exists with credentials inside and permissive permissions. Only the file path appears in argv; the file is deleted as soon as the response is back.

Why curl at all? It is the one universally-installed tool that does TLS well. The signing, the part with actual cryptography and secrets, stays in-process in Wyn.

Recipes

All three recipes are read-only calls, verified against a live AWS account. Each expects sigv4.wyn and transport.wyn alongside it, and credentials in the environment:

sh
eval "$(aws configure export-credentials --profile myprofile --format env)"

Who am I (STS)

The 25-line program at the top of this page. Output:

Account: 123456789012
Arn:     arn:aws:iam::123456789012:user/me
UserId:  AIDAEXAMPLEEXAMPLE

List your buckets (S3)

aws s3 ls in Wyn. S3's ListBuckets is a plain GET on the service root; the response is XML with one <Name> per bucket, so walk the tags:

wyn
import sigv4
import transport

fn main() -> int {
    cfg = sigv4.curl_config("GET", "s3.amazonaws.com", "/", "", "us-east-1", "s3", "")
    body = transport.request(cfg)
    count = 0
    rest = body
    while true {
        name = transport.xml_text(rest, "Name")
        if name.len() == 0 {
            break
        }
        print(name)
        count = count + 1
        pos = rest.index_of("</Name>")
        rest = rest.substring(pos + 7, rest.len())
    }
    print("${count} buckets")
    return 0
}
my-app-artifacts
my-app-logs
my-terraform-state
3 buckets

transport.xml_text is a 14-line first-match tag extractor, not an XML parser. For flat response shapes like these it is all you need.

Map the regions (EC2)

Every enabled EC2 region and its endpoint. Note the regional host: unlike STS and S3 above, EC2 has no global endpoint, so both the host and the signing region name it.

wyn
import sigv4
import transport

fn main() -> int {
    query = "Action=DescribeRegions&Version=2016-11-15"
    cfg = sigv4.curl_config("GET", "ec2.us-east-1.amazonaws.com", "/", query, "us-east-1", "ec2", "")
    body = transport.request(cfg)
    count = 0
    rest = body
    while true {
        name = transport.xml_text(rest, "regionName")
        if name.len() == 0 {
            break
        }
        endpoint = transport.xml_text(rest, "regionEndpoint")
        print(name.pad_right(17, " ") + endpoint)
        count = count + 1
        pos = rest.index_of("</regionEndpoint>")
        rest = rest.substring(pos + 17, rest.len())
    }
    print("${count} regions")
    return 0
}
ap-south-1       ec2.ap-south-1.amazonaws.com
eu-north-1       ec2.eu-north-1.amazonaws.com
eu-west-3        ec2.eu-west-3.amazonaws.com
...
us-west-2        ec2.us-west-2.amazonaws.com
17 regions

Extending the Signer

POST bodies. The payload is already a parameter of curl_config - it feeds x-amz-content-sha256 and the canonical request. To actually send a body, append the data and content type to the config before handing it to the transport:

wyn
body = "Action=GetSessionToken&Version=2011-06-15"
cfg = sigv4.curl_config("POST", "sts.amazonaws.com", "/", "", "us-east-1", "sts", body)
cfg = cfg + "header = \"Content-Type: application/x-www-form-urlencoded\"\n"
cfg = cfg + "data = \"" + body + "\"\n"
result = transport.request(cfg)

The one rule: the payload you hash must be byte-identical to the payload you send.

Session tokens. Already handled. When AWS_SESSION_TOKEN is set, the signer adds x-amz-security-token to both the signed headers and the request, which is what temporary credentials (SSO, assumed roles, instance profiles) require. That token is exactly the kind of secret the 0600 config file exists to protect.

Any other service. The signer is service-agnostic: host, region, and service name are just parameters. ("dynamodb.us-east-1.amazonaws.com", "us-east-1", "dynamodb") with a POST body and a X-Amz-Target header gets you DynamoDB; the same shape works for SQS, Lambda, CloudWatch, and the rest. Consult each service's API reference for its protocol (query string vs JSON body).

What this is not. These are patterns, not a product. A proper aws package wrapping this signer with typed per-service clients, credential file parsing, and pagination is on the roadmap. Until then, 120 lines of sigv4.wyn plus 36 of transport.wyn cover a real slice of read-only AWS automation.

See Also

MIT License - v1.19.1