クイックスタート(5分):SQLCipherでSQLiteを暗号化する
6か月前、私たちが開発するデスクトップクライアントが定期セキュリティ監査で不合格となりました。機密情報である顧客のトークンが、ユーザーのドライブ上に平文のSQLiteファイルとして保存されていたためです。バイナリエディタさえあれば、誰でも.dbファイルを開いてプレーンなASCII文字列としてセッショントークンを読み取れる状態でした。そこでストレージエンジンをSQLCipherへ移行したところ、その日のうちにローカルデータを256ビットAES暗号化で保護することができました。
Pythonやデスクトップ環境では、バイナリ配布パッケージをインストールします:
pip install sqlcipher3-binary
新規データベースを保護するには、接続を開いた直後にわずか2行の設定を追加するだけです:
import sqlcipher3
# データベースファイルへの接続を開く
conn = sqlcipher3.connect("secure_app.db")
cursor = conn.cursor()
# クエリを実行する前に暗号化キーを設定
cursor.execute("PRAGMA key = 'my-super-secret-passphrase';")
# 通常通りテーブルを作成してレコードを挿入
cursor.execute("CREATE TABLE secrets (id INTEGER PRIMARY KEY, token TEXT);")
cursor.execute("INSERT INTO secrets (token) VALUES ('oauth_access_token_xyz');")
conn.commit()
conn.close()
標準のSQLiteツールでsecure_app.dbを確認してみてください。CLIは即座にfile is not a databaseというエラーを出して終了します。正しいキーを入力するまで、テーブル、インデックス、スキーマ、WALジャーナルは完全に暗号化されたまま保護されます。
アーキテクチャ:ページ単位の暗号化
標準のSQLiteは、データを4,096バイトのページ単位でそのまま書き込みます。SQLCipherは、SQLiteの仮想ファイルシステム(VFS)レイヤーでこの処理をインターセプトします。ページがディスクに書き込まれる前に、SQLCipherが認証付きの暗号化コンテナでラップします。
SQLCipherがすべてのページを保護する仕組み
SQLCipherは、ファイル全体に対してストリーム暗号を適用するのではなく、データベースをページ単位で暗号化します。これにより、SQLiteの高速性が損なわれません。対象のページのみを復号するため、50,000行目の読み取りにかかるシーク時間は通常のSQLiteとまったく同じです。
- 暗号化アルゴリズム:CBC(Cipher Block Chaining)モードの256ビットAES。
- ページごとの初期化ベクトル(IV):各ページには一意の16バイトのランダムIVが格納されます。同一のデータを含む2つのページであっても、ディスク上ではまったく異なる暗号文が生成されます。
- HMACによる整合性チェック:SQLCipher 4は、各4KBページの末尾64バイトをHMAC-SHA512署名用に確保しています。エンジンは復号を試みる前にこのダイジェストを検証します。攻撃者がディスク上のデータを1ビットでも改ざんした場合、破損したデータを返すのではなく、即座にそのページを拒否します。
PBKDF2による鍵導出
SQLCipherがパスフレーズをそのまま暗号化キーとして使用することはありません。代わりに、データベースヘッダーに保存された16バイトのソルトとともに、指定された文字列をPBKDF2-HMAC-SHA512に入力します。SQLCipher 4では、デフォルトで256,000回の反復処理(イテレーション)が実行されます。この計算コストによって、オフラインファイルに対する総当たり(ブルートフォース)攻撃や辞書攻撃から保護されます。
本番環境でのワークフロー:マイグレーション、生キー、モバイル対応
基本的なチュートリアルではシンプルなセットアップしか扱いませんが、本番環境への導入には特有の課題が伴います。
平文データベースのSQLCipherへのマイグレーション
既存のSQLiteファイルをPRAGMA keyを使ってインプレース(その場)で直接暗号化することはできません。代わりに、暗号化された空のターゲットデータベースに未暗号化のデータベースをアタッチし、sqlcipher_export()を使用してデータをコピーします:
import sqlcipher3
# 暗号化されたターゲットデータベースを初期化
conn = sqlcipher3.connect("encrypted.db")
cursor = conn.cursor()
cursor.execute("PRAGMA key = 'ProductionPassphrase2026!';")
# 既存の平文データベースをアタッチ
cursor.execute("ATTACH DATABASE 'legacy_plaintext.db' AS plaintext KEY '';")
# スキーマ全体とすべてのレコードをエクスポート
cursor.execute("SELECT sqlcipher_export('main', 'plaintext');")
conn.commit()
# アタッチした既存データベースをデタッチ
cursor.execute("DETACH DATABASE plaintext;")
conn.close()
生16進数キーによるPBKDF2のバイパス
256,000回のPBKDF2処理はそれなりのCPU時間を消費します。OSのKeychainやAndroid Keystoreからすでに安全な256ビットキーを取得できている場合は、鍵導出処理を完全にスキップできます。プレフィックスx'を付けた64文字の生16進数文字列を渡します:
# 32バイト(256ビット)の生キーを渡し、PBKDF2を完全にスキップ
raw_hex = "x'2DD29CA851E3B39E8A29A26A30F2AC8A2DD29CA851E3B39E8A29A26A30F2AC8A'"
cursor.execute(f"PRAGMA key = \"{raw_hex}\";")
モバイルとの統合(Android Room)
AndroidでカスタムOpenSSLバイナリをビルドするのは避けるべきです。Android Jetpack Roomとあわせて、Zetetic公式のAndroidバインディングを使用しましょう:
val passphrase = getPassphraseFromKeystore()
val factory = SupportFactory(SQLiteDatabase.getBytes(passphrase.toCharArray()))
val db = Room.databaseBuilder(context, AppDatabase::class.java, "secure.db")
.openHelperFactory(factory)
.build()
本番環境で6か月運用して得られた知見
1. 開いた接続をキャッシュする
ミドルレンジのAndroid端末(Snapdragon 778Gなど)では、256,000回のPBKDF2処理におよそ85〜130ミリ秒かかります。Apple M2ではこれが約8ミリ秒まで短縮されます。高頻度で実行されるUIループ内でデータベースハンドルを開閉することは絶対に避けてください。アプリケーションのライフサイクル全体を通じてシングルトン接続を維持しましょう。
2. ページキャッシュのチューニング
一度復号されたページは、SQLiteのインメモリキャッシュ内に平文のままRAM上に保持されます。キャッシュされたページに対する読み取りクエリは、通常のSQLiteと同等のネイティブ速度で動作します。キャッシュサイズを拡大して、繰り返されるディスクI/Oと暗号化・復号のオーバーヘッドを防ぎましょう:
-- インメモリページキャッシュに約20MBを割り当て
PRAGMA cache_size = -20000;
3. バルクシーディングデータのストリーミング処理
オフラインのシードデータ投入は、多くの場合、顧客や製品の生ダンプデータから始まります。元のCSVフィードをクリーンなJSONフィクスチャに変換する際、toolcraft.appのCSV to JSONツール(toolcraft.app/ja/tools/data/csv-to-json)のようなゼロ知識(Zero-Knowledge)型のブラウザユーティリティを利用すれば、機密データが外部APIに送信されるのを防ぐことができます。
データ変換後は、明示的なトランザクション内でパラメータ化されたバッチインサートを使用してレコードをロードします。私たちのベンチマークでは、10,000件の挿入を単一のBEGIN TRANSACTIONで囲むことにより、書き込み時間が8.4秒から240ミリ秒へと劇的に短縮されました。
4. PRAGMA rekeyによるキーのローテーション
暗号化キーをローテーションする際、新しいデータベースファイルを作成する必要はありません。認証済みの接続でPRAGMA rekeyを実行します:
# 既存のキーで認証
cursor.execute("PRAGMA key = 'old-passphrase';")
# スキーマテーブルをクエリして認証成功を確認
cursor.execute("SELECT count(*) FROM sqlite_master;")
# 新しいキーですべてのページを再暗号化
cursor.execute("PRAGMA rekey = 'new-strong-passphrase';")
conn.commit()
SQLCipherはファイルヘッダーに新しいソルトを書き込み、既存のページをインプレースで再暗号化します。予期せぬプロセスの強制終了が発生した場合でも、データは安全に保護されます。
