はじめに
この記事はand factory.inc Advent Calendar 2021 4日目の記事です。
and factory iOSエンジニアのy-okuderaです!
最近はちょいちょいFlutterを触ったりもしているのですが、今回はiOSの記事を投稿します。
最近、iOSでデータを暗号化/復号する処理をSwiftで実装してみたので、実装時に調べたことなども含めてまとめてみようと思います。
そもそも暗号化ってなんだろう?
「暗号化」とは、データを何らかの鍵を使わないと意味を成さないような形に変換することです。
暗号化によって変換されたデータを「暗号文」といいます。
鍵を使ってこのプロセスを逆に行い、データを元の形(平文)に戻すことを「復号」といいます。
一口に「暗号化」と言っても、上の図のように一定の規則に従って、ある文字列を別の文字列に置換する換字式暗号(Substitution cipher)もあれば、もっと複雑な数学的アルゴリズムまで様々なものがあります。
セキュリティの観点から考えると、暗号文の解読が困難であればあるほど良いのですが、一方で、複雑すぎるアルゴリズムは実装が困難になったり、処理に時間がかかりすぎたり、キーのサイズが膨大になって管理が困難になってしまいます。そうなると、アプリ開発では現実的ではありません。
暗号化の強度と扱いやすさはトレードオフとなるので、うまくバランスを取る必要があります。
悪意のある人がデータを利用する可能性がある期間
暗号化の強度と扱いやすさのバランスを考えるにあたって、どの程度の強度が必要になるか検討する必要がありますが、実用的には、悪意のある人がデータを利用する可能性がある期間だけデータを保護するのに十分な強度の暗号化が必要です。
悪意のある人がデータを利用する可能性がある期間というのがいつかですが、
- データが保存されている期間
- ネットワークを介してデータが転送されている期間
- データが利用されている期間
あたりが考えられそうです。
それぞれの期間のデータの不正利用ですが、
- データが保存されている期間
- サーバーのDatabaseを攻撃
- クライアントのLocal Storageを攻撃
- ネットワークを介してデータが転送されている期間
- 通信中のデータを盗み取ったり改竄したりするAPI攻撃
- データが利用されている期間
- 画面に表示されているデータを不正に転用(著作物のスクリーンショットなど)
といったように多岐にわたるので、サーバー・クライアント双方で暗号化などセキュリティの考慮が必要になります。
アプリ開発においては、大抵の場合APIリクエストでサーバーからデータを取得しますが、ローカルストレージには保存しないのであれば、サーバーサイドのセキュリティと通信のセキュリティが担保されていれば十分です。
クライアントのローカルストレージにもデータを保存をするのであれば、保持可能な期間はクライアントのローカルストレージにも暗号化などでセキュリティを担保する必要があるでしょう。
暗号化キーの生成と管理について
データを暗号化したり復号するには、特定のキーが必要になりますが、システム上で暗号化の対象となるデータがすべて同じキーで保存されていた場合、万が一そのキーが盗まれてしまったり、解析されてしまったときにすべての暗号化データを復号されて中身の情報を盗られてしまうリスクがあります。
そのため、セキュリティの観点から考えると、暗号化する対象のデータ毎にキーは別々のものであることが重要です。
iOSアプリ開発においても、ダウンロードした画像やPDFなどのデータを暗号化して保存する場合、1つのキーですべてを管理してしまうのではなくて、
対象のデータ毎に個別のキーが生成されるように工夫をする必要があります。
そうしないと、万が一、キー情報が漏洩したあるいは特定されてしまったときにすべてのダウンロードファイル情報をごっそり盗られてしまいます。
また、生成したキーの管理方法についても、十分に気をつける必要があります。
せっかくそれぞれのデータ毎に個別のキーを生成しても、それがセキュアでない場所に保管されていたら、キー自体を漏洩してしまうリスクもあります。
いろいろとセキュリティについて考えてきましたが、ひとつ、大前提として、iOSアプリは、サンドボックス化されていて、通常は保存されたファイルにアクセスされないようになっています。jailbreakされたデバイスで、不正なアプリケーションを使用しているとかでなければ、基本的にはファイルへ直接アクセスすることはできません。
ただ、そのような前提がある中でも、アプリの実装側でセキュリティを担保すること自体は重要なので、何もセキュリティの考慮をしなくてもいいというわけではないと思います。
これらのことも踏まえて、暗号化/復号処理を実装してみます。
開発環境
ここからは、iOSで暗号化/復号処理を実装していきます。
今回は、以下の環境で開発を進めます。
name | version |
---|---|
Xcode | 13.0 |
Deployment Target | iOS 12.0 ~ |
暗号化/復号処理の実装方法について
iOSでデータを暗号化する処理を実装する方法は、以下の3パターンが考えられます。
- C言語で書かれたAppleのオープンソースのCommon Cryptoライブラリを使用する
- CryptoKit Frameworkを使用する (iOS 13以降で利用可)
- CryptoSwiftなどのOSSを使用する
今回は、Deployment TargetにiOS12も含んだプロジェクトで開発をしたので、Common Cryptoライブラリを使用してみました!
今回実装する暗号化/復号処理の前提
- 暗号化キー生成に利用するpasswordは、キーチェーンに保存します
- 暗号化キー生成時に付与するsaltは、暗号化したファイルのpathやinitialization vector(IV)といっしょにセキュアなDatabaseに保存します
暗号化処理の流れ
暗号化処理は以下のような処理をしていきます。
- passwordをキーチェーンから取得。無ければ、ランダム生成してキーチェーンに保存
- CCKeyDerivationPBKDFでpasswordをハッシュ化して暗号化キーを生成(ハッシュ関数はHMAC-SHA256)
- CCCryptで暗号化処理を実行する
- 暗号化済みデータを指定pathのファイルに書き込む
- file_path, salt, ivをファイルのidなどとあわせて、セキュアなDatabaseに保存する
復号処理の流れ
暗号化とほぼ同様ですが、復号処理は以下のような処理をしていきます。
- passwordをキーチェーンから取得
- idなどを指定して、databaseからfile_path, salt, ivを取得
- file_pathから暗号化データを取得
- CCKeyDerivationPBKDFでpasswordをハッシュ化して復号キーを生成(ハッシュ関数はHMAC-SHA256)
- CCCryptで復号処理を実行する
- 復号データを利用する
暗号化/復号処理のソースコード
書くのが疲れてきたので、 Common Cryptoライブラリの各処理の詳細については、既にたくさん情報があるかと思いますので省きますが、ソースコードは載せておきます
暗号化/復号処理のソースコードはこちらです。
import CommonCrypto
public enum AES {
public enum CryptoOperationType: Equatable {
case decrypt
case encrypt
}
public enum Error: Swift.Error {
case secRandomCopyBytesFailed(status: Int)
case keyGenerationFailed(status: Int)
case encodingFailed
case invalidKeyLength
case bufferIsEmpty
case cryptoFailed(status: CCCryptorStatus)
}
public static func generateRandomIv() throws -> Data {
return try generateRandom(byteLength: kCCBlockSizeAES128)
}
public static func generateRandomSalt() throws -> Data {
let saltSize = 20
return try generateRandom(byteLength: saltSize)
}
/// 暗号化
/// - Parameters:
/// - plainData: データ
/// - salt: ソルト価
/// - iv: 初期化ベクトル
/// - password: パスワード
/// - Returns: 暗号化されたデータ
public static func encrypt(plainData: Data, salt: Data, iv: Data, password: String) throws -> Data {
return try crypto(
operation: .encrypt,
sourceData: plainData,
password: password,
salt: salt,
iv: iv
)
}
/// 復号
/// - Parameters:
/// - encryptedData: 暗号化されたデータ
/// - salt: ソルト価
/// - iv: 初期化ベクトル
/// - password: パスワード
/// - Returns: 復号されたデータ
public static func decrypt(encryptedData: Data, salt: Data, iv: Data, password: String) throws -> Data {
return try crypto(
operation: .decrypt,
sourceData: encryptedData,
password: password,
salt: salt,
iv: iv
)
}
/// キー生成
/// - Parameters:
/// - password: パスワード
/// - salt: ソルト価
/// - Returns: キーデータ
private static func createKey(password: String, salt: Data) throws -> Data {
let length = kCCKeySizeAES256
var derivationStatus = Int32(0)
var derivedBytes = [UInt8](repeating: 0, count: length)
guard let passwordData = password.data(using: .utf8) else {
assertionFailure("Encode passwordData failed")
throw Self.Error.encodingFailed
}
let passwordBytes = try passwordData.withUnsafeBytes { rawBufferPointer -> UnsafePointer<Int8> in
guard let passwordRawBytes = rawBufferPointer.baseAddress else {
assertionFailure("passwordBuffer is empty")
throw Self.Error.bufferIsEmpty
}
return passwordRawBytes.assumingMemoryBound(to: Int8.self)
}
let saltBytes = try salt.withUnsafeBytes { rawBufferPointer -> UnsafePointer<UInt8> in
guard let saltRawBytes = rawBufferPointer.baseAddress else {
assertionFailure("saltBuffer is empty")
throw Self.Error.bufferIsEmpty
}
return saltRawBytes.assumingMemoryBound(to: UInt8.self)
}
derivationStatus = CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2), // algorithm
passwordBytes, // password
passwordData.count, // passwordLen
saltBytes, // salt
salt.count, // saltLen
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), // prf
10000, // rounds
&derivedBytes, // derivedKey
length // derivedKeyLen
)
guard derivationStatus == errSecSuccess else {
assertionFailure("Key generation failed")
throw Self.Error.keyGenerationFailed(status: Int(derivationStatus))
}
return Data(bytes: &derivedBytes, count: length)
}
/// ランダムデータ生成
private static func generateRandom(byteLength: Int) throws -> Data {
var outputData = Data(count: byteLength)
let outputDataBytes = outputData.withUnsafeMutableBytes { mutableRawBufferPointer -> UnsafeMutablePointer<UInt8>? in
let outputDataBufferPointer = mutableRawBufferPointer.bindMemory(to: UInt8.self)
return outputDataBufferPointer.baseAddress
}
guard let outputDataBytes = outputDataBytes else {
assertionFailure("outputDataBytes is empty")
throw Self.Error.bufferIsEmpty
}
let status = SecRandomCopyBytes(
kSecRandomDefault, // rnd
byteLength, // count
outputDataBytes // bytes
)
guard status == errSecSuccess else {
assertionFailure("SecRandomCopyBytes failed byteLength: \(byteLength)")
throw Self.Error.secRandomCopyBytesFailed(status: Int(status))
}
return outputData
}
/// 暗号化 / 復号処理
/// - Parameters:
/// - operation: 暗号化 or 復号
/// - sourceData: 操作対象のデータ
/// - password: パスワード
/// - salt: ソルト価
/// - iv: 初期化ベクトル
/// - Returns: 処理結果のデータ
private static func crypto(operation: CryptoOperationType, sourceData: Data, password: String, salt: Data, iv: Data) throws -> Data {
print("start...", operation == .decrypt ? "復号処理" : "暗号化処理")
print("iv", iv)
print("sourceData size", sourceData.count)
let commonKeyData = try createKey(password: password, salt: salt)
print("commonKeyData.count", commonKeyData.count, "kCCKeySizeAES256", kCCKeySizeAES256)
guard commonKeyData.count == kCCKeySizeAES256 else {
assertionFailure("CommonKey invalid size")
throw Self.Error.invalidKeyLength
}
let outputLength: size_t = {
switch operation {
case .decrypt:
// 復号後のデータのサイズを計算
return size_t(sourceData.count + kCCBlockSizeAES128)
case .encrypt:
// 暗号化後のデータのサイズを計算
return size_t(Int(ceil(Double(sourceData.count / kCCBlockSizeAES128)) + 1.0) * kCCBlockSizeAES128)
}
}()
var outputData = Data(count: outputLength)
var numBytesEncrypted: size_t = 0
let outputDataBytes = try outputData.withUnsafeMutableBytes { mutableRawBufferPointer -> UnsafeMutablePointer<UInt8> in
print("outputMutableRawBufferPointer", mutableRawBufferPointer)
let outputBufferPointer = mutableRawBufferPointer.bindMemory(to: UInt8.self)
guard let outputDataBytes = outputBufferPointer.baseAddress else {
assertionFailure("outputBuffer is empty")
throw Self.Error.bufferIsEmpty
}
return outputDataBytes
}
let ivBytes = try iv.withUnsafeBytes { rawBufferPointer -> UnsafePointer<UInt8> in
print("ivMutableRawBufferPointer", rawBufferPointer)
let ivBufferPointer = rawBufferPointer.bindMemory(to: UInt8.self)
guard let ivBytes = ivBufferPointer.baseAddress else {
assertionFailure("ivBuffer is empty")
throw Self.Error.bufferIsEmpty
}
return ivBytes
}
let sourceDataBytes = try sourceData.withUnsafeBytes { rawBufferPointer -> UnsafePointer<UInt8> in
print("sourceDataMutableRawBufferPointer", rawBufferPointer)
let sourceDataBufferPointer = rawBufferPointer.bindMemory(to: UInt8.self)
guard let sourceDataBytes = sourceDataBufferPointer.baseAddress else {
assertionFailure("sourceDataBuffer is empty")
throw Self.Error.bufferIsEmpty
}
return sourceDataBytes
}
let commonKeyDataBytes = try commonKeyData.withUnsafeBytes { rawBufferPointer -> UnsafePointer<UInt8> in
print("commonKeyMutableRawBufferPointer", rawBufferPointer)
let commonKeyBufferPointer = rawBufferPointer.bindMemory(to: UInt8.self)
guard let commonKeyDataBytes = commonKeyBufferPointer.baseAddress else {
assertionFailure("commonKeyBuffer is empty")
throw Self.Error.bufferIsEmpty
}
return commonKeyDataBytes
}
// 暗号化 / 復号処理
let cryptStatus = CCCrypt(
CCOperation(operation == .decrypt ? kCCDecrypt : kCCEncrypt), // op
CCAlgorithm(kCCAlgorithmAES), // alg
CCOptions(kCCOptionPKCS7Padding), // options
commonKeyDataBytes, // key
commonKeyData.count, // keyLength
ivBytes, // iv
sourceDataBytes, // dataIn
sourceData.count, // dataInLength
outputDataBytes, // dataOut
outputLength, // dataOutAvailable
&numBytesEncrypted // dataOutMoved
)
guard cryptStatus == kCCSuccess else {
throw Self.Error.cryptoFailed(status: cryptStatus)
}
print("outputData.count", outputData.count, "outputData.prefix(numBytesEncrypted)", outputData.prefix(numBytesEncrypted))
switch operation {
case .decrypt:
// 追加されているPaddingの分は不要なため、必要なBuffer space分だけのデータを返却する
return outputData.prefix(numBytesEncrypted)
case .encrypt:
return outputData
}
}
}
ファイルサイズと暗号化/復号処理のパフォーマンスについて
暗号化処理や復号処理がどのくらい時間がかかるものなのか、簡易的なUnitTestを書いて確認をしてみました
ファイルサイズに比例して、処理時間が長くなるはずだよねっていうのを確認するだけの目的で実施したのでサンプルデータが少ないですが、以下の4ファイルでパフォーマンステストをしました。
暗号化処理のパフォーマンス計測の対象範囲は、passwordのハッシュ化から暗号化処理までです。
また、復号処理のパフォーマンス計測の対象範囲も暗号化処理の範囲と同様に、passwordのハッシュ化から復号処理までです。
(どちらも、キーチェーンへのアクセスや、ファイルやDatabaseへの書き込み処理は含んでいません。)
ファイル | 586KB.png | 1.5MB.png | 5.6MB.png | 13.2MB.png |
---|---|---|---|---|
Encrypt average Time | 0.088 sec | 0.206 sec | 1.016 sec | 3.377 sec |
Decrypt average Time | 0.007 sec | 0.007 sec | 0.010 sec | 0.022 sec |
ファイルサイズに比例して、暗号化処理も復号処理も処理時間が長くなっているのは想定通りでした!
ただ、暗号化処理と復号処理の速度に差があるのが意外でした
なんとなく、暗号化と復号は同じくらいの処理速度なんだろうなーと思っていたのですが...
複数回パフォーマンス計測してみても、上記の結果から大きな誤差はありませんでした!
今回書いたパフォーマンステストのコードはこちらです。
import XCTest
@testable import DataSource
final class DataSourceTests: XCTestCase {
let testPassword = "Kx4gx-jr3AOCLLAhcmdjoDKSe_AB7GhAd7JSf9HmQDq0zTA0Ny-yXpn4_X9cRpDJ"
// MARK: - Encrypt performance testing
func testPerformance_encrypt_586KB() throws {
let png586KB = UIImage(named: "586KB")!
let salt = try! AES.generateRandomSalt()
let iv = try! AES.generateRandomIv()
self.measure {
// Put the code you want to measure the time of here.
_ = try! AES.encrypt(plainData: png586KB.pngData()!, salt: salt, iv: iv, password: testPassword)
}
}
func testPerformance_encrypt_1_5MB() throws {
let png1_5MB = UIImage(named: "1.5MB")!
let salt = try! AES.generateRandomSalt()
let iv = try! AES.generateRandomIv()
self.measure {
// Put the code you want to measure the time of here.
_ = try! AES.encrypt(plainData: png1_5MB.pngData()!, salt: salt, iv: iv, password: testPassword)
}
}
func testPerformance_encrypt_5_6MB() throws {
let png5_6MB = UIImage(named: "5.6MB")!
let salt = try! AES.generateRandomSalt()
let iv = try! AES.generateRandomIv()
self.measure {
// Put the code you want to measure the time of here.
_ = try! AES.encrypt(plainData: png5_6MB.pngData()!, salt: salt, iv: iv, password: testPassword)
}
}
func testPerformance_encrypt_13_2MB() throws {
let png13_2MB = UIImage(named: "13.2MB")!
let salt = try! AES.generateRandomSalt()
let iv = try! AES.generateRandomIv()
self.measure {
// Put the code you want to measure the time of here.
_ = try! AES.encrypt(plainData: png13_2MB.pngData()!, salt: salt, iv: iv, password: testPassword)
}
}
// MARK: - Decrypt performance testing
func testPerformance_decrypt_586KB() throws {
let png586KB = UIImage(named: "586KB")!
let salt = try! AES.generateRandomSalt()
let iv = try! AES.generateRandomIv()
let encryptedData = try! AES.encrypt(plainData: png586KB.pngData()!, salt: salt, iv: iv, password: testPassword)
self.measure {
// Put the code you want to measure the time of here.
_ = try! AES.decrypt(encryptedData: encryptedData, salt: salt, iv: iv, password: testPassword)
}
}
func testPerformance_decrypt_1_5MB() throws {
let png1_5MB = UIImage(named: "1.5MB")!
let salt = try! AES.generateRandomSalt()
let iv = try! AES.generateRandomIv()
let encryptedData = try! AES.encrypt(plainData: png1_5MB.pngData()!, salt: salt, iv: iv, password: testPassword)
self.measure {
// Put the code you want to measure the time of here.
_ = try! AES.decrypt(encryptedData: encryptedData, salt: salt, iv: iv, password: testPassword)
}
}
func testPerformance_decrypt_5_6MB() throws {
let png5_6MB = UIImage(named: "5.6MB")!
let salt = try! AES.generateRandomSalt()
let iv = try! AES.generateRandomIv()
let encryptedData = try! AES.encrypt(plainData: png5_6MB.pngData()!, salt: salt, iv: iv, password: testPassword)
self.measure {
// Put the code you want to measure the time of here.
_ = try! AES.decrypt(encryptedData: encryptedData, salt: salt, iv: iv, password: testPassword)
}
}
func testPerformance_decrypt_13_2MB() throws {
let png13_2MB = UIImage(named: "13.2MB")!
let salt = try! AES.generateRandomSalt()
let iv = try! AES.generateRandomIv()
let encryptedData = try! AES.encrypt(plainData: png13_2MB.pngData()!, salt: salt, iv: iv, password: testPassword)
self.measure {
// Put the code you want to measure the time of here.
_ = try! AES.decrypt(encryptedData: encryptedData, salt: salt, iv: iv, password: testPassword)
}
}
}
暗号化されているかどうかのチェックについて
ここからは、おまけのような話です
ファイルから取得したデータが暗号化されているかあるいは平文かですが、実際に使ってみないと分からないのは少し不便です。
例えば、暗号化されている画像データがあったとして、それを開いて画面に描画しようと思ったときに初めて画像形式のデータではないので、表示できません。ということがわかるよりは、バイナリデータの段階で判断ができた方が都合が良かったりします。
平文のデータと暗号化されたデータをバイナリエディタで開いてみてみましょう。バイナリエディタとは、バイナリファイルを16進数のバイト列として表示できるエディタです。
今回は、MacのHex Fiendというバイナリエディタを使用してみます。
暗号化前のデータ | 暗号化後のデータ |
---|---|
暗号化前のデータの先頭の8バイト(選択状態になっているところ)を見ると、.PNGと示されているのがわかるかと思います。
この8バイトがPNGであることを示すFile signatureになっています。
PNGファイルであれば、先頭8バイトは常に89 50 4E 47 0D 0A 1A 0A
になります!
仮に、259KB.png
を259KB.pdf
など別の拡張子にリネームしても、中身が書き換えられない限り先頭8バイトは89 50 4E 47 0D 0A 1A 0A
になるので、暗号化されているかどうかという判断だけでなく、これ、本当にpngファイルか?といったことの判断材料にもなるかと思います。
Swiftで先頭8バイトのHexを確認する場合は、以下のような実装になります。
let hex = targetData.prefix(8).map { String(format: "%.2hhx", $0) }.joined()
print("Hex: \(hex)")
先頭8バイトのFile signatureの部分以降も、範囲毎に何を表しているかPNGの仕様として決まっているので、詳しく知りたい場合はファイルフォーマットについて調べてみましょう
また、今回見てみた「PNG」以外の拡張子についても、それぞれのファイル形式毎に決まったFile signatureがあります。
詳しくは、filesignaturesなどで調べることができます!
さいごに
iOSアプリ開発をしている中で、暗号化してローカルにデータを残しておかなければいけないケースは限られると思いますが、今回、普段あまり調べないようなことをいろいろ調査できて面白かったです
Advent Calendar まだまだ続きますので、次の記事もお楽しみに!