なぜLINEがデータベースの暗号化に取り組んでいるか

高梨雄大氏(以下、高梨):「About Affinity Between SQLCipher and Room/CoreDate」というタイトルで、スマートフォン内に保存しているデータベースの暗号化のLINEの取り組みについて紹介したいと思います。

こちらが本セッションのアジェンダです。なぜLINEがデータベースの暗号化に取り組んでいるか、暗号化に用いるSQLCipherは何なのかについて簡単に紹介したあと、AndroidとiOSの、それぞれの事例について具体的に紹介します。

はじめに、私たちの自己紹介をします。私は高梨雄大と申します。2020年に新卒でLINEに入社し、「LINE」のAndroidアプリの開発を担当しています。私は、AndroidのパートでSQLCipherとRoomを組み合わせた際に起こるトラブルの事例と、その対応策について話します。

Bruce Evans氏:LINE iOSエンジニアのBruce Evansと申します。LINEのiOSアプリ開発を担当しています。AndroidのRoomとは違い、iOSのCoreDataは、SQLCipherとの連携がとても難しいですが、今日はその基本的な実装方法についてお話しします。

みなさまは、ふだんAndroidやiOSのアプリケーションを開発されていると思います。このセッションで、データベースの暗号化の大事さ、SQLCipherの実装の具体例についてお話しするので、みなさまの開発に役立つとうれしいです。

なぜデータベースを暗号化する必要があるのか

まずは、なぜデータベースを暗号化する必要があるのかと疑問に思われるかもしれません。最も重要なポイントは、個人情報を守るということです。

国によっては法律的に可能であれば、どれだけ難しいことだとしても、データが要求される可能性はあります。なので、難しいことから不可能なことに変えてしまいたいです。

LINEは、もちろん通信中ではEnd-to-end encryptionを使っていますが、端末上のデータについては、全部復号化のままです。今日のお話は、その復号化しているローカルストレージについてお話しします。これまではOS側の機能を使って暗号化していましたが、より安心・安全のために、OS側で処理する前に情報を暗号化し、さらにユーザーのデータを保護する方針で開発を進めています。

現在、私たちのアプリケーションでは「SQLite」を使っています。なので、保護するための暗号化の仕組みは、SQLiteのインターフェイス上に作ったSQLCipherを使うことに決めました。

SQLCipherを使ったAndroidの事例

高梨:次に、SQLCipherについて簡単に説明します。SQLCipherは、SQLiteデータベースを拡張し、同じインターフェイスで暗号化されたローカルデータストレージを提供するオープンソースのライブラリです。256ビットのAES暗号を利用した耐タンパー性を持つ設計で、iOS、Android、.NETなど、クロスプラットフォームで動作します。

それではここからは、SQLCipherを使ったAndroidの事例について話していきます。まず、AndroidのSQLiteデータベースの利用方法ですが、主に2種類存在します。

1つ目は、android.database.sqliteパッケージの、古くから存在する低レベルなAPIを使う方法です。基本的にSQLiteを使ってできることはなんでもできる、強力なAPIですが、その代わりに使用するにはたくさんの時間と労力が必要になります。

2つ目は、Googleが提供しているAndroidXの「Room」を使う方法です。Roomは、Annotation Processingによってコードを生成し、SQLiteを扱うための抽象化レイヤーを提供するライブラリです。

Roomを使うと、ローレベルAPIで必要だった多くのボイラープレートを削除できます。また「Android Developers」のドキュメントでは、1つ目の低レベルなAPIの代わりにRoomを使うことを強く勧めています。

LINEのAndroidアプリケーションでは、2種類のどちらの方法も利用していますが、新規機能の実装時やリファクタリングの際には、Roomを積極的に利用しています。そのためここでは、SQLCipherとRoomを合わせて利用した際に起こったトラブルの事例と、その対応策を紹介します。

SQLCipherとRoomの組み合わせで起こったトラブル

SQLCipherとRoomの連携は、非常に簡単です。まず、SQLCipherが提供しているSupportFactoryクラスを、暗号化のためのパスフレーズを使ってインスタンス化します。そして、RoomDatabaseクラスのインスタンスを作成するビルダーのopenHelperFactoryに、作成したsupportFactoryのインスタンスを渡すだけで、SQLCipherを使う準備は整います。

SQLCipherを利用した実装が完了し、アプリをリリースしたのですが、特定の条件下でクラッシュが発生していることがわかりました。

問題が発生していた関数は、deleteMessages関数のように、idのリストを受け取り、messageテーブルから、そのidを持つ行を削除するというシンプルなものです。例外は、SQLiteCompiledSqlというクラスのfinalizeメソッドで起こっていて、try-catchなどでは防ぐことができませんでした。

自動テストやQAでは検出が難しく、防ぐことが困難で、どのAndroidアプリでも起こり得る非常にやっかいな問題です。

問題の原因について話す前に、利用しているライブラリのバージョンを示しておきます。Roomは2.3.0、SQLCipherは4.4.3で、私がこのプレゼンテーションを作成している段階では、最新の安定バージョンです。

また、SQLCipherの「GitHub」のIssueでは、本セッションで紹介する問題がすでに報告されていて、次バージョンのリリースで、この問題の原因の1つが修正される予定のようです。

トラブルが発生した原因:SQLCipher側

ここからは、発生した問題の原因について話していきます。SQLCipherとRoomの両方に原因がありました。まずはSQLCipher側の原因について説明します。

原因の確認の前に、これ以降のスライドで出てくるクラスやオブジェクトについて、先に説明しておきます。

1つ目のSQLiteStatementクラスは、事前にコンパイルされた再利用可能なステートメントです。INSERTしたレコードの行のIDや、UPDATEやDELETEした行の数を返すといった、1×1の結果を伴うようなクエリに利用されます。今回問題があったDELETEのクエリでも利用されています。

2つ目のSQLiteCompiledSqlクラスは、SQLステートメントのコンパイルと、そのコンパイルされたオブジェクトの解放をカプセル化したクラスです。

3つ目のsqlite3_stmtは、これだけSQLCipherのクラスではないのですが、ネイティブ側で利用される単一のSQLステートメントを表現するオブジェクトです。

これらの関係をクラス図のようなかたちで表すと、右の図のようになります。SQLiteStatementクラスがフィールドでSQLiteCompiledSqlを管理し、SQLiteCompiledSqlクラスのネイティブ実装で、sqlite3_stmtオブジェクトを扱っています。

まず、SQLiteCompiledSqlクラスのreleaseSqlStatementメソッドを見ていきます。これは、ネイティブ側の実装に存在するsqlite3_stmtオブジェクトを解放するためのメソッドで、ネイティブで実装されたnative_finalizeメソッドを呼び出します。

最初のif文で確認しているnStatementは、sqlite3_stmtオブジェクトの参照を保持するフィールドです。この値が非ゼロの場合には、ネイティブ側に有効なsqlite3_stmtオブジェクトが存在するので、if文の中身を実行してそれを解放します。

重要なのは、native_finalizeメソッドの呼び出しの前に呼ばれているデータベースのロックです。実際にこの部分が、例外の発生源になっています。

releaseSqlStatementメソッドの処理内容をまとめると、ネイティブ側に有効なsqlite3_stmtオブジェクトが存在する場合、つまり一度もreleaseSqlStatementメソッドが呼ばれていない場合に、データベースのロックを取得してから、その解放処理を実行します。

次に、releaseSqlStatementメソッドの呼び出しについて見ていきます。今回紹介した問題に関連するメソッドの呼び出しは2種類あります。

1つ目は、SQLiteStatementクラスのインスタンスがクローズされた時で、管理しているSQLiteCompiledSqlクラスのインスタンスのreleaseSqlStatementメソッドを呼び出します。

2つ目が、SQLiteCompiledSqlクラスのfinalizeメソッドの中、つまりガベージコレクションでオブジェクトが解放される直前です。ここで重要なのは、2つ目のfinalizeメソッドからの呼び出しなので、この部分を詳しく見ていきます。

これが、SQLiteCompiledSqlクラスのfinalizeメソッドの実装です。releaseSqlStatementメソッドと同様に、まずnStatementが0かどうかを確認しています。

releaseSqlStatementメソッドが一度でも呼ばれたら、nStatementは0になりここでアーリー・リターンするので、releaseSqlStatementメソッドが一度も呼ばれていない場合には、それを呼び出します。

今までの話をまとめると、SQLiteCompiledSqlクラスのfinalizeメソッドが呼ばれるガベージコレクションのタイミングで、SQLiteStatementクラスのインスタンスが適切にクローズされていない場合に、releaseSqlStatementメソッドを呼び出し、データベースのロックを取得しようとします。

そこで、長時間ロックが取得できなかった場合に例外が発生するというのが、SQLCipher側の原因です。

トラブルが発生した原因:Room側

次に、Room側の原因について説明します。Room側の原因は、特定の条件を含むクエリから生成されるコードに問題があるというものでした。その条件は、Queryアノテーションを利用した、INSERT、UPDATE、DELETEのいずれかのクエリであること。そしてそのクエリの中で、引数のコレクションが利用されていることです。

複数の引数を実行時に展開してクエリを生成するコードに問題があったので、単にINSERT、UPDATE、DELETEを利用する場合には、問題は起こりません。

それでは、条件を満たすコードであるdeleteMessages関数の定義から、生成されたコードを見ていきます。

Roomによる生成コードはこのようになっていて、軽く説明すると、最初に複数の引数を扱うためのプレースホルダーを展開したクエリの文字列を用意し、それをコンパイルします。コンパイルされたステートメントに、メソッドの呼び出しで渡された引数をバインドし、トランザクション内で処理を実行するといった感じです。

ここで問題となっているのは、SQLiteStatementクラスのインスタンスの解放処理です。compileStatementメソッドの戻り値はSQLCipherを利用している場合には、SQLiteStatementクラスになります。このクラスは、利用が終わったあとにcloseメソッドを呼び出さなければいけない実装になっています。

しかし、ステートメントを実行したあとのfinallyブロックで、ステートメントのcloseメソッドを呼び出していません。これが、特定の条件を含むクエリから生成されるコードに問題があるというRoom側の原因です。

最後に、SQLCipherとRoom、それぞれの原因をまとめます。特定の条件を含むクエリから、Roomはcloseメソッドを呼び出さないコードを生成します。そして、closeメソッドが呼び出されなかった場合に、SQLiteCompiledSqlクラスのインスタンスがfinalizeメソッドの中で解放処理を行います。

解放処理の中で、データベースのロックを時間内に取得できなかった場合に、キャッチできない例外が投げられます。

問題をどのように対処したか

ここからは、LINEのAndroidアプリケーションで問題をどのように対処したかについて話していきます。

本セッションの最初のライブラリのバージョンのスライドでも話しましたが、今後のSQLCipherのアップデートで、問題の一部が修正される可能性が高いです。そのためライブラリ側で修正されるまでの間は、ワークアラウンドで対応することにしました。

2種類のワークアラウンドを検討したので、それを紹介したいと思います。1つ目はRoomの代わりにローレベルなAPIを利用するというもので、2つ目はQueryアノテーションの代わりに、RawQueryアノテーションを利用するというものです。

1つ目のローレベルなAPIを利用するという方法はシンプルで、問題が起こる生成コードのクエリだけを置き換えるというものです。RoomDatabaseクラスは、openHelperからSupportSQLiteDatabaseのインスタンスを取得できるので、それを用いてローレベルAPIを利用できます。

問題があったdeleteMessages関数は、workaround1関数のようなdeleteメソッドを利用した実装に置き換えることができます。この方法のメリットは、コードを理解することが簡単な点です。多くのAndroid開発者は、ローレベルAPIをよく知っているはずです。

デメリットとしては、workaround1関数にRoomDatabaseクラスのインスタンスを渡す必要がある点です。DIを利用してDAOのクラスだけをインジェクトして利用している場合には、RoomDatabaseのインスタンスを使うために、呼び出し元をある程度書き換える必要があります。また、Roomのコード生成のメリットを享受できないこともデメリットです。

withContext関数で、実行スレッドを明示的にワーカースレッドに切り替えたり、適切にトランザクションを管理する必要があります。

2つ目のQueryアノテーションの代わりにRawQueryアノテーションを利用する方法は、生成されるコードで問題が起こらないRawQueryを利用するものです。

例えばこのようにRawQueryアノテーションがついたgetValue関数の定義からRoomが生成したコードは、SQLiteStatementクラスではなく、Cursorを利用し、最後に必ずcloseメソッドを呼び出します。

Cursorのcloseでも、SQLiteCompiledSqlクラスのreleaseSqlStatementメソッドが呼び出されるので、適切に解放処理がされて、問題は起こりません。

RawQueryアノテーションを使う上での注意点

ただRawQueryは、使う上で注意しなければいけないことが2つあります。

1つ目の注意点は、RawQueryアノテーションを使った関数の引数についてです。RawQueryは、SupportSQLiteQuery型の引数を1つ取る関数として定義できるのですが、コンパイル時に検証などの処理が一切行われません。

そのためgetValue関数は、int型の値を返すクエリとして受け取ることが想定されていますが、下の画像のように、まったく関係のないクエリをgetValue関数に与えてもコンパイルは成功してしまいます。そして実行時にエラーが出たり、未定義の値を返したりするので、非常に危険です。

幸いなことにRawQueryアノテーションを使った関数は、SupportSQLiteQueryインターフェイスを実装したクラスの引数の型として指定できます。RawQueryを使いたい関数ごとに、SupportSQLiteQueryインターフェイスを実装したクラスを用意して引数を制限することで、比較的安全に扱うことが可能です。

この例では、workaround2Internal関数がRawQueryを扱う関数で、DeleteMessagesQueryクラスがworkaround2Internal専用のSupportSQLiteQueryインターフェイスの実装です。また、workaround2関数のように、RawQueryの関数をラップしてからDAOの外へ公開することで、deleteMessages関数と同じ引数、戻り値にできます。こうすることで、呼び出し元への影響を最小限に抑えられます。

2つ目の注意点は、RawQueryアノテーションを使った関数の戻り値についてです。戻り値は非voidの中から、KotlinならUnitではない型を指定する必要があります。実際にAndroid Developersのドキュメントには、値を返さない場合にはRawQueryの代わりにRoomDatabaseクラスのqueryメソッドを使うように書いてあります。

ですが、RawQueryの値を返さないクエリに、絶対に使えないというわけではありません。お行儀のよいコードではないのですが、RawQueryアノテーションを使った関数の戻り値をRoomで扱うことができる適当な型のNullableにすることで解決できます。今回は、Nullableなint型にしました。

生成されたコードを見ると、_cursorのmoveToFirstメソッドがfalseを返す、つまり_cursorが空の場合にnullを返すことがわかります。そのため、渡されたDELETEのクエリとその結果の値の整合性が取れているため、実行時エラーが起こりません。

ただしこれは、Roomのコード生成に依存した書き方になってしまっているので、コメントをつけたり、関数をラップして戻り値を扱えないようにするべきです。

この方法のメリットは、Roomのコード生成のメリットを享受できることと、問題があったdeleteMessages関数と同じ引数、戻り値で実装が可能なことです。デメリットとしては、RawQueryを扱う上で引数と戻り値の2つに注意する必要があることです。また、RawQueryはあまり有名な実装方法ではないので、少しコードが理解しにくいかもしれません。

以上2つのメリット、デメリットを比較し、LINEのAndroidアプリケーションでは2つ目のRawQueryアノテーションをQueryアノテーションの代わりに使うワークアラウンドを採用しました。

理由としては、DAOのパブリックな関数の引数と戻り値を維持できることは、ワークアラウンド適用を除去する際のコスト低下につながるため、そして、近い将来にSQLCipher側で問題が解決する可能性が高いので、RawQueryを使うデメリットは受け入れられると考えたためです。

Android側のまとめ

最後に、Android側のまとめです。私たちは、できる限りローレベルAPIよりもRoomを利用していくべきです。その際に、現在のRoomとSQLCipherのバージョンでは、特定の条件が重なった際にキャッチできない例外が発生します。

その問題に対処するために、私たちは、RawQueryを使ったワークアラウンドの利用を決めました。次のスライドからは、iOS側の話になります。Evansさん、よろしくお願いします。

後半へつづく