-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).