diff --git a/flutter_secure_storage/android/build.gradle b/flutter_secure_storage/android/build.gradle index ffbfc2d5..5a2e2b88 100644 --- a/flutter_secure_storage/android/build.gradle +++ b/flutter_secure_storage/android/build.gradle @@ -8,7 +8,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:8.5.1' + classpath 'com.android.tools.build:gradle:8.5.2' } } @@ -30,19 +30,19 @@ android { buildConfig = true } - compileSdk 34 + compileSdk 35 compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } defaultConfig { - minSdkVersion 19 + minSdkVersion 23 } } dependencies { - implementation "androidx.security:security-crypto:1.1.0-alpha06" + implementation("com.google.crypto.tink:tink-android:1.14.1") } diff --git a/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/FlutterSecureStorage.java b/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/FlutterSecureStorage.java index 413ac5d5..3daa93e9 100644 --- a/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/FlutterSecureStorage.java +++ b/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/FlutterSecureStorage.java @@ -9,11 +9,11 @@ import android.util.Log; import androidx.annotation.RequiresApi; -import androidx.security.crypto.EncryptedSharedPreferences; -import androidx.security.crypto.MasterKey; import com.it_nomads.fluttersecurestorage.ciphers.StorageCipher; import com.it_nomads.fluttersecurestorage.ciphers.StorageCipherFactory; +import com.it_nomads.fluttersecurestorage.crypto.EncryptedSharedPreferences; +import com.it_nomads.fluttersecurestorage.crypto.MasterKey; import java.io.IOException; import java.nio.charset.Charset; @@ -33,7 +33,6 @@ public class FlutterSecureStorage { private SharedPreferences preferences; private StorageCipher storageCipher; private StorageCipherFactory storageCipherFactory; - private Boolean failedToUseEncryptedSharedPreferences = false; public FlutterSecureStorage(Context context, Map options) { this.options = options; @@ -52,14 +51,6 @@ boolean getResetOnError() { return options.containsKey("resetOnError") && options.get("resetOnError").equals("true"); } - @SuppressWarnings({"ConstantConditions"}) - private boolean getUseEncryptedSharedPreferences() { - if (failedToUseEncryptedSharedPreferences) { - return false; - } - return options.containsKey("encryptedSharedPreferences") && options.get("encryptedSharedPreferences").equals("true") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M; - } - public boolean containsKey(String key) { ensureInitialized(); return preferences.contains(key); @@ -72,11 +63,7 @@ public String addPrefixToKey(String key) { public String read(String key) throws Exception { ensureInitialized(); - String rawValue = preferences.getString(key, null); - if (getUseEncryptedSharedPreferences()) { - return rawValue; - } - return decodeRawValue(rawValue); + return preferences.getString(key, null); } @SuppressWarnings("unchecked") @@ -90,14 +77,7 @@ public Map readAll() throws Exception { String keyWithPrefix = entry.getKey(); if (keyWithPrefix.contains(ELEMENT_PREFERENCES_KEY_PREFIX)) { String key = entry.getKey().replaceFirst(ELEMENT_PREFERENCES_KEY_PREFIX + '_', ""); - if (getUseEncryptedSharedPreferences()) { - all.put(key, entry.getValue()); - } else { - String rawValue = entry.getValue(); - String value = decodeRawValue(rawValue); - - all.put(key, value); - } + all.put(key, entry.getValue()); } } return all; @@ -108,12 +88,7 @@ public void write(String key, String value) throws Exception { 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.putString(key, value); editor.apply(); } @@ -130,9 +105,6 @@ public void deleteAll() { final SharedPreferences.Editor editor = preferences.edit(); editor.clear(); - if (!getUseEncryptedSharedPreferences()) { - storageCipherFactory.storeCurrentAlgorithms(editor); - } editor.apply(); } @@ -145,49 +117,31 @@ protected void ensureOptions(){ ELEMENT_PREFERENCES_KEY_PREFIX = (String) options.get("preferencesKeyPrefix"); } } + + @SuppressWarnings({"ConstantConditions"}) private void ensureInitialized() { - // Check if already initialized. - // TODO: Disable for now because this will break mixed usage of secureSharedPreference -// if (preferences != null) return; - ensureOptions(); - 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; + try { + preferences = initializeEncryptedSharedPreferencesManager(applicationContext); + checkAndMigrateToEncrypted(preferences); + } catch (Exception e) { + Log.e(TAG, "EncryptedSharedPreferences initialization failed", e); } + } 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); - } + storageCipher = storageCipherFactory.getSavedStorageCipher(applicationContext); +// if (getUseEncryptedSharedPreferences()) { +// storageCipher = storageCipherFactory.getSavedStorageCipher(applicationContext); +// } else if (storageCipherFactory.requiresReEncryption()) { +// reEncryptPreferences(storageCipherFactory, source); +// } else { +// storageCipher = storageCipherFactory.getCurrentStorageCipher(applicationContext); +// } } private void reEncryptPreferences(StorageCipherFactory storageCipherFactory, SharedPreferences source) throws Exception { @@ -216,7 +170,20 @@ private void reEncryptPreferences(StorageCipherFactory storageCipherFactory, Sha } } - private void checkAndMigrateToEncrypted(SharedPreferences source, SharedPreferences target) { + private void checkAndMigrateToEncrypted(SharedPreferences target) { + SharedPreferences source = applicationContext.getSharedPreferences( + SHARED_PREFERENCES_NAME, + Context.MODE_PRIVATE + ); + if (storageCipher == null) { + try { + initStorageCipher(source); + + } catch (Exception e) { + Log.e(TAG, "StorageCipher initialization failed", e); + } + } + try { for (Map.Entry entry : source.getAll().entrySet()) { Object v = entry.getValue(); @@ -235,7 +202,6 @@ private void checkAndMigrateToEncrypted(SharedPreferences source, SharedPreferen } } - @RequiresApi(api = Build.VERSION_CODES.M) private SharedPreferences initializeEncryptedSharedPreferencesManager(Context context) throws GeneralSecurityException, IOException { MasterKey key = new MasterKey.Builder(context) .setKeyGenParameterSpec( diff --git a/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/crypto/EncryptedSharedPreferences.java b/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/crypto/EncryptedSharedPreferences.java new file mode 100644 index 00000000..baf9c75a --- /dev/null +++ b/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/crypto/EncryptedSharedPreferences.java @@ -0,0 +1,583 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.it_nomads.fluttersecurestorage.crypto; + +import static com.it_nomads.fluttersecurestorage.crypto.MasterKey.KEYSTORE_PATH_URI; +import static java.nio.charset.StandardCharsets.UTF_8; +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Pair; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.collection.ArraySet; +import com.google.crypto.tink.Aead; +import com.google.crypto.tink.DeterministicAead; +import com.google.crypto.tink.KeyTemplate; +import com.google.crypto.tink.KeyTemplates; +import com.google.crypto.tink.KeysetHandle; +import com.google.crypto.tink.aead.AeadConfig; +import com.google.crypto.tink.daead.DeterministicAeadConfig; +import com.google.crypto.tink.integration.android.AndroidKeysetManager; +import com.google.crypto.tink.subtle.Base64; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; +/** + * An implementation of {@link SharedPreferences} that encrypts keys and values. + *
+ *
+ * WARNING: The preference file should not be backed up with Auto Backup. When restoring the + * file it is likely the key used to encrypt it will no longer be present. You should exclude all + * EncryptedSharedPreferences from backup using + * backup rules. + *
+ *
+ * Basic use of the class: + * + *
+ *  MasterKey masterKey = new MasterKey.Builder(context)
+ *      .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ *      .build();
+ *
+ *  SharedPreferences sharedPreferences = EncryptedSharedPreferences.create(
+ *      context,
+ *      "secret_shared_prefs",
+ *      masterKey,
+ *      EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ *      EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ *  );
+ *
+ *  // use the shared preferences and editor as you normally would
+ *  SharedPreferences.Editor editor = sharedPreferences.edit();
+ * 
+ */ +public final class EncryptedSharedPreferences implements SharedPreferences { + private static final String KEY_KEYSET_ALIAS = + "__androidx_security_crypto_encrypted_prefs_key_keyset__"; + private static final String VALUE_KEYSET_ALIAS = + "__androidx_security_crypto_encrypted_prefs_value_keyset__"; + private static final String NULL_VALUE = "__NULL__"; + + private static final Integer integerBytes = 4; // 32 / 8 + private static final Integer longBytes = 8; // 64 / 8 + private static final Integer floatBytes = 4; // 32 / 8 + private static final Integer byteBytes = 1; + + final SharedPreferences mSharedPreferences; + final CopyOnWriteArrayList mListeners; + final String mFileName; + final String mMasterKeyAlias; + final Aead mValueAead; + final DeterministicAead mKeyDeterministicAead; + EncryptedSharedPreferences(@NonNull String name, + @NonNull String masterKeyAlias, + @NonNull SharedPreferences sharedPreferences, + @NonNull Aead aead, + @NonNull DeterministicAead deterministicAead) { + mFileName = name; + mSharedPreferences = sharedPreferences; + mMasterKeyAlias = masterKeyAlias; + mValueAead = aead; + mKeyDeterministicAead = deterministicAead; + mListeners = new CopyOnWriteArrayList<>(); + } + /** + * Opens an instance of encrypted SharedPreferences + * + * @param fileName The name of the file to open; can not contain path + * separators. + * @param masterKey The master key to use. + * @param prefKeyEncryptionScheme The scheme to use for encrypting keys. + * @param prefValueEncryptionScheme The scheme to use for encrypting values. + * @return The SharedPreferences instance that encrypts all data. + * @throws GeneralSecurityException when a bad master key or keyset has been attempted + * @throws IOException when fileName can not be used + */ + @NonNull + public static SharedPreferences create(@NonNull Context context, + @NonNull String fileName, + @NonNull MasterKey masterKey, + @NonNull PrefKeyEncryptionScheme prefKeyEncryptionScheme, + @NonNull PrefValueEncryptionScheme prefValueEncryptionScheme) + throws GeneralSecurityException, IOException { + return create(fileName, masterKey.getKeyAlias(), context, + prefKeyEncryptionScheme, prefValueEncryptionScheme); + } + /** + * Opens an instance of encrypted SharedPreferences + * + *

If the masterKeyAlias used here is for a key that is not yet created, this + * method will not be thread safe. Use the alternate signature that is not deprecated for + * multi-threaded contexts. + * + * @param fileName The name of the file to open; can not contain path + * separators. + * @param masterKeyAlias The alias of the master key to use. + * @param context The context to use to open the preferences file. + * @param prefKeyEncryptionScheme The scheme to use for encrypting keys. + * @param prefValueEncryptionScheme The scheme to use for encrypting values. + * @return The SharedPreferences instance that encrypts all data. + * @throws GeneralSecurityException when a bad master key or keyset has been attempted + * @throws IOException when fileName can not be used + */ + @NonNull + private static SharedPreferences create(@NonNull String fileName, + @NonNull String masterKeyAlias, + @NonNull Context context, + @NonNull PrefKeyEncryptionScheme prefKeyEncryptionScheme, + @NonNull PrefValueEncryptionScheme prefValueEncryptionScheme) + throws GeneralSecurityException, IOException { + DeterministicAeadConfig.register(); + AeadConfig.register(); + final Context applicationContext = context.getApplicationContext(); + KeysetHandle daeadKeysetHandle = new AndroidKeysetManager.Builder() + .withKeyTemplate(prefKeyEncryptionScheme.getKeyTemplate()) + .withSharedPref(applicationContext, KEY_KEYSET_ALIAS, fileName) + .withMasterKeyUri(KEYSTORE_PATH_URI + masterKeyAlias) + .build().getKeysetHandle(); + KeysetHandle aeadKeysetHandle = new AndroidKeysetManager.Builder() + .withKeyTemplate(prefValueEncryptionScheme.getKeyTemplate()) + .withSharedPref(applicationContext, VALUE_KEYSET_ALIAS, fileName) + .withMasterKeyUri(KEYSTORE_PATH_URI + masterKeyAlias) + .build().getKeysetHandle(); + DeterministicAead daead = daeadKeysetHandle.getPrimitive(DeterministicAead.class); + Aead aead = aeadKeysetHandle.getPrimitive(Aead.class); + return new EncryptedSharedPreferences(fileName, masterKeyAlias, + applicationContext.getSharedPreferences(fileName, Context.MODE_PRIVATE), aead, + daead); + } + /** + * The encryption scheme to encrypt keys. + */ + public enum PrefKeyEncryptionScheme { + /** + * Pref keys are encrypted deterministically with AES256-SIV-CMAC (RFC 5297). + * + *

For more information please see the Tink documentation: + * + *

AesSivKeyManager.aes256SivTemplate() + */ + AES256_SIV("AES256_SIV"); + private final String mDeterministicAeadKeyTemplateName; + PrefKeyEncryptionScheme(String deterministicAeadKeyTemplateName) { + mDeterministicAeadKeyTemplateName = deterministicAeadKeyTemplateName; + } + KeyTemplate getKeyTemplate() throws GeneralSecurityException { + return KeyTemplates.get(mDeterministicAeadKeyTemplateName); + } + } + /** + * The encryption scheme to encrypt values. + */ + public enum PrefValueEncryptionScheme { + /** + * Pref values are encrypted with AES256-GCM. The associated data is the encrypted pref key. + * + *

For more information please see the Tink documentation: + * + *

AesGcmKeyManager.aes256GcmTemplate() + */ + AES256_GCM("AES256_GCM"); + private final String mAeadKeyTemplateName; + PrefValueEncryptionScheme(String aeadKeyTemplateName) { + mAeadKeyTemplateName = aeadKeyTemplateName; + } + KeyTemplate getKeyTemplate() throws GeneralSecurityException { + return KeyTemplates.get(mAeadKeyTemplateName); + } + } + private static final class Editor implements SharedPreferences.Editor { + private final EncryptedSharedPreferences mEncryptedSharedPreferences; + private final SharedPreferences.Editor mEditor; + private final List mKeysChanged; + private final AtomicBoolean mClearRequested = new AtomicBoolean(false); + Editor(EncryptedSharedPreferences encryptedSharedPreferences, + SharedPreferences.Editor editor) { + mEncryptedSharedPreferences = encryptedSharedPreferences; + mEditor = editor; + mKeysChanged = new CopyOnWriteArrayList<>(); + } + @Override + @NonNull + public SharedPreferences.Editor putString(@Nullable String key, @Nullable String value) { + if (value == null) { + value = NULL_VALUE; + } + byte[] stringBytes = value.getBytes(UTF_8); + int stringByteLength = stringBytes.length; + ByteBuffer buffer = ByteBuffer.allocate(integerBytes + integerBytes + + stringByteLength); + buffer.putInt(EncryptedType.STRING.getId()); + buffer.putInt(stringByteLength); + buffer.put(stringBytes); + putEncryptedObject(key, buffer.array()); + return this; + } + @Override + @NonNull + public SharedPreferences.Editor putStringSet(@Nullable String key, + @Nullable Set values) { + if (values == null) { + values = new ArraySet<>(); + values.add(NULL_VALUE); + } + List byteValues = new ArrayList<>(values.size()); + int totalBytes = values.size() * integerBytes; + for (String strValue : values) { + byte[] byteValue = strValue.getBytes(UTF_8); + byteValues.add(byteValue); + totalBytes += byteValue.length; + } + totalBytes += integerBytes; + ByteBuffer buffer = ByteBuffer.allocate(totalBytes); + buffer.putInt(EncryptedType.STRING_SET.getId()); + for (byte[] bytes : byteValues) { + buffer.putInt(bytes.length); + buffer.put(bytes); + } + putEncryptedObject(key, buffer.array()); + return this; + } + @Override + @NonNull + public SharedPreferences.Editor putInt(@Nullable String key, int value) { + ByteBuffer buffer = ByteBuffer.allocate(integerBytes + integerBytes); + buffer.putInt(EncryptedType.INT.getId()); + buffer.putInt(value); + putEncryptedObject(key, buffer.array()); + return this; + } + @Override + @NonNull + public SharedPreferences.Editor putLong(@Nullable String key, long value) { + ByteBuffer buffer = ByteBuffer.allocate(integerBytes + longBytes); + buffer.putInt(EncryptedType.LONG.getId()); + buffer.putLong(value); + putEncryptedObject(key, buffer.array()); + return this; + } + @Override + @NonNull + public SharedPreferences.Editor putFloat(@Nullable String key, float value) { + ByteBuffer buffer = ByteBuffer.allocate(integerBytes + floatBytes); + buffer.putInt(EncryptedType.FLOAT.getId()); + buffer.putFloat(value); + putEncryptedObject(key, buffer.array()); + return this; + } + @Override + @NonNull + public SharedPreferences.Editor putBoolean(@Nullable String key, boolean value) { + ByteBuffer buffer = ByteBuffer.allocate(integerBytes + byteBytes); + buffer.putInt(EncryptedType.BOOLEAN.getId()); + buffer.put(value ? (byte) 1 : (byte) 0); + putEncryptedObject(key, buffer.array()); + return this; + } + @Override + @NonNull + public SharedPreferences.Editor remove(@Nullable String key) { + if (mEncryptedSharedPreferences.isReservedKey(key)) { + throw new SecurityException(key + " is a reserved key for the encryption keyset."); + } + mEditor.remove(mEncryptedSharedPreferences.encryptKey(key)); + mKeysChanged.add(key); + return this; + } + @Override + @NonNull + public SharedPreferences.Editor clear() { + // Set the flag to clear on commit, this operation happens first on commit. + // Cannot use underlying clear operation, it will remove the keysets and + // break the editor. + mClearRequested.set(true); + return this; + } + @Override + public boolean commit() { + clearKeysIfNeeded(); + try { + return mEditor.commit(); + } finally { + notifyListeners(); + mKeysChanged.clear(); + } + } + @Override + public void apply() { + clearKeysIfNeeded(); + mEditor.apply(); + notifyListeners(); + mKeysChanged.clear(); + } + private void clearKeysIfNeeded() { + // Call "clear" first as per the documentation, remove all keys that haven't + // been modified in this editor. + if (mClearRequested.getAndSet(false)) { + for (String key : mEncryptedSharedPreferences.getAll().keySet()) { + if (!mKeysChanged.contains(key) + && !mEncryptedSharedPreferences.isReservedKey(key)) { + mEditor.remove(mEncryptedSharedPreferences.encryptKey(key)); + } + } + } + } + private void putEncryptedObject(String key, byte[] value) { + if (mEncryptedSharedPreferences.isReservedKey(key)) { + throw new SecurityException(key + " is a reserved key for the encryption keyset."); + } + mKeysChanged.add(key); + if (key == null) { + key = NULL_VALUE; + } + try { + Pair encryptedPair = mEncryptedSharedPreferences + .encryptKeyValuePair(key, value); + mEditor.putString(encryptedPair.first, encryptedPair.second); + } catch (GeneralSecurityException ex) { + throw new SecurityException("Could not encrypt data: " + ex.getMessage(), ex); + } + } + private void notifyListeners() { + for (OnSharedPreferenceChangeListener listener : + mEncryptedSharedPreferences.mListeners) { + for (String key : mKeysChanged) { + listener.onSharedPreferenceChanged(mEncryptedSharedPreferences, key); + } + } + } + } + // SharedPreferences methods + @Override + @NonNull + public Map getAll() { + Map allEntries = new HashMap<>(); + for (Map.Entry entry : mSharedPreferences.getAll().entrySet()) { + if (!isReservedKey(entry.getKey())) { + String decryptedKey = decryptKey(entry.getKey()); + allEntries.put(decryptedKey, + getDecryptedObject(decryptedKey)); + } + } + return allEntries; + } + @Nullable + @Override + public String getString(@Nullable String key, @Nullable String defValue) { + Object value = getDecryptedObject(key); + return (value instanceof String ? (String) value : defValue); + } + @SuppressWarnings("unchecked") + @Nullable + @Override + public Set getStringSet(@Nullable String key, @Nullable Set defValues) { + Set returnValues; + Object value = getDecryptedObject(key); + if (value instanceof Set) { + returnValues = (Set) value; + } else { + returnValues = new ArraySet<>(); + } + return !returnValues.isEmpty() ? returnValues : defValues; + } + @Override + public int getInt(@Nullable String key, int defValue) { + Object value = getDecryptedObject(key); + return (value instanceof Integer ? (Integer) value : defValue); + } + @Override + public long getLong(@Nullable String key, long defValue) { + Object value = getDecryptedObject(key); + return (value instanceof Long ? (Long) value : defValue); + } + @Override + public float getFloat(@Nullable String key, float defValue) { + Object value = getDecryptedObject(key); + return (value instanceof Float ? (Float) value : defValue); + } + @Override + public boolean getBoolean(@Nullable String key, boolean defValue) { + Object value = getDecryptedObject(key); + return (value instanceof Boolean ? (Boolean) value : defValue); + } + @Override + public boolean contains(@Nullable String key) { + if (isReservedKey(key)) { + throw new SecurityException(key + " is a reserved key for the encryption keyset."); + } + String encryptedKey = encryptKey(key); + return mSharedPreferences.contains(encryptedKey); + } + @Override + @NonNull + public SharedPreferences.Editor edit() { + return new Editor(this, mSharedPreferences.edit()); + } + @Override + public void registerOnSharedPreferenceChangeListener( + @NonNull OnSharedPreferenceChangeListener listener) { + mListeners.add(listener); + } + @Override + public void unregisterOnSharedPreferenceChangeListener( + @NonNull OnSharedPreferenceChangeListener listener) { + mListeners.remove(listener); + } + /** + * Internal enum to set the type of encrypted data. + */ + private enum EncryptedType { + STRING(0), + STRING_SET(1), + INT(2), + LONG(3), + FLOAT(4), + BOOLEAN(5); + private final int mId; + EncryptedType(int id) { + mId = id; + } + public int getId() { + return mId; + } + @Nullable + public static EncryptedType fromId(int id) { + switch (id) { + case 0: + return STRING; + case 1: + return STRING_SET; + case 2: + return INT; + case 3: + return LONG; + case 4: + return FLOAT; + case 5: + return BOOLEAN; + } + return null; + } + } + private Object getDecryptedObject(String key) throws SecurityException { + if (isReservedKey(key)) { + throw new SecurityException(key + " is a reserved key for the encryption keyset."); + } + if (key == null) { + key = NULL_VALUE; + } + try { + String encryptedKey = encryptKey(key); + String encryptedValue = mSharedPreferences.getString(encryptedKey, null); + if (encryptedValue == null) { + return null; + } + byte[] cipherText = Base64.decode(encryptedValue, Base64.DEFAULT); + byte[] value = mValueAead.decrypt(cipherText, encryptedKey.getBytes(UTF_8)); + ByteBuffer buffer = ByteBuffer.wrap(value); + buffer.position(0); + int typeId = buffer.getInt(); + EncryptedType type = EncryptedType.fromId(typeId); + if (type == null) { + throw new SecurityException("Unknown type ID for encrypted pref value: " + typeId); + } + switch (type) { + case STRING: + int stringLength = buffer.getInt(); + ByteBuffer stringSlice = buffer.slice(); + buffer.limit(stringLength); + String stringValue = UTF_8.decode(stringSlice).toString(); + if (stringValue.equals(NULL_VALUE)) { + return null; + } + return stringValue; + case INT: + return buffer.getInt(); + case LONG: + return buffer.getLong(); + case FLOAT: + return buffer.getFloat(); + case BOOLEAN: + return buffer.get() != (byte) 0; + case STRING_SET: + ArraySet stringSet = new ArraySet<>(); + while (buffer.hasRemaining()) { + int subStringLength = buffer.getInt(); + ByteBuffer subStringSlice = buffer.slice(); + subStringSlice.limit(subStringLength); + buffer.position(buffer.position() + subStringLength); + stringSet.add(UTF_8.decode(subStringSlice).toString()); + } + if (stringSet.size() == 1 && NULL_VALUE.equals(stringSet.valueAt(0))) { + return null; + } + return stringSet; + default: + throw new SecurityException("Unhandled type for encrypted pref value: " + type); + } + } catch (GeneralSecurityException ex) { + throw new SecurityException("Could not decrypt value. " + ex.getMessage(), ex); + } + } + String encryptKey(String key) { + if (key == null) { + key = NULL_VALUE; + } + try { + byte[] encryptedKeyBytes = mKeyDeterministicAead.encryptDeterministically( + key.getBytes(UTF_8), + mFileName.getBytes()); + return Base64.encode(encryptedKeyBytes); + } catch (GeneralSecurityException ex) { + throw new SecurityException("Could not encrypt key. " + ex.getMessage(), ex); + } + } + String decryptKey(String encryptedKey) { + try { + byte[] clearText = mKeyDeterministicAead.decryptDeterministically( + Base64.decode(encryptedKey, Base64.DEFAULT), + mFileName.getBytes()); + String key = new String(clearText, UTF_8); + if (key.equals(NULL_VALUE)) { + key = null; + } + return key; + } catch (GeneralSecurityException ex) { + throw new SecurityException("Could not decrypt key. " + ex.getMessage(), ex); + } + } + /** + * Check usage of the key and value keysets. + * + * @param key the plain text key + */ + boolean isReservedKey(String key) { + return KEY_KEYSET_ALIAS.equals(key) || VALUE_KEYSET_ALIAS.equals(key); + } + Pair encryptKeyValuePair(String key, byte[] value) + throws GeneralSecurityException { + String encryptedKey = encryptKey(key); + byte[] cipherText = mValueAead.encrypt(value, encryptedKey.getBytes(UTF_8)); + return new Pair<>(encryptedKey, Base64.encode(cipherText)); + } +} \ No newline at end of file diff --git a/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/crypto/MasterKey.java b/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/crypto/MasterKey.java new file mode 100644 index 00000000..e67b7942 --- /dev/null +++ b/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/crypto/MasterKey.java @@ -0,0 +1,368 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.it_nomads.fluttersecurestorage.crypto; + + +import static android.security.keystore.KeyProperties.AUTH_BIOMETRIC_STRONG; +import static android.security.keystore.KeyProperties.AUTH_DEVICE_CREDENTIAL; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; +import androidx.annotation.DoNotInline; +import androidx.annotation.IntRange; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +/** + * Wrapper for a master key used in the library. + * + *

On Android M (API 23) and above, this is class references a key that's stored in the + * Android Keystore. + */ +public final class MasterKey { + static final String KEYSTORE_PATH_URI = "android-keystore://"; + /** + * The default master key alias. + */ + public static final String DEFAULT_MASTER_KEY_ALIAS = "_androidx_security_master_key_"; + /** + * The default and recommended size for the master key. + */ + public static final int DEFAULT_AES_GCM_MASTER_KEY_SIZE = 256; + private static final int DEFAULT_AUTHENTICATION_VALIDITY_DURATION_SECONDS = 5 * 60; + @NonNull + private final String mKeyAlias; + @Nullable + private final KeyGenParameterSpec mKeyGenParameterSpec; + /** + * Algorithm/Cipher choices used for the master key. + */ + public enum KeyScheme { + AES256_GCM + } + /** + * The default validity period for authentication in seconds. + */ + @SuppressLint("MethodNameUnits") + public static int getDefaultAuthenticationValidityDurationSeconds() { + return DEFAULT_AUTHENTICATION_VALIDITY_DURATION_SECONDS; + } + /* package */ MasterKey(@NonNull String keyAlias, @Nullable Object keyGenParameterSpec) { + mKeyAlias = keyAlias; + mKeyGenParameterSpec = (KeyGenParameterSpec) keyGenParameterSpec; + } + /** + * Checks if this key is backed by the Android Keystore. + * + * @return {@code true} if the key is in Android Keystore, {@code false} otherwise. This + * method always returns false when called on Android Lollipop (API 21 and 22). + */ + public boolean isKeyStoreBacked() { + try { + KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore"); + keyStore.load(null); + return keyStore.containsAlias(mKeyAlias); + } catch (KeyStoreException | CertificateException + | NoSuchAlgorithmException | IOException ignored) { + return false; + } + } + /** + * Gets whether user authentication is required to use this key. + */ + public boolean isUserAuthenticationRequired() { + return mKeyGenParameterSpec != null + && Api23Impl.isUserAuthenticationRequired(mKeyGenParameterSpec); + } + /** + * Gets the duration in seconds that the key is unlocked for following user authentication. + * + *

The value returned for this method is only meaningful on Android M+ (API 23) when + * {@link #isUserAuthenticationRequired()} returns {@code true}. + * + * @return The duration the key is unlocked for in seconds. + */ + @SuppressLint("MethodNameUnits") + public int getUserAuthenticationValidityDurationSeconds() { + return mKeyGenParameterSpec == null ? 0 : + Api23Impl.getUserAuthenticationValidityDurationSeconds(mKeyGenParameterSpec); + } + /** + * Gets whether the key is backed by strong box. + */ + public boolean isStrongBoxBacked() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P || mKeyGenParameterSpec == null) { + return false; + } + return Api28Impl.isStrongBoxBacked(mKeyGenParameterSpec); + } + @NonNull + @Override + public String toString() { + return "MasterKey{keyAlias=" + mKeyAlias + + ", isKeyStoreBacked=" + isKeyStoreBacked() + + "}"; + } + @NonNull + /* package */ String getKeyAlias() { + return mKeyAlias; + } + /** + * Builder for generating a {@link MasterKey}. + */ + public static final class Builder { + @NonNull + final String mKeyAlias; + @Nullable + KeyGenParameterSpec mKeyGenParameterSpec; + @Nullable + KeyScheme mKeyScheme; + boolean mAuthenticationRequired; + int mUserAuthenticationValidityDurationSeconds; + boolean mRequestStrongBoxBacked; + final Context mContext; + /** + * Creates a builder for a {@link MasterKey} using the default alias of + * {@link #DEFAULT_MASTER_KEY_ALIAS}. + * + * @param context The context to use with this master key. + */ + public Builder(@NonNull Context context) { + this(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS); + } + /** + * Creates a builder for a {@link MasterKey}. + * + * @param context The context to use with this master key. + */ + public Builder(@NonNull Context context, @NonNull String keyAlias) { + mContext = context.getApplicationContext(); + mKeyAlias = keyAlias; + } + /** + * Sets a {@link KeyScheme} to be used for the master key. + *

This uses a default {@link KeyGenParameterSpec} associated with the provided + * {@code KeyScheme}. + *

NOTE: Either this method OR {@link #setKeyGenParameterSpec} should be used to set + * the parameters to use for building the master key. Calling either function after + * the other will throw an {@link IllegalArgumentException}. + * + * @param keyScheme The KeyScheme to use. + * @return This builder. + */ + @NonNull + public Builder setKeyScheme(@NonNull KeyScheme keyScheme) { + if (keyScheme == KeyScheme.AES256_GCM) { + if (mKeyGenParameterSpec != null) { + throw new IllegalArgumentException("KeyScheme set after setting a " + + "KeyGenParamSpec"); + } + } else { + throw new IllegalArgumentException("Unsupported scheme: " + keyScheme); + } + mKeyScheme = keyScheme; + return this; + } + /** + * When used with {@link #setKeyScheme(KeyScheme)}, sets that the built master key should + * require the user to authenticate before it's unlocked, probably using the + * androidx.biometric library. + * + *

This method sets the validity duration of the key to + * {@link #getDefaultAuthenticationValidityDurationSeconds()}. + * + * @param authenticationRequired Whether user authentication should be required to use + * the key. + * @return This builder. + */ + @NonNull + public Builder setUserAuthenticationRequired(boolean authenticationRequired) { + return setUserAuthenticationRequired(authenticationRequired, + getDefaultAuthenticationValidityDurationSeconds()); + } + /** + * When used with {@link #setKeyScheme(KeyScheme)}, sets that the built master key should + * require the user to authenticate before it's unlocked, probably using the + * androidx.biometric library, and that the key should remain unlocked for the provided + * duration. + * + * @param authenticationRequired Whether user authentication should be + * required to use the key. + * @param userAuthenticationValidityDurationSeconds Duration in seconds that the key + * should remain unlocked following user + * authentication. + * @return This builder. + */ + @NonNull + public Builder setUserAuthenticationRequired(boolean authenticationRequired, + @IntRange(from = 1) int userAuthenticationValidityDurationSeconds) { + mAuthenticationRequired = authenticationRequired; + mUserAuthenticationValidityDurationSeconds = userAuthenticationValidityDurationSeconds; + return this; + } + /** + * Sets whether or not to request this key is strong box backed. This setting is only + * applicable on {@link Build.VERSION_CODES#P} and above, and only on devices that + * support Strongbox. + * + * @param requestStrongBoxBacked Whether to request to use strongbox + * @return This builder. + */ + @NonNull + public Builder setRequestStrongBoxBacked(boolean requestStrongBoxBacked) { + mRequestStrongBoxBacked = requestStrongBoxBacked; + return this; + } + /** + * Sets a custom {@link KeyGenParameterSpec} to use as the basis of the master key. + * NOTE: Either this method OR {@link #setKeyScheme(KeyScheme)} should be used to set + * the parameters to use for building the master key. Calling either function after + * the other will throw an {@link IllegalArgumentException}. + * + * @param keyGenParameterSpec The key spec to use. + * @return This builder. + */ + @NonNull + public Builder setKeyGenParameterSpec(@NonNull KeyGenParameterSpec keyGenParameterSpec) { + if (mKeyScheme != null) { + throw new IllegalArgumentException("KeyGenParamSpec set after setting a " + + "KeyScheme"); + } + if (!mKeyAlias.equals(Api23Impl.getKeystoreAlias(keyGenParameterSpec))) { + throw new IllegalArgumentException("KeyGenParamSpec's key alias does not match " + + "provided alias (" + mKeyAlias + " vs " + + Api23Impl.getKeystoreAlias(keyGenParameterSpec)); + } + mKeyGenParameterSpec = keyGenParameterSpec; + return this; + } + /** + * Builds a {@link MasterKey} from this builder. + * + * @return The master key. + */ + @NonNull + public MasterKey build() throws GeneralSecurityException, IOException { + return Api23Impl.build(this); + } + + static class Api23Impl { + private Api23Impl() { + // This class is not instantiable. + } + @DoNotInline + static String getKeystoreAlias(KeyGenParameterSpec keyGenParameterSpec) { + return keyGenParameterSpec.getKeystoreAlias(); + } + @SuppressWarnings("deprecation") + static MasterKey build(Builder builder) throws GeneralSecurityException, IOException { + if (builder.mKeyScheme == null && builder.mKeyGenParameterSpec == null) { + throw new IllegalArgumentException("build() called before " + + "setKeyGenParameterSpec or setKeyScheme."); + } + if (builder.mKeyScheme == KeyScheme.AES256_GCM) { + KeyGenParameterSpec.Builder keyGenBuilder = new KeyGenParameterSpec.Builder( + builder.mKeyAlias, + KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(DEFAULT_AES_GCM_MASTER_KEY_SIZE); + if (builder.mAuthenticationRequired) { + keyGenBuilder.setUserAuthenticationRequired(true); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Api30Impl.setUserAuthenticationParameters(keyGenBuilder, + builder.mUserAuthenticationValidityDurationSeconds, + AUTH_DEVICE_CREDENTIAL | AUTH_BIOMETRIC_STRONG); + } else { + keyGenBuilder.setUserAuthenticationValidityDurationSeconds( + builder.mUserAuthenticationValidityDurationSeconds); + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + && builder.mRequestStrongBoxBacked) { + if (builder.mContext.getPackageManager().hasSystemFeature( + PackageManager.FEATURE_STRONGBOX_KEYSTORE)) { + Api28Impl.setIsStrongBoxBacked(keyGenBuilder); + } + } + builder.mKeyGenParameterSpec = keyGenBuilder.build(); + } + if (builder.mKeyGenParameterSpec == null) { + // This really should not happen. + throw new NullPointerException( + "KeyGenParameterSpec was null after build() check"); + } + String keyAlias = MasterKeys.getOrCreate(builder.mKeyGenParameterSpec); + return new MasterKey(keyAlias, builder.mKeyGenParameterSpec); + } + @RequiresApi(28) + static class Api28Impl { + private Api28Impl() { + // This class is not instantiable. + } + @DoNotInline + static void setIsStrongBoxBacked(KeyGenParameterSpec.Builder builder) { + builder.setIsStrongBoxBacked(true); + } + } + @RequiresApi(30) + static class Api30Impl { + private Api30Impl() { + // This class is not instantiable. + } + @DoNotInline + static void setUserAuthenticationParameters(KeyGenParameterSpec.Builder builder, + int timeout, + int type) { + builder.setUserAuthenticationParameters(timeout, type); + } + } + } + } + static class Api23Impl { + private Api23Impl() { + // This class is not instantiable. + } + @DoNotInline + static boolean isUserAuthenticationRequired(KeyGenParameterSpec keyGenParameterSpec) { + return keyGenParameterSpec.isUserAuthenticationRequired(); + } + @DoNotInline + static int getUserAuthenticationValidityDurationSeconds( + KeyGenParameterSpec keyGenParameterSpec) { + return keyGenParameterSpec.getUserAuthenticationValidityDurationSeconds(); + } + } + @RequiresApi(28) + static class Api28Impl { + private Api28Impl() { + // This class is not instantiable. + } + @DoNotInline + static boolean isStrongBoxBacked(KeyGenParameterSpec keyGenParameterSpec) { + return keyGenParameterSpec.isStrongBoxBacked(); + } + } +} \ No newline at end of file diff --git a/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/crypto/MasterKeys.java b/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/crypto/MasterKeys.java new file mode 100644 index 00000000..676915c7 --- /dev/null +++ b/flutter_secure_storage/android/src/main/java/com/it_nomads/fluttersecurestorage/crypto/MasterKeys.java @@ -0,0 +1,111 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.it_nomads.fluttersecurestorage.crypto; + +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.ProviderException; +import java.util.Arrays; +import javax.crypto.KeyGenerator; +/** + * Convenient methods to create and obtain master keys in Android Keystore. + * + *

The master keys are used to encrypt data encryption keys for encrypting files and preferences. + */ +public final class MasterKeys { + private MasterKeys() { + } + static final int KEY_SIZE = MasterKey.DEFAULT_AES_GCM_MASTER_KEY_SIZE; + private static final String ANDROID_KEYSTORE = "AndroidKeyStore"; + private static final Object sLock = new Object(); + + /** + * Creates or gets the master key provided + * + *

The encryption scheme is required fields to ensure that the type of + * encryption used is clear to developers. + * + * @param keyGenParameterSpec The key encryption scheme + * @return The key alias for the master key + */ + @NonNull + public static String getOrCreate( + @NonNull KeyGenParameterSpec keyGenParameterSpec) + throws GeneralSecurityException, IOException { + validate(keyGenParameterSpec); + synchronized (sLock) { + if (!MasterKeys.keyExists(keyGenParameterSpec.getKeystoreAlias())) { + generateKey(keyGenParameterSpec); + } + } + return keyGenParameterSpec.getKeystoreAlias(); + } + @VisibleForTesting + static void validate(KeyGenParameterSpec spec) { + if (spec.getKeySize() != KEY_SIZE) { + throw new IllegalArgumentException( + "invalid key size, want " + KEY_SIZE + " bits got " + spec.getKeySize() + + " bits"); + } + if (!Arrays.equals(spec.getBlockModes(), new String[]{KeyProperties.BLOCK_MODE_GCM})) { + throw new IllegalArgumentException( + "invalid block mode, want " + KeyProperties.BLOCK_MODE_GCM + " got " + + Arrays.toString(spec.getBlockModes())); + } + if (spec.getPurposes() != (KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)) { + throw new IllegalArgumentException( + "invalid purposes mode, want PURPOSE_ENCRYPT | PURPOSE_DECRYPT got " + + spec.getPurposes()); + } + if (!Arrays.equals(spec.getEncryptionPaddings(), new String[] + {KeyProperties.ENCRYPTION_PADDING_NONE})) { + throw new IllegalArgumentException( + "invalid padding mode, want " + KeyProperties.ENCRYPTION_PADDING_NONE + " got " + + Arrays.toString(spec.getEncryptionPaddings())); + } + if (spec.isUserAuthenticationRequired() + && spec.getUserAuthenticationValidityDurationSeconds() < 1) { + throw new IllegalArgumentException( + "per-operation authentication is not supported " + + "(UserAuthenticationValidityDurationSeconds must be >0)"); + } + } + private static void generateKey(@NonNull KeyGenParameterSpec keyGenParameterSpec) + throws GeneralSecurityException { + try { + KeyGenerator keyGenerator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + ANDROID_KEYSTORE); + keyGenerator.init(keyGenParameterSpec); + keyGenerator.generateKey(); + } catch (ProviderException providerException) { + // Android 10 (API 29) throws a ProviderException under certain circumstances. Wrap + // that as a GeneralSecurityException so it's more consistent across API levels. + throw new GeneralSecurityException(providerException.getMessage(), providerException); + } + } + private static boolean keyExists(@NonNull String keyAlias) + throws GeneralSecurityException, IOException { + KeyStore keyStore = KeyStore.getInstance(ANDROID_KEYSTORE); + keyStore.load(null); + return keyStore.containsAlias(keyAlias); + } +} \ No newline at end of file diff --git a/flutter_secure_storage/example/android/gradle/wrapper/gradle-wrapper.properties b/flutter_secure_storage/example/android/gradle/wrapper/gradle-wrapper.properties index f37db7f2..1da43f06 100644 --- a/flutter_secure_storage/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/flutter_secure_storage/example/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip diff --git a/flutter_secure_storage/example/android/settings.gradle.kts b/flutter_secure_storage/example/android/settings.gradle.kts index a26fdfc0..13e40edc 100644 --- a/flutter_secure_storage/example/android/settings.gradle.kts +++ b/flutter_secure_storage/example/android/settings.gradle.kts @@ -19,7 +19,7 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.4.1" apply false + id("com.android.application") version "8.7.2" apply false id("org.jetbrains.kotlin.android") version "1.9.24" apply false }