暗号化ライブラリを更新してユーザーデータが全消失した話〜鍵が変われば、すべてが変わる〜

※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
)

このコードのどこに問題があったかというと:

  1. masterKeyのAlias名を明示していない
  2. アップデート後に暗黙的に別の鍵が作られた
  3. secure_prefsという同じ名前のファイルが新しい鍵で再作成された

つまり、見た目は変わらず動いているのに、別の暗号鍵で上書きされたのだ。

データは「消えた」のではない、「読めなくなった」

ここで重要なのは、データそのものは端末に存在していたということ。

ファイルはそこにある。

でも、正しい鍵がないから復号できない=読み込めない

これはつまり、ユーザーから見たら「データが全部消えた」ように見えるわけで、アプリへの信頼は一瞬で崩れる

対処法:鍵の管理は明示的に。テストも怠るな

この事件をきっかけに、以下のような対策を導入した。

対策①:鍵のAliasを明示指定

val masterKey = MasterKey.Builder(context, "my_app_master_key")
    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
    .build()

これにより、ライブラリが暗黙の命名ルールに頼らず、自分たちで鍵の一貫性を保てるようになる。

対策②:暗号化ライブラリの更新時はデータ復号の回帰テストを実施

CIパイプラインに以下のテストを追加:

  • 暗号化済みのSharedPreferencesを保持したテスト端末で更新を適用
  • 更新後に保存値が復号できることを確認
  • バックアップから旧形式の鍵で復号できるか確認

対策③:マイグレーション処理の実装

万が一、鍵の仕様変更が必要になる場合に備え、以下のような安全な移行ステップを用意:

  1. 旧形式の鍵でデータを一旦復号
  2. 平文で一時的に保存(メモリ上でのみ)
  3. 新形式の鍵で再暗号化して保存

教訓:「見えない鍵」が最も重いリスクになる

この失敗を通して学んだ最大のことは、「暗号化データは“ただの文字列”ではない」ということ。

  • ライブラリの更新
  • 鍵の再生成
  • OSバージョン間のKeystore挙動の違い

ほんの少しの変更が、すべてのローカルデータの死に直結する。

まとめ:暗号化は便利だけど、雑に使うな

Androidの暗号化周りは年々整備が進んでおり、開発者が簡単にセキュリティを高められるようになっている。

しかし、簡単に“使える”ことと、安全に“維持できる”ことはまったく別の話だ。

たとえライブラリのアップデートでAPIが変わらなくても、その裏で何が起きているのか──

特に“鍵の扱い”が変わっていないかは常に注視するべきである。

タイトルとURLをコピーしました