iOS 上で巨大なファイルを復号する方法

エキサイトで刺身にたんぽぽを添える仕事をしているエンジニアの坂田です。
[cocorus](https://cocorus.excite.co.jp/) と言うアプリの [iOS版](https://apps.apple.com/jp/app/id505930723) を開発したりしてます。

突然ですが 1GB 超の巨大なファイルの暗号化/復号を iOS (iPhone 実機)上で行おうとして OS 側からアプリを落とされる経験をされたことはありますか?
自分はあります。
当記事では巨大なファイルの復号を iOS 上で行うための方法を紹介します。
# 想定
- マシン  
iPhone (iOS 14等)
- 言語  
Swift 5
- 想定暗号  
aes-128-cbc


# 一般的な復号コード
おそらくセオリー通り実装すると下記のようなコードになるかと思います。

```swift
import CommonCrypto

class CommonCryptoUtility {
    /// 中略

    /// 復号処理
    ///
    /// - Parameters:
    ///   - encryptedData: 暗号化されたData型リソース
    ///   - keyData: 暗号キーバイナリ
    ///   - initializationVectorData: 初期化ベクターバイナリ
    ///   - isPadding: PKCS#7 パディングの有無
    /// - Returns: 復号されたData型リソース
    /// - Throws: CommonCryptoUtilityError のいずれか
    func decrypt(
        encryptedData: Data,
        keyData: Data,
        initializationVectorData: Data,
        isPadding: Bool = true
    ) throws -> Data {
        // 復号化後のメモリーサイズ
        let decryptBufferLength: Int = encryptedData.count
        // 復号化後のメモリを確保
        var decryptBufferData = Data(count: decryptBufferLength)
        // デコード処理バイト数
        var executeBytesCount: size_t = 0

        // デコード
        let options: UInt32 = isPadding ? CCOptions(kCCOptionPKCS7Padding) : 0
        do {
            try keyData.withUnsafeBytes { (keyBytes: UnsafeRawBufferPointer) in
                try initializationVectorData.withUnsafeBytes { (ivBytes: UnsafeRawBufferPointer) in
                    try encryptedData.withUnsafeBytes { (encryptedDataPointer: UnsafeRawBufferPointer) in
                        try decryptBufferData.withUnsafeMutableBytes { decryptBufferDataPointer in
                            guard let keyBytesBaseAddress = keyBytes.baseAddress,
                                let ivBytesBaseAddress = ivBytes.baseAddress,
                                let encryptedDataBytesBaseAddress = encryptedDataPointer.baseAddress,
                                let decryptBufferDataBytesBaseAddress = decryptBufferDataPointer.baseAddress
                            else {
                                throw CommonCryptoUtilityError.decryptFailedWhenConvertBaseAddress
                            }

                            let cryptStatus = CCCrypt(
                                CCOperation(kCCDecrypt),
                                CCAlgorithm(kCCAlgorithmAES),
                                options,
                                keyBytesBaseAddress,
                                keyData.count,
                                ivBytesBaseAddress,
                                encryptedDataBytesBaseAddress,
                                encryptedData.count,
                                decryptBufferDataBytesBaseAddress,
                                decryptBufferLength,
                                &executeBytesCount
                            )

                            guard cryptStatus == kCCSuccess else {
                                throw CommonCryptoUtilityError.decryptFailed
                            }
                        }
                    }
                }
            }
        } catch {
            throw CommonCryptoUtilityError.decryptFailedWhenConvertUnsafePointer
        }

        return decryptBufferData
    }

    // 中略
}
```
ただ、上記のような実装だと暗号化データをすべて渡すので、このままだとメモリを食いつぶして iOS にアプリを落とされてしまいます。
なので、この関数をラップして分割して復号したくなるのが心情です。
幸いなことに、`AES 128 CBC` と言う暗号モードは16バイト単位での暗号化を行っており、暗号ブロックの一つ前のブロックがそのまま今のブロックの初期化ベクターになるとのことです。
つまり、前の16バイトさえわかればどのブロックからでも複合できるということになります。

# 指定ブロックを取り出すための復号処理のラッパー
上記を踏まえてブロック毎に復号するように上記メソッドを呼び出すラッパーを実装すると以下のようになります。

```swift

import CommonCrypto

class CommonCryptoUtility {
    // 中略

    let blockSizeAES128: UInt64 = UInt64(kCCBlockSizeAES128)

    /// 指定された範囲を復号する
    /// - 2 GiB 以内のファイルのみ扱うものとする(32Bit)
    /// - 8 EiB 以内のファイルのみ扱うものとする(64Bit)
    /// - Parameters:
    ///   - sourceURL: ファイルURL
    ///   - keyData: 暗号キーデータ
    ///   - firstInitVectorData: 初期化ベクターデータ
    ///   - requiredSliceOffset: 切り出すオフセット
    ///   - sliceLength: 切り出すバイト数
    /// - Returns: 複合したバイナリデータ
    func decrypt(
        readFrom sourceURL: URL,
        keyData: Data,
        firstInitVectorData: Data,
        offset requiredSliceOffset: UInt64,
        sliceLength: Int
    ) throws -> Data {
        let fileHandler = try FileHandle(forReadingFrom: sourceURL)
        defer {
            fileHandler.closeFile()
        }
        guard let sourceFileSize = MediaManager.getFileSize(from: sourceURL) else {
            throw CommonCryptoUtilityError.destinationStreamOpenFailed
        }
        guard requiredSliceOffset + UInt64(sliceLength) <= sourceFileSize else {
            throw CommonCryptoUtilityError.requestSizeGreaterThanSource
        }
        guard 0 < sliceLength else {
            throw CommonCryptoUtilityError.lengthLessThanOne
        }

        let alignedOffset4decrypt: UInt64 = requiredSliceOffset / blockSizeAES128 * blockSizeAES128
        let heading: Int = Int(requiredSliceOffset - alignedOffset4decrypt)
        let totalDecryptBlockAmount: Int = (sliceLength + heading) / kCCBlockSizeAES128 + 1
        let totalDecryptBlockLength: Int = totalDecryptBlockAmount * kCCBlockSizeAES128
        let dropLength: Int = totalDecryptBlockLength - (sliceLength + heading)

        // iv設定
        let initVectorData: Data
        if alignedOffset4decrypt == 0 {
            initVectorData = firstInitVectorData
        } else {
            fileHandler.seek(toFileOffset: alignedOffset4decrypt - blockSizeAES128)
            initVectorData = fileHandler.readData(ofLength: kCCBlockSizeAES128)
        }
        fileHandler.seek(toFileOffset: UInt64(alignedOffset4decrypt))

        // 復号
        let encryptedChunkData = fileHandler.readData(ofLength: Int(totalDecryptBlockLength))
        let decryptedChunk: Data = try decrypt(
            encryptedData: encryptedChunkData,
            keyData: keyData,
            initializationVectorData: initVectorData,
            isPadding: alignedOffset4decrypt + UInt64(totalDecryptBlockLength) == sourceFileSize
        )

        // 要求されたバイト数を切り出し
        let extractData = decryptedChunk.subdata(in: heading..<(totalDecryptBlockLength - dropLength))

        return extractData
    }

    // 中略
}
```
これでどのオフセットからでもデータを切り出すことが出来るようになりました。

# 分割して復号しつつ保存する
最終目的である、暗号化されたファイルを読んで復号し、ファイルへ書き出すように上記メソッドを更にラップすると以下のようになります。

```swift
import CommonCrypto

class CommonCryptoUtility {
    // 中略

    // 最大処理チャンクサイズ(100MiB)
    let cryptChunkSize: Int = 104857600

    /// ファイルへ復号
    ///
    /// - 暗号化ファイルを読み出し、CommonCryptoUtility.chunkSize毎に復号処理をしてテンポラリファイルへ書き出す。
    /// - Parameters:
    ///   - sourceURL: 暗号化されたファイルのURL
    ///   - destinationURL: 保存先のURL
    ///   - keyData: 暗号キーバイナリ
    ///   - firstInitVectorData: 初期化ベクターバイナリ
    /// - Throws: CommonCryptoUtilityError のいずれか
    func decrypt(
        readFrom sourceURL: URL,
        storeTo destinationURL: URL,
        keyData: Data,
        firstInitVectorData: Data
    ) throws {
        do {
            // ファイルストリームを用意
            guard let destinationData = OutputStream(url: destinationURL, append: false),
                let _sourceFileSize = MediaManager.getFileSize(from: sourceURL)
            else {
                throw CommonCryptoUtilityError.destinationStreamOpenFailed
            }
            destinationData.open()
            defer {
                // ファイルストリームを閉じる
                destinationData.close()
            }
            let sourceFileSize = Int(_sourceFileSize)
            var totalSliceSize = 0
            while totalSliceSize < sourceFileSize {
                let currentChunkSize = sourceFileSize - totalSliceSize > cryptChunkSize ? cryptChunkSize : sourceFileSize - totalSliceSize
                let decryptedChunk: Data
                do {
                    // チャンク毎に復号
                    decryptedChunk = try decrypt(
                        readFrom: sourceURL,
                        keyData: keyData,
                        firstInitVectorData: firstInitVectorData,
                        offset: UInt64(totalSliceSize),
                        sliceLength: currentChunkSize
                    )
                } catch {
                    throw error
                }
                var decryptedBytes = [UInt8](decryptedChunk)
                // 保存先に追記
                let writeResult = destinationData.write(&decryptedBytes, maxLength: decryptedBytes.count)
                guard writeResult >= 0 else {
                    throw CommonCryptoUtilityError.failedToWriteToDestination
                }
                totalSliceSize += currentChunkSize
            }
        }
    }

    // 中略
}
```
これで実用的なメモリ使用量でファイルの復号が出来るようになりました。
しかしながら、複数の巨大な暗号化ファイルを連続して復号すると、`mach_task_basic_info()` などで正しくメモリを開放しているように見えても、Instruments 上ではメモリ使用量が増え続けました。
どうやら iOS 上(.app コンテナを実行する vm 上?)ではまだ開放されていないようで、それらが開放されるまで若干の時差があるようです。
なので、連続して複合処理をする場合は数秒のディレイを入れながら復号すると成功するかと思います。

# 参考
https://github.com/backslash-f/aescryptable/blob/master/Sources/AESCryptable/AESCryptable.swift

# 最後に
良いのか悪いのか [cocorus](https://cocorus.excite.co.jp/) [iOS版](https://apps.apple.com/jp/app/id505930723) の実コードから引っ張ってきています。
ま、アプリはコンテンツが主体なのでこの程度は問題ないでしょう。
それに、これを実装するため参考にしたコードを公開している方々へ、せめてものお返しとして辛かった部分をあえて公開することにしました。

ちなみに切り出しているので上記に書かれていない enum などあるので実際使う場合は適宜置き換えるなり定義するなりしないと動きません。
コードも回りっくどいのは試行錯誤の跡だと思って大目に見てください。
（改善案はお待ちしてます！）

あ、そうそう。
エキサイト株式会社ではエンジニアを随時募集しているようです。
気軽な気持ちで応募してみても良いのではないでしょうか?
https://www.wantedly.com/companies/excite