🔒

flutter_secure_storageを読む

2023/12/13に公開

Flutterでデータをセキュアに保存しようとすると、flutter_secure_storageを使うことになります。

https://pub.dev/packages/flutter_secure_storage

flutter_secure_storageは「iOSではKeychain、AndroidではKeyStoreを使ってデータを保存してくれるライブラリ」と説明されることが多いのではないのでしょうか。チラッとライブラリのコードを見てみたところ、読みこなせない量ではなさそうだったので、AndroidとiOS、そしてWebでどのようにデータを保存しているのかを確認してみました。

記事執筆時点でのflutter_secure_storageのバージョンは、9.0.0です。

https://github.com/mogol/flutter_secure_storage/tree/v9.0.0/flutter_secure_storage

Android

Androidの場合、EncryptedSharedPreferencesを使ってデータを保存する方法と、SharedPreferencesを使ってデータを保存する方法があります。

https://developer.android.com/reference/androidx/security/crypto/EncryptedSharedPreferences

EncryptedSharedPreferencesはAndroid 23以上で利用できる、端末の暗号化機能を使ったSharedPreferencesを利用するためのクラスです。近年では、利用が推奨される傾向にありました。[1]
しかし今回は、EncryptedSharedPreferencesを利用しないケースを主に確認します。まずは、その理由から。

EncryptedSharedPreferencesのケースを見ない理由

以下の2つです。

  1. EncryptedSharedPreferencesがランダムにクラッシュする恐れがある
  2. androidx.security.cryptoの各APIがdeprecatedになる見込みがある

EncryptedSharedPreferencesがランダムにクラッシュする恐れがある

https://stackoverflow.com/questions/73784634/caused-by-com-google-crypto-tink-shaded-protobuf-invalidprotocolbufferexception

こちらのStackOverflowの質問にあるように、EncryptedSharedPreferencesを利用するとランダムにクラッシュする恐れがあります。この問題は、以下のIssueで報告されています。

https://issuetracker.google.com/issues/164901843

この問題は、ライブラリの内部に存在するものであるため、利用するアプリ側で回避できません。

問題が解消される見込みが立っていないため、EncryptedSharedPreferencesを利用する1つのリスクになっています。特にFlutterの場合には、EncreyptedSharedPreferencesをflutter_secure_storageライブラリ経由で利用することになるため、対応が難しくなっています。
(また、後述の理由により、この問題が解消される見込みはそこまで高くありません)

androidx.security.cryptoの各APIがdeprecatedになる見込みがある

先日Twitterで話題になっていた件です。詳細は、下記のgerritが対応を行ったcommitになるため、最も参考になるのではないでしょうか。

https://android-review.googlesource.com/c/platform/frameworks/support/+/2761067

未だに正式な発表に至ったわけではないのですが、次の文章が公式ドキュメントに追加された以上、非推奨になったとみなして良いと思われます。

Jetpack Security Crypto ライブラリは非推奨になりました。これは、アプリ モジュールの build.gradle ファイルに次の依存関係がある場合にのみ影響します。

https://developer.android.com/guide/topics/security/cryptography?hl=ja#jetpack_security_crypto_library


筆者の理解としては、これは「Androidの内部ストレージは、基本的なユースケースにおいては安全である」という前提に立ち返ったものだと思われます。先ほどのgerritのコメントで、次のドキュメントへの参照があります。

https://developer.android.com/privacy-and-security/security-tips?hl=ja#InternalStorage

デフォルトでは、内部ストレージ上に作成したファイルにアクセスできるのは、作成元のアプリに限られます。Android は、プラットフォーム レベルでこの保護機能を実装しており、ほとんどのアプリはこの機能で十分です。

これまでのセキュアな取り組みに反するようですが、確かに、Androidの内部システムはアプリが作成したファイルは作成元のアプリに限られるように作られています。(Androidを支える技術〈Ⅱ〉が詳しいです。)

https://gihyo.jp/book/2017/978-4-7741-8861-4


対応に至った理由のIssueには、現在アクセスできません。
よって、この対応の背景については、現時点では詳細が不明です。

https://issuetracker.google.com/issues/301997816

以下は、筆者が「こんなところなのかなぁ」と思っていることになります。

Androidの内部ストレージセキュリティが突破されるのは、筆者の理解では、root化された端末であったり、端末のパスコードが突破された端末です。前者は基本的なユースケースに含まれない判断が。後者は「端末のセキュリティが突破された」時点でストレージを暗号化して対応できる以上の問題なのではないかなと。

EncryptedSharedPreferencesに保存したデータは、バックアップや端末間の移動を実現できません。こういった機能的に制限される状況があるため、EncryptedSharedPreferencesをはじめとして、androidx.security.cryptoのAPIがdeprecatedになったのではないかと想像しています。

もちろん、「Androidアプリが保存するデータは、暗号化した方が良い」という意見もあります。このandroidx.security.cryptoを使わず保存する値を暗号化しているのが、flutter_secure_storageのencryptedSharedPreferences: falseのケースです。

SharedPreferences + StorageCipher + KeyCipher

EncryptedSharedPreferencesを利用しない場合、SharedPreferencesを利用してデータを保存することになります。この場合、暗号化を行うために、StorageCipherクラスをflutter_secure_storageでは実装しています。以下、簡単に処理を確認していきます。

StorageCipher

保存用のSharedPreferencesのインスタンスを取得している箇所から、コードを見ていくのがわかりやすいと思います。このため、ちょっと長くなるのですが、順々にコードを確認します。

readdeleteメソッドでは、最初にensureInitializedメソッドが呼び出されています。
後ほどコードを確認できるリンクを貼るので、コードブロックで引用する際には、一部の処理を省略しています。

private void ensureInitialized() {
    SharedPreferences nonEncryptedPreferences = applicationContext.getSharedPreferences(
            SHARED_PREFERENCES_NAME,
            Context.MODE_PRIVATE
    );
    if (storageCipher == null) {
        try {
            initStorageCipher(nonEncryptedPreferences);
        } catch (Exception e) {
            Log.e(TAG, "StorageCipher initialization failed", e);
        }
    }
    if (getUseEncryptedSharedPreferences() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
        try {
            preferences = initializeEncryptedSharedPreferencesManager(applicationContext);
            checkAndMigrateToEncrypted(nonEncryptedPreferences, preferences);
        } catch (Exception e) {
            Log.e(TAG, "EncryptedSharedPreferences initialization failed", e);
            preferences = nonEncryptedPreferences;
            failedToUseEncryptedSharedPreferences = true;
        }
    } else {
        preferences = nonEncryptedPreferences;
    }
}

private void initStorageCipher(SharedPreferences source) throws Exception {
    storageCipherFactory = new StorageCipherFactory(source, options);
    if (getUseEncryptedSharedPreferences()) {
        storageCipher = storageCipherFactory.getSavedStorageCipher(applicationContext);
    } else if (storageCipherFactory.requiresReEncryption()) {
        reEncryptPreferences(storageCipherFactory, source);
    } else {
        storageCipher = storageCipherFactory.getCurrentStorageCipher(applicationContext);
    }
}

EncryptedSharedPreferencesを有効にしなかったケースでは、以下の処理が実際に実行されることになります。(なお、EncryptedSharedPreferencesを有効にすると、ここでコードのチェックは終了となります。)

private void ensureInitialized() {
    SharedPreferences nonEncryptedPreferences = applicationContext.getSharedPreferences(
            SHARED_PREFERENCES_NAME,
            Context.MODE_PRIVATE
    );
    if (storageCipher == null) {
        try {
            initStorageCipher(nonEncryptedPreferences);
        } catch (Exception e) {
            Log.e(TAG, "StorageCipher initialization failed", e);
        }
    }

    preferences = nonEncryptedPreferences;
}

private void initStorageCipher(SharedPreferences source) throws Exception {
    storageCipherFactory = new StorageCipherFactory(source, options);
    storageCipher = storageCipherFactory.getCurrentStorageCipher(applicationContext);
}

このため、storageCipherに何が入っているかが問題になります。というのもwriteメソッドでは、このstorageCipherを利用してデータを暗号化しており、readメソッドでは、このstorageCipherを利用してデータを復号しているためです。

void write(String key, String value) throws Exception {
    ensureInitialized();

    SharedPreferences.Editor editor = preferences.edit();

    if (getUseEncryptedSharedPreferences()) {
        editor.putString(key, value);
    } else {
        byte[] result = storageCipher.encrypt(value.getBytes(charset));
        editor.putString(key, Base64.encodeToString(result, 0));
    }
    editor.apply();
}

String read(String key) throws Exception {
    ensureInitialized();

    String rawValue = preferences.getString(key, null);
    if (getUseEncryptedSharedPreferences()) {
        return rawValue;
    }
    return decodeRawValue(rawValue);
}

private String decodeRawValue(String value) throws Exception {
    if (value == null) {
        return null;
    }
    byte[] data = Base64.decode(value, 0);
    byte[] result = storageCipher.decrypt(data);

    return new String(result, charset);
}

では次にgetCurrentStorageCipherの処理を見たいのですが、その前にStorageCipherFactoryのコンストラクタを見てみましょう。

public StorageCipherFactory(SharedPreferences source, Map<String, Object> options) {
    savedKeyAlgorithm = KeyCipherAlgorithm.valueOf(source.getString(ELEMENT_PREFERENCES_ALGORITHM_KEY, DEFAULT_KEY_ALGORITHM.name()));
    savedStorageAlgorithm = StorageCipherAlgorithm.valueOf(source.getString(ELEMENT_PREFERENCES_ALGORITHM_STORAGE, DEFAULT_STORAGE_ALGORITHM.name()));

    final KeyCipherAlgorithm currentKeyAlgorithmTmp = KeyCipherAlgorithm.valueOf(getFromOptionsWithDefault(options, "keyCipherAlgorithm", DEFAULT_KEY_ALGORITHM.name()));
    currentKeyAlgorithm = (currentKeyAlgorithmTmp.minVersionCode <= Build.VERSION.SDK_INT) ? currentKeyAlgorithmTmp : DEFAULT_KEY_ALGORITHM;
    final StorageCipherAlgorithm currentStorageAlgorithmTmp = StorageCipherAlgorithm.valueOf(getFromOptionsWithDefault(options, "storageCipherAlgorithm", DEFAULT_STORAGE_ALGORITHM.name()));
    currentStorageAlgorithm = (currentStorageAlgorithmTmp.minVersionCode <= Build.VERSION.SDK_INT) ? currentStorageAlgorithmTmp : DEFAULT_STORAGE_ALGORITHM;
}

flutter_secure_storageでは、以下の2つのenumが定義されています。

  • KeyCipherAlgorithm
    • RSA_ECB_PKCS1Padding (Android 23未満)
    • RSA_ECB_OAEPwithSHA_256andMGF1Padding (Android 23以上)
  • StorageCipherAlgorithm
    • AES_CBC_PKCS7Padding (Android 23未満)
    • AES_GCM_NoPadding (Android 23以上)

StorageCipherFactoryのコンストラクタは、実行されているAndroid OSに応じてCiperを切り替えます。。

optionsには、keyCipherAlgorithmstorageCipherAlgorithmの2つの値が入っています。これらの値はAndroidOptionsで指定可能です。デフォルトでは、RSA_ECB_PKCS1Padding(KeyCipher)とAES_CBC_PKCS7Padding(StorageCipher)が指定されています。大抵の場合、Android 23以上が利用されているでしょうし、23未満では自動的に古いアルゴリズムへとフォールバックされるため、問題ない処理です。

public StorageCipher getCurrentStorageCipher(Context context) throws Exception {
    final KeyCipher keyCipher = currentKeyAlgorithm.keyCipher.apply(context);
    return currentStorageAlgorithm.storageCipher.apply(context, keyCipher);
}

これらを踏まえると、getCurrentStorageCipherAES_GCM_NoPaddingに対応するStorageCipherが得られることがイメージしやすいかと思います。対応するKeyCiperRSACipherOAEPImplementationです。

public StorageCipher18Implementation(Context context, KeyCipher rsaCipher) throws Exception {
    secureRandom = new SecureRandom();
    String aesPreferencesKey = getAESPreferencesKey();

    SharedPreferences preferences = context.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE);
    SharedPreferences.Editor editor = preferences.edit();

    String aesKey = preferences.getString(aesPreferencesKey, null);

    cipher = getCipher();

    if (aesKey != null) {
        byte[] encrypted;
        try {
            encrypted = Base64.decode(aesKey, Base64.DEFAULT);
            secretKey = rsaCipher.unwrap(encrypted, KEY_ALGORITHM);
            return;
        } catch (Exception e) {
            Log.e("StorageCipher18Impl", "unwrap key failed", e);
        }
    }

    byte[] key = new byte[keySize];
    secureRandom.nextBytes(key);
    secretKey = new SecretKeySpec(key, KEY_ALGORITHM);

    byte[] encryptedKey = rsaCipher.wrap(secretKey);
    editor.putString(aesPreferencesKey, Base64.encodeToString(encryptedKey, Base64.DEFAULT));
    editor.apply();
}

ではStorageCipherの実装を見るぞ…、と読んでいくと、「おや?」と思う方もいるのではないでしょうか。

というのも、StorageCipherではSharedPreferencesにseacretKeyを保存していることがわかります。肝心のgetAESPreferencesKeyは定数の文字列が定義されており、ぜいじゃくな実装のようです。


実際のコードを読みたい方は、下のトグルから確認してください。

引用したコード

KeyCipher

そもそもKeyCipherの役割はなんなのか、という話に立ち返ります。

public StorageCipher18Implementation(Context context, KeyCipher rsaCipher) throws Exception {
    secureRandom = new SecureRandom();
    SharedPreferences.Editor editor = preferences.edit();

    String aesKey = preferences.getString(aesPreferencesKey, null);

    if (aesKey != null) {
        byte[] encrypted;
        try {
            encrypted = Base64.decode(aesKey, Base64.DEFAULT);
            secretKey = rsaCipher.unwrap(encrypted, KEY_ALGORITHM); // ⇦ ここでunwrap
            return;
        } catch (Exception e) {
            Log.e("StorageCipher18Impl", "unwrap key failed", e);
        }
    }

    byte[] key = new byte[keySize];
    secureRandom.nextBytes(key);
    secretKey = new SecretKeySpec(key, KEY_ALGORITHM);

    byte[] encryptedKey = rsaCipher.wrap(secretKey); // ⇦ ここでwrap
    editor.putString(aesPreferencesKey, Base64.encodeToString(encryptedKey, Base64.DEFAULT));
    editor.apply();
}

StorageCipherFactoryのコンストラクタから、KeyChiperがどのように利用されるかを確認すると、secretKeyをPreferenceに保存するために利用されていることがわかります。つまり、KeyChiperはPreferenceに保存される、seacretKeyを暗号化するためのCipherです。

KeyChiperには、2つの実装があります。Android 23未満用のRSACipher18Implementationと、23以上用のRSACipherOAEPImplementationです。細々とした実装の違いはありますが、注目するべきは、AlgorithmParameterSpecを作るmakeAlgorithmParameterSpecメソッドの違いです。

class RSACipher18Implementation implements KeyCipher {
    // Flutter gives deprecation warning without suppress
    @SuppressWarnings("deprecation")
    private AlgorithmParameterSpec makeAlgorithmParameterSpecLegacy(Context context, Calendar start, Calendar end) {
        return new android.security.KeyPairGeneratorSpec.Builder(context)
                .setAlias(keyAlias)
                .setSubject(new X500Principal("CN=" + keyAlias))
                .setSerialNumber(BigInteger.valueOf(1))
                .setStartDate(start.getTime())
                .setEndDate(end.getTime())
                .build();
    }

    // 筆者注: RSACipher18Implementationでも、Android 23以上ならば新しい処理を使うようになっています。
    @RequiresApi(api = Build.VERSION_CODES.M)
    protected AlgorithmParameterSpec makeAlgorithmParameterSpec(Context context, Calendar start, Calendar end) {
        final KeyGenParameterSpec.Builder builder = new KeyGenParameterSpec.Builder(keyAlias, KeyProperties.PURPOSE_DECRYPT | KeyProperties.PURPOSE_ENCRYPT)
                .setCertificateSubject(new X500Principal("CN=" + keyAlias))
                .setDigests(KeyProperties.DIGEST_SHA256)
                .setBlockModes(KeyProperties.BLOCK_MODE_ECB)
                .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1)
                .setCertificateSerialNumber(BigInteger.valueOf(1))
                .setCertificateNotBefore(start.getTime())
                .setCertificateNotAfter(end.getTime());
        return builder.build();
    }
}

public class RSACipherOAEPImplementation extends RSACipher18Implementation {
    @RequiresApi(api = Build.VERSION_CODES.M)
    @Override
    protected AlgorithmParameterSpec makeAlgorithmParameterSpec(Context context, Calendar start, Calendar end) {
        final KeyGenParameterSpec.Builder builder = new KeyGenParameterSpec.Builder(keyAlias, KeyProperties.PURPOSE_DECRYPT | KeyProperties.PURPOSE_ENCRYPT)
                .setCertificateSubject(new X500Principal("CN=" + keyAlias))
                .setDigests(KeyProperties.DIGEST_SHA256)
                .setBlockModes(KeyProperties.BLOCK_MODE_ECB)
                .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP)
                .setCertificateSerialNumber(BigInteger.valueOf(1))
                .setCertificateNotBefore(start.getTime())
                .setCertificateNotAfter(end.getTime());
        return builder.build();
    }
}

Android 23未満で利用されているのはKeyPairGeneratorSpecです。Android 23でdeprecatedになっています。Android 23以上では、KeyGenParameterSpecが利用されています。

https://developer.android.com/reference/android/security/KeyPairGeneratorSpec

https://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec

この2つの違いは、ハードウェア格納型キーストアを利用するかどうかです。筆者のざっくりとした理解では、android.security.keystore配下のAPIを利用することで、Android端末のセキュリティ機能を利用した領域に鍵を保存できます。これにより、鍵を外部から取得することが、非常に難しくなります。

https://source.android.com/docs/security/features/keystore?hl=ja

結果として、Android 23以上の端末では、EncryptedSharedPreferencesを利用しないケースであってもAndroid端末のセキュリティ機能を利用し、値を保存できるようになります。EncryptedSharedPreferences相当の処理を、自前実装しているようなもの、なのではないかなと。

※筆者は、EncryptedSharedPreferencesとflutter_secure_storageの実装の差異を、細かく比較する能力を持っていません。ぜひお近くのセキュリティエンジニアに、実装の違いなどをご確認いただければと思います。

引用したコード

関連Issue

Androidの実装においては、リポジトリのIssueでも議論がなされています。特に有用なのは、以下のIssueです。(この記事と同じく、EncryptedSharedPreferencesを利用しないケースを確認しています。)

https://github.com/mogol/flutter_secure_storage/issues/413

ある意味で、上記Issueを確認すれば、この記事は不要なのかもしれません。どちらかといえば、Androidの実装のパートについては、Issueのコメントを自分で読むためのサポート記事として捉えていただければと思います。


また、暗号化が端末に紐づく以上、AutoBackupを利用できません。
この点については、以下のIssueで議論がなされています。

https://github.com/mogol/flutter_secure_storage/issues/541

flutter_secure_storageをAndroidで利用する際に、ぜひ気をつけておきたい点です。

iOS

iOSの実装はシンプルです。

SwiftFlutterSecureStoragePluginreadwriteメソッドを見ると、追うべき処理の半分が終わります。

private func read(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) {
    let values = parseCall(call)
    if (values.key == nil) {
        result(FlutterError.init(code: "Missing Parameter", message: "write requires key parameter", details: nil))
        return
    }
    
    let response = flutterSecureStorageManager.read(key: values.key!, groupId: values.groupId, accountName: values.accountName, synchronizable: values.synchronizable, accessibility: values.accessibility)
    handleResponse(response, result)
}

private func write(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) {
    if (!((call.arguments as! [String : Any?])["value"] is String)){
        result(FlutterError.init(code: "Invalid Parameter", message: "key parameter must be String", details: nil))
        return;
    }

    let values = parseCall(call)
    if (values.key == nil) {
        result(FlutterError.init(code: "Missing Parameter", message: "write requires key parameter", details: nil))
        return
    }

    if (values.value == nil) {
        result(FlutterError.init(code: "Missing Parameter", message: "write requires value parameter", details: nil))
        return
    }

    let response = flutterSecureStorageManager.write(key: values.key!, value: values.value!, groupId: values.groupId, accountName: values.accountName, synchronizable: values.synchronizable, accessibility: values.accessibility)

    handleResponse(response, result)
}

flutterSecureStorageManager.readflutterSecureStorageManager.writeを呼び出しています。これらはFlutterSecureStoragereadwriteメソッドです。読む必要のあるコードの残り半分が、こちらです。

internal func read(key: String, groupId: String?, accountName: String?, synchronizable: Bool?, accessibility: String?) -> FlutterSecureStorageResponse {
    let keychainQuery = baseQuery(key: key, groupId: groupId, accountName: accountName, synchronizable: synchronizable, accessibility: accessibility, returnData: true)
    
    var ref: AnyObject?
    let status = SecItemCopyMatching(
        keychainQuery as CFDictionary,
        &ref
    )
    
    var value: String? = nil
    
    if (status == noErr) {
        value = String(data: ref as! Data, encoding: .utf8)
    }

    return FlutterSecureStorageResponse(status: status, value: value)
}

internal func write(key: String, value: String, groupId: String?, accountName: String?, synchronizable: Bool?, accessibility: String?) -> FlutterSecureStorageResponse {        
    var keyExists: Bool = false

    switch containsKey(key: key, groupId: groupId, accountName: accountName, synchronizable: synchronizable, accessibility: accessibility) {
    case .success(let exists):
        keyExists = exists
        break;
    case .failure(let err):
        return FlutterSecureStorageResponse(status: err.status, value: nil)
    }

    var keychainQuery = baseQuery(key: key, groupId: groupId, accountName: accountName, synchronizable: synchronizable, accessibility: accessibility, returnData: nil)

    if (keyExists) {
        var attrAccessible = parseAccessibleAttr(accessibility: accessibility);

        let update: [CFString: Any?] = [
            kSecValueData: value.data(using: String.Encoding.utf8),
            kSecAttrAccessible: attrAccessible,
            kSecAttrSynchronizable: synchronizable
        ]
        
        let status = SecItemUpdate(keychainQuery as CFDictionary, update as CFDictionary)
        
        return FlutterSecureStorageResponse(status: status, value: nil)
    } else {
        keychainQuery[kSecValueData] = value.data(using: String.Encoding.utf8)
        
        let status = SecItemAdd(keychainQuery as CFDictionary, nil)

        return FlutterSecureStorageResponse(status: status, value: nil)
    }
}

iOSに馴染みのある方は、この処理を読めば「KeyChainへのアクセスをしているだけ」ということがわかるでしょう。馴染みのない方は、以下のドキュメントをご確認ください。

https://developer.apple.com/documentation/security/keychain_services/keychain_items/searching_for_keychain_items

iOSにおけるKeyChainは、Androidのハードウェア格納型キーストアに該当します。通常iPhoneやiPadにおいては、KeyChainに保存した情報はセキュアであると見なされます。

https://support.apple.com/ja-jp/guide/security/secb0694df1a/web

よって、iOSの実装においては、KeyChainに保存されていることが確認できればOKです。

引用したコード

関連Issue

なお、KeyChainはアカウントに紐付き、アプリの再インストールがなされた場合にデータが保持されます。Androidの実装では、AutoBackupを利用できないため、OSによる動作の差分となりえます。開発中は気づきにくいので、注意が必要です。

https://github.com/mogol/flutter_secure_storage/issues/82

Web

flutter_secure_storageはWebをサポートしています

議論は2019年ごろから、次のIssueで行われています。

https://github.com/mogol/flutter_secure_storage/issues/96

Webの実装は、jsライブラリを利用し、Web Crypto APIを利用しています。

https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API

subtle.dart

flutter_secure_storageのWeb実装を読むためには、jsライブラリの使い方を把握する必要があります。以下は筆者の理解になります。厳密な内容は、jsライブラリのREADMEをご確認ください。

DartからWeb Crypto APIなどを利用するためには、Dartのコードとしてが必要となります。
かつてはjs_facade_genというツールにより、TypeScriptの型定義からDartの型を生成することが試みられていたようですが、すでに開発は停止しています。よって、現在は手作業で型を定義する必要があります。

この型定義を行っているのが、subtle.dartです。

https://github.com/mogol/flutter_secure_storage/blob/v9.0.0/flutter_secure_storage_web/lib/src/subtle.dart

@JSをつけてクラスを定義することで、Dartの他のクラスから参照したときに、Web Crypto APIなどを呼び出せるようになります。当然ではありますが、型定義を失敗したときには、実行時にエラーとなります。flutter_secure_storageのWeb実装では、各定義のコメント部に、参照している型定義のURLが記載されています。非常に読みやすく、助かります。

flutter_secure_storage_web.dart

https://github.com/mogol/flutter_secure_storage/blob/v9.0.0/flutter_secure_storage_web/lib/flutter_secure_storage_web.dart

Webのreadとwriteメソッドを確認していきます。


Future<void> write({
  required String key,
  required String value,
  required Map<String, String> options,
}) async {
  final iv =
      html.window.crypto!.getRandomValues(Uint8List(12)).buffer.asUint8List();

  final algorithm = _getAlgorithm(iv);

  final encryptionKey = await _getEncryptionKey(algorithm, options);

  final encryptedContent = await js_util.promiseToFuture<ByteBuffer>(
    crypto.encrypt(
      algorithm,
      encryptionKey,
      Uint8List.fromList(
        utf8.encode(value),
      ),
    ),
  );

  final encoded =
      "${base64Encode(iv)}.${base64Encode(encryptedContent.asUint8List())}";

  html.window.localStorage["${options[_publicKey]!}.$key"] = encoded;
}


Future<String?> read({
  required String key,
  required Map<String, String> options,
}) async {
  final value = html.window.localStorage["${options[_publicKey]!}.$key"];

  return _decryptValue(value, options);
}

Future<String?> _decryptValue(
  String? cypherText,
  Map<String, String> options,
) async {
  if (cypherText == null) {
    return null;
  }

  final parts = cypherText.split(".");

  final iv = base64Decode(parts[0]);
  final algorithm = _getAlgorithm(iv);

  final decryptionKey = await _getEncryptionKey(algorithm, options);

  final value = base64Decode(parts[1]);

  final decryptedContent = await js_util.promiseToFuture<ByteBuffer>(
    crypto.decrypt(
      _getAlgorithm(iv),
      decryptionKey,
      Uint8List.fromList(value),
    ),
  );

  final plainText = utf8.decode(decryptedContent.asUint8List());

  return plainText;
}

ざっくりいえば、_getEncryptionKeyで得られたkeyを利用して、crypto.encryptcrypto.decryptを呼び出しています。

引数に入っているoptionsは、flutter_secure_storageのWebOptionsです。以下のように、dbNamepublicKeyの2つの値を持っています。

class WebOptions extends Options {
  const WebOptions({
    this.dbName = 'FlutterEncryptedStorage',
    this.publicKey = 'FlutterSecureStorage',
  });

  static const WebOptions defaultOptions = WebOptions();

  final String dbName;
  final String publicKey;

  
  Map<String, String> toMap() => <String, String>{
        'dbName': dbName,
        'publicKey': publicKey,
      };
}

この定数を持つことで、Webの実装では、dbNamepublicKeyを利用して、KeyChainに保存するデータを識別しています。どちらかといえば、Androidの実装に近い印象があります。暗号化と複合をWeb Crypto APIに投げ、localStorageへの操作を行っています。

keyは_getEncryptionKeyメソッドにより、管理されています。

Future<html.CryptoKey> _getEncryptionKey(
  crypto.Algorithm algorithm,
  Map<String, String> options,
) async {
  late html.CryptoKey encryptionKey;
  final key = options[_publicKey]!;

  if (html.window.localStorage.containsKey(key)) {
    final jwk = base64Decode(html.window.localStorage[key]!);

    encryptionKey = await js_util.promiseToFuture<html.CryptoKey>(
      crypto.importKey("raw", jwk, algorithm, false, ["encrypt", "decrypt"]),
    );
  } else {
    //final crypto.getRandomValues(Uint8List(256));

    encryptionKey = await js_util.promiseToFuture<html.CryptoKey>(
      crypto.generateKey(algorithm, true, ["encrypt", "decrypt"]),
    );

    final jsonWebKey = await js_util
        .promiseToFuture<ByteBuffer>(crypto.exportKey("raw", encryptionKey));
    html.window.localStorage[key] = base64Encode(jsonWebKey.asUint8List());
  }

  return encryptionKey;
}

Dartのコードになっているので、比較的読みやすいのではないでしょうか?

生成したencryptionKeyをjwtとしてlocalStorageに保存し、次回以降はその値を利用しています。

Webの実装はセキュアなのか

flutter_secure_storageのWeb実装は正しくFlutterアプリが動作していることを前提として、ある程度は攻撃に対応できるようになっているのでは、と考えています。LocalStorageを利用する際に、直接利用するより、安全に利用できることは間違いありません。

大前提ではあるのですが、ログイン情報などの本当にセンシティブな情報は、firebase_authなどのサービスを利用するべきです。flutter_secure_storageを利用して、Flutter Webでセンシティブな情報を保存する場合には、よくよく調査と検討を行ってください。

以下、筆者の現在の理解を記載します。


モバイル版との大きな違いとして、dev toolの存在があります。LocalStorageはdev toolにより、簡単に閲覧や編集が可能な領域です。このため、flutter_secure_storageの実装を踏まえて、LocalStorageに保存されているjwtを複合することは、一定の技術力があれば可能そうに見えます。この点が問題となる場合には、public keyの値をWebOptionsで変更するなど、(現在の実装では)何らかの対応を行う必要がありそうです。

その他、悪意のあるJSライブラリがアプリに混入する点についても、気をつける必要があると思われます。

https://developer.mozilla.org/ja/docs/Web/API/Window/localStorage

LocalStorageは、同一オリジンポリシーを持っており、同一オリジンのJavaScriptからのみアクセス可能です。このため、サブドメインの扱いなどを誤ることがなければ、LocalStorageに保存されたデータは、他のサイトから読み取られることはありません。

また、CanvasKitによるレンダリングを利用していれば、HTML DOMの差し替えによる攻撃にも対応できそうです。とはいえ、2023年末現在では、モバイル回線向けにHTMLレンダリングが行われることも多いでしょう。この意味で、Flutter以外のSPAフレームワークにおける攻撃が、Flutter Webでも問題になる恐れはあると思われます。

続いて、意図しないJSライブラリの混入について。Flutterの公式ライブラリでは、FlutterやDartのチームが開発していないライブラリに依存しないよう、細心の注意が払われています。

https://github.com/flutter/flutter/issues/122713

https://github.com/flutter/flutter/wiki/Contributing-to-Plugins-and-Packages#dependencies

このため、Flutterの公式ライブラリを利用する限り、意図しないJSライブラリを利用することはありません。LocalStorageがJSライブラリからも読み取れてしまうことが問題になる以上、この点は有利に働くはずです。

問題があるとすれば、アプリの開発のために、開発者が追加するライブラリです。注意が必要なのは、Flutter Web用の実装を確認するのと同時に、ライブラリの中で利用しているJSライブラリも確認する必要があるということです。

Flutter Webでは、典型的にはDartパッケージで利用するためのJSライブラリを、index.htmlにアプリ開発者が追加します。このため、アプリ開発者はindex.htmlを確認することで、どのライブラリがどのJSライブラリを読み込んでいるか把握している、と考えているかもしれません。しかし、Flutter Web向けのライブラリの中には、ライブラリの中で動的にJSライブラリを読み込むものがあるのがあります。

これはDartライブラリの利便性を上げてくれるものですが、LocalStorageのセキュリティには影響を与える恐れがあります。

https://github.com/DavBfr/dart_pdf/blob/printing-5.11.1/printing/lib/printing_web.dart#L90-L96

たとえばprintingは、Web向けのサポートのためにJSライブラリを実行時に読み込んでいます。

このケースはpdfjsを読み込んでいるだけなので、問題はないでしょう。一方で、このように動的にJSライブラリを読み込めることを知らなければ、誤った判断をしてしまう可能性があります。


LocalStorageの振る舞いについては、以下の記事を参考にしています。Flutter Webの開発をしていくために、継続的に勉強せねばと感じているところです。

https://blog.flatt.tech/entry/auth0_access_token_poc

https://mizumotok.hatenablog.jp/entry/2021/08/04/114431

Flutter Webのセキュリティについては、Flutter Web自体が新しいこともあり、まだまだ議論がなされている段階だと思います。

なにか気づいたことなどあれば、ぜひコミュニティに投稿してみてください。

まとめ

flutter_secure_storageは、導入するだけで、データの保存をセキュアに行うことができる素晴らしいライブラリです。そして、AndroidやiOSに馴染みのあるエンジニアであれば、比較的容易に確認できます。最近ではLinuxやWindows、macOSなどもサポートされており、ますます便利になっています。複数のプラットフォーム向けに開発を行えるFlutterの利点を活かす意味でも、shared_preferencesと同等の、欠かせないライブラリの1つだと考えています。

この便利なライブラリに全てお任せ、という形でも(オープンソースですし)問題はないのかな、とも思います。一方で、ライブラリの実装を確認することで、より自信を持ってライブラリを利用できるようになるのではないでしょうか。

本記事をきっかけに、flutter_secure_storageの実装に関心を持ってくれる人が増えてくれれば、とても嬉しいことだなと思っています。

脚注
  1. https://engineering.linecorp.com/ja/blog/securely-advance-encryption-of-SharedPreferences などの記事があります ↩︎

GitHubで編集を提案

Discussion