Kit.Core/LibExternal/Minio/V4Authenticator.cs

576 lines
24 KiB
C#

/*
* MinIO .NET Library for Amazon S3 Compatible Cloud Storage, (C) 2017, 2020 MinIO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Minio.Helper;
namespace Minio;
/// <summary>
/// V4Authenticator implements IAuthenticator interface.
/// </summary>
internal class V4Authenticator
{
//
// Excerpts from @lsegal - https://github.com/aws/aws-sdk-js/issues/659#issuecomment-120477258
//
// User-Agent:
//
// This is ignored from signing because signing this causes problems with generating pre-signed URLs
// (that are executed by other agents) or when customers pass requests through proxies, which may
// modify the user-agent.
//
// Authorization:
//
// Is skipped for obvious reasons
//
private static readonly HashSet<string> ignoredHeaders = new(StringComparer.OrdinalIgnoreCase)
{
"authorization", "user-agent"
};
private readonly string accessKey;
private readonly string region;
private readonly string secretKey;
private readonly string sessionToken;
private readonly string sha256EmptyFileHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
/// <summary>
/// Authenticator constructor.
/// </summary>
/// <param name="secure"></param>
/// <param name="accessKey">Access key id</param>
/// <param name="secretKey">Secret access key</param>
/// <param name="region">Region if specifically set</param>
/// <param name="sessionToken">sessionToken</param>
public V4Authenticator(bool secure, string accessKey, string secretKey, string region = "",
string sessionToken = "")
{
IsSecure = secure;
this.accessKey = accessKey;
this.secretKey = secretKey;
IsAnonymous = Utils.IsAnonymousClient(accessKey, secretKey);
this.region = region;
this.sessionToken = sessionToken;
}
internal bool IsAnonymous { get; }
internal bool IsSecure { get; }
private string GetRegion(string endpoint)
{
if (!string.IsNullOrEmpty(region)) return region;
var endpointRegion = RegionHelper.GetRegionFromEndpoint(endpoint);
return string.IsNullOrEmpty(endpointRegion) ? "us-east-1" : endpointRegion;
}
/// <summary>
/// Implements Authenticate interface method for IAuthenticator.
/// </summary>
/// <param name="requestBuilder">Instantiated IRestRequest object</param>
/// <param name="isSts">boolean; if true role credentials, otherwise IAM user</param>
public string Authenticate(HttpRequestMessageBuilder requestBuilder, bool isSts = false)
{
var signingDate = DateTime.UtcNow;
SetContentSha256(requestBuilder, isSts);
requestBuilder.RequestUri = requestBuilder.Request.RequestUri;
var requestUri = requestBuilder.RequestUri;
if (requestUri.Port is 80 or 443)
SetHostHeader(requestBuilder, requestUri.Host);
else
SetHostHeader(requestBuilder, requestUri.Host + ":" + requestUri.Port);
SetDateHeader(requestBuilder, signingDate);
SetSessionTokenHeader(requestBuilder, sessionToken);
var headersToSign = GetHeadersToSign(requestBuilder);
var signedHeaders = GetSignedHeaders(headersToSign);
var canonicalRequest = GetCanonicalRequest(requestBuilder, headersToSign);
ReadOnlySpan<byte> canonicalRequestBytes = Encoding.UTF8.GetBytes(canonicalRequest);
var hash = ComputeSha256(canonicalRequestBytes);
var canonicalRequestHash = BytesToHex(hash);
var endpointRegion = GetRegion(requestUri.Host);
var stringToSign = GetStringToSign(endpointRegion, signingDate, canonicalRequestHash, isSts);
var signingKey = GenerateSigningKey(endpointRegion, signingDate, isSts);
ReadOnlySpan<byte> stringToSignBytes = Encoding.UTF8.GetBytes(stringToSign);
var signatureBytes = SignHmac(signingKey, stringToSignBytes);
var signature = BytesToHex(signatureBytes);
return GetAuthorizationHeader(signedHeaders, signature, signingDate, endpointRegion, isSts);
}
/// <summary>
/// Get credential string of form {ACCESSID}/date/region/serviceKind/aws4_request.
/// </summary>
/// <param name="signingDate">Signature initiated date</param>
/// <param name="region">Region for the credential string</param>
/// <param name="isSts">boolean; if true role credentials, otherwise IAM user</param>
/// <returns>Credential string for the authorization header</returns>
public string GetCredentialString(DateTime signingDate, string region, bool isSts = false)
{
var scope = GetScope(region, signingDate, isSts);
return $"{accessKey}/{scope}";
}
/// <summary>
/// Constructs an authorization header.
/// </summary>
/// <param name="signedHeaders">All signed http headers</param>
/// <param name="signature">Hexadecimally encoded computed signature</param>
/// <param name="signingDate">Date for signature to be signed</param>
/// <param name="region">Requested region</param>
/// <param name="isSts">boolean; if true role credentials, otherwise IAM user</param>
/// <returns>Fully formed authorization header</returns>
private string GetAuthorizationHeader(string signedHeaders, string signature, DateTime signingDate, string region,
bool isSts = false)
{
var scope = GetScope(region, signingDate, isSts);
return $"AWS4-HMAC-SHA256 Credential={accessKey}/{scope}, SignedHeaders={signedHeaders}, Signature={signature}";
}
/// <summary>
/// Concatenates sorted list of signed http headers.
/// </summary>
/// <param name="headersToSign">Sorted dictionary of headers to be signed</param>
/// <returns>All signed headers</returns>
private string GetSignedHeaders(SortedDictionary<string, string> headersToSign)
{
return string.Join(";", headersToSign.Keys);
}
/// <summary>
/// Determines and returns the kind of service
/// </summary>
/// <param name="isSts">boolean; if true role credentials, otherwise IAM user</param>
/// <returns>returns the kind of service as a string</returns>
private string GetService(bool isSts)
{
return isSts ? "sts" : "s3";
}
/// <summary>
/// Generates signing key based on the region and date.
/// </summary>
/// <param name="region">Requested region</param>
/// <param name="signingDate">Date for signature to be signed</param>
/// <param name="isSts">boolean; if true role credentials, otherwise IAM user</param>
/// <returns>bytes of computed hmac</returns>
private ReadOnlySpan<byte> GenerateSigningKey(string region, DateTime signingDate, bool isSts = false)
{
ReadOnlySpan<byte> dateRegionServiceKey;
ReadOnlySpan<byte> requestBytes;
ReadOnlySpan<byte> serviceBytes = Encoding.UTF8.GetBytes(GetService(isSts));
ReadOnlySpan<byte> formattedDateBytes =
Encoding.UTF8.GetBytes(signingDate.ToString("yyyyMMdd", CultureInfo.InvariantCulture));
ReadOnlySpan<byte> formattedKeyBytes = Encoding.UTF8.GetBytes($"AWS4{secretKey}");
var dateKey = SignHmac(formattedKeyBytes, formattedDateBytes);
ReadOnlySpan<byte> regionBytes = Encoding.UTF8.GetBytes(region);
var dateRegionKey = SignHmac(dateKey, regionBytes);
dateRegionServiceKey = SignHmac(dateRegionKey, serviceBytes);
requestBytes = Encoding.UTF8.GetBytes("aws4_request");
//var hmac = SignHmac(dateRegionServiceKey, requestBytes);
//var signingKey = Encoding.UTF8.GetString(hmac);
return SignHmac(dateRegionServiceKey, requestBytes);
}
/// <summary>
/// Compute hmac of input content with key.
/// </summary>
/// <param name="key">Hmac key</param>
/// <param name="content">Bytes to be hmac computed</param>
/// <returns>Computed hmac of input content</returns>
private ReadOnlySpan<byte> SignHmac(ReadOnlySpan<byte> key, ReadOnlySpan<byte> content)
{
#if NETSTANDARD
using var hmac = new HMACSHA256(key.ToArray());
hmac.Initialize();
return hmac.ComputeHash(content.ToArray());
#else
return HMACSHA256.HashData(key, content);
#endif
}
/// <summary>
/// Get string to sign.
/// </summary>
/// <param name="region">Requested region</param>
/// <param name="signingDate">Date for signature to be signed</param>
/// <param name="canonicalRequestHash">Hexadecimal encoded sha256 checksum of canonicalRequest</param>
/// <param name="isSts">boolean; if true role credentials, otherwise IAM user</param>
/// <returns>String to sign</returns>
private string GetStringToSign(string region, DateTime signingDate,
string canonicalRequestHash, bool isSts = false)
{
var scope = GetScope(region, signingDate, isSts);
return $"AWS4-HMAC-SHA256\n{signingDate:yyyyMMddTHHmmssZ}\n{scope}\n{canonicalRequestHash}";
}
/// <summary>
/// Get scope.
/// </summary>
/// <param name="region">Requested region</param>
/// <param name="signingDate">Date for signature to be signed</param>
/// <param name="isSts">boolean; if true role credentials, otherwise IAM user</param>
/// <returns>Scope string</returns>
private string GetScope(string region, DateTime signingDate, bool isSts = false)
{
return $"{signingDate:yyyyMMdd}/{region}/{GetService(isSts)}/aws4_request";
}
/// <summary>
/// Compute sha256 checksum.
/// </summary>
/// <param name="body">Bytes body</param>
/// <returns>Bytes of sha256 checksum</returns>
private ReadOnlySpan<byte> ComputeSha256(ReadOnlySpan<byte> body)
{
#if NETSTANDARD
using var sha = SHA256.Create();
ReadOnlySpan<byte> hash
= sha.ComputeHash(body.ToArray());
#else
ReadOnlySpan<byte> hash = SHA256.HashData(body);
#endif
return hash;
}
/// <summary>
/// Convert bytes to hexadecimal string.
/// </summary>
/// <param name="checkSum">Bytes of any checksum</param>
/// <returns>Hexlified string of input bytes</returns>
private string BytesToHex(ReadOnlySpan<byte> checkSum)
{
return BitConverter.ToString(checkSum.ToArray()).Replace("-", string.Empty, StringComparison.OrdinalIgnoreCase)
.ToLowerInvariant();
}
/// <summary>
/// Generate signature for post policy.
/// </summary>
/// <param name="region">Requested region</param>
/// <param name="signingDate">Date for signature to be signed</param>
/// <param name="policyBase64">Base64 encoded policy JSON</param>
/// <returns>Computed signature</returns>
public string PresignPostSignature(string region, DateTime signingDate, string policyBase64)
{
var signingKey = GenerateSigningKey(region, signingDate);
ReadOnlySpan<byte> stringToSignBytes = Encoding.UTF8.GetBytes(policyBase64);
var signatureBytes = SignHmac(signingKey, stringToSignBytes);
var signature = BytesToHex(signatureBytes);
return signature;
}
/// <summary>
/// Presigns any input client object with a requested expiry.
/// </summary>
/// <param name="requestBuilder">Instantiated requestBuilder</param>
/// <param name="expires">Expiration in seconds</param>
/// <param name="region">Region of storage</param>
/// <param name="sessionToken">Value for session token</param>
/// <param name="reqDate"> Optional requestBuilder date and time in UTC</param>
/// <returns>Presigned url</returns>
internal string PresignURL(HttpRequestMessageBuilder requestBuilder, int expires, string region = "",
string sessionToken = "", DateTime? reqDate = null)
{
var signingDate = reqDate ?? DateTime.UtcNow;
if (string.IsNullOrWhiteSpace(region)) region = GetRegion(requestBuilder.RequestUri.Host);
var requestUri = requestBuilder.RequestUri;
var requestQuery = requestUri.Query;
var headersToSign = GetHeadersToSign(requestBuilder);
if (!string.IsNullOrEmpty(sessionToken)) headersToSign["X-Amz-Security-Token"] = sessionToken;
if (requestQuery.Length > 0) requestQuery += "&";
requestQuery += "X-Amz-Algorithm=AWS4-HMAC-SHA256&";
requestQuery += "X-Amz-Credential="
+ Uri.EscapeDataString(accessKey + "/" + GetScope(region, signingDate))
+ "&";
requestQuery += "X-Amz-Date="
+ signingDate.ToString("yyyyMMddTHHmmssZ", CultureInfo.InvariantCulture)
+ "&";
requestQuery += "X-Amz-Expires="
+ expires
+ "&";
requestQuery += "X-Amz-SignedHeaders=host";
var presignUri = new UriBuilder(requestUri) { Query = requestQuery }.Uri;
var canonicalRequest = GetPresignCanonicalRequest(requestBuilder.Method, presignUri, headersToSign);
var headers = string.Concat(headersToSign.Select(p => $"&{p.Key}={Utils.UrlEncode(p.Value)}"));
ReadOnlySpan<byte> canonicalRequestBytes = Encoding.UTF8.GetBytes(canonicalRequest);
var canonicalRequestHash = BytesToHex(ComputeSha256(canonicalRequestBytes));
var stringToSign = GetStringToSign(region, signingDate, canonicalRequestHash);
var signingKey = GenerateSigningKey(region, signingDate);
ReadOnlySpan<byte> stringToSignBytes = Encoding.UTF8.GetBytes(stringToSign);
var signatureBytes = SignHmac(signingKey, stringToSignBytes);
var signature = BytesToHex(signatureBytes);
// Return presigned url.
var signedUri = new UriBuilder(presignUri) { Query = $"{requestQuery}{headers}&X-Amz-Signature={signature}" };
if (signedUri.Uri.IsDefaultPort) signedUri.Port = -1;
return Convert.ToString(signedUri, CultureInfo.InvariantCulture);
}
/// <summary>
/// Get presign canonical requestBuilder.
/// </summary>
/// <param name="requestMethod">HTTP method used for this requestBuilder</param>
/// <param name="uri">
/// Full url for this requestBuilder, including all query parameters except for headers and
/// X-Amz-Signature
/// </param>
/// <param name="headersToSign">The key-value of headers.</param>
/// <returns>Presigned canonical requestBuilder</returns>
internal string GetPresignCanonicalRequest(HttpMethod requestMethod, Uri uri,
SortedDictionary<string, string> headersToSign)
{
var canonicalStringList = new LinkedList<string>();
_ = canonicalStringList.AddLast(requestMethod.ToString());
var path = uri.AbsolutePath;
_ = canonicalStringList.AddLast(path);
var queryParams = uri.Query.TrimStart('?').Split('&').ToList();
queryParams.AddRange(headersToSign.Select(cv =>
$"{Utils.UrlEncode(cv.Key)}={Utils.UrlEncode(cv.Value.Trim())}"));
queryParams.Sort(StringComparer.Ordinal);
var query = string.Join("&", queryParams);
_ = canonicalStringList.AddLast(query);
var canonicalHost = GetCanonicalHost(uri);
_ = canonicalStringList.AddLast($"host:{canonicalHost}");
_ = canonicalStringList.AddLast(string.Empty);
_ = canonicalStringList.AddLast("host");
_ = canonicalStringList.AddLast("UNSIGNED-PAYLOAD");
return string.Join("\n", canonicalStringList);
}
private static string GetCanonicalHost(Uri url)
{
if (url.Port is > 0 and not 80 and not 443)
return $"{url.Host}:{url.Port}";
return url.Host;
}
/// <summary>
/// Get canonical requestBuilder.
/// </summary>
/// <param name="requestBuilder">Instantiated requestBuilder object</param>
/// <param name="headersToSign">Dictionary of http headers to be signed</param>
/// <returns>Canonical Request</returns>
private string GetCanonicalRequest(HttpRequestMessageBuilder requestBuilder,
SortedDictionary<string, string> headersToSign)
{
var canonicalStringList = new LinkedList<string>();
// METHOD
_ = canonicalStringList.AddLast(requestBuilder.Method.ToString());
var queryParamsDict = new Dictionary<string, string>(StringComparer.Ordinal);
if (requestBuilder.QueryParameters is not null)
foreach (var kvp in requestBuilder.QueryParameters)
queryParamsDict[kvp.Key] = Uri.EscapeDataString(kvp.Value);
var queryParams = "";
if (queryParamsDict.Count > 0)
{
var sb1 = new StringBuilder();
var queryKeys = new List<string>(queryParamsDict.Keys);
queryKeys.Sort(StringComparer.Ordinal);
foreach (var p in queryKeys)
{
if (sb1.Length > 0)
_ = sb1.Append('&');
_ = sb1.AppendFormat(CultureInfo.InvariantCulture, "{0}={1}", p, queryParamsDict[p]);
}
queryParams = sb1.ToString();
}
var isFormData = false;
if (requestBuilder.Request.Content?.Headers?.ContentType is not null)
isFormData = string.Equals(requestBuilder.Request.Content.Headers.ContentType.ToString(),
"application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase);
if (string.IsNullOrEmpty(queryParams) && isFormData)
{
// Convert stream content to byte[]
var cntntByteData = Span<byte>.Empty;
if (requestBuilder.Request.Content is not null)
cntntByteData = requestBuilder.Request.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult();
// UTF conversion - String from bytes
queryParams = Encoding.UTF8.GetString(cntntByteData);
}
if (!string.IsNullOrEmpty(queryParams) &&
!isFormData &&
!string.Equals(requestBuilder.RequestUri.Query, "?location=", StringComparison.OrdinalIgnoreCase))
requestBuilder.RequestUri = new Uri(requestBuilder.RequestUri + "?" + queryParams);
_ = canonicalStringList.AddLast(requestBuilder.RequestUri.AbsolutePath);
_ = canonicalStringList.AddLast(queryParams);
// Headers to sign
foreach (var header in headersToSign.Keys)
_ = canonicalStringList.AddLast(header + ":" + S3utils.TrimAll(headersToSign[header]));
_ = canonicalStringList.AddLast(string.Empty);
_ = canonicalStringList.AddLast(string.Join(";", headersToSign.Keys));
if (headersToSign.TryGetValue("x-amz-content-sha256", out var value))
_ = canonicalStringList.AddLast(value);
else
_ = canonicalStringList.AddLast(sha256EmptyFileHash);
return string.Join("\n", canonicalStringList);
}
public static IDictionary<string, TValue> ToDictionary<TValue>(object obj)
{
var json = JsonSerializer.Serialize(obj);
var dictionary = JsonSerializer.Deserialize<Dictionary<string, TValue>>(json);
return dictionary;
}
/// <summary>
/// Get headers to be signed.
/// </summary>
/// <param name="requestBuilder">Instantiated requesst</param>
/// <returns>Sorted dictionary of headers to be signed</returns>
private SortedDictionary<string, string> GetHeadersToSign(HttpRequestMessageBuilder requestBuilder)
{
var headers = requestBuilder.HeaderParameters.ToList();
var sortedHeaders = new SortedDictionary<string, string>(StringComparer.Ordinal);
foreach (var header in headers)
{
var headerName = header.Key.ToLowerInvariant();
var headerValue = header.Value;
if (!ignoredHeaders.Contains(headerName)) sortedHeaders.Add(headerName, headerValue);
}
return sortedHeaders;
}
/// <summary>
/// Sets 'x-amz-date' http header.
/// </summary>
/// <param name="requestBuilder">Instantiated requestBuilder object</param>
/// <param name="signingDate">Date for signature to be signed</param>
private void SetDateHeader(HttpRequestMessageBuilder requestBuilder, DateTime signingDate)
{
requestBuilder.AddOrUpdateHeaderParameter("x-amz-date",
signingDate.ToString("yyyyMMddTHHmmssZ", CultureInfo.InvariantCulture));
}
/// <summary>
/// Set 'Host' http header.
/// </summary>
/// <param name="requestBuilder">Instantiated requestBuilder object</param>
/// <param name="hostUrl">Host url</param>
private void SetHostHeader(HttpRequestMessageBuilder requestBuilder, string hostUrl)
{
requestBuilder.AddOrUpdateHeaderParameter("Host", hostUrl);
}
/// <summary>
/// Set 'X-Amz-Security-Token' http header.
/// </summary>
/// <param name="requestBuilder">Instantiated requestBuilder object</param>
/// <param name="sessionToken">session token</param>
private void SetSessionTokenHeader(HttpRequestMessageBuilder requestBuilder, string sessionToken)
{
if (!string.IsNullOrEmpty(sessionToken))
requestBuilder.AddOrUpdateHeaderParameter("X-Amz-Security-Token", sessionToken);
}
/// <summary>
/// Set 'x-amz-content-sha256' http header.
/// </summary>
/// <param name="requestBuilder">Instantiated requestBuilder object</param>
/// <param name="isSts">boolean; if true role credentials, otherwise IAM user</param>
private void SetContentSha256(HttpRequestMessageBuilder requestBuilder, bool isSts = false)
{
if (IsAnonymous)
return;
// No need to compute SHA256 if the endpoint scheme is https
// or the command method is not a Post to delete multiple files
var isMultiDeleteRequest = false;
if (requestBuilder.Method == HttpMethod.Post)
isMultiDeleteRequest =
requestBuilder.QueryParameters.Any(p => p.Key.Equals("delete", StringComparison.OrdinalIgnoreCase));
if ((IsSecure && !isSts) || isMultiDeleteRequest)
{
requestBuilder.AddOrUpdateHeaderParameter("x-amz-content-sha256", "UNSIGNED-PAYLOAD");
return;
}
// For insecure, authenticated requests set sha256 header instead of MD5.
if (requestBuilder.Method.Equals(HttpMethod.Put) ||
requestBuilder.Method.Equals(HttpMethod.Post))
{
var body = requestBuilder.Content;
if (body.IsEmpty)
{
requestBuilder.AddOrUpdateHeaderParameter("x-amz-content-sha256", sha256EmptyFileHash);
return;
}
#if NETSTANDARD
using var sha = SHA256.Create();
var hash
= sha.ComputeHash(body.ToArray());
#else
var hash = SHA256.HashData(body.Span);
#endif
var hex = BitConverter.ToString(hash).Replace("-", string.Empty, StringComparison.OrdinalIgnoreCase)
.ToLowerInvariant();
requestBuilder.AddOrUpdateHeaderParameter("x-amz-content-sha256", hex);
}
else if (!IsSecure && !requestBuilder.Content.IsEmpty)
{
ReadOnlySpan<byte> bytes = Encoding.UTF8.GetBytes(requestBuilder.Content.ToString());
#if NETSTANDARD
#pragma warning disable CA5351 // Do Not Use Broken Cryptographic Algorithms
using var md5
= MD5.Create();
#pragma warning restore CA5351 // Do Not Use Broken Cryptographic Algorithms
var hash
= md5.ComputeHash(bytes.ToArray());
#else
ReadOnlySpan<byte> hash = MD5.HashData(bytes);
#endif
var base64 = Convert.ToBase64String(hash);
requestBuilder.AddHeaderParameter("Content-Md5", base64);
}
else
{
requestBuilder.AddOrUpdateHeaderParameter("x-amz-content-sha256", sha256EmptyFileHash);
}
}
}