LINE株式会社は、2023年10月1日にLINEヤフー株式会社になりました。LINEヤフー株式会社の新しいブログはこちらです。 LINEヤフー Tech Blog

Blog


大規模開発でSharedPreferencesの暗号化を安全に進める

こんにちは。コミュニケーションアプリ「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種類を検討しました。

  1. EncryptedSharedPreferencesを利用する。
  2. LINE内製の暗号化ライブラリとSharedPreferencesを組み合わせて使う。

1番目の方法は、前述の通りAndroid標準の暗号化方法です。 2番目の方法は、内製ライブラリということもあり、ユーザーが別の端末に買い替えた際のデータの移行をサポートしています。

セキュリティチームと相談をした結果、Androidプラットフォームが提供している標準的なライブラリを利用することのメリットと、内製ライブラリを用いた場合に発生する毎回手動で暗号化・復号化を行う手間を考慮し、EncryptedSharedPreferencesを利用することにしました。

また、このタイミングでDataStoreへの移行も検討しましたが、残念ながら現在も暗号化をサポートしていません。そのため、2番目の方法と同様の理由で諦めました。

技術検証

EncryptedSharedPreferencesを利用する方針が立ったので、まずはそれが実用に値するのか検証を行いました。 最初に、検証用端末と適当なダミーデータを使い、パフォーマンスの計測を行いました。

こちらは長さが異なる1000件のStringの読み書きにかかる時間を計測した結果です。暗号化・復号(読み書きの度に実行されます)による処理時間の増加はありますが、一般的な使い方をしている場合には全く問題が無いことがわかりました。

次に、ユーザーの端末上EncryptedSharedPreferencesが正しく動作するかどうかの検証をしました。LINE AndroidでSharedPreferencesを利用して保存しているデータの中には、サーバーに保存していないデータも存在します。そのようなデータが壊れてしまうと復元が不可能なため、動作の検証をした後に利用するという手順で慎重に進めます。 具体的には、ダミーデータを使ったユーザー端末上でのテストを行い、その後、一部のSharedPreferencesだけをEncryptedSharedPreferencesに段階的に置き換えつつ、エラーのログを監視しました。 一部のデバイス上でKeystoreが壊れている問題があったのですが、暗号化していないSharedPreferencesに処理をフォールバックすることで対応し、問題なく利用が可能なことを確認できました。

課題

利用する技術が決定し検証も完了したので、すぐにでもEncryptedSharedPreferencesの利用を開始できる状態だったのですが、以下の2つの課題がありました。

  1. SharedPreferencesの利用箇所が多く、様々な機能に使われている。
  2. 既にユーザーの端末上に存在するSharedPreferencesのデータをEncryptedSharedPreferencesに移行する必要がある。

LINE Androidのプロジェクトの中にSharedPreferencesの利用箇所は約250箇所もあり、約100人のAndroid開発者がそれぞれ別々の機能を開発している状況です。そのため、私や一部のチームだけで全てのSharedPreferencesを暗号化し、その変更による全ての影響を把握することは困難です。また、新しい実装も全てSharedPreferencesではなくEncryptedSharedPreferencesを使っていく必要があります。そのため、それぞれの機能の開発者が簡単にEncryptedSharedPreferencesを導入でき、また既存のSharedPreferencesのデータを自動的に移行できる仕組みを作りました。

どのように暗号化を進めるのか

暗号化を他チームに依頼するために以下の3つを実施しました。

  1. SharedPreferencesEncryptedSharedPreferencesの違いを意識せずに使うための機能の実装。
  2. 自動でデータを移行するための機能の実装。
  3. SharedPreferencesの利用を抑制するためのリンターの作成。

1. SharedPreferencesEncryptedSharedPreferencesの違いを意識せずに使うための機能の実装

EncryptedSharedPreferencesSharedPreferencesを実装したクラスなので、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のオーバーライド
}

SharedPreferencesWrapperWithMigratorSharedPreferencesのラッパーで、インスタンスが作成された際に自動的にデータ移行を実行します。 また、全ての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()を使うように警告を出しています。 将来的に全てのSharedPreferencesEncryptedSharedPreferencesで置き換わった段階で、警告からエラーに変更して、意図しない利用を防止しようと思っています。

まとめ

SharedPreferencesを暗号化するために行った、技術選定・検証と課題の解決方法について説明しました。今回紹介したものはまだ数十か所にしか適用されておらず、これから全体的に暗号化を進めていく予定です。 LINEのような大規模な開発現場においては、安全でミスが起こり得ないような形で、他チームへの展開をしていくタスクがたくさんあります。どのような仕事をしているのかを、このブログを通して皆さんに知ってもらえたら嬉しいです。