//! AWS Signature Version 4, the part S3 needs. //! //! Signing is a fixed recipe: canonicalise the request, hash it, derive a key //! from the secret for that day and region, and sign the string-to-sign. It is //! all here in the open, and pinned to the published AWS `get-vanilla` vector, //! so the signer is checked rather than trusted. module remote import crypto.hmac import crypto.sha256 import time pub struct Signer { pub: access_key string secret_key string region string service string // S3 signs the payload hash as well; the AWS test vectors for the generic // service do not. The field exists so the rest of the algorithm can be // checked against those vectors. sign_payload bool = true } // sign returns the headers that authenticate one request: every header passed // in, plus host, x-amz-date and (for S3) x-amz-content-sha256, plus the // Authorization header itself. `now` is used exactly as given, so the caller // decides the zone — `time.now()` is local, and S3 wants UTC. pub fn (signer Signer) sign(method string, url string, query string, headers map[string]string, body []u8, now time.Time) map[string]string { parts := split_url(url) payload_hash := sha256_hex(body) amz_date := amz_timestamp(now) mut signed := map[string]string{} for name, value in headers { signed[name.to_lower().trim_space()] = value.trim_space() } signed['host'] = parts.host signed['x-amz-date'] = amz_date if signer.sign_payload { signed['x-amz-content-sha256'] = payload_hash } mut names := signed.keys() names.sort() mut canonical_headers := '' for name in names { canonical_headers += '${name}:${signed[name]}\n' } signed_names := names.join(';') canonical_request := '${method}\n${parts.path}\n${canonical_query(query)}\n${canonical_headers}\n${signed_names}\n${payload_hash}' date := amz_date[..8] scope := '${date}/${signer.region}/${signer.service}/aws4_request' string_to_sign := 'AWS4-HMAC-SHA256\n${amz_date}\n${scope}\n${sha256_hex(canonical_request.bytes())}' signature := hmac.new(signing_key(signer, date), string_to_sign.bytes(), sha256.sum256, 64).hex() mut out := signed.clone() out['authorization'] = 'AWS4-HMAC-SHA256 Credential=${signer.access_key}/${scope}, SignedHeaders=${signed_names}, Signature=${signature}' return out } // signing_key is the HMAC chain: the secret becomes a key for the day, then for // the region, then for the service, then for the request. fn signing_key(signer Signer, date string) []u8 { k_date := hmac.new('AWS4${signer.secret_key}'.bytes(), date.bytes(), sha256.sum256, 64) k_region := hmac.new(k_date, signer.region.bytes(), sha256.sum256, 64) k_service := hmac.new(k_region, signer.service.bytes(), sha256.sum256, 64) return hmac.new(k_service, 'aws4_request'.bytes(), sha256.sum256, 64) } // sha256_hex is the digest form SigV4 puts in the canonical request. fn sha256_hex(data []u8) string { return sha256.sum256(data).hex() } // amz_timestamp is the one date format SigV4 uses. pub fn amz_timestamp(moment time.Time) string { return '${moment.year}${pad2(moment.month)}${pad2(moment.day)}T${pad2(moment.hour)}${pad2(moment.minute)}${pad2(moment.second)}Z' } fn pad2(value int) string { return if value < 10 { '0${value}' } else { '${value}' } } pub struct UrlParts { pub: host string // host[:port], as it appears in the Host header path string // canonical URI, already encoded } // split_url takes a URL apart for the canonical request. The URL is the one // that will be sent, already encoded by its builder, so its path is signed as // it stands: encoding it again would sign `%2520` for a `%20` on the wire. pub fn split_url(url string) UrlParts { mut rest := url if at := rest.index('://') { rest = rest[at + 3..] } mut host := rest mut path := '/' if slash := rest.index('/') { host = rest[..slash] path = rest[slash..] } if question := path.index('?') { path = path[..question] } return UrlParts{ host: host path: path } } // canonical_query sorts the query parameters and encodes them the way SigV4 // wants them. pub fn canonical_query(query string) string { if query == '' { return '' } mut pairs := []string{} for part in query.split('&') { if part == '' { continue } if at := part.index('=') { pairs << '${encode(part[..at], false)}=${encode(part[at + 1..], false)}' } else { pairs << '${encode(part, false)}=' } } pairs.sort() return pairs.join('&') } // encode is RFC 3986: unreserved characters stay, everything else becomes %XX. // `keep_slash` is for paths, where a slash separates key segments. pub fn encode(text string, keep_slash bool) string { mut out := '' for byte in text.bytes() { if (byte >= `A` && byte <= `Z`) || (byte >= `a` && byte <= `z`) || (byte >= `0` && byte <= `9`) || byte in [`-`, `_`, `.`, `~`] || (keep_slash && byte == `/`) { out += byte.ascii_str() } else { out += '%${byte.hex().to_upper()}' } } return out }