Files
hash-wallet/lib/core/key_service.dart
T
jwinterm 985cbf0064 fix(ios): recover wallet password across Keychain param changes; no crash
getWalletPassword force-unwrapped the secure-storage read, so a null iOS
Keychain result (item written by an older build under different Keychain
params, e.g. after a TestFlight update) hard-crashed the app and the wallet
showed "Corrupted seeds" — reported by a tester whose older Wownero and BTC
wallets broke on update.

Now it:
- falls back to the legacy readNoIOptions path (queries the OTHER iOS Keychain
  accessibility), which can AUTO-RECOVER such wallets without a seed restore;
- falls back to the #__<name>_bak__# backup password the Monero migration
  writes before re-encrypting;
- throws a catchable error instead of a null-check crash, so the loader can
  offer seed/restore recovery rather than the app dying.

Only changes behavior on the previously-crashing null path — strictly safer.

Not the file cipher (iOS is always Salsa20) nor the Monero migration
(Wownero/BTC don't run it): the wallet files are intact, only the Keychain
password was unreadable. flutter_secure_storage stays pinned (ca897a08) and the
accessibility stays first_unlock; those must not change without a migration
(see docs/internal-notes.md).
2026-08-03 14:48:29 -04:00

64 lines
2.7 KiB
Dart

import 'package:hash_wallet/core/secure_storage.dart';
import 'package:hash_wallet/entities/secret_store_key.dart';
import 'package:hash_wallet/entities/encrypt.dart';
class KeyService {
KeyService(this._secureStorage);
final SecureStorage _secureStorage;
Future<String> getWalletPassword({required String walletName}) async {
final key =
generateStoreKeyFor(key: SecretStoreKey.moneroWalletPassword, walletName: walletName);
// Primary read (current iOS Keychain accessibility).
var encodedPassword = await _secureStorage.read(key: key);
// Fallback 1: the legacy read path queries the OTHER iOS Keychain
// accessibility. A wallet whose password item was written by an older
// build with different Keychain params reads back null on the primary
// path — this recovers it. That is the "corrupted seeds after a TestFlight
// update" failure (Wownero/BTC/etc.: wallet files intact, Keychain entry
// unreadable under the new params).
encodedPassword ??= await _secureStorage.readNoIOptions(key: key);
// Fallback 2: the backup password the Monero password migration saves
// under a side key BEFORE it re-encrypts, in case the main save did not
// persist (see WalletLoadingService.updateMoneroWalletPassword).
if (encodedPassword == null) {
final bakKey = generateStoreKeyFor(
key: SecretStoreKey.moneroWalletPassword,
walletName: '#__${walletName}_bak__#');
encodedPassword = await _secureStorage.read(key: bakKey) ??
await _secureStorage.readNoIOptions(key: bakKey);
}
// Fail with a clear, CATCHABLE error instead of a null-check crash, so the
// loader can surface the seed/restore recovery path rather than the app
// hard-crashing ("crashed and wouldn't let me in").
if (encodedPassword == null) {
throw Exception(
'Wallet password not found in secure storage for "$walletName". '
'The keychain entry may have been lost across an app update; '
'restore this wallet from its seed.');
}
return decodeWalletPassword(password: encodedPassword);
}
Future<void> saveWalletPassword({required String walletName, required String password}) async {
final key =
generateStoreKeyFor(key: SecretStoreKey.moneroWalletPassword, walletName: walletName);
final encodedPassword = encodeWalletPassword(password: password);
await _secureStorage.write(key: key, value: encodedPassword);
}
Future<void> deleteWalletPassword({required String walletName}) async {
final key =
generateStoreKeyFor(key: SecretStoreKey.moneroWalletPassword, walletName: walletName);
await _secureStorage.delete(key: key);
}
}