///|
fn sha256_hex(data : String) -> String {
@crypto.bytes_to_hex_string(@crypto.sha256(@utf8.encode(data)))
}
///|
fn sha256_bytes_hex(data : Bytes) -> String {
@crypto.bytes_to_hex_string(@crypto.sha256(data))
}
///|
fn hmac_sha256(key : BytesView, data : String) -> FixedArray[Byte] {
@crypto.hmac(@crypto.SHA256::new(), key, @utf8.encode(data))
}
///|
fn hmac_sha256_hex(key : BytesView, data : String) -> String {
@crypto.bytes_to_hex_string(hmac_sha256(key, data))
}
///|
/// Derive the SigV4 signing key for the given date, region and service.
pub fn signing_key(
secret~ : String,
date~ : String,
region~ : String,
service~ : String,
) -> Bytes {
let k_date = hmac_sha256(@utf8.encode("AWS4\{secret}"), date)
let k_region = hmac_sha256(k_date.unsafe_reinterpret_as_bytes(), region)
let k_service = hmac_sha256(k_region.unsafe_reinterpret_as_bytes(), service)
hmac_sha256(k_service.unsafe_reinterpret_as_bytes(), "aws4_request").unsafe_reinterpret_as_bytes()
}
///|
fn normalize_header_value(value : String) -> String {
let out = StringBuilder()
let mut in_whitespace = false
for ch in value.trim() {
if ch.is_ascii_whitespace() {
if !in_whitespace {
out.write_char(' ')
in_whitespace = true
}
} else {
out.write_char(ch)
in_whitespace = false
}
}
out.to_string()
}
///|
fn canonical_header_pairs(pairs : Array[(String, String)]) -> (String, String) {
pairs.sort_by(fn(left, right) { ascii_compare(left.0, right.0) })
let coalesced : Array[(String, Array[String])] = []
for pair in pairs {
let (name, value) = pair
match coalesced.last() {
Some((last_name, values)) if last_name == name => values.push(value)
_ => coalesced.push((name, [value]))
}
}
let canonical = coalesced
.map(fn(pair) { "\{pair.0}:\{pair.1.join(",")}\n" })
.join("")
let signed = coalesced.map(fn(pair) { pair.0 }).join(";")
(canonical, signed)
}
///|
fn canonical_headers_of(headers : Array[(String, String)]) -> (String, String) {
canonical_header_pairs(
headers.map(fn(pair) { (pair.0.to_lower(), normalize_header_value(pair.1)) }),
)
}
///|
/// The canonical-request payload line for each payload-hash mode.
fn payload_hash_line(body : Bytes, payload_hash~ : PayloadHash) -> String {
match payload_hash {
Sha256 => sha256_bytes_hex(body)
Empty => sha256_bytes_hex(@utf8.encode(""))
Unsigned => "UNSIGNED-PAYLOAD"
Provided(hash) => hash
}
}
///|
/// Set `name` to `value` on a copy of `headers`: existing entries with the
/// same (case-insensitive) name are replaced in place, otherwise the header
/// is appended.
fn upsert_header(
headers : Array[(String, String)],
name : String,
value : String,
) -> Array[(String, String)] {
let lower = name.to_lower()
if headers.any(fn(header) { header.0.to_lower() == lower }) {
headers.map(fn(header) {
if header.0.to_lower() == lower {
(header.0, value)
} else {
header
}
})
} else {
let out = headers.copy()
out.push((name, value))
out
}
}
///|
/// The headers that will be sent with the request: the originals plus
/// `X-Amz-Date`, plus `X-Amz-Content-Sha256` when `payload_checksum_kind`
/// is `XAmzSha256`, plus `X-Amz-Security-Token` when a session token is
/// present and not omitted.
fn prepare_headers(
request : HttpRequest,
credentials : Credentials,
options : SigningOptions,
payload_line : String,
) -> Array[(String, String)] {
let with_date = upsert_header(
request.headers,
"X-Amz-Date",
options.timestamp,
)
let with_body = if options.payload_checksum_kind is XAmzSha256 {
upsert_header(with_date, "X-Amz-Content-Sha256", payload_line)
} else {
with_date
}
match (credentials.session_token, options.omit_session_token) {
(Some(token), false) =>
upsert_header(with_body, "X-Amz-Security-Token", token)
_ => with_body
}
}
///|
/// The subset of the given headers that participate in the signature:
/// headers named in `options.excluded_headers` are dropped (they are still
/// sent with the request), as is `X-Amz-Security-Token` when
/// `omit_session_token` is set.
fn headers_for_signing(
headers : Array[(String, String)],
credentials : Credentials,
options : SigningOptions,
) -> Array[(String, String)] {
let omit_token = credentials.session_token is Some(_) &&
options.omit_session_token
headers.filter(fn(header) {
let name = header.0.to_lower()
let token_ok = name != "x-amz-security-token" || !omit_token
let excluded = options.excluded_headers
.iter()
.any(fn(pattern) { pattern.to_lower() == name })
token_ok && !excluded
})
}
///|
/// Render a `@http.RequestMethod` as the uppercase verb used on the
/// canonical request line.
fn method_to_string(meth : @http.RequestMethod) -> String {
match meth {
Get => "GET"
Head => "HEAD"
Post => "POST"
Put => "PUT"
Delete => "DELETE"
Connect => "CONNECT"
Options => "OPTIONS"
Trace => "TRACE"
Patch => "PATCH"
}
}
///|
/// Build the `AWS4-HMAC-SHA256` string-to-sign from a canonical request.
fn build_string_to_sign(
canonical_request~ : String,
timestamp~ : String,
region~ : String,
service~ : String,
) -> String {
let date = timestamp[:8].to_owned()
let scope = "\{date}/\{region}/\{service}/aws4_request"
["AWS4-HMAC-SHA256", timestamp, scope, sha256_hex(canonical_request)].join(
"\n",
)
}
///|
/// Compute the canonical-layer intermediates for header signing: the
/// canonical request and string-to-sign built from the request's signing
/// headers (the prepared headers minus any session token excluded by
/// `omit_session_token`), the `SignedHeaders=` value, and the resolved
/// payload line.
pub fn canonical_parts(
request~ : HttpRequest,
credentials~ : Credentials,
options~ : SigningOptions,
) -> CanonicalParts {
let payload_line = payload_hash_line(
request.body,
payload_hash=options.payload_hash,
)
let signing_headers = headers_for_signing(
prepare_headers(request, credentials, options, payload_line),
credentials,
options,
)
let (canonical_headers, signed_headers) = canonical_headers_of(
signing_headers,
)
let canonical_request = [
method_to_string(request.meth),
canonical_uri(
request.path,
normalize=options.normalize_path,
percent_encoding=options.percent_encoding,
),
canonical_query_string(request.query),
canonical_headers,
signed_headers,
payload_line,
].join("\n")
{
canonical_request,
string_to_sign: build_string_to_sign(
canonical_request~,
timestamp=options.timestamp,
region=options.region,
service=options.service,
),
signed_headers,
payload_hash: payload_line,
}
}
///|
/// Sign an HTTP request with AWS Signature Version 4 and return a new
/// request ready to send as-is: the query parameters sorted into canonical
/// order (render the wire form with `canonical_query_string`), the signing
/// headers applied — `X-Amz-Date`, plus `X-Amz-Content-Sha256` when
/// `payload_checksum_kind` is `XAmzSha256`, plus `X-Amz-Security-Token` when
/// a session token is present and not omitted — and finally the computed
/// `Authorization` header, which replaces any caller-supplied `Authorization`
/// header. Headers named in `excluded_headers` are sent but not signed.
pub fn sign(
request : HttpRequest,
credentials : Credentials,
options : SigningOptions,
) -> HttpRequest {
let parts = canonical_parts(request~, credentials~, options~)
let signature = hmac_sha256_hex(
signing_key(
secret=credentials.secret_access_key,
date=options.timestamp[:8].to_owned(),
region=options.region,
service=options.service,
),
parts.string_to_sign,
)
// drop any caller-supplied Authorization header so the computed one is
// the only one on the signed request
let prepared = prepare_headers(
request,
credentials,
options,
parts.payload_hash,
).filter(fn(header) { header.0.to_lower() != "authorization" })
let date = options.timestamp[:8].to_owned()
let scope = "\{date}/\{options.region}/\{options.service}/aws4_request"
let final_headers = prepared.copy()
if credentials.session_token is Some(token) && options.omit_session_token {
final_headers.push(("X-Amz-Security-Token", token))
}
final_headers.push(
(
"Authorization",
"AWS4-HMAC-SHA256 Credential=\{credentials.access_key_id}/\{scope}, SignedHeaders=\{parts.signed_headers}, Signature=\{signature}",
),
)
{ ..request, query: sorted_query(request.query), headers: final_headers, }
}
///|
/// Sign a request with the SigV4 query-string auth variant and return the
/// fully signed URL together with the `SignedHeaders` value: every header
/// listed in `signed_headers` (semicolon-joined, lowercase names) must be
/// sent verbatim with the request for the signature to verify. Headers named
/// in `excluded_headers` are never signed.
///
/// The auth components (`X-Amz-Algorithm`, `X-Amz-Credential`,
/// `X-Amz-Date`, `X-Amz-Expires`, `X-Amz-SignedHeaders`,
/// `X-Amz-Security-Token` when present, and `X-Amz-Signature`) land in the
/// URL query string rather than headers.
///
/// The payload line follows `options.payload_hash`; shared-download URLs
/// conventionally use `Unsigned`, while signing a request against a known
/// body uses `Sha256` or `Provided`.
///
/// Raises if `request.headers` has no `Host` header.
///
/// `expires` is bounded by SigV4 to `[1, 604800]` (1 second to 7 days); the
/// bound is not enforced here — AWS rejects out-of-range values server-side.
/// Existing `query` entries are taken as sent and merged with the auth
/// params.
/// `scheme` defaults to `https` (use e.g. `wss` for WebSocket endpoints).
pub fn presign_url(
request~ : HttpRequest,
credentials~ : Credentials,
options~ : SigningOptions,
expires~ : Int,
scheme? : String = "https",
) -> PresignedUrl raise {
let host = match
request.headers.filter(fn(header) { header.0.to_lower() == "host" }) {
[(_, value), ..] => value
_ =>
fail(
"presign_url requires a Host header in request.headers (the signed URL and the signature are both derived from it)",
)
}
let date = options.timestamp[:8].to_owned()
let scope = "\{date}/\{options.region}/\{options.service}/aws4_request"
let (canonical_headers, signed_headers) = canonical_headers_of(
headers_for_signing(request.headers, credentials, options),
)
let auth_params : Array[(String, String?)] = [
("X-Amz-Algorithm", Some("AWS4-HMAC-SHA256")),
(
"X-Amz-Credential",
Some(encode_query_component("\{credentials.access_key_id}/\{scope}")),
),
("X-Amz-Date", Some(options.timestamp)),
("X-Amz-Expires", Some(expires.to_string())),
("X-Amz-SignedHeaders", Some(encode_query_component(signed_headers))),
]
// A session token either joins the signed auth params or rides in the URL
// after the signature, unsigned (same semantics as header signing).
let unsigned_token : String? = match
(credentials.session_token, options.omit_session_token) {
(Some(token), false) => {
auth_params.push(
("X-Amz-Security-Token", Some(encode_query_component(token))),
)
None
}
(Some(token), true) => Some(token)
(None, _) => None
}
let uri = canonical_uri(
request.path,
normalize=options.normalize_path,
percent_encoding=options.percent_encoding,
)
let query = canonical_query_string(request.query + auth_params)
let canonical_request = [
method_to_string(request.meth),
uri,
query,
canonical_headers,
signed_headers,
payload_hash_line(request.body, payload_hash=options.payload_hash),
].join("\n")
let string_to_sign = build_string_to_sign(
canonical_request~,
timestamp=options.timestamp,
region=options.region,
service=options.service,
)
let signature = hmac_sha256_hex(
signing_key(
secret=credentials.secret_access_key,
date=options.timestamp[:8].to_owned(),
region=options.region,
service=options.service,
),
string_to_sign,
)
let url = "\{scheme}://\{host}\{uri}?\{query}&X-Amz-Signature=\{signature}"
{
url: match unsigned_token {
Some(token) =>
url + "&X-Amz-Security-Token=" + encode_query_component(token)
None => url
},
signed_headers,
}
}