SalesforceのApexでREST APIを使用してS3に署名バージョン4でアクセスする方法

SalesforceのApexを使用して、REST APIでAmazon S3に署名バージョン4を使用してアクセスする方法の日本語記事があまりなかったので、メモとして残しておく。

# 前提条件
- AWSアカウントを所有しており、IAMユーザーを作成し、アクセスキーとシークレットキーを取得していること
- S3バケットのアクセス権やSalesforceの設定が適切にされていること

# 実装
以下の公式ドキュメントの手順にしたがって実装。詳細は以下を参照。
[署名付き AWS API リクエストを作成する](https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/create-signed-request.html)

:::note warn
`secret, secretKey, region`はクラス変数等で定義しておく
:::

## 手順1: 正規リクエストを作成する
``` Apex
private String generateCanonicalRequest(String method, String filename, String query, Map<String, String> headers, String hashedPayload) {
    // HTTPMethod
    String canonicalRequest = method + '\n';
    // CanonicalURI
    canonicalRequest += '/' + filename + '\n';
    // CanonicalQueryString
    canonicalRequest += query + '\n';

    List<String> headersList = new List<String>(headers.keySet());
    headersList.sort();
    // CanonicalHeaders
    for (String header : headersList) {
        String headerContent = header.toLowerCase() + ':' + headers.get(header).trim();
        canonicalRequest += headerContent + '\n';
    }
    canonicalRequest += '\n';
    // SignedHeaders
    for (String header : headersList) {
        canonicalRequest += header.toLowerCase() + ';';
    }
    canonicalRequest = canonicalRequest.removeEnd(';');
    canonicalRequest += '\n';
    // HashedPayload
    canonicalRequest += hashedPayload;

    return canonicalRequest;
}
```

## 手順2: 正規リクエストのハッシュを作成する
以下の手順3で実行

## 手順3: 署名文字列を作成する
``` Apex
private String generateStringToSign(String algorithm, String requestDateTime, String credentialScope, String canonicalRequest) {
    // Algorithm
    String stringToSign = algorithm + '\n';
    // RequestDateTime
    stringToSign += requestDateTime + '\n';
    // CredentialScope
    stringToSign += credentialScope + '\n';
    // HashedCanonicalRequest
    stringToSign += EncodingUtil.convertToHex(Crypto.generateDigest('SHA-256', Blob.valueOf(canonicalRequest)));

    return stringToSign;
}
```

## 手順4: 署名を計算する
``` Apex
private Blob generateSigningKey(String date, String service) {
    Blob dateKey = Crypto.generateMac('HMACSHA256', Blob.valueOf(date), Blob.valueOf('AWS4' + this.secret));
    Blob dateRegionKey = Crypto.generateMac('HMACSHA256', Blob.valueOf(this.region), dateKey);
    Blob dateRegionServiceKey = Crypto.generateMac('HMACSHA256', Blob.valueOf(service), dateRegionKey);
    Blob signingKey = Crypto.generateMac('HMACSHA256', Blob.valueOf('aws4_request'), dateRegionServiceKey);

    return signingKey;
}
```
## 手順4.5: 署名の作成
手順5で使用する
``` Apex
private Blob generateSignature(String stringToSign, Blob signingKey) {
    return Crypto.generateMac('HMACSHA256', Blob.valueOf(stringToSign), signingKey);
}
```

## 手順5: リクエストヘッダーに署名を追加する
``` Apex
private String generateAuthorizationHeader(String credentialScope, String algorithm, Map<String, String> headers, Blob signature, String requestDateTime) {
    String signedHeaders = '';
    
    List<String> headersList = new List<String>(headers.keySet());
    headersList.sort();
    for (String header : headersList) {
        signedHeaders += header.toLowerCase() + ';';
    }
    signedHeaders = signedHeaders.removeEnd(';');

    String authorizationHeader = algorithm + ' ';
    authorizationHeader += 'Credential=' + this.secretKey + '/' + credentialScope + ', ';
    authorizationHeader += 'SignedHeaders=' + signedHeaders + ', ';
    authorizationHeader += 'Signature=' + EncodingUtil.convertToHex(signature).toLowerCase();

    return authorizationHeader;
}
```

## 手順6: HTTPリクエストを実行する
### GETメソッド
``` Apex
public HTTPResponse getObject(String filename) {
    String method = 'GET';
    String service = 's3';
    String algorithm = 'AWS4-HMAC-SHA256';
    Datetime now = Datetime.now();
    String date = now.formatGMT('yyyyMMdd');
    String requestDateTime = now.formatGMT('yyyyMMdd\'T\'HHmmss\'Z\'');
    String credentialScope = date + '/' + this.region + '/' + service + '/aws4_request';

    String bucketName = '';
    String hostName = '';
    String endpointUrl = 'https://' + bucketName + '.' + hostName + '/' + filename;

    // リクエストペイロードのハッシュを生成
    String hashedPayload = EncodingUtil.convertToHex(Crypto.generateDigest('SHA-256', Blob.valueOf('')));

    // ヘッダーを定義
    Map<String, String> headers = new Map<String, String>{
        'host' => bucketName + '.' + hostName,
        'x-amz-date' => requestDateTime,
        'x-amz-content-sha256' => hashedPayload
    };

    // 正規リクエストを作成
    String canonicalRequest = generateCanonicalRequest(method, filename, '', headers, hashedPayload);
    // 署名文字列を作成
    String stringToSign = generateStringToSign(algorithm, requestDateTime, credentialScope, canonicalRequest);
    // 署名を計算
    Blob signingKey = generateSigningKey(date, service);
    // 署名の作成
    Blob signature = generateSignature(stringToSign, signingKey);
    // リクエストヘッダーを作成
    String authorizationHeader = generateAuthorizationHeader(credentialScope, algorithm, headers, signature, requestDateTime);

    HttpRequest req = new HttpRequest();
    req.setMethod(method);
    req.setEndpoint(endpointUrl);
    for (String header : headers.keySet()) {
        req.setHeader(header, headers.get(header));
    }
    req.setHeader('Authorization', authorizationHeader);

    Http http = new Http();
    HTTPResponse res = http.send(req);

    return res;
}
```

### PUTメソッド
基本的にはGETメソッドと同様のため、変更箇所以外は省略
#### テキストファイルをアップロードする場合
``` diff_Apex
- public HTTPResponse getObject(String filename) {
-     String method = 'GET';
+ public void putTextObject(String filename, String body) {
+     String method = 'PUT';
-     String hashedPayload = EncodingUtil.convertToHex(Crypto.generateDigest('SHA-256', Blob.valueOf('')));
+     String hashedPayload = EncodingUtil.convertToHex(Crypto.generateDigest('SHA-256', Blob.valueOf(body)));
+     req.setHeader('Content-Length', String.valueOf(body.length()));
+     req.setHeader('Content-Encoding', 'UTF-8');
+     req.setHeader('Content-type', ContentType('csv'));
+     req.setHeader('Connection', 'keep-alive');
+     req.setHeader('ACL', 'public-read');
+     req.setBodyAsBlob(Blob.valueOf(body));
-     return res;
```

#### イメージファイルをアップロードする場合
``` diff_Apex
- public HTTPResponse getObject(String filename) {
-     String method = 'GET';
+ public void putImageObject(String filename, Blob body) {
+     String method = 'PUT';
-     String hashedPayload = EncodingUtil.convertToHex(Crypto.generateDigest('SHA-256', Blob.valueOf('')));
+     String hashedPayload = EncodingUtil.convertToHex(Crypto.generateDigest('SHA-256', body));
+     req.setHeader('Content-Length', String.valueOf(EncodingUtil.base64Encode(body).length()));
+     req.setHeader('Content-type', ContentType('jpeg'));
+     req.setHeader('Connection', 'keep-alive');
+     req.setHeader('ACL', 'public-read');
+     req.setBodyAsBlob(body);
-     return res;
```

#### オブジェクトコピーの場合
``` diff_Apex
- public HTTPResponse getObject(String filename) {
-     String method = 'GET';
+ public HTTPResponse copyObject(String fromFilename, String filename) {
+     String method = 'PUT';
-     Map<String, String> headers = new Map<String, String>{
-         'host' => bucketName + '.' + hostName,
-         'x-amz-date' => requestDateTime,
-         'x-amz-content-sha256' => hashedPayload
-     };
+     Map<String, String> headers = new Map<String, String>{
+         'host' => bucketName + '.' + hostName,
+         'x-amz-date' => requestDateTime,
+         'x-amz-content-sha256' => hashedPayload
+         'x-amz-copy-source' => '/' + bucketName + '/' + fromFilename
+     };
```

# 参考文献
- [S3のRESTリクエストを署名バージョン4で行う](https://qiita.com/ototo/items/dab78254efd67c2bd110)
- [AWS 4 Authentication with Apex](https://www.linkedin.com/pulse/aws-4-authentication-apex-shubham-sonar--1e/)