userver: /data/code/userver/libraries/s3api/src/s3api/authenticators/signature_v4.cpp Source File
Loading...
Searching...
No Matches
signature_v4.cpp
1#include <userver/s3api/authenticators/signature_v4.hpp>
2
3#include <algorithm>
4#include <chrono>
5#include <map>
6#include <optional>
7#include <stdexcept>
8#include <string_view>
9#include <utility>
10#include <vector>
11
12#include <fmt/format.h>
13#include <boost/algorithm/string.hpp>
14
15#include <userver/crypto/hash.hpp>
16#include <userver/http/common_headers.hpp>
17#include <userver/s3api/authenticators/utils.hpp>
18#include <userver/s3api/models/request.hpp>
19#include <userver/utils/datetime_light.hpp>
20
21USERVER_NAMESPACE_BEGIN
22
23namespace s3api::authenticators {
24
25namespace {
26
27constexpr std::string_view kAlgorithm = "AWS4-HMAC-SHA256";
28constexpr std::string_view kAws4Request = "aws4_request";
29constexpr std::string_view kUnsignedPayload = "UNSIGNED-PAYLOAD";
30
31bool IsUnreservedChar(char c) {
32 if ((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) {
33 return true;
34 }
35 return c == '-' || c == '_' || c == '.' || c == '~';
36}
37
38void PercentEncodeByteTo(unsigned char byte, std::string& result) {
39 static constexpr char kHexDigits[] = "0123456789ABCDEF";
40 result.push_back('%');
41 result.push_back(kHexDigits[byte >> 4]);
42 result.push_back(kHexDigits[byte & 0x0F]);
43}
44
45std::string UriEncode(std::string_view value, bool encode_slash) {
46 std::string result;
47 result.reserve(value.size());
48
49 for (auto c : value) {
50 if (IsUnreservedChar(c) || (c == '/' && !encode_slash)) {
51 result.push_back(c);
52 } else {
53 PercentEncodeByteTo(static_cast<unsigned char>(c), result);
54 }
55 }
56
57 return result;
58}
59
60std::optional<int> ParseHexDigit(char c) {
61 if (c >= '0' && c <= '9') {
62 return c - '0';
63 }
64 if (c >= 'A' && c <= 'F') {
65 return c - 'A' + 10;
66 }
67 if (c >= 'a' && c <= 'f') {
68 return c - 'a' + 10;
69 }
70 return std::nullopt;
71}
72
73std::string PercentDecode(std::string_view value) {
74 std::string result;
75 result.reserve(value.size());
76
77 for (std::size_t i = 0; i < value.size(); ++i) {
78 if (value[i] == '%' && i + 2 < value.size()) {
79 const auto high = ParseHexDigit(value[i + 1]);
80 const auto low = ParseHexDigit(value[i + 2]);
81 if (high && low) {
82 result.push_back(static_cast<char>((*high * 16) + *low));
83 i += 2;
84 continue;
85 }
86 }
87 result.push_back(value[i]);
88 }
89
90 return result;
91}
92
93std::string TrimAndCollapseSpaces(std::string_view value) {
94 std::string result;
95 result.reserve(value.size());
96
97 bool pending_space = false;
98 for (auto c : value) {
99 if (std::isspace(static_cast<unsigned char>(c))) {
100 pending_space = !result.empty();
101 continue;
102 }
103
104 if (pending_space) {
105 result.push_back(' ');
106 pending_space = false;
107 }
108
109 result.push_back(c);
110 }
111
112 return result;
113}
114
115struct RequestTarget {
116 std::string_view path;
117 std::string_view query;
118};
119
120RequestTarget SplitRequestTarget(const std::string& req) {
121 const std::string_view target{req};
122 const auto query_pos = target.find('?');
123
124 if (query_pos == std::string_view::npos) {
125 return RequestTarget{
126 .path = target,
127 .query = {},
128 };
129 }
130
131 return RequestTarget{
132 .path = target.substr(0, query_pos),
133 .query = target.substr(query_pos + 1),
134 };
135}
136
137bool IsVirtualHostAddressing(std::string_view host, std::string_view bucket) {
138 if (bucket.empty()) {
139 return true;
140 }
141 if (host.size() <= bucket.size() || host[bucket.size()] != '.') {
142 return false;
143 }
144 return host.substr(0, bucket.size()) == bucket;
145}
146
147std::string MakeCanonicalUri(const Request& request, std::string_view host, std::string_view path) {
148 std::string raw_path;
149
150 if (!IsVirtualHostAddressing(host, request.bucket)) {
151 raw_path = request.bucket + "/";
152 }
153
154 raw_path += PercentDecode(path);
155
156 return "/" + UriEncode(raw_path, /*encode_slash=*/false);
157}
158
159using QueryParams = std::vector<std::pair<std::string, std::string>>;
160
161QueryParams ParseQuery(std::string_view query) {
162 QueryParams result;
163
164 while (!query.empty()) {
165 const auto param = query.substr(0, query.find('&'));
166 query.remove_prefix(std::min(query.size(), param.size() + 1));
167
168 if (param.empty()) {
169 continue;
170 }
171
172 const auto eq_pos = param.find('=');
173 if (eq_pos == std::string_view::npos) {
174 result.emplace_back(PercentDecode(param), std::string{});
175 } else {
176 result.emplace_back(PercentDecode(param.substr(0, eq_pos)), PercentDecode(param.substr(eq_pos + 1)));
177 }
178 }
179
180 return result;
181}
182
183std::string MakeCanonicalQueryString(QueryParams params) {
184 for (auto& [name, value] : params) {
185 name = UriEncode(name, /*encode_slash=*/true);
186 value = UriEncode(value, /*encode_slash=*/true);
187 }
188 std::ranges::sort(params);
189
190 std::string result;
191
192 for (const auto& [name, value] : params) {
193 if (!result.empty()) {
194 result.push_back('&');
195 }
196 result.append(name);
197 result.push_back('=');
198 result.append(value);
199 }
200
201 return result;
202}
203
204struct CanonicalHeaders {
205 // "name1:value1\nname2:value2\n" with lowercase names sorted alphabetically
206 std::string headers;
207 // "name1;name2"
208 std::string signed_headers;
209};
210
211CanonicalHeaders MakeCanonicalHeaders(const std::map<std::string, std::string>& headers) {
212 CanonicalHeaders result;
213
214 for (const auto& [name, value] : headers) {
215 result.headers += fmt::format("{}:{}\n", name, value);
216 if (!result.signed_headers.empty()) {
217 result.signed_headers.push_back(';');
218 }
219 result.signed_headers += name;
220 }
221
222 return result;
223}
224
225std::string MakeCanonicalRequest(
226 const Request& request,
227 std::string_view host,
228 const CanonicalHeaders& canonical_headers,
229 QueryParams extra_query_params,
230 std::string_view payload_hash
231) {
232 const auto target = SplitRequestTarget(request.req);
233
234 auto query_params = ParseQuery(target.query);
235 std::ranges::move(extra_query_params, std::back_inserter(query_params));
236
237 return fmt::format(
238 "{}\n{}\n{}\n{}\n{}\n{}",
239 ToStringView(request.method),
240 MakeCanonicalUri(request, host, target.path),
241 MakeCanonicalQueryString(std::move(query_params)),
242 canonical_headers.headers,
243 canonical_headers.signed_headers,
244 payload_hash
245 );
246}
247
248struct SigningScope {
249 std::time_t now{};
250 std::string amz_date;
251 std::string date_stamp;
252 std::string credential_scope;
253};
254
255SigningScope MakeSigningScope(std::string_view region, std::string_view service) {
256 const auto now = utils::datetime::Now();
257
258 SigningScope scope;
259 scope.now = std::chrono::system_clock::to_time_t(now);
260 scope.amz_date = utils::datetime::UtcTimestring(now, "%Y%m%dT%H%M%SZ");
261 scope.date_stamp = scope.amz_date.substr(0, 8); // 4 - year, 2 - month, 2 - day
262 scope.credential_scope = fmt::format("{}/{}/{}/{}", scope.date_stamp, region, service, kAws4Request);
263
264 return scope;
265}
266
267std::string MakeStringToSign(std::string_view canonical_request, const SigningScope& scope) {
268 return fmt::format(
269 "{}\n{}\n{}\n{}",
270 kAlgorithm,
271 scope.amz_date,
272 scope.credential_scope,
273 crypto::hash::Sha256(canonical_request, crypto::hash::OutputEncoding::kHex)
274 );
275}
276
277std::string MakeSignature(
278 std::string_view string_to_sign,
279 const SigningScope& scope,
280 std::string_view region,
281 std::string_view service,
282 const Secret& secret_key
283) {
284 // https://docs.aws.amazon.com/AmazonS3/latest/developerguide/sigv4-query-string-auth.html#query-string-auth-v4-signing
285
286 static constexpr auto kBinary = crypto::hash::OutputEncoding::kBinary;
287
288 auto key = crypto::hash::HmacSha256("AWS4" + secret_key.GetUnderlying(), scope.date_stamp, kBinary);
289 key = crypto::hash::HmacSha256(key, region, kBinary);
290 key = crypto::hash::HmacSha256(key, service, kBinary);
291 key = crypto::hash::HmacSha256(key, kAws4Request, kBinary);
292
293 return crypto::hash::HmacSha256(key, string_to_sign, crypto::hash::OutputEncoding::kHex);
294}
295
296std::string GetHostHeaderValue(const Request& request) {
297 const auto it = request.headers.find(USERVER_NAMESPACE::http::headers::kHost);
298 if (it == request.headers.end() || it->second.empty()) {
299 throw std::runtime_error("AWS Signature V4 requires the 'Host' header, set it before signing the request");
300 }
301 return TrimAndCollapseSpaces(it->second);
302}
303
304} // namespace
305
306std::unordered_map<std::string, std::string> SignatureV4::Auth(const Request& request) const {
307 // https://docs.aws.amazon.com/AmazonS3/latest/developerguide/sig-v4-header-based-auth.html
308
309 const auto scope = MakeSigningScope(region_, service_);
310 const auto host = GetHostHeaderValue(request);
311 auto payload_hash = crypto::hash::Sha256(request.body, crypto::hash::OutputEncoding::kHex);
312
313 std::map<std::string, std::string> headers_to_sign;
314 for (const auto& [name, value] : request.headers) {
315 headers_to_sign[boost::algorithm::to_lower_copy(name)] = TrimAndCollapseSpaces(value);
316 }
317 headers_to_sign["host"] = host;
318 headers_to_sign["x-amz-date"] = scope.amz_date;
319 headers_to_sign["x-amz-content-sha256"] = payload_hash;
320
321 const auto canonical_headers = MakeCanonicalHeaders(headers_to_sign);
322 const auto canonical_request = MakeCanonicalRequest(request, host, canonical_headers, {}, payload_hash);
323 const auto string_to_sign = MakeStringToSign(canonical_request, scope);
324 const auto signature = MakeSignature(string_to_sign, scope, region_, service_, secret_key_);
325
326 auto authorization = fmt::format(
327 "{} Credential={}/{}, SignedHeaders={}, Signature={}",
328 kAlgorithm,
329 access_key_,
330 scope.credential_scope,
331 canonical_headers.signed_headers,
332 signature
333 );
334
335 return {
336 {"Authorization", std::move(authorization)},
337 {"X-Amz-Date", scope.amz_date},
338 {"X-Amz-Content-Sha256", std::move(payload_hash)},
339 };
340}
341
342std::unordered_map<std::string, std::string> SignatureV4::Sign(const Request& request, std::time_t expires) const {
343 // https://docs.aws.amazon.com/AmazonS3/latest/developerguide/sigv4-query-string-auth.html
344
345 const auto scope = MakeSigningScope(region_, service_);
346 const auto host = GetHostHeaderValue(request);
347
348 const auto expires_in = std::max<std::time_t>(expires - scope.now, 1);
349
350 std::unordered_map<std::string, std::string> sign_params{
351 {"X-Amz-Algorithm", std::string{kAlgorithm}},
352 {"X-Amz-Credential", fmt::format("{}/{}", access_key_, scope.credential_scope)},
353 {"X-Amz-Date", scope.amz_date},
354 {"X-Amz-Expires", std::to_string(expires_in)},
355 {"X-Amz-SignedHeaders", "host"},
356 };
357
358 const CanonicalHeaders canonical_headers{
359 .headers = fmt::format("host:{}\n", host),
360 .signed_headers = "host",
361 };
362
363 const auto canonical_request = MakeCanonicalRequest(
364 request,
365 host,
366 canonical_headers,
367 QueryParams{sign_params.begin(), sign_params.end()},
368 kUnsignedPayload
369 );
370
371 const auto string_to_sign = MakeStringToSign(canonical_request, scope);
372
373 sign_params.emplace("X-Amz-Signature", MakeSignature(string_to_sign, scope, region_, service_, secret_key_));
374
375 return sign_params;
376}
377
378} // namespace s3api::authenticators
379
380USERVER_NAMESPACE_END