diff --git a/lib/app.dart b/lib/app.dart index 1712981e9..a4cbeec6c 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -8,6 +8,7 @@ import 'package:desktop_window_bootstrap/desktop_window_bootstrap.dart'; import 'src/app_bootstrap.dart'; import 'src/core/layout/app_layout.dart'; import 'src/core/motion/onboarding_motion.dart'; +import 'src/core/security/wallet_lock_controller.dart'; import 'src/core/theme/app_theme_host.dart'; import 'src/core/theme/legacy_material_theme.dart'; import 'src/core/widgets/network_fallback_toast.dart'; @@ -591,21 +592,23 @@ class ZcashWalletApp extends ConsumerWidget { // (buttons, TextFields) win the gesture arena first, keeping // focused buttons focused when re-clicked. child: _RpcEndpointFailoverToastListener( - child: DesktopWindowTitlebarSafeArea( - child: GestureDetector( - onTap: () { - // Leaf-only: skip when the primary focus is a - // `FocusScopeNode` rather than a concrete `FocusNode`. - // Unfocusing the scope itself strips the scope's - // "most-recently-focused child" memory, which leaves the - // next Tab with no deterministic starting point. - final primary = FocusManager.instance.primaryFocus; - if (primary != null && primary is! FocusScopeNode) { - primary.unfocus(); - } - }, - behavior: HitTestBehavior.translucent, - child: child!, + child: AutoLockObserver( + child: DesktopWindowTitlebarSafeArea( + child: GestureDetector( + onTap: () { + // Leaf-only: skip when the primary focus is a + // `FocusScopeNode` rather than a concrete `FocusNode`. + // Unfocusing the scope itself strips the scope's + // "most-recently-focused child" memory, which leaves the + // next Tab with no deterministic starting point. + final primary = FocusManager.instance.primaryFocus; + if (primary != null && primary is! FocusScopeNode) { + primary.unfocus(); + } + }, + behavior: HitTestBehavior.translucent, + child: child!, + ), ), ), ), diff --git a/lib/src/core/layout/app_main_sidebar.dart b/lib/src/core/layout/app_main_sidebar.dart index ed5c3b6c6..a91ad0410 100644 --- a/lib/src/core/layout/app_main_sidebar.dart +++ b/lib/src/core/layout/app_main_sidebar.dart @@ -6,6 +6,7 @@ import '../../providers/account_provider.dart'; import '../../providers/app_security_provider.dart'; import '../../providers/sync_provider.dart'; import '../profile_pictures.dart'; +import '../security/wallet_lock_controller.dart'; import '../theme/app_theme.dart'; import '../widgets/app_icon.dart'; import '../widgets/app_profile_picture.dart'; @@ -44,12 +45,16 @@ class _AppMainSidebarState extends ConsumerState { }); try { - securityNotifier.lock(); - accountNotifier.clearSensitiveStateForLock(); + final lockFuture = lockWalletSession( + securityNotifier: securityNotifier, + accountNotifier: accountNotifier, + syncNotifier: syncNotifier, + awaitSync: true, + ); if (mounted) { context.go('/unlock'); } - await syncNotifier.clearSensitiveStateForLock(); + await lockFuture; } finally { if (mounted) { setState(() { diff --git a/lib/src/core/security/wallet_lock_controller.dart b/lib/src/core/security/wallet_lock_controller.dart new file mode 100644 index 000000000..d350854d6 --- /dev/null +++ b/lib/src/core/security/wallet_lock_controller.dart @@ -0,0 +1,104 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../main.dart' show log; +import '../../providers/account_provider.dart'; +import '../../providers/app_security_provider.dart'; +import '../../providers/sync_provider.dart'; +import '../../providers/wallet_provider.dart'; + +const Duration kAutoLockBackgroundTimeout = Duration(minutes: 10); + +bool shouldAutoLock({ + required Duration elapsed, + Duration threshold = kAutoLockBackgroundTimeout, +}) { + return elapsed >= threshold; +} + +Future lockWalletSession({ + required AppSecurityNotifier securityNotifier, + required AccountNotifier accountNotifier, + required SyncNotifier syncNotifier, + bool awaitSync = false, +}) async { + securityNotifier.lock(); + accountNotifier.clearSensitiveStateForLock(); + final syncFuture = syncNotifier.clearSensitiveStateForLock(); + if (awaitSync) { + await syncFuture; + } else { + unawaited(syncFuture); + } +} + +class AutoLockObserver extends ConsumerStatefulWidget { + const AutoLockObserver({super.key, required this.child}); + + final Widget child; + + @override + ConsumerState createState() => _AutoLockObserverState(); +} + +class _AutoLockObserverState extends ConsumerState { + AppLifecycleListener? _listener; + final Stopwatch _clock = Stopwatch()..start(); + Duration? _monoHiddenAt; + DateTime? _wallHiddenAt; + + @override + void initState() { + super.initState(); + _listener = AppLifecycleListener(onHide: _onHide, onShow: _onShow); + } + + @override + void dispose() { + _listener?.dispose(); + _listener = null; + super.dispose(); + } + + bool _isLockable() { + final security = ref.read(appSecurityProvider); + if (!security.isUnlocked) return false; + final wallet = ref.read(walletProvider).value; + return wallet?.hasWallet ?? false; + } + + void _onHide() { + if (!_isLockable()) return; + _monoHiddenAt = _clock.elapsed; + _wallHiddenAt = DateTime.now(); + } + + void _onShow() { + final monoHiddenAt = _monoHiddenAt; + final wallHiddenAt = _wallHiddenAt; + _monoHiddenAt = null; + _wallHiddenAt = null; + if (monoHiddenAt == null || wallHiddenAt == null) return; + if (!_isLockable()) return; + final monoElapsed = _clock.elapsed - monoHiddenAt; + final wallElapsed = DateTime.now().difference(wallHiddenAt); + if (!shouldAutoLock(elapsed: monoElapsed) && + !shouldAutoLock(elapsed: wallElapsed)) { + return; + } + log( + 'AutoLock: monoElapsed=$monoElapsed wallElapsed=$wallElapsed, ' + 'locking wallet session', + ); + lockWalletSession( + securityNotifier: ref.read(appSecurityProvider.notifier), + accountNotifier: ref.read(accountProvider.notifier), + syncNotifier: ref.read(syncProvider.notifier), + ); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/lib/src/core/storage/app_secure_store.dart b/lib/src/core/storage/app_secure_store.dart index e0c2af731..d49e7ae50 100644 --- a/lib/src/core/storage/app_secure_store.dart +++ b/lib/src/core/storage/app_secure_store.dart @@ -3,7 +3,13 @@ import 'dart:convert'; import 'dart:math'; import 'package:cryptography/cryptography.dart'; -import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting; +import 'package:flutter/foundation.dart' + show + TargetPlatform, + debugPrint, + defaultTargetPlatform, + kIsWeb, + visibleForTesting; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../config/network_config.dart'; @@ -20,6 +26,8 @@ const _passwordVerifierSaltKey = 'zcash_password_verifier_salt'; const _passwordRotationInProgressKey = 'zcash_rotation_in_progress'; const _passwordRotationRollbackFailedKind = 'rollbackFailed'; const _accountMnemonicKeyPrefix = 'zcash_account_mnemonic_'; +const _accountMnemonicMigrationCompleteKey = + 'zcash_mnemonic_storage_migrated_v1'; class PasswordRotationRecoveryFailedException implements Exception { const PasswordRotationRecoveryFailedException(); @@ -30,12 +38,18 @@ class PasswordRotationRecoveryFailedException implements Exception { } class AppSecureStore { - AppSecureStore._({FlutterSecureStorage? storage}) - : _storage = storage ?? _defaultStorage(); + AppSecureStore._({ + FlutterSecureStorage? storage, + FlutterSecureStorage? mnemonicStorage, + }) : _storage = storage ?? _defaultStorage(), + _mnemonicStorage = mnemonicStorage ?? _defaultMnemonicStorage(); @visibleForTesting - AppSecureStore.testing({required FlutterSecureStorage storage}) - : _storage = storage; + AppSecureStore.testing({ + required FlutterSecureStorage storage, + FlutterSecureStorage? mnemonicStorage, + }) : _storage = storage, + _mnemonicStorage = mnemonicStorage ?? storage; static final AppSecureStore instance = AppSecureStore._(); @@ -57,6 +71,27 @@ class AppSecureStore { ); } + static FlutterSecureStorage _defaultMnemonicStorage() { + final service = secureStoreServiceForNetwork(kZcashDefaultNetworkName); + final macOsService = _mnemonicSecureStoreServiceForNetwork( + kZcashDefaultNetworkName, + ); + return FlutterSecureStorage( + iOptions: IOSOptions( + accountName: service, + accessibility: KeychainAccessibility.first_unlock, + ), + aOptions: kZcashDefaultNetworkName == 'main' + ? AndroidOptions.defaultOptions + : AndroidOptions(sharedPreferencesName: service), + mOptions: MacOsOptions( + accountName: macOsService, + accessibility: KeychainAccessibility.unlocked, + usesDataProtectionKeychain: true, + ), + ); + } + static final Cipher _cipher = AesGcm.with256bits(); static final Pbkdf2 _kdf = Pbkdf2( macAlgorithm: Hmac.sha256(), @@ -65,6 +100,7 @@ class AppSecureStore { ); final FlutterSecureStorage _storage; + final FlutterSecureStorage _mnemonicStorage; final _secretMutationLock = _AsyncLock(); SecretKey? _cachedSecretKey; String? _sessionPassword; @@ -92,31 +128,29 @@ class AppSecureStore { bool requireUnlockedSession = false, }) { return _secretMutationLock.run(() async { - if (requireUnlockedSession && !hasSessionPassword) { - return null; - } - if (!hasSessionPassword) { - throw StateError('Secret storage requires an unlocked session.'); - } - + if (_shouldSkipLockedSecretRead(requireUnlockedSession)) return null; final raw = await _storage.read(key: key); - if (raw == null || raw.isEmpty) return null; - - final payload = _EncryptedPayload.tryParse(raw); - if (payload == null) { - return null; - } + return _decryptStoredSecretString( + raw, + key: key, + requireUnlockedSession: requireUnlockedSession, + ); + }); + } - final secretKey = await _getSecretKey(); - final clearText = await _cipher.decrypt( - SecretBox( - payload.cipherText, - nonce: payload.nonce, - mac: Mac(payload.mac), - ), - secretKey: secretKey, + Future readAccountMnemonic( + String accountUuid, { + bool requireUnlockedSession = false, + }) { + return _secretMutationLock.run(() async { + if (_shouldSkipLockedSecretRead(requireUnlockedSession)) return null; + final key = _accountMnemonicKey(accountUuid); + final raw = await _mnemonicStorage.read(key: key); + return _decryptStoredSecretString( + raw, + key: key, + requireUnlockedSession: requireUnlockedSession, ); - return utf8.decode(clearText); }); } @@ -126,36 +160,47 @@ class AppSecureStore { Future writeSecretString(String key, String value) { return _secretMutationLock.run(() async { - final secretKey = await _getSecretKey(); - final nonce = _randomBytes(12); - final secretBox = await _cipher.encrypt( - utf8.encode(value), - secretKey: secretKey, - nonce: nonce, - ); - await _storage.write( - key: key, - value: _EncryptedPayload( - nonce: secretBox.nonce, - cipherText: secretBox.cipherText, - mac: secretBox.mac.bytes, - ).serialize(), + await _storage.write(key: key, value: await _encryptSecretString(value)); + }); + } + + Future writeAccountMnemonic(String accountUuid, String mnemonic) { + return _secretMutationLock.run(() async { + await _mnemonicStorage.write( + key: _accountMnemonicKey(accountUuid), + value: await _encryptSecretString(mnemonic), ); }); } Future delete(String key) async { if (key.startsWith(_accountMnemonicKeyPrefix)) { - await _secretMutationLock.run(() => _storage.delete(key: key)); + await _secretMutationLock.run(() async { + await _mnemonicStorage.delete(key: key); + await _deleteLegacyAccountMnemonicBestEffort(key); + }); return; } await _storage.delete(key: key); } - Future deleteAll() async { - await _storage.deleteAll(); - _cachedSecretKey = null; - _sessionPassword = null; + Future deleteAccountMnemonic(String accountUuid) { + final key = _accountMnemonicKey(accountUuid); + return _secretMutationLock.run(() async { + await _mnemonicStorage.delete(key: key); + await _deleteLegacyAccountMnemonicBestEffort(key); + }); + } + + Future deleteAll() { + return _secretMutationLock.run(() async { + await _storage.deleteAll(); + if (!identical(_mnemonicStorage, _storage)) { + await _mnemonicStorage.deleteAll(); + } + _cachedSecretKey = null; + _sessionPassword = null; + }); } Future readPlain(String key) { @@ -228,7 +273,13 @@ class AppSecureStore { ); final newSecretKey = await _deriveSecretKeyForPassword(newPassword); try { - final storedValues = await _storage.readAll(); + final migration = await _migrateAccountMnemonicsAfterUnlockLocked(); + if (!migration.legacyCleanupComplete) { + throw StateError( + 'Failed to migrate account mnemonics before password rotation.', + ); + } + final storedValues = await _mnemonicStorage.readAll(); final rotatedSecrets = <_PasswordRotationEntry>[]; final rollbackSecrets = <_PasswordRotationRollbackEntry>[]; @@ -316,11 +367,13 @@ class AppSecureStore { clearSessionPassword(); } - Future clearPasswordConfiguration() async { - await delete(_passwordVerifierSaltKey); - await delete(_passwordVerifierKey); - await delete(_passwordRotationInProgressKey); - clearSessionPassword(); + Future clearPasswordConfiguration() { + return _secretMutationLock.run(() async { + await _storage.delete(key: _passwordVerifierSaltKey); + await _storage.delete(key: _passwordVerifierKey); + await _storage.delete(key: _passwordRotationInProgressKey); + clearSessionPassword(); + }); } /// Checks the wallet password without opening or refreshing the encrypted @@ -350,10 +403,31 @@ class AppSecureStore { final isMatch = await verifyPasswordOnly(password); if (isMatch) { setSessionPassword(password); + try { + final migratedForRead = await migrateAccountMnemonicsAfterUnlock(); + if (!migratedForRead) { + clearSessionPassword(); + return false; + } + } catch (error, stackTrace) { + clearSessionPassword(); + debugPrint( + 'AppSecureStore: failed to migrate account mnemonics after unlock: ' + '$error\n$stackTrace', + ); + return false; + } } return isMatch; } + Future migrateAccountMnemonicsAfterUnlock() { + return _secretMutationLock.run(() async { + final migration = await _migrateAccountMnemonicsAfterUnlockLocked(); + return migration.mnemonicsAvailable; + }); + } + void setSessionPassword(String password) { _sessionPassword = password; _cachedSecretKey = null; @@ -364,6 +438,120 @@ class AppSecureStore { _cachedSecretKey = null; } + bool _shouldSkipLockedSecretRead(bool requireUnlockedSession) { + return requireUnlockedSession && !hasSessionPassword; + } + + Future _decryptStoredSecretString( + String? raw, { + required String key, + required bool requireUnlockedSession, + }) async { + if (requireUnlockedSession && !hasSessionPassword) { + return null; + } + if (!hasSessionPassword) { + throw StateError('Secret storage requires an unlocked session.'); + } + if (raw == null || raw.isEmpty) return null; + + final payload = _EncryptedPayload.tryParse(raw); + if (payload == null) { + return null; + } + + final secretKey = await _getSecretKey(); + return _decryptPayloadForKey(key, payload, secretKey); + } + + Future _encryptSecretString(String value) async { + final secretKey = await _getSecretKey(); + final nonce = _randomBytes(12); + final secretBox = await _cipher.encrypt( + utf8.encode(value), + secretKey: secretKey, + nonce: nonce, + ); + return _EncryptedPayload( + nonce: secretBox.nonce, + cipherText: secretBox.cipherText, + mac: secretBox.mac.bytes, + ).serialize(); + } + + Future<_AccountMnemonicMigrationResult> + _migrateAccountMnemonicsAfterUnlockLocked() async { + if (!_usesSeparateMacOsMnemonicStorage || + identical(_mnemonicStorage, _storage)) { + return _AccountMnemonicMigrationResult.complete; + } + if (await readPlain(_accountMnemonicMigrationCompleteKey) == 'true') { + return _AccountMnemonicMigrationResult.complete; + } + + final legacyValues = await _storage.readAll(); + var mnemonicsAvailable = true; + var legacyCleanupComplete = true; + for (final entry in legacyValues.entries) { + if (!_isAccountMnemonicKey(entry.key)) continue; + + try { + final existing = await _mnemonicStorage.read(key: entry.key); + if (existing == null) { + await _mnemonicStorage.write(key: entry.key, value: entry.value); + } + } catch (error, stackTrace) { + mnemonicsAvailable = false; + legacyCleanupComplete = false; + debugPrint( + 'AppSecureStore: failed to copy account mnemonic "${entry.key}": ' + '$error\n$stackTrace', + ); + continue; + } + + try { + await _storage.delete(key: entry.key); + } catch (error, stackTrace) { + legacyCleanupComplete = false; + debugPrint( + 'AppSecureStore: failed to delete legacy account mnemonic ' + '"${entry.key}": ' + '$error\n$stackTrace', + ); + } + } + if (legacyCleanupComplete) { + try { + await writePlain(_accountMnemonicMigrationCompleteKey, 'true'); + } catch (error, stackTrace) { + debugPrint( + 'AppSecureStore: failed to mark account mnemonic migration complete: ' + '$error\n$stackTrace', + ); + } + } + return _AccountMnemonicMigrationResult( + mnemonicsAvailable: mnemonicsAvailable, + legacyCleanupComplete: legacyCleanupComplete, + ); + } + + Future _deleteLegacyAccountMnemonicBestEffort(String key) async { + if (!_usesSeparateMacOsMnemonicStorage || + identical(_mnemonicStorage, _storage)) { + return; + } + try { + await _storage.delete(key: key); + } catch (error, stackTrace) { + debugPrint( + 'AppSecureStore: failed to delete legacy account mnemonic "$key": ' + '$error\n$stackTrace', + ); + } + } + Future _getSecretKey() async { final sessionPassword = _sessionPassword; if (sessionPassword == null) { @@ -453,7 +641,7 @@ class AppSecureStore { _PasswordRotationRecord rotation, ) async { for (final entry in rotation.entries) { - await writePlain(entry.key, entry.rotatedValue); + await _mnemonicStorage.write(key: entry.key, value: entry.rotatedValue); } await writePlain(_passwordVerifierSaltKey, rotation.newVerifierSalt); await writePlain(_passwordVerifierKey, rotation.newVerifier); @@ -465,7 +653,10 @@ class AppSecureStore { ) async { try { for (final entry in rollback.entries) { - await writePlain(entry.key, entry.originalValue); + await _mnemonicStorage.write( + key: entry.key, + value: entry.originalValue, + ); } if (rollback.oldVerifierSalt == null) { await delete(_passwordVerifierSaltKey); @@ -538,6 +729,34 @@ class AppSecureStore { } } +bool get _usesSeparateMacOsMnemonicStorage => + !kIsWeb && defaultTargetPlatform == TargetPlatform.macOS; + +bool _isAccountMnemonicKey(String key) => + key.startsWith(_accountMnemonicKeyPrefix); + +String _accountMnemonicKey(String accountUuid) => + '$_accountMnemonicKeyPrefix$accountUuid'; + +String _mnemonicSecureStoreServiceForNetwork(String networkName) { + return '${secureStoreServiceForNetwork(networkName)}.mnemonic'; +} + +class _AccountMnemonicMigrationResult { + const _AccountMnemonicMigrationResult({ + required this.mnemonicsAvailable, + required this.legacyCleanupComplete, + }); + + static const complete = _AccountMnemonicMigrationResult( + mnemonicsAvailable: true, + legacyCleanupComplete: true, + ); + + final bool mnemonicsAvailable; + final bool legacyCleanupComplete; +} + class _AsyncLock { Future _tail = Future.value(); diff --git a/lib/src/providers/account_provider.dart b/lib/src/providers/account_provider.dart index 04e6c73af..da9809ba7 100644 --- a/lib/src/providers/account_provider.dart +++ b/lib/src/providers/account_provider.dart @@ -93,10 +93,7 @@ class AccountNotifier extends AsyncNotifier { } // Store mnemonic per-account - await _storage.writeSecretString( - 'zcash_account_mnemonic_$accountUuid', - mnemonic, - ); + await _storage.writeAccountMnemonic(accountUuid, mnemonic); // Update account list final newAccount = AccountInfo( @@ -172,10 +169,7 @@ class AccountNotifier extends AsyncNotifier { unifiedAddress = result.unifiedAddress; } - await _storage.writeSecretString( - 'zcash_account_mnemonic_$accountUuid', - mnemonic, - ); + await _storage.writeAccountMnemonic(accountUuid, mnemonic); final newAccount = AccountInfo( uuid: accountUuid, @@ -247,10 +241,7 @@ class AccountNotifier extends AsyncNotifier { unifiedAddress = result.unifiedAddress; } - await _storage.writeSecretString( - 'zcash_account_mnemonic_$accountUuid', - mnemonic, - ); + await _storage.writeAccountMnemonic(accountUuid, mnemonic); final newAccount = AccountInfo( uuid: accountUuid, @@ -374,7 +365,7 @@ class AccountNotifier extends AsyncNotifier { '${rustDeleteWatch.elapsedMilliseconds}ms uuid=$uuid', ); try { - await _storage.delete('zcash_account_mnemonic_$uuid'); + await _storage.deleteAccountMnemonic(uuid); } catch (e, st) { log('removeAccount: failed to delete mnemonic for $uuid: $e\n$st'); } @@ -542,18 +533,12 @@ class AccountNotifier extends AsyncNotifier { Future getActiveMnemonic() async { final uuid = state.value?.activeAccountUuid; if (uuid == null) return null; - return _storage.readSecretStringWithOptions( - 'zcash_account_mnemonic_$uuid', - requireUnlockedSession: true, - ); + return _storage.readAccountMnemonic(uuid, requireUnlockedSession: true); } /// Get the mnemonic for a specific account. Future getMnemonicForAccount(String uuid) async { - return _storage.readSecretStringWithOptions( - 'zcash_account_mnemonic_$uuid', - requireUnlockedSession: true, - ); + return _storage.readAccountMnemonic(uuid, requireUnlockedSession: true); } // ======================== Helpers ======================== diff --git a/test/core/security/wallet_lock_controller_test.dart b/test/core/security/wallet_lock_controller_test.dart new file mode 100644 index 000000000..77409a058 --- /dev/null +++ b/test/core/security/wallet_lock_controller_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:zcash_wallet/src/core/security/wallet_lock_controller.dart'; + +void main() { + group('shouldAutoLock', () { + test('returns false for zero elapsed', () { + expect(shouldAutoLock(elapsed: Duration.zero), isFalse); + }); + + test('returns false when elapsed is shorter than threshold', () { + expect( + shouldAutoLock( + elapsed: kAutoLockBackgroundTimeout - const Duration(seconds: 1), + ), + isFalse, + ); + }); + + test('returns true at exactly the threshold boundary', () { + expect(shouldAutoLock(elapsed: kAutoLockBackgroundTimeout), isTrue); + }); + + test('returns true when elapsed exceeds threshold', () { + expect( + shouldAutoLock(elapsed: kAutoLockBackgroundTimeout * 3), + isTrue, + ); + }); + + test('respects a custom threshold override', () { + expect( + shouldAutoLock( + elapsed: const Duration(seconds: 30), + threshold: const Duration(seconds: 15), + ), + isTrue, + ); + expect( + shouldAutoLock( + elapsed: const Duration(seconds: 30), + threshold: const Duration(minutes: 1), + ), + isFalse, + ); + }); + + test('default threshold is 10 minutes', () { + expect(kAutoLockBackgroundTimeout, const Duration(minutes: 10)); + }); + + test('returns false when elapsed is negative (backward-time guard)', () { + expect( + shouldAutoLock(elapsed: const Duration(seconds: -1)), + isFalse, + ); + }); + }); +} diff --git a/test/core/storage/app_secure_store_test.dart b/test/core/storage/app_secure_store_test.dart index 948213055..f98c36732 100644 --- a/test/core/storage/app_secure_store_test.dart +++ b/test/core/storage/app_secure_store_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zcash_wallet/src/core/security/password_policy.dart'; @@ -9,6 +10,7 @@ import 'package:zcash_wallet/src/core/storage/app_secure_store.dart'; const _oldPassword = 'Oldpass1!'; const _newPassword = 'Newpass1!'; const _wrongPassword = 'Wrongpass1!'; +const _accountUuid = 'test-account'; const _mnemonicKey = 'zcash_account_mnemonic_test-account'; const _externalEncryptedKey = 'external_encrypted_key'; const _mnemonic = 'abandon abandon abandon abandon abandon abandon'; @@ -16,25 +18,29 @@ const _mnemonic = 'abandon abandon abandon abandon abandon abandon'; const _passwordVerifierKey = 'zcash_password_verifier'; const _passwordVerifierSaltKey = 'zcash_password_verifier_salt'; const _rotationInProgressKey = 'zcash_rotation_in_progress'; +const _migrationCompleteKey = 'zcash_mnemonic_storage_migrated_v1'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); late AppSecureStore store; + TargetPlatform? previousTargetPlatform; setUp(() async { + previousTargetPlatform = debugDefaultTargetPlatformOverride; FlutterSecureStorage.setMockInitialValues({}); - store = AppSecureStore.instance; + store = AppSecureStore.testing(storage: const FlutterSecureStorage()); await store.deleteAll(); }); tearDown(() async { await store.deleteAll(); + debugDefaultTargetPlatformOverride = previousTargetPlatform; }); test('changePassword rotates mnemonic payloads and verifier', () async { await store.configurePassword(_oldPassword); - await store.writeSecretString(_mnemonicKey, _mnemonic); + await store.writeAccountMnemonic(_accountUuid, _mnemonic); final didChange = await store.changePassword( currentPassword: _oldPassword, @@ -46,7 +52,7 @@ void main() { store.clearSessionPassword(); expect(await store.verifyPasswordOnly(_oldPassword), isFalse); expect(await store.verifyPassword(_newPassword), isTrue); - expect(await store.readSecretStringWithOptions(_mnemonicKey), _mnemonic); + expect(await store.readAccountMnemonic(_accountUuid), _mnemonic); }); test('changePassword journal stores only roll-forward data', () async { @@ -55,7 +61,7 @@ void main() { ); store = AppSecureStore.testing(storage: blockingStorage); await store.configurePassword(_oldPassword); - await store.writeSecretString(_mnemonicKey, _mnemonic); + await store.writeAccountMnemonic(_accountUuid, _mnemonic); final originalPayload = await store.readPlain(_mnemonicKey); final oldVerifier = await store.readPlain(_passwordVerifierKey); @@ -112,7 +118,7 @@ void main() { store.clearSessionPassword(); expect(await store.verifyPasswordOnly(_oldPassword), isFalse); expect(await store.verifyPassword(_newPassword), isTrue); - expect(await store.readSecretStringWithOptions(_mnemonicKey), _mnemonic); + expect(await store.readAccountMnemonic(_accountUuid), _mnemonic); }, ); @@ -132,7 +138,7 @@ void main() { expect(await store.readPlain(_rotationInProgressKey), isNull); expect(await store.readPlain(_mnemonicKey), originalPayload); expect(await store.verifyPassword(_oldPassword), isTrue); - expect(await store.readSecretStringWithOptions(_mnemonicKey), _mnemonic); + expect(await store.readAccountMnemonic(_accountUuid), _mnemonic); }, ); @@ -156,7 +162,7 @@ void main() { test('changePassword only rotates app-managed mnemonic payloads', () async { await store.configurePassword(_oldPassword); - await store.writeSecretString(_mnemonicKey, _mnemonic); + await store.writeAccountMnemonic(_accountUuid, _mnemonic); await store.writeSecretString(_externalEncryptedKey, 'external secret'); final externalPayload = await store.readPlain(_externalEncryptedKey); @@ -167,7 +173,7 @@ void main() { expect(didChange, isTrue); expect(await store.readPlain(_externalEncryptedKey), externalPayload); - expect(await store.readSecretStringWithOptions(_mnemonicKey), _mnemonic); + expect(await store.readAccountMnemonic(_accountUuid), _mnemonic); }); test('changePassword rejects unreadable mnemonic payloads', () async { @@ -192,7 +198,7 @@ void main() { final failingStorage = _FailingWriteStorage(failKey: _passwordVerifierKey); store = AppSecureStore.testing(storage: failingStorage); await store.configurePassword(_oldPassword); - await store.writeSecretString(_mnemonicKey, _mnemonic); + await store.writeAccountMnemonic(_accountUuid, _mnemonic); final originalPayload = await store.readPlain(_mnemonicKey); failingStorage.failNextMatchingWrite = true; @@ -215,7 +221,7 @@ void main() { expect(await store.readPlain(_mnemonicKey), originalPayload); expect(await store.verifyPasswordOnly(_newPassword), isFalse); expect(await store.verifyPassword(_oldPassword), isTrue); - expect(await store.readSecretStringWithOptions(_mnemonicKey), _mnemonic); + expect(await store.readAccountMnemonic(_accountUuid), _mnemonic); }); test( @@ -257,20 +263,21 @@ void main() { expect(await store.readPlain(_rotationInProgressKey), isNull); expect(await store.verifyPasswordOnly(_oldPassword), isFalse); expect(await store.verifyPassword(_newPassword), isTrue); - expect(await store.readSecretStringWithOptions(_mnemonicKey), _mnemonic); + expect(await store.readAccountMnemonic(_accountUuid), _mnemonic); }, ); test('changePassword waits for concurrent mnemonic writes', () async { + const lateAccountUuid = 'late-account'; const lateMnemonicKey = 'zcash_account_mnemonic_late-account'; const lateMnemonic = 'legal winner thank year wave sausage worth useful'; final blockingStorage = _BlockingWriteStorage(blockKey: lateMnemonicKey); store = AppSecureStore.testing(storage: blockingStorage); await store.configurePassword(_oldPassword); - await store.writeSecretString(_mnemonicKey, _mnemonic); + await store.writeAccountMnemonic(_accountUuid, _mnemonic); blockingStorage.blockNextWrite = true; - final lateWrite = store.writeSecretString(lateMnemonicKey, lateMnemonic); + final lateWrite = store.writeAccountMnemonic(lateAccountUuid, lateMnemonic); await blockingStorage.writeStarted.future; final rotation = store.changePassword( @@ -293,10 +300,7 @@ void main() { store.clearSessionPassword(); expect(await store.verifyPassword(_newPassword), isTrue); - expect( - await store.readSecretStringWithOptions(lateMnemonicKey), - lateMnemonic, - ); + expect(await store.readAccountMnemonic(lateAccountUuid), lateMnemonic); }); test( @@ -342,7 +346,7 @@ void main() { ); store = AppSecureStore.testing(storage: blockingStorage); await store.configurePassword(_oldPassword); - await store.writeSecretString(_mnemonicKey, _mnemonic); + await store.writeAccountMnemonic(_accountUuid, _mnemonic); blockingStorage.blockNextWrite = true; final rotation = store.changePassword( @@ -351,14 +355,231 @@ void main() { ); await blockingStorage.writeStarted.future; - final delete = store.delete(_mnemonicKey); + final delete = store.deleteAccountMnemonic(_accountUuid); blockingStorage.release(); await rotation; await delete; store.clearSessionPassword(); expect(await store.verifyPassword(_newPassword), isTrue); - expect(await store.readPlain(_mnemonicKey), isNull); + expect(await store.readAccountMnemonic(_accountUuid), isNull); + }, + ); + + test('account mnemonic writes use mnemonic storage only', () async { + final regularStorage = _MapStorage('regular'); + final mnemonicStorage = _MapStorage('mnemonic'); + store = AppSecureStore.testing( + storage: regularStorage, + mnemonicStorage: mnemonicStorage, + ); + + await store.configurePassword(_oldPassword); + await store.writeAccountMnemonic(_accountUuid, _mnemonic); + + expect(regularStorage.valueFor(_mnemonicKey), isNull); + expect(mnemonicStorage.valueFor(_mnemonicKey), isNotNull); + expect(await store.readAccountMnemonic(_accountUuid), _mnemonic); + }); + + test( + 'locked mnemonic read skips keychain when session is required', + () async { + final operations = []; + final regularStorage = _MapStorage('regular', operations: operations); + final mnemonicStorage = _MapStorage('mnemonic', operations: operations); + store = AppSecureStore.testing( + storage: regularStorage, + mnemonicStorage: mnemonicStorage, + ); + + await store.configurePassword(_oldPassword); + await store.writeAccountMnemonic(_accountUuid, _mnemonic); + store.clearSessionPassword(); + operations.clear(); + + expect( + await store.readAccountMnemonic( + _accountUuid, + requireUnlockedSession: true, + ), + isNull, + ); + expect(operations, isEmpty); + }, + ); + + test( + 'macOS unlock migrates legacy mnemonic payloads write then delete', + () async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + final operations = []; + final regularStorage = _MapStorage('regular', operations: operations); + final mnemonicStorage = _MapStorage('mnemonic', operations: operations); + store = AppSecureStore.testing( + storage: regularStorage, + mnemonicStorage: mnemonicStorage, + ); + + await store.configurePassword(_oldPassword); + await store.writeSecretString(_mnemonicKey, _mnemonic); + store.clearSessionPassword(); + operations.clear(); + + expect(await store.verifyPassword(_oldPassword), isTrue); + + expect(regularStorage.valueFor(_mnemonicKey), isNull); + expect(mnemonicStorage.valueFor(_mnemonicKey), isNotNull); + expect(await store.readAccountMnemonic(_accountUuid), _mnemonic); + expect( + operations, + containsAllInOrder([ + 'mnemonic.write $_mnemonicKey', + 'regular.delete $_mnemonicKey', + ]), + ); + }, + ); + + test('macOS mnemonic migration flag skips repeated legacy scans', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + final operations = []; + final regularStorage = _MapStorage('regular', operations: operations); + final mnemonicStorage = _MapStorage('mnemonic', operations: operations); + store = AppSecureStore.testing( + storage: regularStorage, + mnemonicStorage: mnemonicStorage, + ); + + await store.configurePassword(_oldPassword); + await store.writeSecretString(_mnemonicKey, _mnemonic); + store.clearSessionPassword(); + + expect(await store.verifyPassword(_oldPassword), isTrue); + expect(regularStorage.valueFor(_migrationCompleteKey), 'true'); + expect(operations, contains('regular.readAll')); + + operations.clear(); + store.clearSessionPassword(); + + expect(await store.verifyPassword(_oldPassword), isTrue); + expect(operations, isNot(contains('regular.readAll'))); + }); + + test('macOS unlock fails if mnemonic copy migration fails', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + final regularStorage = _MapStorage('regular'); + final mnemonicStorage = _FailingMapStorage('mnemonic'); + store = AppSecureStore.testing( + storage: regularStorage, + mnemonicStorage: mnemonicStorage, + ); + + await store.configurePassword(_oldPassword); + await store.writeSecretString(_mnemonicKey, _mnemonic); + store.clearSessionPassword(); + mnemonicStorage.failNextWriteFor(_mnemonicKey); + + expect(await store.verifyPassword(_oldPassword), isFalse); + expect(store.hasSessionPassword, isFalse); + expect(regularStorage.valueFor(_mnemonicKey), isNotNull); + expect(mnemonicStorage.valueFor(_mnemonicKey), isNull); + expect(regularStorage.valueFor(_migrationCompleteKey), isNull); + }); + + test( + 'macOS unlock allows legacy cleanup retry after delete failure', + () async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + final regularStorage = _FailingMapStorage('regular'); + final mnemonicStorage = _MapStorage('mnemonic'); + store = AppSecureStore.testing( + storage: regularStorage, + mnemonicStorage: mnemonicStorage, + ); + + await store.configurePassword(_oldPassword); + await store.writeSecretString(_mnemonicKey, _mnemonic); + store.clearSessionPassword(); + regularStorage.failNextDeleteFor(_mnemonicKey); + + expect(await store.verifyPassword(_oldPassword), isTrue); + expect(await store.readAccountMnemonic(_accountUuid), _mnemonic); + expect(regularStorage.valueFor(_mnemonicKey), isNotNull); + expect(regularStorage.valueFor(_migrationCompleteKey), isNull); + }, + ); + + test( + 'macOS mnemonic reads do not fallback before unlock migration', + () async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + final regularStorage = _MapStorage('regular'); + final mnemonicStorage = _MapStorage('mnemonic'); + store = AppSecureStore.testing( + storage: regularStorage, + mnemonicStorage: mnemonicStorage, + ); + + await store.configurePassword(_oldPassword); + await store.writeSecretString(_mnemonicKey, _mnemonic); + + expect(await store.readAccountMnemonic(_accountUuid), isNull); + }, + ); + + test('deleteAll waits for concurrent mnemonic writes', () async { + const lateMnemonicKey = 'zcash_account_mnemonic_delete-all-account'; + const lateMnemonic = 'legal winner thank year wave sausage worth useful'; + final blockingStorage = _BlockingWriteStorage(blockKey: lateMnemonicKey); + store = AppSecureStore.testing(storage: blockingStorage); + await store.configurePassword(_oldPassword); + + blockingStorage.blockNextWrite = true; + final lateWrite = store.writeSecretString(lateMnemonicKey, lateMnemonic); + await blockingStorage.writeStarted.future; + + var deleteAllCompleted = false; + final deleteAll = store.deleteAll().then((_) { + deleteAllCompleted = true; + }); + await Future.delayed(const Duration(milliseconds: 20)); + expect(deleteAllCompleted, isFalse); + + blockingStorage.release(); + await lateWrite; + await deleteAll; + + expect(await store.readPlain(lateMnemonicKey), isNull); + expect(store.hasSessionPassword, isFalse); + }); + + test( + 'clearPasswordConfiguration waits for concurrent mnemonic writes', + () async { + const lateMnemonicKey = 'zcash_account_mnemonic_clear-password-account'; + const lateMnemonic = 'legal winner thank year wave sausage worth useful'; + final blockingStorage = _BlockingWriteStorage(blockKey: lateMnemonicKey); + store = AppSecureStore.testing(storage: blockingStorage); + await store.configurePassword(_oldPassword); + + blockingStorage.blockNextWrite = true; + final lateWrite = store.writeSecretString(lateMnemonicKey, lateMnemonic); + await blockingStorage.writeStarted.future; + + var clearCompleted = false; + final clear = store.clearPasswordConfiguration().then((_) { + clearCompleted = true; + }); + await Future.delayed(const Duration(milliseconds: 20)); + expect(clearCompleted, isFalse); + + blockingStorage.release(); + await lateWrite; + await clear; + + expect(await store.readPlain(lateMnemonicKey), isNotNull); + expect(store.hasSessionPassword, isFalse); }, ); } @@ -397,6 +618,155 @@ class _FailingWriteStorage extends FlutterSecureStorage { } } +class _MapStorage extends FlutterSecureStorage { + _MapStorage(this.name, {List? operations}) + : operations = operations ?? []; + + final String name; + final List operations; + final _values = {}; + + String? valueFor(String key) => _values[key]; + + @override + Future read({ + required String key, + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async { + operations.add('$name.read $key'); + return _values[key]; + } + + @override + Future write({ + required String key, + required String? value, + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async { + operations.add('$name.write $key'); + if (value == null) { + _values.remove(key); + return; + } + _values[key] = value; + } + + @override + Future delete({ + required String key, + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async { + operations.add('$name.delete $key'); + _values.remove(key); + } + + @override + Future> readAll({ + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async { + operations.add('$name.readAll'); + return Map.from(_values); + } + + @override + Future deleteAll({ + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async { + operations.add('$name.deleteAll'); + _values.clear(); + } +} + +class _FailingMapStorage extends _MapStorage { + _FailingMapStorage(super.name); + + final _failNextWrite = {}; + final _failNextDelete = {}; + + void failNextWriteFor(String key) { + _failNextWrite.add(key); + } + + void failNextDeleteFor(String key) { + _failNextDelete.add(key); + } + + @override + Future write({ + required String key, + required String? value, + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) { + if (_failNextWrite.remove(key)) { + throw StateError('forced map write failure for $key'); + } + return super.write( + key: key, + value: value, + iOptions: iOptions, + aOptions: aOptions, + lOptions: lOptions, + webOptions: webOptions, + mOptions: mOptions, + wOptions: wOptions, + ); + } + + @override + Future delete({ + required String key, + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) { + if (_failNextDelete.remove(key)) { + throw StateError('forced map delete failure for $key'); + } + return super.delete( + key: key, + iOptions: iOptions, + aOptions: aOptions, + lOptions: lOptions, + webOptions: webOptions, + mOptions: mOptions, + wOptions: wOptions, + ); + } +} + class _BlockingWriteStorage extends FlutterSecureStorage { _BlockingWriteStorage({required this.blockKey});