Haio 0.2.X
Homebrew Asset IO/CDN
Loading...
Searching...
No Matches
s3.cpp
Go to the documentation of this file.
1#include <bucket/bucket.hpp>
2
3#include <boost/asio/use_awaitable.hpp>
4#include <boost/url/parse.hpp>
5#include <boost/url/url.hpp>
6
7#include <wolfssl/options.h>
8#include <wolfssl/wolfcrypt/hmac.h>
9#include <wolfssl/wolfcrypt/sha256.h>
10
11#include <array>
12#include <stdexcept>
13#include <cstdlib>
14#include <ctime>
15#include <iostream>
16#include <string>
17#include <vector>
18
19namespace asio = boost::asio;
20namespace urls = boost::urls;
21
22namespace {
23
24using Digest = std::array<uint8_t, WC_SHA256_DIGEST_SIZE>;
25
26std::string toHex(const uint8_t* bytes, size_t size) {
27 static constexpr char hex[] = "0123456789abcdef";
28 std::string out(size * 2, '0');
29 for (size_t at = 0; at < size; at++) {
30 out[at * 2] = hex[bytes[at] >> 4];
31 out[at * 2 + 1] = hex[bytes[at] & 0x0F];
32 }
33 return out;
34}
35
36Digest sha256(std::string_view text) {
37 Digest out{};
38 wc_Sha256 state;
39 if (wc_InitSha256(&state) != 0) throw std::runtime_error("cannot start sha256");
40 wc_Sha256Update(&state, reinterpret_cast<const uint8_t*>(text.data()), static_cast<word32>(text.size()));
41 wc_Sha256Final(&state, out.data());
42 wc_Sha256Free(&state);
43 return out;
44}
45
46Digest hmac(const uint8_t* key, size_t keySize, std::string_view text) {
47 Digest out{};
48 Hmac state;
49 if (wc_HmacInit(&state, nullptr, INVALID_DEVID) != 0) throw std::runtime_error("cannot start hmac");
50 wc_HmacSetKey(&state, WC_SHA256, key, static_cast<word32>(keySize));
51 wc_HmacUpdate(&state, reinterpret_cast<const uint8_t*>(text.data()), static_cast<word32>(text.size()));
52 wc_HmacFinal(&state, out.data());
53 wc_HmacFree(&state);
54 return out;
55}
56
57Digest hmac(const Digest& key, std::string_view text) {
58 return hmac(key.data(), key.size(), text);
59}
60
61std::string env(const char* name) {
62 const char* value = std::getenv(name);
63 return value ? value : std::string{};
64}
65
66/**
67 * every byte outside the unreserved set is escaped, and "/" stays a separator.
68 *
69 * this has to agree with the signature exactly: a path escaped one way here and
70 * another way in the canonical request signs something the server never sees, and the
71 * answer is a 403 that says nothing about which of the two was wrong.
72 */
73std::string encodePath(std::string_view path) {
74 static constexpr char hex[] = "0123456789ABCDEF";
75 std::string out;
76 out.reserve(path.size());
77
78 for (const unsigned char c : path) {
79 const bool plain = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
80 || c == '-' || c == '_' || c == '.' || c == '~' || c == '/';
81 if (plain) {
82 out.push_back(static_cast<char>(c));
83 } else {
84 out.push_back('%');
85 out.push_back(hex[c >> 4]);
86 out.push_back(hex[c & 0x0F]);
87 }
88 }
89 return out;
90}
91
92struct Stamp {
93 std::string date; // 20240115
94 std::string moment; // 20240115T103000Z
95};
96
97Stamp nowUtc() {
98 const auto clock = std::time(nullptr);
99 std::tm parts{};
100 gmtime_r(&clock, &parts);
101
102 char date[16];
103 char moment[32];
104 std::strftime(date, sizeof(date), "%Y%m%d", &parts);
105 std::strftime(moment, sizeof(moment), "%Y%m%dT%H%M%SZ", &parts);
106 return Stamp{date, moment};
107}
108
109}
110
111namespace Haio::Cdn::Bucket {
112
113std::string awsSignatureV4(std::string_view secret, std::string_view date, std::string_view region,
114 std::string_view service, std::string_view stringToSign) {
115 const std::string seed = "AWS4" + std::string(secret);
116 const auto signing =
117 hmac(hmac(hmac(hmac(reinterpret_cast<const uint8_t*>(seed.data()), seed.size(), date),
118 region), service), "aws4_request");
119 return toHex(hmac(signing, stringToSign).data(), WC_SHA256_DIGEST_SIZE);
120}
121
122/**
123 * signature version 4, which is what turns an https request into an s3 one.
124 *
125 * the credentials are read from the environment on every request rather than kept,
126 * so rotating them does not need a restart, and nothing secret is ever written in the
127 * config file. without them the request goes out unsigned, which is exactly right for
128 * a public bucket and gives a plain 403 from aws for a private one.
129 */
130asio::awaitable<Blob> fetchS3(const BucketConfig& bucket, std::string path) {
131 const auto endpoint = urls::parse_uri_reference(bucket.url);
132 if (!endpoint) {
133 std::cerr << "invalid s3 endpoint: " << bucket.url << "\n";
134 throw Failure(ErrorCode::Internal, "this bucket is misconfigured");
135 }
136
137 const std::string host{endpoint->has_port() ? std::string(endpoint->host()) + ":" + std::string(endpoint->port())
138 : std::string(endpoint->host())};
139
140 // "?region=" told the config which region this is; it is no part of the object
141 std::string prefix{endpoint->path()};
142 while (!prefix.empty() && prefix.back() == '/') prefix.pop_back();
143
144 auto resource = prefix + (path.empty() || path.front() == '/' ? "" : "/") + path;
145 if (resource.empty() || resource.front() != '/') resource.insert(resource.begin(), '/');
146
147 const auto canonicalPath = encodePath(resource);
148 const auto target = "https://" + host + canonicalPath;
149
150 // the config says it; the environment is only asked when the config stays quiet
151 const auto key = bucket.accessKey.empty() ? env("AWS_ACCESS_KEY_ID") : bucket.accessKey;
152 const auto secret = bucket.secretKey.empty() ? env("AWS_SECRET_ACCESS_KEY") : bucket.secretKey;
153 if (key.empty() || secret.empty()) {
154 // a public bucket needs no signature; a private one will say so itself
155 co_return co_await fetchUrlWith(target, std::move(path), {});
156 }
157
158 const auto stamp = nowUtc();
159 const auto token = bucket.sessionToken.empty() ? env("AWS_SESSION_TOKEN") : bucket.sessionToken;
160
161 // a GET carries no body, and the hash of nothing is still part of what is signed
162 const auto payload = toHex(sha256("").data(), WC_SHA256_DIGEST_SIZE);
163
164 std::string signedHeaders = "host;x-amz-content-sha256;x-amz-date";
165 std::string canonicalHeaders = "host:" + host + "\n"
166 + "x-amz-content-sha256:" + payload + "\n"
167 + "x-amz-date:" + stamp.moment + "\n";
168 if (!token.empty()) {
169 // the list and the block have to stay in the same order, and it is alphabetical
170 signedHeaders += ";x-amz-security-token";
171 canonicalHeaders += "x-amz-security-token:" + token + "\n";
172 }
173
174 const auto canonicalRequest = "GET\n" + canonicalPath + "\n\n" + canonicalHeaders + "\n"
175 + signedHeaders + "\n" + payload;
176
177 const auto scope = stamp.date + "/" + bucket.region + "/s3/aws4_request";
178 const auto toSign = "AWS4-HMAC-SHA256\n" + stamp.moment + "\n" + scope + "\n"
179 + toHex(sha256(canonicalRequest).data(), WC_SHA256_DIGEST_SIZE);
180
181 const auto signature = awsSignatureV4(secret, stamp.date, bucket.region, "s3", toSign);
182
183 Headers headers{
184 {"x-amz-content-sha256", payload},
185 {"x-amz-date", stamp.moment},
186 {"authorization", "AWS4-HMAC-SHA256 Credential=" + key + "/" + scope
187 + ", SignedHeaders=" + signedHeaders + ", Signature=" + signature},
188 };
189 if (!token.empty()) headers.emplace("x-amz-security-token", token);
190
191 co_return co_await fetchUrlWith(target, std::move(path), std::move(headers));
192}
193
194}
std::map< std::string, std::string > Headers
Definition bucket.hpp:37
boost::asio::awaitable< Blob > fetchS3(const BucketConfig &bucket, std::string path)
Definition s3.cpp:130
boost::asio::awaitable< Blob > fetchUrlWith(std::string url, std::string pathForFormat, Headers headers)
Definition http.cpp:244
std::string awsSignatureV4(std::string_view secret, std::string_view date, std::string_view region, std::string_view service, std::string_view stringToSign)
Definition s3.cpp:113
Failure(Haio::ErrorCode why, const std::string &what)
Definition bucket.hpp:30