白黒羊

Amazon S3のREST APIを叩いてファイルを取得する

Why?

こちらを参考に、AddressableのProviderを自作することでAmazon S3の認証もしつつデータを持ってくることができるのではないかと思いました。

Amazon S3からAssetBundleをダウンロードするだけの記事はいくつか見かけたのですが、そのデータをPublicにしたくない場合の認証に少し手間取ったのでメモを残しておきます。

.NET用のSDKを使うとそのあたりは簡単なのですが、おそらくAndroidだとSDKが入っているとうまく動かない気がするのと、UnityWebRequestを使わないとAssetBundleがキャッシュされないので毎回ダウンロードされてしまうという事態に。

結局REST API+UnityWebRequestを使うことにしました。

公式Libraryありました

Examples_ Signature Calculations in AWS Signature Version 4 – Amazon Simple Storage Service

これ使えば一発でSignature作れます。
Unityで使うには少し手を加えないといけませんが便利です。

全部書いてから気がつきました。
書いてしまったのでメモだけ残しておきます。

RequestHeaderを作る

これが完成形です。

Authorization: AWS4-HMAC-SHA256 Credential=<your-access-key-id>/YYYYMMDD/<aws-region>/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=fe5f80f77d5fa3beca038a248ff027d0445342fe2855ddc963176630326f1024

違いがよくわかってないのですが、 公式マニュアルの[Transferring Payload in a Single Chunk]に沿って作ってみます。

Canonical Requestを作る

<HTTPMethod>\n
<CanonicalURI>\n
<CanonicalQueryString>\n
<CanonicalHeaders>\n
<SignedHeaders>\n
<HashedPayload>

String to Signを作る

<Algorithm>\n
<RequestDateTime>\n
<CredentialScope>\n
<HashedCanonicalRequest>

Regionはここにあるものを使います。

AWS service endpoints – AWS General Reference

Regionが us-east-1 ではないときはHostをAwsService-Region.amazonaws.comにしないといけないけど、us-east-1のときはRegion.amazonaws.comになるのがちょっと罠。

Signatureを作る

DateKey              = HMAC-SHA256("AWS4"+"<SecretAccessKey>", "<YYYYMMDD>")
DateRegionKey        = HMAC-SHA256(<DateKey>, "<aws-region>")
DateRegionServiceKey = HMAC-SHA256(<DateRegionKey>, "<aws-service>")
SigningKey           = HMAC-SHA256(<DateRegionServiceKey>, "aws4_request")

頻繁によくわからなくなるけど、SHA256の計算とHMAC-SHA256の計算は違う……(それはそう)。

コード

// Amazon S3
private const string AccessKey = "AKIAIOSFODNN7EXAMPLE";
private const string SecretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
private const string Region = "ap-northeast-1";
private const string AwsService = "s3";
private const string BucketName = "examplebucket";
private const string Aws4Request = "aws4_request";
private const string Key = "test.txt";
private static readonly string Host = $"{BucketName}.{AwsService}-{Region}.amazonaws.com";
private readonly string _remotePath = $"https://{Host}/";
private string _signedHeaders;
private string _dateTime;
private string _date;
private string _hashedPayload;
private Dictionary<string, string> _canonicalHeaderMap = new Dictionary<string, string>();
private const string NewLine = "\n";

// 最初に呼ぶ関数
private void Start()
{
    _date = DateTime.UtcNow.ToString("yyyyMMdd");
    _dateTime = DateTime.UtcNow.ToString("yyyyMMddTHHmmssZ");
    _hashedPayload = Hex(string.Empty);
    _canonicalHeaderMap = new Dictionary<string, string>
    {
        {"host", Host},
        {"x-amz-content-sha256", _hashedPayload},
        {"x-amz-date", _dateTime},
    };
    _signedHeaders = string.Join(";", 
        _canonicalHeaderMap.Keys
            .OrderBy(header => header)
            .Select(header => header));
}

private void SendWebRequest()
{
    var webRequest = UnityWebRequestAssetBundle.GetAssetBundle($"{_remotePath}{Key");
    
    webRequest.SetRequestHeader("Authorization", 
        $"AWS4-HMAC-SHA256 Credential={AccessKey}/{_date}/{Region}/{AwsService}/{Aws4Request}, " +
        $"SignedHeaders={_signedHeaders}, " +
        $"Signature={CreateSignature(Key)}");
    webRequest.SetRequestHeader("x-amz-content-sha256", Hex(string.Empty));
    webRequest.SetRequestHeader("x-amz-date", _dateTime);
    
    _requestOperation = webRequest.SendWebRequest();
}

private string CreateSignature(string key)
{
    var signingKey = CreateSigningKey();
    var canonicalRequest = CreateCanonicalRequest(key);
    var stringToSign = CreateStringToSign(canonicalRequest);

    var hash = HmacSha256(signingKey, stringToSign);
    
    return BitConverter.ToString(hash).Replace("-", "").ToLower();
}

private byte[] CreateSigningKey()
{
    var dateKey = HmacSha256($"AWS4{SecretKey}", _date);
    var dateRegionKey = HmacSha256(dateKey, Region);
    var dateRegionServiceKey = HmacSha256(dateRegionKey, AwsService);
    return HmacSha256(dateRegionServiceKey, Aws4Request);
}

private string CreateStringToSign(string canonicalRequest)
{
    const string algorithm = "AWS4-HMAC-SHA256";
    var credentialScope = $"{_date}/{Region}/{AwsService}/{Aws4Request}";
    var hashedCanonicalRequest = Hex(canonicalRequest);

    var stringToSign = $"{algorithm}{NewLine}" +
                       $"{_dateTime}{NewLine}" +
                       $"{credentialScope}{NewLine}" +
                       $"{hashedCanonicalRequest}";

    return stringToSign;
}

private string CreateCanonicalRequest(string key)
{
    const string httpMethod = UnityWebRequest.kHttpVerbGET;
    var canonicalUri = $"/{key}";
    var canonicalQueryString = string.Empty;
    
    var canonicalHeaders = string.Join(NewLine,
        _canonicalHeaderMap
            .OrderBy(pair => pair.Key)
            .Select(pair => $"{pair.Key}:{pair.Value}")) + NewLine;
    
    var canonicalRequest = $"{httpMethod}{NewLine}" +
                           $"{canonicalUri}{NewLine}" +
                           $"{canonicalQueryString}{NewLine}" +
                           $"{canonicalHeaders}{NewLine}" +
                           $"{_signedHeaders}{NewLine}" +
                           $"{_hashedPayload}";

    return canonicalRequest;
}

private static byte[] HmacSha256(byte[] key, string value)
{
    byte[] hash;
    var hashedValue = Encoding.UTF8.GetBytes(value);
    using (var hmac = new HMACSHA256(key))
    {
        hash = hmac.ComputeHash(hashedValue);
    }

    return hash;
}

private static byte[] HmacSha256(string key, string value)
{
    byte[] hash;
    var hashedKey = Encoding.UTF8.GetBytes(key);
    var hashedValue = Encoding.UTF8.GetBytes(value);
    using (var hmac = new HMACSHA256(hashedKey))
    {
        hash = hmac.ComputeHash(hashedValue);
    }

    return hash;
}

private static string Hex(string value)
{
    var hashProvider = new SHA256CryptoServiceProvider();
    return string.Join(string.Empty,
        hashProvider
            .ComputeHash(Encoding.UTF8.GetBytes(value))
            .Select(x => $"{x:x2}"));
}

自分のコードが合っているのかを調べる

AWSAccessKeyId:AKIAIOSFODNN7EXAMPLE
AWSSecretAccessKey:wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
date:20130524T000000Z
bucket name:examplebucket
Region: us-east-1
Key: test.txt

で計算したときに、

Canonical Request

GET
/test.txt

host:examplebucket.s3.amazonaws.com
range:bytes=0-9
x-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
x-amz-date:20130524T000000Z

host;range;x-amz-content-sha256;x-amz-date
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855  

StringToSign

AWS4-HMAC-SHA256
20130524T000000Z
20130524/us-east-1/s3/aws4_request
7344ae5b7ee6c3e7e6b0fe0640412a37625d1fbfff95c48bbb2dc43964946972

こうなって、さらに、

Signature

f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41

Authorization header

AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request,SignedHeaders=date;host;x-amz-content-sha256;x-amz-date;x-amz-storage-class,Signature=98ad721746da40c64f1a55b78f14c238d841ea1380cd77a1b5971af0ece108bd

これと一致すれば正解らしいです。

Signature Calculations for the Authorization Header_ Transferring Payload in a Single Chunk (AWS Signature Version 4) – Amazon Simple Storage Service のExampleを参考にしました。
これで全部合ってるのにデータが取れなかったら何か他のところに原因があるかもしれないので400とか404とかのステータスコード見てググって戦いましょう……。

参考

公式

Amazon S3 REST API Introduction – Amazon Simple Storage Service

Authenticating Requests: Using the Authorization Header (AWS Signature Version 4)

その他

PHPのコードが書かれていて参考になりました。
少し古いので注意です。

PHP で Amazon S3 の REST API を使用 #1 _ 林檎生活100