😎

Supabase/Expoにおける認証ストレージの脆弱性分析とセキュアな実装パターン

に公開

はじめに

SupabaseとExpo (React Native) の組み合わせは、モバイルアプリ開発を加速させる強力なスタックです。特にSupabaseの認証機能は非常に便利で、多くの開発者が利用しています。

しかし、Supabaseの公式ドキュメントに掲載されているExpo向けの認証ストレージに関するコード(LargeSecureStore)には、実は深刻なセキュリティ上の脆弱性が存在します。

この記事では、その脆弱性の原因を技術的に解明し、安全に利用するための具体的な修正コードを提示します。

なぜカスタムストレージLargeSecureStoreが必要なのか?

この問題の背景には、Expoが提供するセキュアなストレージライブラリ expo-secure-store の仕様にあります。

expo-secure-store は、iOSのKeychainやAndroidのKeystoreといったOSレベルの強固なセキュリティ機構を利用して、機密情報を安全に保存するための公式推奨ライブラリです。しかし、このライブラリには保存できる値のサイズが2048バイトまでという厳格な制限があります。

一方、Supabaseが発行するJWT(JSON Web Token)は、ユーザーのロールやメタデータなど多くの情報を含むため、この2048バイトの制限を超えてしまうことがあります。

この問題を回避するために、Supabaseのドキュメントでは LargeSecureStore というカスタムストレージクラスを実装するアプローチが紹介されています。

そのアイデアは以下の通りです。

  1. 暗号鍵(小さいデータ)を生成し、安全な SecureStore に保存する。
  2. その鍵を使って、サイズの大きいJWTを暗号化する。
  3. 暗号化されたJWT(大きいデータ)を、サイズ制限のない AsyncStorage に保存する。

このハイブリッドアプローチのコンセプト自体は非常に合理的です。AsyncStorage は暗号化されないため単体では危険ですが、SecureStore に保管された鍵で暗号化することで安全性を担保するという、確立された設計パターンです。

問題は、そのアイデアを実装した具体的なコードにありました。

公式コードの致命的な脆弱性

公式ドキュメントに記載されている LargeSecureStore の実装には、応用暗号学の世界で「やってはいけない」とされる典型的なミスが含まれています。

公式ドキュメントのコード
import { createClient } from "@supabase/supabase-js";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as SecureStore from 'expo-secure-store';
import * as aesjs from 'aes-js';
import 'react-native-get-random-values';

// As Expo's SecureStore does not support values larger than 2048
// bytes, an AES-256 key is generated and stored in SecureStore, while
// it is used to encrypt/decrypt values stored in AsyncStorage.
class LargeSecureStore {
  private async _encrypt(key: string, value: string) {
    const encryptionKey = crypto.getRandomValues(new Uint8Array(256 / 8));

    const cipher = new aesjs.ModeOfOperation.ctr(encryptionKey, new aesjs.Counter(1));
    const encryptedBytes = cipher.encrypt(aesjs.utils.utf8.toBytes(value));

    await SecureStore.setItemAsync(key, aesjs.utils.hex.fromBytes(encryptionKey));

    return aesjs.utils.hex.fromBytes(encryptedBytes);
  }

  private async _decrypt(key: string, value: string) {
    const encryptionKeyHex = await SecureStore.getItemAsync(key);
    if (!encryptionKeyHex) {
      return encryptionKeyHex;
    }

    const cipher = new aesjs.ModeOfOperation.ctr(aesjs.utils.hex.toBytes(encryptionKeyHex), new aesjs.Counter(1));
    const decryptedBytes = cipher.decrypt(aesjs.utils.hex.toBytes(value));

    return aesjs.utils.utf8.fromBytes(decryptedBytes);
  }

  async getItem(key: string) {
    const encrypted = await AsyncStorage.getItem(key);
    if (!encrypted) { return encrypted; }

    return await this._decrypt(key, encrypted);
  }

  async removeItem(key: string) {
    await AsyncStorage.removeItem(key);
    await SecureStore.deleteItemAsync(key);
  }

  async setItem(key: string, value: string) {
    const encrypted = await this._encrypt(key, value);

    await AsyncStorage.setItem(key, encrypted);
  }
}

const supabaseUrl = YOUR_REACT_NATIVE_SUPABASE_URL
const supabaseAnonKey = YOUR_REACT_NATIVE_SUPABASE_ANON_KEY

const supabase = createClient(supabaseUrl, supabaseAnonKey, {
  auth: {
    storage: new LargeSecureStore(),
    autoRefreshToken: true,
    persistSession: true,
    detectSessionInUrl: false,
  },
});

問題点1:静的なNonce(IV)の再利用

このコードの最も深刻な問題は、AES-CTR(Counter)モードという暗号化方式の使い方が間違っている点です。

new aesjs.ModeOfOperation.ctr(encryptionKey, new aesjs.Counter(1))

この部分で、暗号化の初期化ベクトル(IV)またはNonceと呼ばれる値に、常に 1 という固定値(静的な値)を指定しています。

暗号の世界には「同じ鍵とNonceのペアを、異なるデータの暗号化に決して再利用してはならない」という黄金律があります。このルールを破ると、暗号は劇的に弱体化し、解読可能になってしまいます。

攻撃者は、異なるタイミングで暗号化された2つの暗号文(例えば、トークン更新前後のJWT)を入手できれば、それらをXOR(排他的論理和)するだけで、元の平文同士をXORしたデータを作り出せます。JWTは構造が予測可能であるため、そこから元のJWTを復元し、セッションを乗っ取ることが可能になるのです。

問題点2:暗号鍵の永続性の欠如

もう一つの論理的な欠陥は、_encrypt メソッドが呼ばれるたびに、crypto.getRandomValues毎回新しい暗号鍵を生成している点です。

これでは、一度保存したデータを後から復号することができません。アプリを再起動したり、セッションを読み込んだりする際には、保存時に使われた鍵が必要ですが、その鍵はすでに失われているため、正しく復号できず、永続的なセッション管理が破綻します。

解決策:安全なLargeSecureStoreの実装

supabase.ts (より安全な実装)
import AsyncStorage from "@react-native-async-storage/async-storage";
import { createClient } from "@supabase/supabase-js";
import * as aesjs from "aes-js";
import * as SecureStore from "expo-secure-store";
import "react-native-get-random-values";

// As Expo"s SecureStore does not support values larger than 2048
// bytes, an AES-256 key is generated and stored in SecureStore, while
// it is used to encrypt/decrypt values stored in AsyncStorage.
class LargeSecureStore {
  private async _getEncryptionKey(key: string) {
    const keyHex = await SecureStore.getItemAsync(key);
    if (keyHex) {
      return aesjs.utils.hex.toBytes(keyHex);
    }

    const newKey = crypto.getRandomValues(new Uint8Array(256 / 8));
    await SecureStore.setItemAsync(key, aesjs.utils.hex.fromBytes(newKey));
    return newKey;
  }

  private async _encrypt(key: string, value: string) {
    const encryptionKey = await this._getEncryptionKey(key);

    const iv = crypto.getRandomValues(new Uint8Array(16));

    const cipher = new aesjs.ModeOfOperation.ctr(encryptionKey, new aesjs.Counter(iv));
    const encryptedBytes = cipher.encrypt(aesjs.utils.utf8.toBytes(value));

    const ivHex = aesjs.utils.hex.fromBytes(iv);
    const encryptedHex = aesjs.utils.hex.fromBytes(encryptedBytes);

    return ivHex + encryptedHex;
  }

  private async _decrypt(key: string, value: string) {
    const encryptionKey = await this._getEncryptionKey(key);

    const ivHex = value.substring(0, 16 * 2);
    const encryptedHex = value.substring(16 * 2);
    const iv = aesjs.utils.hex.toBytes(ivHex);

    const cipher = new aesjs.ModeOfOperation.ctr(encryptionKey, new aesjs.Counter(iv));
    const decryptedBytes = cipher.decrypt(aesjs.utils.hex.toBytes(encryptedHex));

    return aesjs.utils.utf8.fromBytes(decryptedBytes);
  }

  async getItem(key: string) {
    const encrypted = await AsyncStorage.getItem(key);
    if (!encrypted) { return encrypted; }

    try {
      return await this._decrypt(key, encrypted);
    } catch (error) {
      console.error("Failed to decrypt data, removing corrupted item:", error);
      await this.removeItem(key);
      return null;
    }
  }

  async removeItem(key: string) {
    await AsyncStorage.removeItem(key);
    await SecureStore.deleteItemAsync(key);
  }

  async setItem(key: string, value: string) {
    const encrypted = await this._encrypt(key, value);

    await AsyncStorage.setItem(key, encrypted);
  }
}

const supabaseUrl = YOUR_REACT_NATIVE_SUPABASE_URL
const supabaseAnonKey = YOUR_REACT_NATIVE_SUPABASE_ANON_KEY

export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
  auth: {
    storage: new LargeSecureStore(),
    autoRefreshToken: true,
    persistSession: true,
    detectSessionInUrl: false,
  },
});

修正点の解説

1. 暗号鍵の永続化 (_getEncryptionKeyメソッド)

公式コードの問題点であった「毎回新しい鍵を生成する」ロジックを修正しました。

  • _getEncryptionKeyメソッドを新設。
  • SecureStore内に鍵が存在するかを確認し、あればそれを再利用、なければ新しく生成して保存します。
  • これにより、一度暗号化したデータを後から正しく復号できるようになり、セッションの永続性が担保されます。

2. 安全なNonce(IV)の管理

最も深刻だったセキュリティ脆弱性を修正しました。

  • ランダムなIVの生成: crypto.getRandomValuesを使い、暗号化のたびに暗号論的に安全でランダムな16バイトのIVを生成します。これにより、Nonceが再利用されることはありません。
  • IVの保存: 生成したIVは秘密情報ではないため、暗号文と一緒に保存するのが標準的な手法です。ここでは、IVを16進数文字列に変換し、暗号化されたデータの先頭に結合しています。
  • 復号時の利用: 復号時は、保存された文字列の先頭16バイト(16進数で32文字)をIVとして切り出し、残りを暗号文として利用します。これにより、暗号化時に使われたものと全く同じIVで復号処理を行うことができます。

3. 堅牢性の向上(エラーハンドリング)

getItemメソッド内の復号処理をtry...catchブロックで囲みました。これにより、AsyncStorage内のデータが何らかの理由で破損していた場合でも、アプリがクラッシュするのを防ぎ、安全に処理を継続できます。

まとめ

Supabaseは非常に優れたサービスですが、公式ドキュメントであっても、今回のようにセキュリティ上見過ごせない問題が含まれていることがあります。公式ドキュメントはあくまでも簡易的な実装例を示しているに過ぎないものが多数存在します。特に認証や暗号化といったクリティカルな領域では、提供されたコードを鵜呑みにしてはいけません。その背後にある原理を理解し、安全性を確認する姿勢が開発者には求められます。

今回提示した修正済みのLargeSecureStoreは、expo-secure-storeのサイズ制限という課題を、セキュリティを犠牲にすることなく解決します。ExpoとSupabaseでアプリを開発している、またはこれから開発する予定の方は、ぜひこの安全な実装を参考にしてください。

この記事が、コーディングライフをより安全にする一助となれば幸いです。セキュアな実装で、自信を持ってプロダクトを世に送り出しましょう!

※記載されている情報や内容は、作成時点での筆者の知見に基づくものです。厳格な確認はしていますが、その正確性・完全性・信頼性・有用性について、いかなる保証もいたしません。

Discussion