※ChatGPTを使用して記事を作成しています。
ある日突然、テストユーザーの一人がSlackにこう書き込んだ。
「なんかアプリ開いたらログアウトしてて、再ログインしたらお気に入りも履歴も全部消えてます…」
まさか、と思ってサーバーを確認するがデータは正常。
DBにも、セッションにも異常はない。
しかし、ローカルストレージ(EncryptedSharedPreferences)に保存していたデータが、全員いっせいに「空」になっていた。
その原因は、暗号化ライブラリを“正しく更新しなかった”ことによるものだった。
今回は、暗号化と鍵管理の怖さを痛感した、開発人生屈指の震える失敗談をお届けします。
事の発端:いつものライブラリアップデート
問題の発端は、ある依存ライブラリのセキュリティ脆弱性対応だった。
使っていたのは Jetpack の EncryptedSharedPreferences
。
これは、アプリ内のSharedPreferencesに保存する値を暗号化してくれる便利な仕組みだ。
val sharedPreferences = EncryptedSharedPreferences.create(
"secure_prefs",
masterKeyAlias,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
当時、アプリで保存していたのは以下のような情報:
- ログインセッションキー
- ユーザー設定(テーマカラー、通知設定など)
- お気に入り記事一覧(IDだけ)
そして、ある日GitHubのDependabotが「暗号化ライブラリのアップデートを推奨」と通知してきた。
中身を見ると、security-crypto
の脆弱性修正が含まれており、依存しているmasterkey
ライブラリのバージョンが上がっていた。
特にAPI変更もなかったため、私は何も考えずにバージョンだけ上げてPRをマージした。
そして迎えた破滅の朝
リリースから数日。
ユーザーから立て続けにこんな報告が寄せられた。
- 「アプリを起動したら勝手にログアウトしていた」
- 「テーマ設定がリセットされた」
- 「履歴が全て消えている」
- 「通知設定が勝手にONになってる」
サーバー側のデータは正常。ログにも異常はない。
不思議に思い、手元の実機を使って調査を始めた。
そして、ある変化に気づいた。
EncryptedSharedPreferences
のファイルサイズが明らかに小さくなっていた。
中を覗いてみると、確かに空。
それまで保存されていたデータがすべて読み取れなくなっていた。
原因:暗号鍵の再生成により、既存データが復号不可に
詳しく調査したところ、今回の原因は次の通りだった。
1. MasterKey の鍵の仕様が変更されていた
AndroidXのsecurity-crypto
ライブラリは、内部で暗号鍵(MasterKey)を使ってデータを暗号化する。
アップデート前と後で、鍵のアルゴリズムや保存方式が変更されていた。
具体的には、以下のような変更点があった:
- 鍵の生成方法が微妙に変わり、既存の
keyset
が再利用されなくなった - Android Keystore との接続方式やAliasの解釈が変わっていた
EncryptedSharedPreferences
が新しい鍵でファイルを上書きしてしまった
その結果、以前の暗号鍵では復号できず、既存の保存データは“読めないゴミ”と化してしまった。
2. 新バージョンでは既存鍵を探しにいかないパスが存在
本来であれば、既存のMasterKeyがある場合はそれを再利用するようになっている。
しかし、私たちの実装は以下のように何も明示的に指定していなかった。
val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build()
これにより、アップデート後のライブラリは新たに別の鍵Aliasを生成し、まったく異なる鍵でファイルを上書きしてしまった。
当時のコードと問題点
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val sharedPreferences = EncryptedSharedPreferences.create(
"secure_prefs",
masterKey,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
このコードのどこに問題があったかというと:
masterKey
のAlias名を明示していない- アップデート後に暗黙的に別の鍵が作られた
secure_prefs
という同じ名前のファイルが新しい鍵で再作成された
つまり、見た目は変わらず動いているのに、別の暗号鍵で上書きされたのだ。
データは「消えた」のではない、「読めなくなった」
ここで重要なのは、データそのものは端末に存在していたということ。
ファイルはそこにある。
でも、正しい鍵がないから復号できない=読み込めない。
これはつまり、ユーザーから見たら「データが全部消えた」ように見えるわけで、アプリへの信頼は一瞬で崩れる。
対処法:鍵の管理は明示的に。テストも怠るな
この事件をきっかけに、以下のような対策を導入した。
対策①:鍵のAliasを明示指定
val masterKey = MasterKey.Builder(context, "my_app_master_key")
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
これにより、ライブラリが暗黙の命名ルールに頼らず、自分たちで鍵の一貫性を保てるようになる。
対策②:暗号化ライブラリの更新時はデータ復号の回帰テストを実施
CIパイプラインに以下のテストを追加:
- 暗号化済みのSharedPreferencesを保持したテスト端末で更新を適用
- 更新後に保存値が復号できることを確認
- バックアップから旧形式の鍵で復号できるか確認
対策③:マイグレーション処理の実装
万が一、鍵の仕様変更が必要になる場合に備え、以下のような安全な移行ステップを用意:
- 旧形式の鍵でデータを一旦復号
- 平文で一時的に保存(メモリ上でのみ)
- 新形式の鍵で再暗号化して保存
教訓:「見えない鍵」が最も重いリスクになる
この失敗を通して学んだ最大のことは、「暗号化データは“ただの文字列”ではない」ということ。
- ライブラリの更新
- 鍵の再生成
- OSバージョン間のKeystore挙動の違い
ほんの少しの変更が、すべてのローカルデータの死に直結する。
まとめ:暗号化は便利だけど、雑に使うな
Androidの暗号化周りは年々整備が進んでおり、開発者が簡単にセキュリティを高められるようになっている。
しかし、簡単に“使える”ことと、安全に“維持できる”ことはまったく別の話だ。
たとえライブラリのアップデートでAPIが変わらなくても、その裏で何が起きているのか──
特に“鍵の扱い”が変わっていないかは常に注視するべきである。