Search code examples
amazon-s3delphi-xeindy10

Receiving 400 Bad request when making S3 GET request


I am trying to make an AWS version 4 authorization signed GET request to S3, and receive a bad request error 400 Code:InvalidRequest Message:Missing required header for this request: x-amz-content-sha256

If I prefix the header with "Authorization: ", I get error Code:InvalidArgument Message:Unsupported Authorization Type <ArgumentName>Authorization</ArgumentName> <ArgumentValue>Authorization: AWS4-HMAC-SHA256 Credential=XXXXXXXXXXXXXXXXXXX/20200408/eu-west-3/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=vdchzint97uwyt3g%2fjehszrc8zpkbjsx4tfqacsqfow%3d</ArgumentValue>

I'm using Delphi XE5 with Indy's TIdHTTP component. Can anyone tell me what I am doing wrong? I have included my code below.

begin
  bucket := 'mybucket.ata-test';
  obj := 'test.xml';
  region := 'eu-west-3';
  service := 's3';
  aws := 'amazonaws.com';
  YYYYMMDD := FormatDateTime('yyyymmdd', now);
  amzDate := FormatDateTime('yyyymmdd"T"hhnnss"Z"', TTimeZone.Local.ToUniversalTime(Now), TFormatSettings.Create('en-US'));
  emptyHash := lowercase(SHA256HashAsHex(''));
  host := Format('%s.%s.%s.%s', [bucket, service, region, aws]);
  url := Format('%s://%s.%s.%s.%s/%s', ['https', bucket, service, region, aws, obj]);

// *** 1. Build the Canonical Request for Signature Version 4 ***
  // HTTPRequestMethod
  CanonicalRequest := URLEncodeValue('GET') +#10;
  // CanonicalURI
  CanonicalRequest := CanonicalRequest + '/' + URLEncodeValue(obj) +#10;
  // CanonicalQueryString (empty just a newline)
  CanonicalRequest := CanonicalRequest +#10;
  // CanonicalHeaders
  CanonicalRequest := CanonicalRequest + 'host:' + Trim(host) +#10
                                       + 'x-amz-content-sha256:' + emptyHash +#10
                                       + 'x-amz-date:' + Trim(amzDate) +#10;
  // SignedHeaders
  CanonicalRequest := CanonicalRequest + 'host;x-amz-content-sha256;x-amz-date' +#10;
  // HexEncode(Hash(RequestPayload)) - (hash of an empty string)
  CanonicalRequest := CanonicalRequest + emptyHash;

// *** 2. Create a String to Sign for Signature Version 4 ***
  StringToSign := 'AWS4-HMAC-SHA256' +#10
                  + amzDate +#10
                  + UTF8String(YYYYMMDD) +'/'+ UTF8String(region) +'/'+ UTF8String(service) +UTF8String('/aws4_request') +#10
                  + lowercase(SHA256HashAsHex(CanonicalRequest));

// *** 3. Calculate the Signature for AWS Signature Version 4 ***
  DateKey := CalculateHMACSHA256(YYYYMMDD, 'AWS4' + SecretAccessKey);
  DateRegionKey := CalculateHMACSHA256(region, DateKey);
  DateRegionServiceKey := CalculateHMACSHA256(service, DateRegionKey);
  SigningKey := CalculateHMACSHA256('aws4_request', DateRegionServiceKey);

  Signature := lowercase(UrlEncodeValue(CalculateHMACSHA256(StringToSign, SigningKey)));

// *** 4. Create Authorisation Header and Add the Signature to the HTTP Request ***
  AuthorisationHeader := 'AWS4-HMAC-SHA256 Credential='+AccessIdKey+'/'+YYYYMMDD+'/'+region+'/'+service+'/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature='+signature;  
// (Gives <Code>InvalidRequest</Code> <Message>Missing required header for this request: x-amz-content-sha256</Message>)

// Have also tried
// AuthorisationHeader := 'Authorization: AWS4-HMAC-SHA256 Credential='+AccessIdKey+'/'+YYYYMMDD+'/'+region+'/'+service+'/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature='+signature;  
// (Gives <Code>InvalidArgument</Code> <Message>Unsupported Authorization Type</Message>)

// *** 5. Add Header and Make Request ***
  stm := TMemoryStream.Create;
  try
    try
      Idhttp.Request.CustomHeaders.FoldLines := False;
      Idhttp.Request.CustomHeaders.AddValue('Authorization', AuthorisationHeader);
      Idhttp.Get(URL, stm);
    except
      on PE: EIdHTTPProtocolException do begin
        s := PE.ErrorMessage;
        Raise;
      end;
      on E: Exception do begin
        s := E.Message;
        Raise;
      end;
    end;

    stm.Position := 0;
    Memo1.Lines.LoadFromStream(stm);
  finally
    FreeAndNil(stm);
  end; 
end;

function SHA256HashAsHex(const value: string): String;
/// used for stringtosign
var
  sha: TIdHashSHA256;
begin
  LoadOpenSSLLibrary;
  if not TIdHashSHA256.IsAvailable then
    raise Exception.Create('SHA256 hashing is not available!');
  sha := TIdHashSHA256.Create;
  try
    result := sha.HashStringAsHex(value, nil);
  finally
    sha.Free;
  end;
end;

function CalculateHMACSHA256(const value, salt: String): String;
/// used for signingkey
var
  hmac: TIdHMACSHA256;
  hash: TIdBytes;
begin
  LoadOpenSSLLibrary;
  if not TIdHashSHA256.IsAvailable then
    raise Exception.Create('SHA256 hashing is not available!');
  hmac := TIdHMACSHA256.Create;
  try
    hmac.Key := IndyTextEncoding_UTF8.GetBytes(salt);
    hash := hmac.HashValue(IndyTextEncoding_UTF8.GetBytes(value));
    Result := EncodeBytes64(TArray<Byte>(hash));
  finally
    hmac.Free;
  end;
end;

Solution

  • A few things I notice in your code:

    • when creating the YYYYMMDD and amzDate values, you are calling Now() twice, which creates a race condition that has the potential of causing those variables to represent different dates. Unlikely, but possible. To avoid that, you should call Now() only 1 time and save the result to a local TDateTime variable, and then use that variable in all of your FormatDateTime() calls.
    dtNow := Now();
    YYYYMMDD := FormatDateTime('yyyymmdd', dtNow);
    amzDate := FormatDateTime('yyyymmdd"T"hhnnss"Z"', TTimeZone.Local.ToUniversalTime(dtNow), TFormatSettings.Create('en-US'));
    
    • When using TIdHTTP's Request.CustomHeaders property to set a custom Authorization header, make sure that you also set the Request.BasicAuthentication property to False as well, otherwise TIdHTTP may create its own Authorization: Basic ... header using its Request.Username and Request.Password properties. You don't want two Authorization headers in your GET request.
    Idhttp.Request.BasicAuthentication := False;
    
    • You are using x-amz-content-sha256 and x-amz-date headers in your authorization calculations, but you are not adding those headers to the actual HTTP request. TIdHTTP will add the Host header for you, but you need to add the other headers yourself.
    Idhttp.Request.CustomHeaders.AddValue('x-amz-content-sha256', emptyHash);
    Idhttp.Request.CustomHeaders.AddValue('x-amz-date', amzDate);
    
    • Your SHA256HashAsHex() function is not specifying a byte encoding when calling Indy's TIdHashSHA256.HashStringAsHex() method (in fact, it is going out of its way to explicitly set the encoding to nil). As such, Indy's default byte encoding will be used, which is US-ASCII (unless you set Indy's GIdDefaultTextEncoding variable in the IdGlobal unit to something else). However, your CalculateHMACSHA256() function is explicitly using UTF-8 instead. Your SHA256HashAsHex() function should use IndyTextEncoding_UTF8 to match:
    result := sha.HashStringAsHex(value, IndyTextEncoding_UTF8);
    
    • the input salt and output value for CalculateHMACSHA256() needs to be binary bytes, not strings, and certainly not base64-encoded or hex-encoded strings. Nothing in the Calculate the Signature for AWS Signature Version 4 documentation mentions the use of base64 at all.
    var
      DateKey, RegionKey, ServiceKey, SigningKey: TArray<Byte>;
    ...
    
    // *** 3. Calculate the Signature for AWS Signature Version 4 ***
    DateKey := CalculateHMACSHA256(YYYYMMDD, TEncoding.UTF8.GetBytes('AWS4' + SecretAccessKey));
    RegionKey := CalculateHMACSHA256(region, DateKey);
    ServiceKey := CalculateHMACSHA256(service, RegionKey);
    SigningKey := CalculateHMACSHA256('aws4_request', ServiceKey);
    
    Signature := CalculateHMACSHA256Hex(StringToSign, SigningKey); 
    
    ... 
    
    function CalculateHMACSHA256(const value: string; const salt: TArray<Byte>): TArray<Byte>;
    /// used for signingkey
    var
      hmac: TIdHMACSHA256;
      hash: TIdBytes;
    begin
      LoadOpenSSLLibrary;
      if not TIdHashSHA256.IsAvailable then
        raise Exception.Create('SHA256 hashing is not available!');
      hmac := TIdHMACSHA256.Create;
      try
        hmac.Key := TIdBytes(salt);
        hash := hmac.HashValue(IndyTextEncoding_UTF8.GetBytes(value));
        Result := TArray<Byte>(hash);
      finally
        hmac.Free;
      end;
    end;
    
    function CalculateHMACSHA256Hex(const value: string; const salt: TArray<Byte>): string;
    var
      hash: TArray<Byte>;
    begin
      hash := CalculateHMACSHA256(value, salt)
      Result := lowercase(ToHex(TIdBytes(hash)));
    end;