flutter_secure_storageを読む
Flutterでデータをセキュアに保存しようとすると、flutter_secure_storageを使うことになります。
flutter_secure_storageは「iOSではKeychain、AndroidではKeyStoreを使ってデータを保存してくれるライブラリ」と説明されることが多いのではないのでしょうか。チラッとライブラリのコードを見てみたところ、読みこなせない量ではなさそうだったので、AndroidとiOS、そしてWebでどのようにデータを保存しているのかを確認してみました。
記事執筆時点でのflutter_secure_storageのバージョンは、9.0.0
です。
Android
Androidの場合、EncryptedSharedPreferencesを使ってデータを保存する方法と、SharedPreferencesを使ってデータを保存する方法があります。
EncryptedSharedPreferencesはAndroid 23以上で利用できる、端末の暗号化機能を使ったSharedPreferencesを利用するためのクラスです。近年では、利用が推奨される傾向にありました。[1]
しかし今回は、EncryptedSharedPreferencesを利用しないケースを主に確認します。まずは、その理由から。
EncryptedSharedPreferencesのケースを見ない理由
以下の2つです。
- EncryptedSharedPreferencesがランダムにクラッシュする恐れがある
- androidx.security.cryptoの各APIがdeprecatedになる見込みがある
EncryptedSharedPreferencesがランダムにクラッシュする恐れがある
こちらのStackOverflowの質問にあるように、EncryptedSharedPreferencesを利用するとランダムにクラッシュする恐れがあります。この問題は、以下のIssueで報告されています。
この問題は、ライブラリの内部に存在するものであるため、利用するアプリ側で回避できません。
問題が解消される見込みが立っていないため、EncryptedSharedPreferencesを利用する1つのリスクになっています。特にFlutterの場合には、EncreyptedSharedPreferencesをflutter_secure_storageライブラリ経由で利用することになるため、対応が難しくなっています。
(また、後述の理由により、この問題が解消される見込みはそこまで高くありません)
androidx.security.cryptoの各APIがdeprecatedになる見込みがある
先日Twitterで話題になっていた件です。詳細は、下記のgerritが対応を行ったcommitになるため、最も参考になるのではないでしょうか。
未だに正式な発表に至ったわけではないのですが、次の文章が公式ドキュメントに追加された以上、非推奨になったとみなして良いと思われます。
Jetpack Security Crypto ライブラリは非推奨になりました。これは、アプリ モジュールの build.gradle ファイルに次の依存関係がある場合にのみ影響します。
筆者の理解としては、これは「Androidの内部ストレージは、基本的なユースケースにおいては安全である」という前提に立ち返ったものだと思われます。先ほどのgerritのコメントで、次のドキュメントへの参照があります。
デフォルトでは、内部ストレージ上に作成したファイルにアクセスできるのは、作成元のアプリに限られます。Android は、プラットフォーム レベルでこの保護機能を実装しており、ほとんどのアプリはこの機能で十分です。
これまでのセキュアな取り組みに反するようですが、確かに、Androidの内部システムはアプリが作成したファイルは作成元のアプリに限られるように作られています。(Androidを支える技術〈Ⅱ〉が詳しいです。)
対応に至った理由のIssueには、現在アクセスできません。
よって、この対応の背景については、現時点では詳細が不明です。
以下は、筆者が「こんなところなのかなぁ」と思っていることになります。
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のインスタンスを取得している箇所から、コードを見ていくのがわかりやすいと思います。このため、ちょっと長くなるのですが、順々にコードを確認します。
read
やdelete
メソッドでは、最初に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
には、keyCipherAlgorithm
とstorageCipherAlgorithm
の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);
}
これらを踏まえると、getCurrentStorageCipher
はAES_GCM_NoPadding
に対応するStorageCipher
が得られることがイメージしやすいかと思います。対応するKeyCiper
はRSACipherOAEPImplementation
です。
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
は定数の文字列が定義されており、ぜいじゃくな実装のようです。
実際のコードを読みたい方は、下のトグルから確認してください。
引用したコード
FlutterSecureStorage#ensureInitialized
FlutterSecureStorage#initStorageCipher
StorageCipherFactory
StorageCipherFactory#getCurrentStorageCipher
StorageCipher18Implementation
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
が利用されています。
この2つの違いは、ハードウェア格納型キーストアを利用するかどうかです。筆者のざっくりとした理解では、android.security.keystore
配下のAPIを利用することで、Android端末のセキュリティ機能を利用した領域に鍵を保存できます。これにより、鍵を外部から取得することが、非常に難しくなります。
結果として、Android 23以上の端末では、EncryptedSharedPreferencesを利用しないケースであってもAndroid端末のセキュリティ機能を利用し、値を保存できるようになります。EncryptedSharedPreferences相当の処理を、自前実装しているようなもの、なのではないかなと。
※筆者は、EncryptedSharedPreferencesとflutter_secure_storageの実装の差異を、細かく比較する能力を持っていません。ぜひお近くのセキュリティエンジニアに、実装の違いなどをご確認いただければと思います。
引用したコード
RSACipher18Implementation
RSACipherOAEPImplementation
関連Issue
Androidの実装においては、リポジトリのIssueでも議論がなされています。特に有用なのは、以下のIssueです。(この記事と同じく、EncryptedSharedPreferencesを利用しないケースを確認しています。)
ある意味で、上記Issueを確認すれば、この記事は不要なのかもしれません。どちらかといえば、Androidの実装のパートについては、Issueのコメントを自分で読むためのサポート記事として捉えていただければと思います。
また、暗号化が端末に紐づく以上、AutoBackupを利用できません。
この点については、以下のIssueで議論がなされています。
flutter_secure_storageをAndroidで利用する際に、ぜひ気をつけておきたい点です。
iOS
iOSの実装はシンプルです。
SwiftFlutterSecureStoragePlugin
のread
とwrite
メソッドを見ると、追うべき処理の半分が終わります。
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.read
とflutterSecureStorageManager.write
を呼び出しています。これらはFlutterSecureStorage
のread
とwrite
メソッドです。読む必要のあるコードの残り半分が、こちらです。
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へのアクセスをしているだけ」ということがわかるでしょう。馴染みのない方は、以下のドキュメントをご確認ください。
iOSにおけるKeyChainは、Androidのハードウェア格納型キーストアに該当します。通常iPhoneやiPadにおいては、KeyChainに保存した情報はセキュアであると見なされます。
よって、iOSの実装においては、KeyChainに保存されていることが確認できればOKです。
引用したコード
FlutterSecureStoragePlugin
FlutterSecureStorage
関連Issue
なお、KeyChainはアカウントに紐付き、アプリの再インストールがなされた場合にデータが保持されます。Androidの実装では、AutoBackupを利用できないため、OSによる動作の差分となりえます。開発中は気づきにくいので、注意が必要です。
Web
flutter_secure_storageはWebをサポートしています
議論は2019年ごろから、次のIssueで行われています。
Webの実装は、jsライブラリを利用し、Web Crypto APIを利用しています。
subtle.dart
flutter_secure_storageのWeb実装を読むためには、jsライブラリの使い方を把握する必要があります。以下は筆者の理解になります。厳密な内容は、jsライブラリのREADMEをご確認ください。
DartからWeb Crypto APIなどを利用するためには、Dartのコードとして型が必要となります。
かつてはjs_facade_genというツールにより、TypeScriptの型定義からDartの型を生成することが試みられていたようですが、すでに開発は停止しています。よって、現在は手作業で型を定義する必要があります。
この型定義を行っているのが、subtle.dart
です。
@JS
をつけてクラスを定義することで、Dartの他のクラスから参照したときに、Web Crypto APIなどを呼び出せるようになります。当然ではありますが、型定義を失敗したときには、実行時にエラーとなります。flutter_secure_storageのWeb実装では、各定義のコメント部に、参照している型定義のURLが記載されています。非常に読みやすく、助かります。
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.encrypt
とcrypto.decrypt
を呼び出しています。
引数に入っているoptions
は、flutter_secure_storageのWebOptions
です。以下のように、dbName
とpublicKey
の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の実装では、dbName
とpublicKey
を利用して、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ライブラリがアプリに混入する点についても、気をつける必要があると思われます。
LocalStorageは、同一オリジンポリシーを持っており、同一オリジンのJavaScriptからのみアクセス可能です。このため、サブドメインの扱いなどを誤ることがなければ、LocalStorageに保存されたデータは、他のサイトから読み取られることはありません。
また、CanvasKitによるレンダリングを利用していれば、HTML DOMの差し替えによる攻撃にも対応できそうです。とはいえ、2023年末現在では、モバイル回線向けにHTMLレンダリングが行われることも多いでしょう。この意味で、Flutter以外のSPAフレームワークにおける攻撃が、Flutter Webでも問題になる恐れはあると思われます。
続いて、意図しないJSライブラリの混入について。Flutterの公式ライブラリでは、FlutterやDartのチームが開発していないライブラリに依存しないよう、細心の注意が払われています。
このため、Flutterの公式ライブラリを利用する限り、意図しないJSライブラリを利用することはありません。LocalStorageがJSライブラリからも読み取れてしまうことが問題になる以上、この点は有利に働くはずです。
問題があるとすれば、アプリの開発のために、開発者が追加するライブラリです。注意が必要なのは、Flutter Web用の実装を確認するのと同時に、ライブラリの中で利用しているJSライブラリも確認する必要があるということです。
Flutter Webでは、典型的にはDartパッケージで利用するためのJSライブラリを、index.html
にアプリ開発者が追加します。このため、アプリ開発者はindex.html
を確認することで、どのライブラリがどのJSライブラリを読み込んでいるか把握している、と考えているかもしれません。しかし、Flutter Web向けのライブラリの中には、ライブラリの中で動的にJSライブラリを読み込むものがあるのがあります。
これはDartライブラリの利便性を上げてくれるものですが、LocalStorageのセキュリティには影響を与える恐れがあります。
たとえばprintingは、Web向けのサポートのためにJSライブラリを実行時に読み込んでいます。
このケースはpdfjsを読み込んでいるだけなので、問題はないでしょう。一方で、このように動的にJSライブラリを読み込めることを知らなければ、誤った判断をしてしまう可能性があります。
LocalStorageの振る舞いについては、以下の記事を参考にしています。Flutter Webの開発をしていくために、継続的に勉強せねばと感じているところです。
Flutter Webのセキュリティについては、Flutter Web自体が新しいこともあり、まだまだ議論がなされている段階だと思います。
なにか気づいたことなどあれば、ぜひコミュニティに投稿してみてください。
まとめ
flutter_secure_storageは、導入するだけで、データの保存をセキュアに行うことができる素晴らしいライブラリです。そして、AndroidやiOSに馴染みのあるエンジニアであれば、比較的容易に確認できます。最近ではLinuxやWindows、macOSなどもサポートされており、ますます便利になっています。複数のプラットフォーム向けに開発を行えるFlutterの利点を活かす意味でも、shared_preferencesと同等の、欠かせないライブラリの1つだと考えています。
この便利なライブラリに全てお任せ、という形でも(オープンソースですし)問題はないのかな、とも思います。一方で、ライブラリの実装を確認することで、より自信を持ってライブラリを利用できるようになるのではないでしょうか。
本記事をきっかけに、flutter_secure_storageの実装に関心を持ってくれる人が増えてくれれば、とても嬉しいことだなと思っています。
Discussion