こんにちは。コミュニケーションアプリ「LINE」のAndroidクライアント開発をしている高梨です。 今回は、AndroidでKey-Value形式でデータ保存が可能なSharedPreferences
の暗号化に取り組んだ話を、どのように進行したのかに着目しながら紹介したいと思います。
なぜ暗号化をするのか
LINEではユーザーの情報を守るために、End-to-end encryptionを行い、通信を保護しています。 しかし、ユーザーの端末上に保存されたメッセージや個人情報は、平文のまま保存されています。 これらの端末上のデータを暗号化し保護することで、LINEをもっと安心して使ってもらうことが暗号化の目的です。 このブログでは、SharedPreferences
の暗号化のみを扱いますが、私達はデータベースの暗号化にも取り組んでいます。 詳しくは、LINE DEVELOPER DAY 2021のセッションで話したので、もし興味があったら見てください。
どのように暗号化するのか
SharedPreferences
を暗号化するために、私達はEncryptedSharedPreferences利用しています。 EncryptedSharedPreferences
はAndroid Jetpackライブラリの一部として提供されている、SharedPreferences
をラップしたクラスです。 Android Keystore システムを利用して暗号化されたEncryptedSharedPreferences
は、高いセキュリティレベルとパフォーマンスを両立することが可能です。
技術選定
暗号化の方法については、以下の2種類を検討しました。
EncryptedSharedPreferences
を利用する。- LINE内製の暗号化ライブラリと
SharedPreferences
を組み合わせて使う。
1番目の方法は、前述の通りAndroid標準の暗号化方法です。 2番目の方法は、内製ライブラリということもあり、ユーザーが別の端末に買い替えた際のデータの移行をサポートしています。
セキュリティチームと相談をした結果、Androidプラットフォームが提供している標準的なライブラリを利用することのメリットと、内製ライブラリを用いた場合に発生する毎回手動で暗号化・復号化を行う手間を考慮し、EncryptedSharedPreferences
を利用することにしました。
また、このタイミングでDataStoreへの移行も検討しましたが、残念ながら現在も暗号化をサポートしていません。そのため、2番目の方法と同様の理由で諦めました。
技術検証
EncryptedSharedPreferences
を利用する方針が立ったので、まずはそれが実用に値するのか検証を行いました。 最初に、検証用端末と適当なダミーデータを使い、パフォーマンスの計測を行いました。
こちらは長さが異なる1000件のString
の読み書きにかかる時間を計測した結果です。暗号化・復号(読み書きの度に実行されます)による処理時間の増加はありますが、一般的な使い方をしている場合には全く問題が無いことがわかりました。
次に、ユーザーの端末上でEncryptedSharedPreferences
が正しく動作するかどうかの検証をしました。LINE AndroidでSharedPreferences
を利用して保存しているデータの中には、サーバーに保存していないデータも存在します。そのようなデータが壊れてしまうと復元が不可能なため、動作の検証をした後に利用するという手順で慎重に進めます。 具体的には、ダミーデータを使ったユーザー端末上でのテストを行い、その後、一部のSharedPreferences
だけをEncryptedSharedPreferences
に段階的に置き換えつつ、エラーのログを監視しました。 一部のデバイス上でKeystoreが壊れている問題があったのですが、暗号化していないSharedPreferences
に処理をフォールバックすることで対応し、問題なく利用が可能なことを確認できました。
課題
利用する技術が決定し検証も完了したので、すぐにでもEncryptedSharedPreferences
の利用を開始できる状態だったのですが、以下の2つの課題がありました。
SharedPreferences
の利用箇所が多く、様々な機能に使われている。- 既にユーザーの端末上に存在する
SharedPreferences
のデータをEncryptedSharedPreferences
に移行する必要がある。
LINE Androidのプロジェクトの中にSharedPreferences
の利用箇所は約250箇所もあり、約100人のAndroid開発者がそれぞれ別々の機能を開発している状況です。そのため、私や一部のチームだけで全てのSharedPreferences
を暗号化し、その変更による全ての影響を把握することは困難です。また、新しい実装も全てSharedPreferences
ではなくEncryptedSharedPreferences
を使っていく必要があります。そのため、それぞれの機能の開発者が簡単にEncryptedSharedPreferences
を導入でき、また既存のSharedPreferences
のデータを自動的に移行できる仕組みを作りました。
どのように暗号化を進めるのか
暗号化を他チームに依頼するために以下の3つを実施しました。
SharedPreferences
とEncryptedSharedPreferences
の違いを意識せずに使うための機能の実装。- 自動でデータを移行するための機能の実装。
SharedPreferences
の利用を抑制するためのリンターの作成。
1. SharedPreferences
とEncryptedSharedPreferences
の違いを意識せずに使うための機能の実装
EncryptedSharedPreferences
はSharedPreferences
を実装したクラスなので、SharedPreferences
としてインスタンスを扱うことで、既存の呼び出し元を変更せずに置き換えが可能です。
fun getSharedPreferences(name: String): SharedPreferences {
val rawSharedPref = context.getSharedPreferences(name, Context.MODE_PRIVATE)
val encryptedSharedPrefName = getEncryptedSharedPrefName(name)
val encryptedSharedPref = getEncryptedSharedPreferences(encryptedSharedPrefName, Context.MODE_PRIVATE)
return if (!rawSharedPref.isEmpty) {
rawSharedPref
} else {
encryptedSharedPref
}
}
このような関数を用意することで、既にSharedPreferences
のデータが存在する場合にはそのままSharedPreferences
を利用し、そうでない場合にはEncryptedSharedPreferences
を利用することができます。 呼び出し元はContext.getSharedPreferences()
の代わりに、新しくこの関数からSharedPreferences
のインスタンスを取り出すように変更するだけです。
2. 自動でデータを移行するための機能の実装
SharedPreferences
からEncryptedSharedPreferences
へのデータの移行自体は簡単で、以下のように全てのデータを読み出して、それを書き込むだけです。
encryptedSharedPref.editAndCommit {
for ((key, value) in rawSharedPref.all) {
if (value != null) {
putObject(key, value)
}
}
}
private fun SharedPreferences.Editor.putObject(key: String, value: Any) = when (value) {
is String -> putString(key, value)
is Set<*> -> putStringSet(key, value as Set<String>)
is Int -> putInt(key, value)
is Long -> putLong(key, value)
is Float -> putFloat(key, value)
is Boolean -> putBoolean(key, value)
else -> error("$value has unexpected type.")
}
型安全に全てのデータを読み出すことはできないので、SharedPreferences
でサポートされている全ての型についてダウンキャストをする必要はありますが、これだけでデータの移行は完了します。
次にこの移行の関数をいつ呼び出すのか考えました。シンプルに進めるのならばアプリの起動時、Application.onCreate()
で呼び出せば十分そうです。 しかし、他のチームに依頼をして、それぞれに段階的に暗号化の適用を進めてもらうため、データ移行のための関数の呼び出し忘れなどのミスが無いとは言い切れないでしょう。 最終的に、呼び出し元には隠す形で自動でデータ移行をしてくれる仕組みを作りました。以下のコードが実際の実装の抜粋です。
class SharedPreferencesMigrator(
private val rawSharedPref: SharedPreferences,
private val encryptedSharedPref: SharedPreferences,
private val migrationExecutor: Executor
) {
// データの移行が完了したら、countDown()が呼ばれる。
private val latchForMigration: CountDownLatch = CountDownLatch(1)
// データの移行に成功したらtrueになる。
private var shouldUseEncryptedPref: Boolean = false
fun requestMigration() = migrationExecutor.execute {
migrateToEncryptedPref()
}
fun getSharedPreferencesAfterMigration(): SharedPreferences {
try {
latchForMigration.await()
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
}
return if (shouldUseEncryptedPref) encryptedSharedPref else rawSharedPref
}
}
SharedPreferencesMigrator
はデータの移行をするためのクラスで、非同期でデータ移行を実行するrequestMigration()
と、データ移行が完了するまで待機し、完了結果に応じて適切なSharedPreferences
返すgetSharedPreferencesAfterMigration()
が定義されています。
class SharedPreferencesWrapperWithMigrator(
rawSharedPref: SharedPreferences,
encryptedSharedPref: SharedPreferences,
migrationExecutor: Executor,
) : SharedPreferences {
private val migrator: SharedPreferencesMigrator = SharedPreferencesMigrator(
rawSharedPref = rawSharedPref,
encryptedSharedPref = encryptedSharedPref,
migrationExecutor
)
init {
migrator.requestMigration()
}
override fun getAll(): MutableMap<String, *> =
migrator.getSharedPreferencesAfterMigration().all
override fun getString(key: String, defValue: String?): String? =
migrator.getSharedPreferencesAfterMigration().getString(key, defValue)
// その他のSharedPreferencesのオーバーライド
}
SharedPreferencesWrapperWithMigrator
はSharedPreferences
のラッパーで、インスタンスが作成された際に自動的にデータ移行を実行します。 また、全てのSharedPreferences
の関数はデータの移行が完了するまで処理を待機するため、データ移行中の中途半端な状態でのデータの読み出しを防止します。
class TransparentEncryptedSharedPrefProvider(...) {
private val migrationExecutor: Executor = Executors.newCachedThreadPool()
private val prefWrapperCache: MutableMap<String, SharedPreferencesWrapperWithMigrator> = mutableMapOf()
@Synchronized
fun getSharedPreferences(name: String): SharedPreferences {
...
return if (prefWrapperCache.containsKey(name) || !rawSharedPref.isEmpty) {
prefWrapperCache.getOrPut(name) {
SharedPreferencesWrapperWithMigrator(
rawSharedPref = rawSharedPref,
encryptedSharedPref = encryptedSharedPref,
migrationExecutor
)
}
} else {
encryptedSharedPref
}
}
}
TransparentEncryptedSharedPrefProvider
はデータの移行を並行して行うSharedPreferencesWrapperWithMigrator
のインスタンスを管理し、データの移行処理が一度だけ実行されることを保証します。
3. SharedPreferences
の利用を抑制するためのリンターの作成
最後に作成した仕組みを使うように強制するために、リンターを作成しました。
Context.getSharedPreferences()
を利用している場合に、TransparentEncryptedSharedPrefProvider.getSharedPreferences()
を使うように警告を出しています。 将来的に全てのSharedPreferences
がEncryptedSharedPreferences
で置き換わった段階で、警告からエラーに変更して、意図しない利用を防止しようと思っています。
まとめ
SharedPreferences
を暗号化するために行った、技術選定・検証と課題の解決方法について説明しました。今回紹介したものはまだ数十か所にしか適用されておらず、これから全体的に暗号化を進めていく予定です。 LINEのような大規模な開発現場においては、安全でミスが起こり得ないような形で、他チームへの展開をしていくタスクがたくさんあります。どのような仕事をしているのかを、このブログを通して皆さんに知ってもらえたら嬉しいです。