Skip to main content

src/aws@internal@sigv4a.erl

-module(aws@internal@sigv4a).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/aws/internal/sigv4a.gleam").
-export([ecdsa_private_key_from_bytes/1, string_to_sign/2, canonical_request/3, sign_with_credentials/3, sign/4, ecdsa_p256_sign/2, ecdsa_p256_verify/3, ecdsa_p256_public_key/1, derive_signing_key/2, sign_with_iam_credentials/4]).
-export_type([ecdsa_private_key/0, sigv4a_options/0, sigv4a_credentials/0, canonical_parts/0]).

-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.

?MODULEDOC(
    " SigV4a — AWS Signature Version 4 with asymmetric ECDSA P-256\n"
    " signatures, used by S3 Multi-Region Access Points (MRAP) and a\n"
    " few other multi-region offerings.\n"
    "\n"
    " The canonical-request shape is identical to SigV4 except for\n"
    " the algorithm string (AWS4-ECDSA-P256-SHA256) and the\n"
    " `X-Amz-Region-Set` header that carries the comma-joined region\n"
    " list. The string-to-sign uses the same five-line shape; the\n"
    " credential scope drops the region because SigV4a is region-\n"
    " agnostic by design.\n"
    "\n"
    " **Deterministic signatures via RFC 6979.** Signing routes\n"
    " through `aws/internal/ecdsa_deterministic` which derives the\n"
    " ECDSA nonce `k` from `(d, sha256(sts))` via HMAC-DRBG. Two\n"
    " calls with the same `(credentials, request)` produce\n"
    " byte-identical signatures, which makes the aws-c-auth v4a\n"
    " corpus pinnable at the signature-byte level (see\n"
    " `test/ecdsa_deterministic_test.gleam` for the RFC 6979 §A.2.5\n"
    " reference-vector pins).\n"
    "\n"
    " **AWS-deterministic key derivation** is wired via\n"
    " `derive_signing_key/2` — feeds an IAM (access-key-id,\n"
    " secret-access-key) pair through AWS's HMAC-SHA256 + P-256\n"
    " modular-reduction KDF and returns the 32-byte EC private\n"
    " scalar the SigV4a spec requires. Pinned by\n"
    " `test/sigv4a_key_derivation_test.gleam` against the aws-c-auth\n"
    " v4a fixture's `public-key.json` (X / Y derived from the\n"
    " canonical `AKIDEXAMPLE` / `wJalrXUtnFEMI...` pair).\n"
    "\n"
    " Canonical-request helpers (`canonical_headers`, `signed_headers`,\n"
    " `canonical_query_string`, `build_canonical_uri`,\n"
    " `normalize_path`) live in `aws/internal/sigv4_canonical` and\n"
    " are shared with the SigV4 module.\n"
).

-type ecdsa_private_key() :: {ecdsa_private_key, bitstring()}.

-type sigv4a_options() :: {sigv4a_options,
        binary(),
        list(binary()),
        binary(),
        boolean(),
        boolean(),
        boolean()}.

-type sigv4a_credentials() :: {sigv4a_credentials,
        binary(),
        ecdsa_private_key(),
        gleam@option:option(binary())}.

-type canonical_parts() :: {canonical_parts,
        binary(),
        binary(),
        binary(),
        list(aws@internal@http_request:header())}.

-file("src/aws/internal/sigv4a.gleam", 57).
?DOC(
    " Build an `EcdsaPrivateKey` from a 32-byte scalar. Returns\n"
    " `Error(_)` when the input is the wrong length — SigV4a is\n"
    " strictly P-256, so any other key size is a bug.\n"
).
-spec ecdsa_private_key_from_bytes(bitstring()) -> {ok, ecdsa_private_key()} |
    {error, binary()}.
ecdsa_private_key_from_bytes(Bytes) ->
    case erlang:byte_size(Bytes) of
        32 ->
            {ok, {ecdsa_private_key, Bytes}};

        _ ->
            {error,
                <<"SigV4a private key must be a 32-byte P-256 scalar"/utf8>>}
    end.

-file("src/aws/internal/sigv4a.gleam", 190).
?DOC(
    " Build the SigV4a string-to-sign (`AWS4-ECDSA-P256-SHA256\\n<ts>\\n<scope>\\n<creq_hash>`).\n"
    " The scope drops the region — `X-Amz-Region-Set` carries it\n"
    " instead — so only `opts.timestamp` (which holds the YYYYMMDD\n"
    " date in its first 8 chars) and `opts.service` contribute.\n"
).
-spec string_to_sign(binary(), sigv4a_options()) -> binary().
string_to_sign(Canonical, Opts) ->
    Date = gleam@string:slice(erlang:element(2, Opts), 0, 8),
    Scope = <<<<<<Date/binary, "/"/utf8>>/binary,
            (erlang:element(4, Opts))/binary>>/binary,
        "/aws4_request"/utf8>>,
    Creq_hash = aws_ffi:hex_encode(
        aws_ffi:sha256(gleam_stdlib:identity(Canonical))
    ),
    <<<<<<<<<<"AWS4-ECDSA-P256-SHA256\n"/utf8,
                        (erlang:element(2, Opts))/binary>>/binary,
                    "\n"/utf8>>/binary,
                Scope/binary>>/binary,
            "\n"/utf8>>/binary,
        Creq_hash/binary>>.

-file("src/aws/internal/sigv4a.gleam", 267).
?DOC(
    " Strip `X-Amz-Security-Token` from the header list used for the\n"
    " canonical request when both a session token is present and\n"
    " `opts.omit_session_token` is `True`. The token stays in the\n"
    " outgoing request via `prepared_headers` — it just doesn't\n"
    " participate in the signature. Mirror of\n"
    " `sigv4.headers_for_signing`.\n"
).
-spec headers_for_signing(
    list(aws@internal@http_request:header()),
    sigv4a_credentials(),
    sigv4a_options()
) -> list(aws@internal@http_request:header()).
headers_for_signing(Prepared, Creds, Opts) ->
    case {erlang:element(4, Creds), erlang:element(7, Opts)} of
        {{some, _}, true} ->
            gleam@list:filter(
                Prepared,
                fun(H) ->
                    string:lowercase(erlang:element(2, H)) /= <<"x-amz-security-token"/utf8>>
                end
            );

        {_, _} ->
            Prepared
    end.

-file("src/aws/internal/sigv4a.gleam", 390).
-spec upsert(list(aws@internal@http_request:header()), binary(), binary()) -> list(aws@internal@http_request:header()).
upsert(Headers, Name, Value) ->
    Lower = string:lowercase(Name),
    Already = gleam@list:any(
        Headers,
        fun(H) -> string:lowercase(erlang:element(2, H)) =:= Lower end
    ),
    case Already of
        true ->
            gleam@list:map(
                Headers,
                fun(H@1) ->
                    case string:lowercase(erlang:element(2, H@1)) =:= Lower of
                        true ->
                            {header, erlang:element(2, H@1), Value};

                        false ->
                            H@1
                    end
                end
            );

        false ->
            lists:append(Headers, [{header, Name, Value}])
    end.

-file("src/aws/internal/sigv4a.gleam", 240).
-spec prepare_headers(
    aws@internal@http_request:http_request(),
    sigv4a_options(),
    sigv4a_credentials(),
    binary(),
    binary()
) -> list(aws@internal@http_request:header()).
prepare_headers(Req, Opts, Creds, Payload_hash, Region_set_value) ->
    Base = begin
        _pipe = erlang:element(5, Req),
        _pipe@1 = upsert(_pipe, <<"X-Amz-Date"/utf8>>, erlang:element(2, Opts)),
        upsert(_pipe@1, <<"X-Amz-Region-Set"/utf8>>, Region_set_value)
    end,
    With_token = case erlang:element(4, Creds) of
        {some, T} ->
            upsert(Base, <<"X-Amz-Security-Token"/utf8>>, T);

        none ->
            Base
    end,
    case erlang:element(5, Opts) of
        true ->
            upsert(With_token, <<"X-Amz-Content-Sha256"/utf8>>, Payload_hash);

        false ->
            With_token
    end.

-file("src/aws/internal/sigv4a.gleam", 146).
?DOC(
    " Build the SigV4a canonical request bytes from `req` + `creds` +\n"
    " `opts`. Returns the canonical request, the semicolon-joined\n"
    " signed-headers line, the payload-hash hex, and the prepared\n"
    " header list (which the signing step appends `Authorization` to).\n"
    " Pure function — no signing, no network.\n"
).
-spec canonical_request(
    aws@internal@http_request:http_request(),
    sigv4a_credentials(),
    sigv4a_options()
) -> canonical_parts().
canonical_request(Req, Creds, Opts) ->
    Region_set_value = gleam@string:join(erlang:element(3, Opts), <<","/utf8>>),
    Body_to_hash = case erlang:element(5, Opts) of
        true ->
            erlang:element(6, Req);

        false ->
            gleam_stdlib:identity(<<""/utf8>>)
    end,
    Payload_hash = aws_ffi:hex_encode(aws_ffi:sha256(Body_to_hash)),
    Prepared = prepare_headers(Req, Opts, Creds, Payload_hash, Region_set_value),
    Signing_headers = headers_for_signing(Prepared, Creds, Opts),
    Canonical_uri = aws@internal@sigv4_canonical:build_canonical_uri(
        erlang:element(3, Req),
        erlang:element(6, Opts)
    ),
    Canonical_query = aws@internal@sigv4_canonical:canonical_query_string(
        erlang:element(4, Req)
    ),
    Canonical_headers_block = aws@internal@sigv4_canonical:canonical_headers(
        Signing_headers
    ),
    Signed_headers_list = aws@internal@sigv4_canonical:signed_headers(
        Signing_headers
    ),
    Creq = <<<<<<<<<<<<<<<<<<<<(erlang:element(2, Req))/binary, "\n"/utf8>>/binary,
                                        Canonical_uri/binary>>/binary,
                                    "\n"/utf8>>/binary,
                                Canonical_query/binary>>/binary,
                            "\n"/utf8>>/binary,
                        Canonical_headers_block/binary>>/binary,
                    "\n"/utf8>>/binary,
                Signed_headers_list/binary>>/binary,
            "\n"/utf8>>/binary,
        Payload_hash/binary>>,
    {canonical_parts, Creq, Signed_headers_list, Payload_hash, Prepared}.

-file("src/aws/internal/sigv4a.gleam", 207).
?DOC(
    " Sign `req` with the bundled `creds`. Adds `Authorization`,\n"
    " `X-Amz-Date`, `X-Amz-Region-Set`, and (when `creds.session_token`\n"
    " is `Some`) `X-Amz-Security-Token`. `X-Amz-Content-Sha256` is\n"
    " emitted when `opts.sign_body` is set.\n"
).
-spec sign_with_credentials(
    aws@internal@http_request:http_request(),
    sigv4a_credentials(),
    sigv4a_options()
) -> aws@internal@http_request:http_request().
sign_with_credentials(Req, Creds, Opts) ->
    Parts = canonical_request(Req, Creds, Opts),
    Sts = string_to_sign(erlang:element(2, Parts), Opts),
    Date = gleam@string:slice(erlang:element(2, Opts), 0, 8),
    Scope = <<<<<<Date/binary, "/"/utf8>>/binary,
            (erlang:element(4, Opts))/binary>>/binary,
        "/aws4_request"/utf8>>,
    Sts_hash = aws_ffi:sha256(gleam_stdlib:identity(Sts)),
    Sig_der = aws@internal@ecdsa_deterministic:sign_p256(
        erlang:element(2, erlang:element(3, Creds)),
        Sts_hash
    ),
    Sig_hex = aws_ffi:hex_encode(Sig_der),
    Auth = <<<<<<<<<<<<<<"AWS4-ECDSA-P256-SHA256 Credential="/utf8,
                                (erlang:element(2, Creds))/binary>>/binary,
                            "/"/utf8>>/binary,
                        Scope/binary>>/binary,
                    ", SignedHeaders="/utf8>>/binary,
                (erlang:element(3, Parts))/binary>>/binary,
            ", Signature="/utf8>>/binary,
        Sig_hex/binary>>,
    Final_headers = lists:append(
        erlang:element(5, Parts),
        [{header, <<"Authorization"/utf8>>, Auth}]
    ),
    {http_request,
        erlang:element(2, Req),
        erlang:element(3, Req),
        erlang:element(4, Req),
        Final_headers,
        erlang:element(6, Req)}.

-file("src/aws/internal/sigv4a.gleam", 111).
?DOC(
    " Sign `req` with `private_key` and `access_key_id`. Always\n"
    " excludes a session token. For credentials that carry an STS\n"
    " token use `sign_with_credentials` instead.\n"
).
-spec sign(
    aws@internal@http_request:http_request(),
    ecdsa_private_key(),
    binary(),
    sigv4a_options()
) -> aws@internal@http_request:http_request().
sign(Req, Private_key, Access_key_id, Opts) ->
    sign_with_credentials(
        Req,
        {sigv4a_credentials, Access_key_id, Private_key, none},
        Opts
    ).

-file("src/aws/internal/sigv4a.gleam", 286).
?DOC(
    " ECDSA P-256 signature over `data`, returning the DER-encoded\n"
    " blob. Erlang's `crypto:sign/4` uses a random nonce per call;\n"
    " signatures verify correctly server-side but won't match\n"
    " RFC-6979 deterministic-nonce reference vectors.\n"
).
-spec ecdsa_p256_sign(bitstring(), bitstring()) -> bitstring().
ecdsa_p256_sign(Private_key, Data) ->
    aws_ffi:ecdsa_p256_sign(Private_key, Data).

-file("src/aws/internal/sigv4a.gleam", 291).
?DOC(
    " ECDSA P-256 verification. `public_key` is the uncompressed\n"
    " SEC1 form (`04 || X || Y`, 65 bytes).\n"
).
-spec ecdsa_p256_verify(bitstring(), bitstring(), bitstring()) -> boolean().
ecdsa_p256_verify(Public_key, Data, Signature) ->
    aws_ffi:ecdsa_p256_verify(Public_key, Data, Signature).

-file("src/aws/internal/sigv4a.gleam", 302).
?DOC(
    " Uncompressed SEC1 public key (`04 || X || Y`, 65 bytes) for a\n"
    " given 32-byte P-256 private scalar. Surfaced so callers can pin\n"
    " derived keys against AWS test fixtures (which ship the public\n"
    " counterpart) without re-implementing curve arithmetic.\n"
).
-spec ecdsa_p256_public_key(bitstring()) -> bitstring().
ecdsa_p256_public_key(Private_key) ->
    aws_ffi:ecdsa_p256_public_key(Private_key).

-file("src/aws/internal/sigv4a.gleam", 345).
-spec derive_loop(bitstring(), bitstring(), integer()) -> ecdsa_private_key().
derive_loop(Input_key, Access_key_bytes, Counter) ->
    case Counter > 254 of
        true ->
            erlang:error(#{gleam_error => panic,
                    message => <<"SigV4a key derivation: counter exceeded 254 — IAM secret may be malformed"/utf8>>,
                    file => <<?FILEPATH/utf8>>,
                    module => <<"aws/internal/sigv4a"/utf8>>,
                    function => <<"derive_loop"/utf8>>,
                    line => 358});

        false ->
            Kdf_context = <<Access_key_bytes/bitstring, Counter:8>>,
            Fis = <<"AWS4-ECDSA-P256-SHA256"/utf8,
                0:8,
                Kdf_context/bitstring,
                256:32/big>>,
            Buf = <<1:32/big, Fis/bitstring>>,
            Tag = aws_ffi:hmac_sha256(Input_key, Buf),
            K0@1 = case Tag of
                <<K0:256/big>> -> K0;
                _assert_fail ->
                    erlang:error(#{gleam_error => let_assert,
                                message => <<"Pattern match failed, no pattern matched the value."/utf8>>,
                                file => <<?FILEPATH/utf8>>,
                                module => <<"aws/internal/sigv4a"/utf8>>,
                                function => <<"derive_loop"/utf8>>,
                                line => 367,
                                value => _assert_fail,
                                start => 13963,
                                'end' => 14000,
                                pattern_start => 13974,
                                pattern_end => 13994})
            end,
            case K0@1 =< 16#ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc63254f of
                true ->
                    {ecdsa_private_key, <<((K0@1 + 1)):256/big>>};

                false ->
                    derive_loop(Input_key, Access_key_bytes, Counter + 1)
            end
    end.

-file("src/aws/internal/sigv4a.gleam", 336).
?DOC(
    " AWS SigV4a deterministic key derivation: turn an IAM\n"
    " (access-key-id, secret-access-key) pair into the 32-byte\n"
    " P-256 private scalar that `sign/4` accepts. Matches the\n"
    " algorithm in `aws-sigv4::sign::v4a::generate_signing_key`:\n"
    "   1. `input_key = \"AWS4A\" || secret_access_key` (UTF-8)\n"
    "   2. Loop counter `c = 1, 2, …`:\n"
    "        `kdf_context = access_key_id || c`\n"
    "        `fis = \"AWS4-ECDSA-P256-SHA256\" || 0x00 || kdf_context || 256:i32-be`\n"
    "        `buf = 1:i32-be || fis`\n"
    "        `tag = HMAC-SHA256(input_key, buf)` (32 bytes)\n"
    "        `k0 = U256(tag)` — big-endian\n"
    "        if `k0 ≤ N-2` (with `N` = P-256 order): return `k0 + 1`.\n"
    "   3. Otherwise `c += 1` and retry. The counter loop almost\n"
    "      always terminates on `c = 1`; the probability of rejection\n"
    "      per iteration is `(2^256 - (N-2)) / 2^256 ≈ 2^-128`.\n"
).
-spec derive_signing_key(binary(), binary()) -> ecdsa_private_key().
derive_signing_key(Access_key_id, Secret_access_key) ->
    Input_key = gleam_stdlib:identity(
        <<"AWS4A"/utf8, Secret_access_key/binary>>
    ),
    Access_key_bytes = gleam_stdlib:identity(Access_key_id),
    derive_loop(Input_key, Access_key_bytes, 1).

-file("src/aws/internal/sigv4a.gleam", 311).
?DOC(
    " One-call SigV4a signing that takes the IAM\n"
    " (access-key-id, secret-access-key) pair directly. Equivalent\n"
    " to `sign(req, derive_signing_key(akid, secret), akid, opts)`\n"
    " — derives the EC private scalar from the IAM secret then\n"
    " delegates. Use this when you have raw IAM credentials and\n"
    " don't already need to hold onto the derived key (e.g. for\n"
    " reuse across many requests with the same identity).\n"
).
-spec sign_with_iam_credentials(
    aws@internal@http_request:http_request(),
    binary(),
    binary(),
    sigv4a_options()
) -> aws@internal@http_request:http_request().
sign_with_iam_credentials(Req, Access_key_id, Secret_access_key, Opts) ->
    Key = derive_signing_key(Access_key_id, Secret_access_key),
    sign(Req, Key, Access_key_id, Opts).