Azure Functions でファイルをAES暗号化しながらコピーする


Azure Functions でファイルをAES暗号化しながらコピーする

前回投稿から超久しぶりな投稿です。
.NET Core 3.0も出て、ASP.NET Core 2.0系の記事も賞味期限切れなので、今回は全く別の事を書きます。
いくつかの業務で必要になりそうだったので、検証してみた結果です。
タイトル見て実装まで思いついてしまうような実力を持った方は読む必要が無いような記事です。

なんでそんなことする必要がある?

Azure Blob Storageで暗号化したいなら Storage Service Encryption を使えばいいじゃん!と思いますが、この機能がリリースされる前から自前で暗号化してファイルを置いておいた為、移行するコストが高すぎる、みたいなケースもあります。

その場合、当然どこかでファイルを暗号化する必要があるのですが、ローカル環境からAzureにファイル転送する場合には、転送前に暗号化したファイルを送れば済む話です。
しかし、Azure Blobにから元ファイルを別のBlobに移動する際に暗号化する、なんて要件があったりする場合もあります。
間に暗号化処理が挟まるとなると、 AzCopy を使えば済む話でもありません。

どこで動かそう?

例えばAzure Functionsでテンポラリ領域にダウンロードしてファイルを暗号化し、暗号化済みファイルを転送する、なんてプロセスを想定した場合、Consumption planのAzure Functionsでのテンポラリ領域は500MB程度なので、最大でも250MB未満のファイルの転送しかできないことになります。
App Service planの場合はプランの内容に応じて話は変わりますが、共用であるテンポラリ領域を無尽蔵に使ったりすると、肝心のWebアプリケーション側にも影響を出しかねず、試してみたいとは思えません。
となると、豊富なローカルディスクディスク容量が使えるAzure Batchや、VMを普通に立てる等の対策を考えるかもしれませんが、そもそも常時動くわけでもない、(あえて言いますが)たかがファイルコピー如きに専用のVMを用意したくありません。

Azure Functionsで動かしたい

一周回って、Consumption planのAzure Functionsに戻ってきました。
なら、テンポラリやメモリを無駄遣いしないAzure Functionsのコードを書けばいいじゃない、という結論です。
すみません。前置きが長すぎますね。

C#コード

この例ではAES 128bit CBCモードで暗号化します。
要は「Blobも当然Streamで扱えるんだから、入力と出力をStreamにしてCryptoStreamを通せばいいや」という単純な話です。
実のところ、別にAzure Blobに限らずStreamさえ取れれば、どんなデータ転送でも使ます。
だからタイトルを見て実装まで思いつく人も多かろうと思います。(結局Streamの話になってしまうので)

    public static class BlobEncryptionCopy
    {
        [FunctionName("BlobEncryptionCopy")]
        public static void Run([QueueTrigger("copyenc")] string blobPath,
            [Blob("input/{queueTrigger}", FileAccess.Read, Connection = "AzureWebJobsInputStorage")] Stream inputBlobStream,
            [Blob("output/{queueTrigger}", FileAccess.Write, Connection = "AzureWebJobsOutputStorage")] Stream outputBlobStream,
            ILogger log)
        {
            log.LogInformation($"Start C# Queue trigger function processed: {blobPath}");
            var encryptionKey = Convert.FromBase64String(Environment.GetEnvironmentVariable("AesKeyBase64String"));
            EncryptStream(inputBlobStream, outputBlobStream, encryptionKey);
            log.LogInformation($"End C# Queue trigger function processed: {blobPath}");
        }

        private const int AesBlockByteSize = 16;
        private const int AesBlockBitSize = 128;
        private const int BufferSize = 4194304;

        private static void EncryptStream(Stream inputStream, Stream outputStream, byte[] encryptKey)
        {
            if (encryptKey == null)
                throw new ArgumentNullException(nameof(encryptKey));
            if (encryptKey.Length < AesBlockByteSize)
                throw new ArgumentException("AES 128bit encryption key is too short.", nameof(encryptKey));

            // AES暗号化キーの長さを強制的に16バイトで切る
            Array.Resize<byte>(ref encryptKey, AesBlockByteSize);

            using (var aes = new AesManaged())
            {
                // AES暗号化の設定
                aes.BlockSize = AesBlockBitSize;
                aes.KeySize = AesBlockBitSize;
                aes.Mode = CipherMode.CBC;
                aes.Padding = PaddingMode.PKCS7;
                aes.Key = encryptKey;

                // ランダムなIVを生成
                var iv = new byte[AesBlockByteSize];
                using (var rng = new RNGCryptoServiceProvider())
                {
                    rng.GetNonZeroBytes(iv);
                    aes.IV = iv;
                }

                using (var encryptor = aes.CreateEncryptor())
                using (var cs = new CryptoStream(outputStream, encryptor, CryptoStreamMode.Write))
                {
                    // 復号用にIVの値をファイル先頭に出力する
                    outputStream.Write(iv, 0, AesBlockByteSize);

                    int len;
                    var buffer = new byte[BufferSize];
                    while ((len = inputStream.Read(buffer, 0, BufferSize)) > 0)
                    {
                        cs.Write(buffer, 0, len);
                    }
                    cs.FlushFinalBlock();
                }
            }
        }

コアとなる部分を抜き出して書いており、エラー処理は削っているので適宜加えてください。
下手にMicrosoft.Azure.Storage.Blobのnugetパッケージを参照していると動作しなくなるのもいつもの事なので、バインドのみで済ませています。

コード説明

見ての通り、Queueトリガーで、Queueに書かれたBlobパスを入力元から出力先のBlobにAES暗号化しながらコピーします。
入出力のBlob StorageもFunctionsにバインドして、最初からStreamを受け取る形にしています。
最初からStreamを受け取っているので、出力用のStreamをCryptoStreamでラップして、暗号化したデータを転送します。

入出力のBlob StorageはBlobAttributeでバインドを設定し、接続情報(Connection)はAppServiceのアプリケーション設定から読み込む形です。(ここではあえて省略せずに [AzureWebJobs(In|Out)putStorage] と記載していますが、 "AzureWebJobs" の部分は省略してもアプリケーション設定を取得してくれます)

同様に暗号化キーもアプリケーション設定にBase64で指定しているので、 "Environment.GetEnvironmentVariable" メソッドで取得しています。

また、IV (Initial Vector)については、今回はランダム生成してファイルの先頭に書き込む形をとっています。

動作確認

これで数KB、200MB、800MB、4GBと4種類のファイルを暗号化しながら転送したところ、4GBの物を除いて転送が成功しました。
4GBの物はタイムアウトで失敗していたので、タイムアウト時間を延長したところ、正常に転送できました。
host.jsonに追記して、現状Consumption planの最大値である10分になるように指定しました。

host.json
{
  "version": "2.0",
  "functionTimeout": "00:10:00"
}

勿論、ダウンロードして復号化すれば、暗号化前のファイルと同じになります。

終わりに

Consumption planでも大き目なファイルの暗号化転送ができました。
ただし、あくまで検証レベルの内容なので、その点にはご留意ください。

やはり、Streamなんだから当然だろという突っ込みが入りそうですね。
普通にできそうなことを実際にやってみて、やっぱりできたという記録だと思っていただければ幸いです。