モバイルアプリケーションは、Webアプリケーションとは異なるセキュリティ上の課題を抱えています。端末がユーザーの手元にあるため、中間者攻撃、リバースエンジニアリング、ローカルデータの漏洩といったリスクに直面します。特に金融系アプリやヘルスケアアプリなど、機密性の高いデータを扱う場合は、堅牢なセキュリティ対策が不可欠です。
本記事では、モバイルアプリセキュリティの中でも特に重要な「証明書ピンニング」と「データ暗号化」にフォーカスし、Android(Kotlin)とiOS(Swift)の両プラットフォームでの実装方法を解説します。
証明書ピンニング(Certificate Pinning)とは、アプリが通信するサーバーの証明書を事前にアプリ内に埋め込み、通信時にその証明書と一致するかを検証する技術です。これにより、中間者攻撃(MITM攻撃)を効果的に防止できます。
通常のSSL/TLS通信では、OSにインストールされたルート証明書を信頼チェーンとして検証します。しかし、攻撃者が端末に不正なルート証明書をインストールした場合、正規の通信を傍受される可能性があります。証明書ピンニングは、この脆弱性を解消するための重要な対策です。
Androidでは、OkHttpのCertificatePinnerを使った実装が一般的です。
import okhttp3.CertificatePinner
import okhttp3.OkHttpClient
class SecureApiClient {
private val certificatePinner = CertificatePinner.Builder()
.add(
"api.example.com",
"sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
)
.add(
"api.example.com",
"sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="
)
.build()
val client = OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
.build()
}
ピンの値は、サーバー証明書のSPKI(Subject Public Key Info)のSHA-256ハッシュです。バックアップピンとして複数のハッシュを登録することを強く推奨します。
iOSではURLSessionDelegateを使って証明書ピンニングを実装します。
import Foundation
import CryptoKit
class PinnedSessionDelegate: NSObject, URLSessionDelegate {
private let pinnedHashes: Set<String> = [
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="
]
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard let serverTrust = challenge.protectionSpace.serverTrust,
let certificate = SecTrustGetCertificateAtIndex(serverTrust, 0) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let serverCertData = SecCertificateCopyData(certificate) as Data
let serverHash = SHA256.hash(data: serverCertData)
let hashString = Data(serverHash).base64EncodedString()
if pinnedHashes.contains(hashString) {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
}
AndroidではJetpack SecurityライブラリのEncryptedSharedPreferencesを活用するのが最も簡便かつ安全な方法です。
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
class SecureStorage(context: Context) {
private val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
private val sharedPreferences = EncryptedSharedPreferences.create(
"secure_prefs",
masterKeyAlias,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
fun saveToken(token: String) {
sharedPreferences.edit().putString("auth_token", token).apply()
}
fun getToken(): String? {
return sharedPreferences.getString("auth_token", null)
}
}
iOSでは、Keychainが最も安全なデータ保存先です。
import Security
class KeychainManager {
static func save(key: String, data: Data) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
return status == errSecSuccess
}
static func load(key: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
SecItemCopyMatching(query as CFDictionary, &result)
return result as? Data
}
}
証明書ピンニングとデータ暗号化に加えて、以下の対策も併せて実施することを推奨します。
モバイルアプリのセキュリティは多層防御が基本です。単一の対策に依存するのではなく、複数の防御層を組み合わせることで、攻撃者にとってのハードルを高くすることが重要です。