Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions flutter_secure_storage/android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<manifest
xmlns:tools="http://schemas.android.com/tools"
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android"
package="com.it_nomads.fluttersecurestorage">
<uses-sdk tools:overrideLibrary="androidx.security"/>
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@

import android.content.Context;
import android.content.SharedPreferences;
import android.hardware.biometrics.BiometricManager;
import android.hardware.biometrics.BiometricPrompt;
import android.os.Build;
import android.os.CancellationSignal;
import android.security.keystore.KeyGenParameterSpec;
import android.security.keystore.KeyProperties;
import android.security.keystore.UserNotAuthenticatedException;
import android.util.Base64;
import android.util.Log;

import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;

import com.it_nomads.fluttersecurestorage.ciphers.StorageCipher;
import com.it_nomads.fluttersecurestorage.ciphers.StorageCipherFactory;
Expand All @@ -18,50 +24,69 @@
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.KeyStoreException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;

public class FlutterSecureStorage {

private static final String TAG = "FlutterSecureStorage";
private static final Charset CHARSET = StandardCharsets.UTF_8;
private static final String DEFAULT_PREF_NAME = "FlutterSecureStorage";
private static final String DEFAULT_KEY_PREFIX = "VGhpcyBpcyB0aGUgcHJlZml4IGZvciBhIHNlY3VyZSBzdG9yYWdlCg";
private static final String PREF_OPTION_NAME = "sharedPreferencesName";
private static final String PREF_OPTION_PREFIX = "preferencesKeyPrefix";
private static final String PREF_OPTION_DELETE_ON_FAILURE = "resetOnError";
private static final String PREF_KEY_MIGRATED = "preferencesMigrated";

@NonNull
private final SharedPreferences encryptedPreferences;
private final FlutterSecureStorageConfig config;
@NonNull
private String preferencesKeyPrefix = DEFAULT_KEY_PREFIX;

public FlutterSecureStorage(Context context, Map<String, Object> options) throws GeneralSecurityException, IOException {
String sharedPreferencesName = DEFAULT_PREF_NAME;
if (options.containsKey(PREF_OPTION_NAME)) {
var value = options.get(PREF_OPTION_NAME);
if (value instanceof String && !((String) value).isEmpty()) {
sharedPreferencesName = (String) value;
}
}
private final Context context;

if (options.containsKey(PREF_OPTION_PREFIX)) {
var value = options.get(PREF_OPTION_PREFIX);
if (value instanceof String && !((String) value).isEmpty()) {
preferencesKeyPrefix = (String) value;
}
}
private SharedPreferences encryptedPreferences;

boolean deleteOnFailure = false;
private FlutterSecureStorage(@NonNull Context context, Map<String, Object> options) {
this.context = context;
this.config = new FlutterSecureStorageConfig(options);
}

if (options.containsKey(PREF_OPTION_DELETE_ON_FAILURE)) {
var value = options.get(PREF_OPTION_DELETE_ON_FAILURE);
if (value instanceof String) {
deleteOnFailure = Boolean.parseBoolean((String) value);
}
}
public static void create(@NonNull Context context, Map<String, Object> options, SecureStorageInitCallback callback) {
FlutterSecureStorage storage = new FlutterSecureStorage(context, options);

storage.authenticateIfNeeded(() -> {
try {
SharedPreferences encryptedPreferences = storage.initializeEncryptedSharedPreferencesManager(storage.config.getSharedPreferencesName());
boolean migrated = encryptedPreferences.getBoolean(PREF_KEY_MIGRATED, false);
if (!migrated) {
storage.migrateToEncryptedPreferences(storage.config.getSharedPreferencesName(), encryptedPreferences, storage.config.shouldDeleteOnFailure(), options);
}

Log.d(TAG, "Encrypted preferences initialized successfully.");
storage.encryptedPreferences = encryptedPreferences;
callback.onComplete(storage, null); // Initialization successful

} catch (KeyStoreException | UserNotAuthenticatedException e) {
Log.w(TAG, "Authentication failed: Unable to access secure keystore. Check biometric settings.", e);
callback.onComplete(null, e);

} catch (GeneralSecurityException | IOException e) {
if (!storage.config.shouldDeleteOnFailure()) {
Log.w(TAG, "Initialization failed: Secure storage could not be initialized. 'deleteOnFailure' is false, skipping reset.", e);
callback.onComplete(null, e);
} else {
Log.w(TAG, "Initialization failed: Resetting storage as 'deleteOnFailure' is enabled.", e);
context.getSharedPreferences(storage.config.getSharedPreferencesName(), Context.MODE_PRIVATE).edit().clear().apply();

encryptedPreferences = getEncryptedSharedPreferences(deleteOnFailure, options, context.getApplicationContext(), sharedPreferencesName);
try {
SharedPreferences encryptedPreferences = storage.initializeEncryptedSharedPreferencesManager(storage.config.getSharedPreferencesName());
Log.i(TAG, "Secure storage successfully re-initialized after reset.");
storage.encryptedPreferences = encryptedPreferences;
callback.onComplete(storage, null); // Re-initialization successful
} catch (Exception f) {
Log.e(TAG, "Critical failure: Initialization after reset failed.", f);
callback.onComplete(null, f);
}
}
}
});
}

public boolean containsKey(String key) {
Expand Down Expand Up @@ -90,55 +115,66 @@ public Map<String, String> readAll() {
for (Map.Entry<String, ?> entry : allEntries.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
if (key.startsWith(preferencesKeyPrefix) && value instanceof String) {
String originalKey = key.replaceFirst(preferencesKeyPrefix + "_", "");
if (key.startsWith(config.getSharedPreferencesKeyPrefix()) && value instanceof String) {
String originalKey = key.replaceFirst(config.getSharedPreferencesKeyPrefix() + "_", "");
result.put(originalKey, (String) value);
}
}
return result;
}

private String addPrefixToKey(String key) {
return preferencesKeyPrefix + "_" + key;
private void authenticateIfNeeded(Runnable onSuccess) {
if (config.shouldUseBiometrics()) {
authenticateUser(onSuccess);
} else {
onSuccess.run();
}
}

private SharedPreferences getEncryptedSharedPreferences(boolean deleteOnFailure, Map<String, Object> options, Context context, String sharedPreferencesName) throws GeneralSecurityException, IOException {
try {
final SharedPreferences encryptedPreferences = initializeEncryptedSharedPreferencesManager(context, sharedPreferencesName);
boolean migrated = encryptedPreferences.getBoolean(PREF_KEY_MIGRATED, false);
if (!migrated) {
migrateToEncryptedPreferences(context, sharedPreferencesName, encryptedPreferences, deleteOnFailure, options);
}
return encryptedPreferences;
} catch (GeneralSecurityException | IOException e) {

if (!deleteOnFailure) {
Log.w(TAG, "initialization failed, resetOnError false, so throwing exception.", e);
throw e;
}
Log.w(TAG, "initialization failed, resetting storage", e);

context.getSharedPreferences(sharedPreferencesName, Context.MODE_PRIVATE).edit().clear().apply();
private String addPrefixToKey(String key) {
return config.getSharedPreferencesKeyPrefix() + "_" + key;
}

try {
return initializeEncryptedSharedPreferencesManager(context, sharedPreferencesName);
} catch (Exception f) {
Log.e(TAG, "initialization after reset failed", f);
throw f;
}
}
@RequiresApi(api = Build.VERSION_CODES.R)
private MasterKey createSecretKeyApi30() throws GeneralSecurityException, IOException {
return new MasterKey.Builder(context)
.setUserAuthenticationRequired(config.shouldUseBiometrics(), 2)
.setRequestStrongBoxBacked(true)
.setKeyGenParameterSpec(new KeyGenParameterSpec.Builder(
MasterKey.DEFAULT_MASTER_KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setIsStrongBoxBacked(true)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(config.shouldUseBiometrics()) // Enforce user authentication
.setUserAuthenticationValidityDurationSeconds(2)
.setInvalidatedByBiometricEnrollment(true)
.setKeySize(256)
.build())
.build();
}

private SharedPreferences initializeEncryptedSharedPreferencesManager(Context context, String sharedPreferencesName) throws GeneralSecurityException, IOException {
MasterKey masterKey = new MasterKey.Builder(context)
private MasterKey createSecretKeyApi2329() throws GeneralSecurityException, IOException {
return new MasterKey.Builder(context)
.setKeyGenParameterSpec(new KeyGenParameterSpec.Builder(
MasterKey.DEFAULT_MASTER_KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(config.shouldUseBiometrics()) // Enforce user authentication
.setUserAuthenticationValidityDurationSeconds(1)
.setKeySize(256)
.build())
.build();
}
private SharedPreferences initializeEncryptedSharedPreferencesManager(String sharedPreferencesName) throws GeneralSecurityException, IOException {
MasterKey masterKey;

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
masterKey = createSecretKeyApi30();
} else {
masterKey = createSecretKeyApi2329();
}

return EncryptedSharedPreferences.create(
context,
Expand All @@ -149,7 +185,7 @@ private SharedPreferences initializeEncryptedSharedPreferencesManager(Context co
);
}

private void migrateToEncryptedPreferences(Context context, String sharedPreferencesName, SharedPreferences target, boolean deleteOnFailure, Map<String, Object> options) {
private void migrateToEncryptedPreferences(String sharedPreferencesName, SharedPreferences target, boolean deleteOnFailure, Map<String, Object> options) {
SharedPreferences source = context.getSharedPreferences(sharedPreferencesName, Context.MODE_PRIVATE);

Map<String, ?> sourceEntries = source.getAll();
Expand All @@ -164,7 +200,7 @@ private void migrateToEncryptedPreferences(Context context, String sharedPrefere
for (Map.Entry<String, ?> entry : sourceEntries.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
if (key.startsWith(preferencesKeyPrefix) && value instanceof String) {
if (key.startsWith(config.getSharedPreferencesKeyPrefix()) && value instanceof String) {
try {
String decryptedValue = decryptValue((String) value, cipher);
target.edit().putString(key, decryptedValue).apply();
Expand Down Expand Up @@ -208,4 +244,51 @@ private String decryptValue(String value, StorageCipher cipher) throws Exception
byte[] data = Base64.decode(value, Base64.DEFAULT);
return new String(cipher.decrypt(data), CHARSET);
}

private void authenticateUser(Runnable onSuccess) {
BiometricPrompt promptInfo = null;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
promptInfo = new BiometricPrompt.Builder(context)
.setTitle(config.getBiometricPromptTitle())
.setSubtitle(config.getPrefOptionBiometricPromptSubtitle())
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG | BiometricManager.Authenticators.DEVICE_CREDENTIAL)
.build();
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
promptInfo = new BiometricPrompt.Builder(context)
.setTitle(config.getBiometricPromptTitle())
.setSubtitle(config.getPrefOptionBiometricPromptSubtitle())
.build();
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
CancellationSignal cancellationSignal = new CancellationSignal();
Executor executor = Executors.newSingleThreadExecutor();

BiometricPrompt.AuthenticationCallback callback = new BiometricPrompt.AuthenticationCallback() {
@Override
public void onAuthenticationSucceeded(BiometricPrompt.AuthenticationResult result) {
super.onAuthenticationSucceeded(result);
Log.d(TAG, "Authentication Succeeded!");
onSuccess.run();
}

@Override
public void onAuthenticationFailed() {
super.onAuthenticationFailed();
Log.d(TAG, "Authentication Failed. Try again.");
}

@Override
public void onAuthenticationError(int errorCode, CharSequence errString) {
super.onAuthenticationError(errorCode, errString);
Log.e(TAG, "Authentication Error: " + errString);
}
};

promptInfo.authenticate(cancellationSignal, executor, callback);
} else {
onSuccess.run(); // Proceed without authentication on unsupported devices
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package com.it_nomads.fluttersecurestorage;

import androidx.annotation.NonNull;

import java.util.Map;

public class FlutterSecureStorageConfig {

private static final String DEFAULT_PREF_NAME = "FlutterSecureStorage";
private static final String DEFAULT_KEY_PREFIX = "VGhpcyBpcyB0aGUgcHJlZml4IGZvciBhIHNlY3VyZSBzdG9yYWdlCg";

public static final String PREF_OPTION_NAME = "sharedPreferencesName";
public static final String PREF_OPTION_PREFIX = "preferencesKeyPrefix";
public static final String PREF_OPTION_DELETE_ON_FAILURE = "resetOnError";
public static final String PREF_OPTION_SHOULD_USE_BIOMETRICS = "shouldUseBiometrics";

public static final String PREF_OPTION_BIOMETRIC_PROMPT_TITLE = "prefOptionBiometricPromptTitle";
public static final String PREF_OPTION_BIOMETRIC_PROMPT_SUBTITLE = "prefOptionBiometricPromptSubtitle";

private final String sharedPreferencesName;
private final String sharedPreferencesKeyPrefix;
private final boolean deleteOnFailure;
private final boolean shouldUseBiometrics;

private final String biometricPromptTitle;
private final String biometricPromptSubtitle;

public FlutterSecureStorageConfig(Map<String, Object> options) {
this.sharedPreferencesName = getStringOption(options, PREF_OPTION_NAME, DEFAULT_PREF_NAME);
this.sharedPreferencesKeyPrefix = getStringOption(options, PREF_OPTION_PREFIX, DEFAULT_KEY_PREFIX);
this.deleteOnFailure = getBooleanOption(options, PREF_OPTION_DELETE_ON_FAILURE, false);
this.biometricPromptTitle = getStringOption(options, PREF_OPTION_BIOMETRIC_PROMPT_TITLE, "Authenticate to access");
this.biometricPromptSubtitle = getStringOption(options, PREF_OPTION_BIOMETRIC_PROMPT_SUBTITLE, "Use biometrics or device credentials");

this.shouldUseBiometrics = getBooleanOption(options, PREF_OPTION_SHOULD_USE_BIOMETRICS, false);
}

private String getStringOption(Map<String, Object> options, String key, String defaultValue) {
if (options.containsKey(key)) {
Object value = options.get(key);
if (value instanceof String strValue) {
if (!strValue.isEmpty()) {
return strValue;
}
}
}
return defaultValue;
}

private boolean getBooleanOption(Map<String, Object> options, String key, boolean defaultValue) {
Object value = options.get(key);
if (value instanceof String) {
return Boolean.parseBoolean((String) value);
}

return defaultValue;
}

public String getSharedPreferencesName() { return sharedPreferencesName; }
public String getSharedPreferencesKeyPrefix() { return sharedPreferencesKeyPrefix; }
public boolean shouldDeleteOnFailure() { return deleteOnFailure; }

public boolean shouldUseBiometrics() { return shouldUseBiometrics; }

public String getBiometricPromptTitle() { return biometricPromptTitle; }
public String getPrefOptionBiometricPromptSubtitle() { return biometricPromptSubtitle; }

@NonNull
@Override
public String toString() {
return "FlutterSecureStorageConfig{" +
"sharedPreferencesName='" + sharedPreferencesName + '\'' +
", sharedPreferencesKeyPrefix='" + sharedPreferencesKeyPrefix + '\'' +
", deleteOnFailure=" + deleteOnFailure +
'}';
}
}
Loading
Loading