diff --git a/modules/ensemble/lib/action/secure_storage.dart b/modules/ensemble/lib/action/secure_storage.dart index 8bbf27dbd..722d2d5e5 100644 --- a/modules/ensemble/lib/action/secure_storage.dart +++ b/modules/ensemble/lib/action/secure_storage.dart @@ -6,18 +6,22 @@ import 'package:ensemble/framework/event.dart'; import 'package:ensemble/framework/scope.dart'; import 'package:ensemble/screen_controller.dart'; import 'package:flutter/material.dart'; -import 'package:ensemble/util/utils.dart'; class SetSecureStorage extends EnsembleAction { SetSecureStorage({ required this.key, this.value, + this.algorithm, + this.mode, this.onComplete, this.onError, }); final String key; final dynamic value; + // Optional encryption configuration + final String? algorithm; + final String? mode; final EnsembleAction? onComplete; final EnsembleAction? onError; @@ -28,6 +32,8 @@ class SetSecureStorage extends EnsembleAction { return SetSecureStorage( key: payload['key'], value: payload['value'], + algorithm: payload['algorithm'], + mode: payload['mode'], onComplete: EnsembleAction.from(payload['onComplete']), onError: EnsembleAction.from(payload['onError']), ); @@ -39,12 +45,22 @@ class SetSecureStorage extends EnsembleAction { try { final evaluatedKey = scopeManager.dataContext.eval(key); final evaluatedValue = scopeManager.dataContext.eval(value); + final evaluatedAlgorithm = + algorithm != null ? scopeManager.dataContext.eval(algorithm) : null; + final evaluatedMode = + mode != null ? scopeManager.dataContext.eval(mode) : null; // Create inputs in the format expected by EncryptedStorageManager - final inputs = { + final inputs = { 'key': evaluatedKey, 'value': evaluatedValue, }; + if (evaluatedAlgorithm != null) { + inputs['algorithm'] = evaluatedAlgorithm; + } + if (evaluatedMode != null) { + inputs['mode'] = evaluatedMode; + } EncryptedStorageManager.setSecureStorage(inputs); @@ -64,11 +80,15 @@ class SetSecureStorage extends EnsembleAction { class GetSecureStorage extends EnsembleAction { GetSecureStorage({ required this.key, + this.algorithm, + this.mode, this.onComplete, this.onError, }); final String key; + final String? algorithm; + final String? mode; final EnsembleAction? onComplete; final EnsembleAction? onError; @@ -78,6 +98,8 @@ class GetSecureStorage extends EnsembleAction { } return GetSecureStorage( key: payload['key'], + algorithm: payload['algorithm'], + mode: payload['mode'], onComplete: EnsembleAction.from(payload['onComplete']), onError: EnsembleAction.from(payload['onError']), ); @@ -86,12 +108,22 @@ class GetSecureStorage extends EnsembleAction { @override Future execute(BuildContext context, ScopeManager scopeManager, {DataContext? dataContext}) async { - var value; + var value; try { final evaluatedKey = scopeManager.dataContext.eval(key); + final evaluatedAlgorithm = + algorithm != null ? scopeManager.dataContext.eval(algorithm) : null; + final evaluatedMode = + mode != null ? scopeManager.dataContext.eval(mode) : null; // Create inputs in the format expected by EncryptedStorageManager - final inputs = {'key': evaluatedKey}; + final inputs = {'key': evaluatedKey}; + if (evaluatedAlgorithm != null) { + inputs['algorithm'] = evaluatedAlgorithm; + } + if (evaluatedMode != null) { + inputs['mode'] = evaluatedMode; + } value = EncryptedStorageManager.getSecureStorage(inputs); @@ -112,11 +144,15 @@ class GetSecureStorage extends EnsembleAction { class ClearSecureStorage extends EnsembleAction { ClearSecureStorage({ required this.key, + this.algorithm, + this.mode, this.onComplete, this.onError, }); final String key; + final String? algorithm; + final String? mode; final EnsembleAction? onComplete; final EnsembleAction? onError; @@ -126,6 +162,8 @@ class ClearSecureStorage extends EnsembleAction { } return ClearSecureStorage( key: payload['key'], + algorithm: payload['algorithm'], + mode: payload['mode'], onComplete: EnsembleAction.from(payload['onComplete']), onError: EnsembleAction.from(payload['onError']), ); @@ -136,9 +174,19 @@ class ClearSecureStorage extends EnsembleAction { {DataContext? dataContext}) async { try { final evaluatedKey = scopeManager.dataContext.eval(key); + final evaluatedAlgorithm = + algorithm != null ? scopeManager.dataContext.eval(algorithm) : null; + final evaluatedMode = + mode != null ? scopeManager.dataContext.eval(mode) : null; // Create inputs in the format expected by EncryptedStorageManager - final inputs = {'key': evaluatedKey}; + final inputs = {'key': evaluatedKey}; + if (evaluatedAlgorithm != null) { + inputs['algorithm'] = evaluatedAlgorithm; + } + if (evaluatedMode != null) { + inputs['mode'] = evaluatedMode; + } EncryptedStorageManager.clearSecureStorage(inputs); diff --git a/modules/ensemble/lib/framework/encrypted_storage_manager.dart b/modules/ensemble/lib/framework/encrypted_storage_manager.dart index 95be213ad..cc8c8afb1 100644 --- a/modules/ensemble/lib/framework/encrypted_storage_manager.dart +++ b/modules/ensemble/lib/framework/encrypted_storage_manager.dart @@ -9,7 +9,6 @@ class EncryptedStorageManager { static final EncryptedStorageManager _instance = EncryptedStorageManager._internal(); static Key? _key; - static Encrypter? _encrypter; static const String PREFIX = 'enc_'; EncryptedStorageManager._internal(); @@ -17,7 +16,7 @@ class EncryptedStorageManager { factory EncryptedStorageManager() => _instance; static void _ensureInitialized() { - if (_encrypter != null) return; + if (_key != null) return; final keyString = SecretsStore().getProperty('encryptionKey'); @@ -32,18 +31,20 @@ class EncryptedStorageManager { } _key = Key.fromUtf8(keyString); - _encrypter = Encrypter(AES(_key!, mode: AESMode.cbc)); } static void setSecureStorage(dynamic inputs) { String key; dynamic val; + String? algorithm; + String? mode; // Handle different input formats if (inputs is Map) { key = _extractKey(inputs, 'setSecureStorage'); val = inputs['value']; - + algorithm = Utils.optionalString(inputs['algorithm']); + mode = Utils.optionalString(inputs['mode']); } else if (inputs is String) { key = inputs; val = null; @@ -57,10 +58,9 @@ class EncryptedStorageManager { return; } - - try { - final encryptedValue = _encryptValue(val); + final encryptedValue = + _encryptValue(val, algorithm: algorithm, mode: mode); StorageManager().write(PREFIX + key, encryptedValue); } catch (e) { throw LanguageError('Failed to encrypt and store value: ${e.toString()}'); @@ -68,12 +68,18 @@ class EncryptedStorageManager { } static dynamic getSecureStorage(dynamic inputs) { + String? algorithm; + String? mode; final key = _extractKey(inputs, 'getSecureStorage'); + if (inputs is Map) { + algorithm = Utils.optionalString(inputs['algorithm']); + mode = Utils.optionalString(inputs['mode']); + } try { final storedValue = StorageManager().read(PREFIX + key); if (storedValue == null) return null; - return _decryptValue(storedValue); + return _decryptValue(storedValue, algorithm: algorithm, mode: mode); } catch (e) { // Log error for debugging but return null to avoid breaking the app print('Error getting secure storage for key $key: $e'); @@ -106,8 +112,24 @@ class EncryptedStorageManager { return key; } - // Utility function to convert value to encrypted string - static String _encryptValue(dynamic value) { + // Utility function to convert value to encrypted string. + // + // Supported algorithms (case-insensitive): + // - "aes" (default; supports modes below) + // - "salsa20" + // - "fernet" + // + // Supported AES modes (case-insensitive, defaults to "cbc"): + // - cbc + // - cfb64 + // - ctr + // - ecb + // - ofb64gctr + // - ofb64 + // - sic + // - gcm + static String _encryptValue(dynamic value, + {String? algorithm, String? mode}) { _ensureInitialized(); String plainText; @@ -123,25 +145,118 @@ class EncryptedStorageManager { plainText = " "; // Handle empty strings } - final iv = IV.fromSecureRandom(16); - final encrypted = _encrypter!.encrypt(plainText, iv: iv); - return iv.base64 + ':' + encrypted.base64; + final selectedAlgorithm = (algorithm ?? 'aes').toLowerCase(); + + Encrypted encrypted; + String modeString = ''; + String ivBase64 = ''; + + if (selectedAlgorithm == 'aes') { + final aesMode = _parseAesMode(mode); + final iv = IV.fromSecureRandom(16); + final encrypter = Encrypter(AES(_key!, mode: aesMode)); + encrypted = encrypter.encrypt(plainText, iv: iv); + modeString = _aesModeToConfigString(aesMode); + ivBase64 = iv.base64; + } else if (selectedAlgorithm == 'salsa20') { + // Salsa20 uses an 8-byte IV/nonce. + final iv = IV.fromSecureRandom(8); + final encrypter = Encrypter(Salsa20(_key!)); + encrypted = encrypter.encrypt(plainText, iv: iv); + modeString = 'default'; + ivBase64 = iv.base64; + } else if (selectedAlgorithm == 'fernet') { + // Fernet manages IV internally; we don't need to store an IV. + final encrypter = Encrypter(Fernet(_key!)); + encrypted = encrypter.encrypt(plainText); + modeString = 'default'; + ivBase64 = ''; + } else { + throw LanguageError( + 'Unsupported encryption algorithm. Supported algorithms are: aes, salsa20, fernet.'); + } + + // New self-describing format: + // enc:v1:::: + return 'enc:v1:$selectedAlgorithm:$modeString:$ivBase64:${encrypted.base64}'; } // Utility function to decrypt value from stored string - static dynamic _decryptValue(String storedValue) { + static dynamic _decryptValue(String storedValue, + {String? algorithm, String? mode}) { _ensureInitialized(); - final parts = storedValue.split(':'); - if (parts.length != 2) { - // Invalid format, possibly corrupted data - return null; - } + String decrypted; + + if (storedValue.startsWith('enc:v1:')) { + // New-format value: enc:v1:::: + final parts = storedValue.split(':'); + if (parts.length != 6) { + // Invalid format, possibly corrupted data + return null; + } - final iv = IV.fromBase64(parts[0]); - final encrypted = Encrypted.fromBase64(parts[1]); + final storedAlgorithm = parts[2]; + final storedMode = parts[3]; + final ivBase64 = parts[4]; + final cipherBase64 = parts[5]; + + // Decide which algorithm to use for decryption: + // - If the value carries an algorithm, that is the source of truth. + // - If the caller also passed an algorithm and it conflicts, fail fast. + // - If the value does not carry an algorithm (shouldn't happen for v1), + // fall back to the caller's algorithm or "aes". + String selectedAlgorithm; + if (storedAlgorithm.isNotEmpty) { + selectedAlgorithm = storedAlgorithm.toLowerCase(); + if (algorithm != null && algorithm.toLowerCase() != selectedAlgorithm) { + throw LanguageError( + 'Configured algorithm does not match stored algorithm for this value.'); + } + } else { + selectedAlgorithm = (algorithm ?? 'aes').toLowerCase(); + } + final effectiveMode = storedMode.isNotEmpty ? storedMode : mode; + + final encrypted = Encrypted.fromBase64(cipherBase64); + + if (selectedAlgorithm == 'aes') { + final aesMode = _parseAesMode(effectiveMode); + if (ivBase64.isEmpty) { + // Missing IV for AES is invalid. + return null; + } + final iv = IV.fromBase64(ivBase64); + final encrypter = Encrypter(AES(_key!, mode: aesMode)); + decrypted = encrypter.decrypt(encrypted, iv: iv); + } else if (selectedAlgorithm == 'salsa20') { + if (ivBase64.isEmpty) { + // Missing IV/nonce for Salsa20 is invalid. + return null; + } + final iv = IV.fromBase64(ivBase64); + final encrypter = Encrypter(Salsa20(_key!)); + decrypted = encrypter.decrypt(encrypted, iv: iv); + } else if (selectedAlgorithm == 'fernet') { + final encrypter = Encrypter(Fernet(_key!)); + decrypted = encrypter.decrypt(encrypted); + } else { + // Unknown algorithm for secure storage + return null; + } + } else { + // Legacy format: :, assumed AES-CBC + final parts = storedValue.split(':'); + if (parts.length != 2) { + // Invalid format, possibly corrupted data + return null; + } - final decrypted = _encrypter!.decrypt(encrypted, iv: iv); + final iv = IV.fromBase64(parts[0]); + final encrypted = Encrypted.fromBase64(parts[1]); + final encrypter = Encrypter(AES(_key!, mode: AESMode.cbc)); + decrypted = encrypter.decrypt(encrypted, iv: iv); + } // Handle empty string placeholder if (decrypted == " ") { @@ -160,4 +275,52 @@ class EncryptedStorageManager { return decrypted; } + + static AESMode _parseAesMode(String? input) { + final value = (input ?? 'cbc').toLowerCase(); + switch (value) { + case 'cbc': + return AESMode.cbc; + case 'cfb64': + return AESMode.cfb64; + case 'ctr': + return AESMode.ctr; + case 'ecb': + return AESMode.ecb; + case 'ofb64gctr': + case 'ofb-64/gctr': + return AESMode.ofb64Gctr; + case 'ofb64': + case 'ofb-64': + return AESMode.ofb64; + case 'sic': + return AESMode.sic; + case 'gcm': + return AESMode.gcm; + default: + throw LanguageError( + 'Unsupported AES mode "$input". Supported modes are: cbc, cfb64, ctr, ecb, ofb64gctr, ofb64, sic, gcm.'); + } + } + + static String _aesModeToConfigString(AESMode mode) { + switch (mode) { + case AESMode.cbc: + return 'cbc'; + case AESMode.cfb64: + return 'cfb64'; + case AESMode.ctr: + return 'ctr'; + case AESMode.ecb: + return 'ecb'; + case AESMode.ofb64Gctr: + return 'ofb64gctr'; + case AESMode.ofb64: + return 'ofb64'; + case AESMode.sic: + return 'sic'; + case AESMode.gcm: + return 'gcm'; + } + } }