エキサイトで刺身にたんぽぽを添える仕事をしているエンジニアの坂田です。
cocorus と言うアプリの iOS版 を開発したりしてます。
突然ですが 1GB 超の巨大なファイルの暗号化/復号を iOS (iPhone 実機)上で行おうとして OS 側からアプリを落とされる経験をされたことはありますか?
自分はあります。
当記事では巨大なファイルの復号を iOS 上で行うための方法を紹介します。
想定
- マシン
iPhone (iOS 14等) - 言語
Swift 5 - 想定暗号
aes-128-cbc
一般的な復号コード
おそらくセオリー通り実装すると下記のようなコードになるかと思います。
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バイトさえわかればどのブロックからでも複合できるということになります。
指定ブロックを取り出すための復号処理のラッパー
上記を踏まえてブロック毎に復号するように上記メソッドを呼び出すラッパーを実装すると以下のようになります。
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
}
// 中略
}
これでどのオフセットからでもデータを切り出すことが出来るようになりました。
分割して復号しつつ保存する
最終目的である、暗号化されたファイルを読んで復号し、ファイルへ書き出すように上記メソッドを更にラップすると以下のようになります。
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 上?)ではまだ開放されていないようで、それらが開放されるまで若干の時差があるようです。
なので、連続して複合処理をする場合は数秒のディレイを入れながら復号すると成功するかと思います。
参考
最後に
良いのか悪いのか cocorus iOS版 の実コードから引っ張ってきています。
ま、アプリはコンテンツが主体なのでこの程度は問題ないでしょう。
それに、これを実装するため参考にしたコードを公開している方々へ、せめてものお返しとして辛かった部分をあえて公開することにしました。
ちなみに切り出しているので上記に書かれていない enum などあるので実際使う場合は適宜置き換えるなり定義するなりしないと動きません。
コードも回りっくどいのは試行錯誤の跡だと思って大目に見てください。
(改善案はお待ちしてます!)
あ、そうそう。
エキサイト株式会社ではエンジニアを随時募集しているようです。
気軽な気持ちで応募してみても良いのではないでしょうか?
https://www.wantedly.com/companies/excite