From ae72f1411d513fe608d3497c70e6221e6577a32e Mon Sep 17 00:00:00 2001 From: Chralu Date: Tue, 4 Jun 2024 14:41:26 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20Test=20Vault.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + .../vault/lib/password_vault_cipher.dart | 8 +- .../datasources/vault/vault.dart | 86 ++++++---- lib/main.dart | 8 +- .../views/authenticate/auto_lock_guard.dart | 4 +- pubspec.lock | 8 + pubspec.yaml | 2 + ...st.dart => vault_secure_storage_test.dart} | 2 +- test/vault_test.dart | 151 ++++++++++++++++++ test/vault_test.mocks.dart | 52 ++++++ 10 files changed, 285 insertions(+), 38 deletions(-) rename test/{secure_storage_test.dart => vault_secure_storage_test.dart} (99%) create mode 100644 test/vault_test.dart create mode 100644 test/vault_test.mocks.dart diff --git a/.gitignore b/.gitignore index 4b7a9528a..eddcb138b 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ .svn/ dist/ +# Test +test/tmp_data # Fastlane diff --git a/lib/infrastructure/datasources/vault/lib/password_vault_cipher.dart b/lib/infrastructure/datasources/vault/lib/password_vault_cipher.dart index 8cb5f2b8e..5aff67f54 100644 --- a/lib/infrastructure/datasources/vault/lib/password_vault_cipher.dart +++ b/lib/infrastructure/datasources/vault/lib/password_vault_cipher.dart @@ -2,9 +2,9 @@ part of '../vault.dart'; /// Encryption key is AES encrypted before storage class PasswordVaultCipher implements VaultCipher { - PasswordVaultCipher({required this.password}); + PasswordVaultCipher({required this.passphrase}); - final String password; + final String passphrase; Uint8List? _key; @@ -24,11 +24,11 @@ class PasswordVaultCipher implements VaultCipher { final encryptionKey = await Hive.readEncryptedSecureKey( secureStorage, - password, + passphrase, ) ?? await Hive.generateAndStoreEncryptedSecureKey( secureStorage, - password, + passphrase, ); return encryptionKey; diff --git a/lib/infrastructure/datasources/vault/vault.dart b/lib/infrastructure/datasources/vault/vault.dart index efbe3117a..677fb76f5 100644 --- a/lib/infrastructure/datasources/vault/vault.dart +++ b/lib/infrastructure/datasources/vault/vault.dart @@ -15,20 +15,6 @@ part 'lib/vault.encrypted_securedkey_extension.dart'; part 'lib/vault.raw_securedkey_extension.dart'; abstract class VaultCipher { - factory VaultCipher(String password) { - return kIsWeb - ? PasswordVaultCipher(password: password) - : SimpleVaultCipher(); - } - - static Future get isSetup async { - return kIsWeb ? PasswordVaultCipher.isSetup : SimpleVaultCipher.isSetup; - } - - static Future clear() async { - return kIsWeb ? PasswordVaultCipher.clear() : SimpleVaultCipher.clear(); - } - Future get(); Future updateSecureKey( @@ -36,22 +22,67 @@ abstract class VaultCipher { ); } -typedef VaultPasswordDelegate = Future Function(); +abstract class VaultCipherFactory { + factory VaultCipherFactory() => + kIsWeb ? PasswordVaultCipherFactory() : SimpleVaultCipherFactory(); + + VaultCipher build(String password); + + Future get isSetup; + + Future clear(); +} + +class PasswordVaultCipherFactory implements VaultCipherFactory { + @override + VaultCipher build(String password) => PasswordVaultCipher( + passphrase: password, + ); + + @override + Future get isSetup => PasswordVaultCipher.isSetup; + + @override + Future clear() => PasswordVaultCipher.clear(); +} + +class SimpleVaultCipherFactory implements VaultCipherFactory { + @override + VaultCipher build(String password) => SimpleVaultCipher(); + + @override + Future clear() => SimpleVaultCipher.clear(); + + @override + Future get isSetup => SimpleVaultCipher.isSetup; +} + +typedef VaultPassphraseDelegate = Future Function(); typedef VaultAutolockDelegate = Future Function(); class Vault { - Vault._(); + Vault._({VaultCipherFactory? cipherFactory}) { + _cipherFactory = cipherFactory ?? VaultCipherFactory(); + } - factory Vault.instance() { - Vault._instance ??= Vault._(); + factory Vault.instance({VaultCipherFactory? cipherFactory}) { + Vault._instance ??= Vault._(cipherFactory: cipherFactory); return Vault._instance!; } + @visibleForTesting + static Future reset() async { + Vault._instance = null; + await Hive.deleteFromDisk(); + } + + late final VaultCipherFactory _cipherFactory; + static const _logName = 'Vault'; static Vault? _instance; - VaultPasswordDelegate? passwordDelegate; + VaultPassphraseDelegate? passphraseDelegate; VaultAutolockDelegate? shouldBeLocked; VaultCipher? _vaultCipher; @@ -74,7 +105,7 @@ class Vault { 'Unlocking vault', name: _logName, ); - _vaultCipher = VaultCipher(password); + _vaultCipher = _cipherFactory.build(password); // Ensures we are able to retrieve the encryption key await _vaultCipher!.get(); @@ -85,7 +116,7 @@ class Vault { } Future get isSetup async { - return VaultCipher.isSetup; + return _cipherFactory.isSetup; } Future boxExists(String name) { @@ -109,11 +140,12 @@ class Vault { 'Clearing vault secure key', name: _logName, ); - await VaultCipher.clear(); + await _cipherFactory.clear(); + await lock(); } Future updateSecureKey( - String newPassword, + String passphrase, ) async { log( 'Updating vault secure key', @@ -122,7 +154,7 @@ class Vault { if (_vaultCipher == null) { throw const Failure.locked(); } - await _vaultCipher!.updateSecureKey(newPassword); + await _vaultCipher!.updateSecureKey(passphrase); } Future> openBox( @@ -205,7 +237,7 @@ class Vault { return; } - if (passwordDelegate == null) { + if (passphraseDelegate == null) { throw Exception( 'Vault.passwordDelegate must be set before opening Boxes.', ); @@ -214,8 +246,8 @@ class Vault { 'Requesting user action to unlock', name: _logName, ); - final password = await passwordDelegate!(); + final passphrase = await passphraseDelegate!(); - await unlock(password); + await unlock(passphrase); } } diff --git a/lib/main.dart b/lib/main.dart index 0546afe1f..92d73fc1b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -316,7 +316,7 @@ class SplashState extends ConsumerState with WidgetsBindingObserver { .addListener(removeNativeSplash); } - VaultPasswordDelegate? _passwordDelegate; + VaultPassphraseDelegate? _passwordDelegate; @override void initState() { @@ -337,7 +337,7 @@ class SplashState extends ConsumerState with WidgetsBindingObserver { ), canCancel: false, ); - Vault.instance().passwordDelegate = _passwordDelegate; + Vault.instance().passphraseDelegate = _passwordDelegate; WidgetsBinding.instance.addPostFrameCallback((_) async { await initializeProviders(); @@ -349,8 +349,8 @@ class SplashState extends ConsumerState with WidgetsBindingObserver { void dispose() { /// If some other screen updated the passwordDelegate, /// then we should not reset it. - if (Vault.instance().passwordDelegate == _passwordDelegate) { - Vault.instance().passwordDelegate = null; + if (Vault.instance().passphraseDelegate == _passwordDelegate) { + Vault.instance().passphraseDelegate = null; } super.dispose(); diff --git a/lib/ui/views/authenticate/auto_lock_guard.dart b/lib/ui/views/authenticate/auto_lock_guard.dart index b6ee9c567..04c46d79b 100644 --- a/lib/ui/views/authenticate/auto_lock_guard.dart +++ b/lib/ui/views/authenticate/auto_lock_guard.dart @@ -62,7 +62,7 @@ class _AutoLockGuardState extends ConsumerState WidgetsBinding.instance.addObserver(this); Vault.instance() - ..passwordDelegate = _forceAuthent + ..passphraseDelegate = _forceAuthent ..shouldBeLocked = _shouldBeLocked; } @@ -72,7 +72,7 @@ class _AutoLockGuardState extends ConsumerState WidgetsBinding.instance.removeObserver(this); LockMaskOverlay.instance().hide(); Vault.instance() - ..passwordDelegate = null + ..passphraseDelegate = null ..shouldBeLocked = null; super.dispose(); diff --git a/pubspec.lock b/pubspec.lock index b1ebdc890..198164495 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1374,6 +1374,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + mockito: + dependency: "direct dev" + description: + name: mockito + sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917" + url: "https://pub.dev" + source: hosted + version: "5.4.4" msix: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index c22f35586..280868802 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -293,6 +293,8 @@ dev_dependencies: hive_generator: ^2.0.1 # Automatically generate code for converting to and from JSON by annotating Dart classes. json_serializable: ^6.7.0 + # Mocking library (tests) + mockito: ^5.4.4 # A command-line tool that create Msix installer from your flutter windows-build files. msix: ^3.16.7 # Simple yet powerful Flutter-native UI testing framework eliminating limitations of flutter_test, integration_test, and flutter_driver. diff --git a/test/secure_storage_test.dart b/test/vault_secure_storage_test.dart similarity index 99% rename from test/secure_storage_test.dart rename to test/vault_secure_storage_test.dart index a387b322a..6dd61d8ef 100644 --- a/test/secure_storage_test.dart +++ b/test/vault_secure_storage_test.dart @@ -9,7 +9,7 @@ import 'package:pointycastle/pointycastle.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); group( - 'Vault', + 'Vault - SecureStorage', () { setUp(() async { FlutterSecureStorage.setMockInitialValues({}); diff --git a/test/vault_test.dart b/test/vault_test.dart new file mode 100644 index 000000000..a4c4f4183 --- /dev/null +++ b/test/vault_test.dart @@ -0,0 +1,151 @@ +import 'dart:io'; + +import 'package:aewallet/infrastructure/datasources/vault/vault.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive/hive.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:pointycastle/export.dart'; + +@GenerateNiceMocks([MockSpec()]) +import 'vault_test.mocks.dart'; + +class VaultDelegate { + VaultDelegate(); + + Future passwordDelegate() async { + throw UnimplementedError(); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + /// Enforce usage of the [PasswordVaultCipherFactory]. + /// That way, we actually test the vault key encryption. + Vault vault() => Vault.instance(cipherFactory: PasswordVaultCipherFactory()); + + group( + 'Vault', + () { + late MockVaultDelegate vaultDelegate; + setUp(() async { + Hive.init('${Directory.current.path}/test/tmp_data'); + await Vault.reset(); + FlutterSecureStorage.setMockInitialValues({}); + }); + + void _setupUserInputPassphrase(String passphrase) { + vaultDelegate = MockVaultDelegate(); + when(vaultDelegate.passwordDelegate()) + .thenAnswer((_) async => passphrase); + vault().passphraseDelegate = vaultDelegate.passwordDelegate; + } + + test( + 'Should ask for passphrase when creating the vault [box]', + () async { + // GIVEN + _setupUserInputPassphrase('passphrase'); + const boxName = 'abox'; + + // WHEN + final box = await vault().openBox(boxName); + + // THEN + expect(box, const TypeMatcher>()); + verify(vaultDelegate.passwordDelegate()).called(1); + }, + ); + + test( + 'Should ask for passphrase when creating the vault [lazybox]', + () async { + // GIVEN + _setupUserInputPassphrase('passphrase'); + const boxName = 'abox'; + + // WHEN + final box = await vault().openLazyBox(boxName); + + // THEN + expect(box, const TypeMatcher>()); + verify(vaultDelegate.passwordDelegate()).called(1); + }, + ); + + test( + 'Should not ask for passphrase when Vault already unlocked', + () async { + // GIVEN + _setupUserInputPassphrase('passphrase'); + await vault().unlock('passphrase'); + const boxName = 'abox'; + + // WHEN + final box = await vault().openBox(boxName); + + // THEN + expect(box, const TypeMatcher>()); + verifyNever(vaultDelegate.passwordDelegate()); + }, + ); + + test( + 'Should ask for passphrase when Vault locked', + () async { + // GIVEN + _setupUserInputPassphrase('passphrase'); + await vault().unlock('passphrase'); + await vault().lock(); + + // WHEN + final box = await vault().openBox('aBox'); + + // THEN + expect(box, const TypeMatcher>()); + verify(vaultDelegate.passwordDelegate()).called(1); + }, + ); + + test( + 'Should ask for passphrase after clearing secure key', + () async { + // GIVEN + _setupUserInputPassphrase('passphrase'); + await vault().unlock('passphrase'); + await vault().clearSecureKey(); + + // WHEN + final box = await vault().openBox('aBox'); + + // THEN + expect(box, const TypeMatcher>()); + verify(vaultDelegate.passwordDelegate()).called(1); + }, + ); + + test( + 'Should reject data reading with wrong passphrase', + () async { + // GIVEN + _setupUserInputPassphrase('oldPassphrase'); + + const boxName = 'box'; + final box = await vault().openBox(boxName); + await box.put('aKey', 'aValue'); + + await vault().updateSecureKey('newPassphrase'); + await vault().lock(); + + // WHEN + expect( + () => vault().openBox(boxName), + throwsA(isA()), + ); + }, + ); + }, + ); +} diff --git a/test/vault_test.mocks.dart b/test/vault_test.mocks.dart new file mode 100644 index 000000000..f981bee38 --- /dev/null +++ b/test/vault_test.mocks.dart @@ -0,0 +1,52 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in aewallet/test/vault_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i4; + +import 'vault_test.dart' as _i2; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +/// A class which mocks [VaultDelegate]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockVaultDelegate extends _i1.Mock implements _i2.VaultDelegate { + @override + _i3.Future passwordDelegate() => (super.noSuchMethod( + Invocation.method( + #passwordDelegate, + [], + ), + returnValue: _i3.Future.value(_i4.dummyValue( + this, + Invocation.method( + #passwordDelegate, + [], + ), + )), + returnValueForMissingStub: + _i3.Future.value(_i4.dummyValue( + this, + Invocation.method( + #passwordDelegate, + [], + ), + )), + ) as _i3.Future); +}