Search code examples
delphiamazon-s3delphi-10.1-berlin

How to generate an Amazon's S3 presigned URL


I'm trying to generate presigned URLs for Amazon' S3 objects, but I can't find a GeneratePreSignedUrl class or method on Delphi 10.1 Data.Cloud.AmazonAPI as other languages have. Example on .NET : http://docs.aws.amazon.com/AmazonS3/latest/dev/ShareObjectPreSignedURLDotNetSDK.html

I have only been able to locate a third-party DLL (Chilkat) that provides this functionality to Delphi : https://www.example-code.com/delphiDll/aws_pre_signed_url_v4.asp but I would prefer to avoid third-party libraries as much as possible (I will keep it as a last resort).

Has anyone generated those URLs using the AmazonAPI provided by Delphi 10.1 ?.

PS: I have to serve files stored on S3, at the moment my Datasnap server retrieves them and returns them to the client as big base64 strings, but the web developer says that he would prefer to just get an URL (the presigned URL to an S3 object).

Thank you.


Solution

  • I've programmed a function that generates presigned URLs from scratch, without dependencies on the AWS SDK or specific components for Amazon.

    The calculations for the presigned URLs follow Amazon's documentation.

    https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html

    https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html

    SessionToken is used on temporary credentials and it can be left blank for long-term credentials. Operation must be "GET" or "PUT" to download or upload files respectively. And finally ExpirySeconds is the time period this URL is going to be valid, by default it's set to expire in 2 hours (7200 seconds).

    uses
      System.SysUtils,
      System.DateUtils,
      System.StrUtils,
      System.Hash,
      System.NetEncoding;
    
    function GetPresignedURL(AccessKey, SecretAccess, SessionToken, Region, Bucket, ObjectKey, Operation: string; ExpirySeconds: integer = 7200): string;
    var
      Host, DateISO8601, DateYYYYMMDD, CredentialScope, SessionTokenParameter, CanonicalRequest, StringToSign, Signature: string;
      SigningKey: TBytes;
      SignedHeaders, CanonicalQuery, Algorithm, Credential: string;
    
      function HMACSHA256(Data: string; Key: TBytes): TBytes;
      begin
        Result := THashSHA2.GetHMACAsBytes(TEncoding.UTF8.GetBytes(Data), Key, THashSHA2.TSHA2Version.SHA256);
      end;
    
      function BytesToHex(const ABytes: TBytes): string;
      const HexChars: array[0..15] of Char = '0123456789abcdef';
      begin
        SetLength(Result, Length(ABytes) * 2);
        for var i := 0 to Length(ABytes) - 1 do
        begin
          Result[i * 2 + 1] := HexChars[(ABytes[i] shr 4) and $0F]; // High nibble
          Result[i * 2 + 2] := HexChars[ABytes[i] and $0F];         // Low nibble
        end;
      end;
    begin
      if (Operation.ToUpper <> 'GET') and (Operation.ToUpper <> 'PUT') then
        raise Exception.CreateFmt('Only GET & PUT Operations are allowed. You requested a %s Operation', [Operation]);
    
      // Construct basic parameters
      DateISO8601 := FormatDateTime('YYYYMMDD"T"HHNNSS"Z"', TTimeZone.Local.ToUniversalTime(Now));
      DateYYYYMMDD := Copy(DateISO8601, 1, 8);
      Host := Format('%s.s3.%s.amazonaws.com', [Bucket, Region]);
    
      // Credential Scope
      CredentialScope := Format('%s/%s/s3/aws4_request', [DateYYYYMMDD, Region]);
      Credential := Format('%s/%s', [AccessKey, CredentialScope]);
    
      if SessionToken.Trim = '' then
        SessionTokenParameter := ''
      else
        SessionTokenParameter := '&X-Amz-Security-Token=' + TNetEncoding.URL.Encode(SessionToken);
    
      // Signed Headers
      if SessionToken.Trim = '' then
        SignedHeaders := 'host'
      else
        SignedHeaders := 'host;x-amz-security-token';
    
      // Canonical Query String
      CanonicalQuery :=
        'X-Amz-Algorithm=AWS4-HMAC-SHA256' +
        '&X-Amz-Credential=' + TNetEncoding.URL.Encode(Credential) +
        '&X-Amz-Date=' + DateISO8601 +
        '&X-Amz-Expires=' + IntToStr(ExpirySeconds) +
        '&X-Amz-SignedHeaders=' + SignedHeaders +
        SessionTokenParameter;
    
      // Canonical Request
      CanonicalRequest :=
        Operation.ToUpper + #10 + '/' + TNetEncoding.URL.Encode(ObjectKey) + #10 +
        CanonicalQuery + #10 +
        'host:' + Host + #10 +
        IfThen(SessionToken.Trim = '', '', 'x-amz-security-token:' + SessionToken + #10) + #10 +
        SignedHeaders + #10 +
        'UNSIGNED-PAYLOAD';
    
      // String to Sign
      Algorithm := 'AWS4-HMAC-SHA256';
      StringToSign :=
        Algorithm + #10 +
        DateISO8601 + #10 +
        CredentialScope + #10 +
        THashSHA2.GetHashString(CanonicalRequest, THashSHA2.TSHA2Version.SHA256);
    
      // Ensure HMAC uses string inputs and correctly works with string results
      SigningKey := HMACSHA256(DateYYYYMMDD, TEncoding.UTF8.GetBytes('AWS4' + SecretAccess));
      SigningKey := HMACSHA256(Region, SigningKey);
      SigningKey := HMACSHA256('s3', SigningKey);
      SigningKey := HMACSHA256('aws4_request', SigningKey);
    
      // Compute Signature
      Signature := BytesToHex(HMACSHA256(StringToSign, SigningKey));
    
      // Construct the final URL
      Result := Format('https://%s/%s?%s&X-Amz-Signature=%s%s', [Host, ObjectKey, CanonicalQuery, Signature, SessionTokenParameter])
    end;
    

    Alternatively, if you don't mind installing third-party components then you can also use the AWS SDK port to Delphi from Landgraf:

    https://landgraf.dev/en/aws-s3-support-in-aws-sdk-for-delphi/

    https://github.com/landgraf-dev/aws-sdk-delphi

    function GetPresignedURL(const AccessKey, SecretAccess, Region, Bucket, Filename: string; Operation: THttpVerb; ExpirySeconds: integer = 7200): string;
    begin
      SetEnvironmentVariable('AWS_REGION', Region);
      var S3Client := TAmazonS3Client.Create(AccessKey, SecretAccess);
    
      var Request := TGetPreSignedUrlRequest.Create;
      Request.BucketName := Bucket;
      Request.Key := Filename;
      Request.Expires := Now.IncSecond(ExpirySeconds); 
      Request.Verb := Operation;
      Request.Protocol := TProtocol.HTTPS;
    
      Result := S3Client.GetPreSignedURL(Request);
    end;