サインアウトを押したあと、ログイン画面に戻るところまでは手元で確かめておりました。おかしいと思ったのは、その端末を再起動してアプリを開き直したときです。ログイン済みの画面が、何ごともなかったように出てきました。
コードは素直に書いてあったはずでした。SecureStore.deleteItemAsync を呼び、続けて getItemAsync が null を返すことを確認してから、サインイン画面へ遷移する——その順番です。確認は通っておりました。それでも値は残っていたのです。
原因は仕様の穴ではなく、私の確かめ方にありました。同じ取り違えは、トークンや API キーの後始末を書いている方なら、どなたでも踏みうるかたちをしています。
サインアウトの直後に null が返っても、消えたとは限りません
Expo の公式リファレンスによれば、deleteItemAsync は「値を削除できなかった場合に reject する Promise」を返します(SecureStore — Expo Documentation)。ここまでは素直です。
ところが Android では、その reject に至る前に、プロセス内に持っているマップから先に鍵が外れていることがあります。読み出しはそのマップを見て「あるか/ないか」を判定しますので、同じプロセスが生きているあいだは null が返り続けます。ディスク上のファイルには、暗号化された値がそのまま残ったままです。
アプリを再起動すると、マップはディスクから読み直されます。私の端末で値が戻って見えたのは、そのためでした。
| 画面で見えること | 実際に起きていること | 気づける場面 |
|---|---|---|
| 削除が成功し、サインアウトできた | ディスクからも消えている | — |
削除の例外を握りつぶしたが、読み出しは null | ディスクには残っている | 再起動、または別プロセスからの読み出し |
| 削除が例外を投げ、その場で気づいた | ディスクには残っている | その場 |
二段目が厄介です。画面の上では一段目とまったく同じに見えます。
Android の SecureStore が「無い」と答える根拠
Android では、SecureStore の値は SharedPreferences に置かれ、Android Keystore の鍵で暗号化されます。iOS ではキーチェーンに kSecClassGenericPassword として入ります。同じ API に見えても、下にある器はまったく別のものです。
この挙動は Expo 側の Issue として報告されております。削除がディスクへ届かずに reject した時点で、メモリ上のマップはすでに空になっており、読み出しがそのマップに問い合わせる限り「無い」と答え続ける——という指摘です(expo/expo Issue #49934)。プロセスを作り直せば値は戻ります。何も上書きしていないのですから、当然といえば当然でした。
null が「削除できた」以外の意味を持つ経路は、もう一つあります。公式リファレンスは、requireAuthentication: true で保存した値について、生体情報の登録内容が変わると鍵が無効化され、その後は読み出せなくなると書いています。無効化された鍵に対する getItemAsync も null を返します。指紋を一つ追加しただけのユーザーが、削除した覚えのない値を失う——これも読み出しでは区別がつきません。
読み出しの null は、少なくとも三つの状態をひとまとめにしています。値がそもそも無い場合と、削除に失敗してマップだけが空になっている場合と、鍵が無効化された場合です。この三つを一つの if で引き受けさせていたことが、そもそもの無理でした。
もう一つ、方向の違う落とし穴が iOS にあります。公式ドキュメントは、iOS のキーチェーンに保存した値は同じ bundle ID で再インストールすると残ることがあると明記しています。Android は再インストールで消えます。「消えたかどうか」の感覚が、プラットフォームごとに逆向きにずれるのです。
削除の結果を握りつぶさない — 最小の書き直し
私が書いていたのは、こういうコードでした。
// Before: 失敗しても「読めなければよい」と考えていました
async function signOut() {
try {
await SecureStore.deleteItemAsync('session_token');
} catch {
// ここで握りつぶしていました
}
const left = await SecureStore.getItemAsync('session_token');
if (left === null) {
navigateToSignIn();
}
}読み出しによる確認は、うまくいっているときには正しく動きます。困るのは、いちばん知りたい場面でだけ嘘をつくことです。削除が失敗した回に限って null が返るのですから、検査としては向きが逆でした。
書き直したのは、たった一つの関数です。
import * as SecureStore from 'expo-secure-store';
type ClearResult = { cleared: boolean; reason?: string };
export async function clearSecret(key: string): Promise<ClearResult> {
try {
await SecureStore.deleteItemAsync(key);
return { cleared: true };
} catch (e) {
return {
cleared: false,
reason: e instanceof Error ? e.message : String(e),
};
}
}例外を潰す代わりに、結果として持ち上げているだけです。呼び出し側は cleared を見て、サインアウトの完了を宣言してよいかを決められるようになります。
削除できたかどうかは、読み出しではなく削除の結果で判断します。 この一行を先に決めてから、周りのコードを組み直しました。
なお、消す前にダミー値で上書きしておく手も考えました。上書きが通れば元の値は読めなくなりますので、残骸の危険は小さくなります。ただ、その書き込み自体が失敗する可能性は残ります。ですので上書きは保険として置き、判断の根拠は戻り値のままにしております。
残ってしまったときに、次の起動で取り返す
失敗を検知できるようになると、次は「検知したあとどうするか」が必要になります。ユーザーを画面の前で待たせ続けるわけにはまいりませんので、私は次の起動へ持ち越す形にしました。
控えを置く場所は、秘密ではない側のストレージで足ります。残すのは鍵の名前だけで、値は持たせません。
import AsyncStorage from '@react-native-async-storage/async-storage';
const PENDING_KEY = 'secure_cleanup_pending';
async function readPending(): Promise<string[]> {
const raw = await AsyncStorage.getItem(PENDING_KEY);
return raw ? (JSON.parse(raw) as string[]) : [];
}
export async function requestClear(key: string): Promise<boolean> {
const result = await clearSecret(key);
if (result.cleared) {
return true;
}
const pending = await readPending();
if (!pending.includes(key)) {
pending.push(key);
await AsyncStorage.setItem(PENDING_KEY, JSON.stringify(pending));
}
return false;
}
// アプリ起動直後、最初の画面を出す前に一度だけ呼びます
export async function retryPendingClears(): Promise<void> {
const pending = await readPending();
if (pending.length === 0) {
return;
}
const stillLeft: string[] = [];
for (const key of pending) {
const result = await clearSecret(key);
if (!result.cleared) {
stillLeft.push(key);
}
}
if (stillLeft.length > 0) {
await AsyncStorage.setItem(PENDING_KEY, JSON.stringify(stillLeft));
} else {
await AsyncStorage.removeItem(PENDING_KEY);
}
}retryPendingClears を起動直後に置くところが要点です。マップがディスクから読み直された直後であれば、前回失敗した削除がそのまま通ることがあります。私の端末では、この一手で残骸が消えました。
控えを AsyncStorage に置いているのは、SecureStore が書けない状況で SecureStore に控えを書こうとしても仕方がないためです。ここに入るのは鍵の名前だけですので、読まれて困るものは含みません。
再試行は何度走っても同じ結果になるように書いてあります。すでに消えている鍵に対する削除は、例外を投げずに通ります。控えが残り続ける場合は、ストレージ側に別の問題があると考えて、そこで調べ始めるのがよいのだと思います。
壊れた状態のまま起動を繰り返す設計には、別の危うさもあります。起動時に走る処理を増やすときの線引きについては、壊れたキャッシュで毎回起動時に落ちるアプリ——ユーザーが自力で抜け出せる「セーフモード起動」を設計するにまとめております。
端末の後始末より先に、サーバー側を失効させます
ここまで書いておいて申し上げるのも妙ですが、端末に残るかどうかを完全に制御することは、そもそもこちら側にはできません。ストレージが逼迫していても、OS 側の事情で書き込みが弾かれても、結果は同じです。
ですので、順番を変えました。
- サーバーへ失効を投げます(成功を待ちます)
- 端末の値を
clearSecretで消します - 消せなければ控えを置き、次の起動で再試行します
この並びにすると、今度は「電波が無いところでサインアウトを押された場合」が残ります。1 の成功を待てませんので、そのままでは先へ進めません。
私が選んだのは、端末側に「このセッションはもう使わない」という印を先に立てる方法です。印が立っている間、アプリはトークンを読み出しても API 呼び出しには使いません。そのうえで、失効の送信と値の削除を控えに積み、次に通信できたときと、次の起動時に、それぞれ再試行します。ユーザーから見れば、押した瞬間にサインアウトは終わっています。
この順番であれば、2 と 3 が転んでも、端末に残るのはもう通らない文字列です。端末の削除は後始末であって、安全の根拠ではありません。 私はこの並びだけは、急いでいる日でも崩さないようにしております。
秘密の置き場所そのものを見直すなら、ビルドに焼き込まれる値との切り分けも一度整理しておくと迷いが減ります。そちらはEAS の secret は「アプリに入れない」設定ではありません — 接頭辞と可視性を別々に決めるに書き残しました。
今日、最初に触る1か所
サインアウト処理の catch を開いてみてください。そこで何も書かずに閉じている波かっこがあれば、それを戻り値に変えるところから始められます。関数一つ分の手間で、「消えたつもり」がどれくらい起きているかが見えるようになります。
私はこの確認を、公開中のアプリを順に開いて一つずつ進めております。手が速く動く作業ではありませんが、あとから取り返せない種類の取りこぼしですので、静かに続けるほかないのだと感じています。
最後までお読みいただき、ありがとうございました。