Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lib/packages/repository/wallet_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ class WalletRepository {

Future<void> deleteWallet(int id) => _appDatabase.deleteWallet(id);

/// Full purge for the user-facing delete: removes the encrypted-seed row AND
/// the AES-GCM mnemonic key, so no recoverable seed material remains on
/// device.
Future<void> purgeWallet(int id) async {
await _appDatabase.deleteWalletCompletely(id);
await _secureStorage.deleteMnemonicKey();
}

Future<WalletInfo> _decryptWalletInfo(WalletInfo info) async {
final key = await _secureStorage.getOrCreateMnemonicKey();
final decryptedSeed = SecureStorage.decryptSeed(key, info.seed);
Expand Down
4 changes: 3 additions & 1 deletion lib/packages/service/wallet_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,9 @@ class WalletService {

Future<void> deleteCurrentWallet() async {
final id = _settingsRepository.currentWalletId!;
await _repository.deleteWallet(id);
// Full purge (seed row + mnemonic key), not an account-only delete — so no
// recoverable seed survives delete.
await _repository.purgeWallet(id);
await _settingsRepository.removeCurrentWalletId();
}

Expand Down
8 changes: 8 additions & 0 deletions lib/packages/storage/secure_storage.dart
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,14 @@ class SecureStorage {
return key;
}

/// Removes the AES-GCM key that decrypts stored seeds. Once gone, any
/// surviving encrypted seed is permanently undecryptable; a fresh key is
/// lazily minted on next creation.
// @no-integration-test: forwards to FlutterSecureStorage (Android Keystore /
// iOS Keychain) over a platform channel; real keystore removal is only
// verifiable on-device — the unit test mocks the plugin.
Future<void> deleteMnemonicKey() => _secureStorage.delete(key: _mnemonicEncryptionKey);

static String encryptSeed(Uint8List key, String plaintext) {
final iv = _secureRandomBytes(12);
final cipher = GCMBlockCipher(AESEngine())
Expand Down
8 changes: 8 additions & 0 deletions lib/packages/storage/wallet_storage.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ extension WalletStorage on AppDatabase {
Future<int> deleteWallet(int walletId) =>
(delete(walletAccountInfos)..where((row) => row.wallet.equals(walletId))).go();

/// Deletes the `walletInfos` row itself (the encrypted-seed record) after
/// clearing its dependent `walletAccountInfos` rows (FK in
/// [WalletAccountInfos.wallet]).
Future<void> deleteWalletCompletely(int walletId) => transaction(() async {
await (delete(walletAccountInfos)..where((row) => row.wallet.equals(walletId))).go();
await (delete(walletInfos)..where((row) => row.id.equals(walletId))).go();
});

Future<bool> get hasWallet => select(walletInfos).get().then((result) => result.isNotEmpty);
}

Expand Down
29 changes: 29 additions & 0 deletions test/packages/repository/wallet_repository_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -145,5 +145,34 @@ void main() {
final afterAccounts = await db.getWalletAccounts(walletId);
expect(afterAccounts, isEmpty);
});

test('purgeWallet removes the walletInfos seed row AND the mnemonic key', () async {
// The user-facing delete must leave no recoverable seed material —
// neither the encrypted row nor the AES key.
when(() => secureStorage.deleteMnemonicKey()).thenAnswer((_) async {});

final walletId = await repo.createWallet(walletName, WalletType.software, seed, address);
await db.insertWalletAccount(walletId, 'acc-0', 0);
expect(await db.getWalletById(walletId), isNotNull);

await repo.purgeWallet(walletId);

expect(await db.getWalletById(walletId), isNull); // encrypted seed row gone
expect(await db.getWalletAccounts(walletId), isEmpty); // accounts gone
verify(() => secureStorage.deleteMnemonicKey()).called(1); // AES key removed
});

test('deleteWallet (account-only) leaves the seed row and mnemonic key intact', () async {
// Onboarding-regenerate contract: the account-only primitive must NOT
// wipe the seed row or the AES key.
final walletId = await repo.createWallet(walletName, WalletType.software, seed, address);
await db.insertWalletAccount(walletId, 'acc-0', 0);

await repo.deleteWallet(walletId);

expect(await db.getWalletById(walletId), isNotNull); // row survives
expect(await db.getWalletAccounts(walletId), isEmpty); // accounts gone
verifyNever(() => secureStorage.deleteMnemonicKey()); // key untouched
});
});
}
6 changes: 5 additions & 1 deletion test/packages/service/wallet_service_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ void main() {
when(() => settings.saveCurrentWalletId(any())).thenAnswer((_) async => true);
when(() => settings.removeCurrentWalletId()).thenAnswer((_) async => true);
when(() => repo.deleteWallet(any())).thenAnswer((_) async {});
when(() => repo.purgeWallet(any())).thenAnswer((_) async {});
when(() => repo.updateAddress(any(), any())).thenAnswer((_) async {});
});

Expand Down Expand Up @@ -440,7 +441,10 @@ void main() {

await service.deleteCurrentWallet();

verify(() => repo.deleteWallet(8)).called(1);
// User-facing delete must fully purge (seed row + mnemonic key), not
// the account-only delete.
verify(() => repo.purgeWallet(8)).called(1);
verifyNever(() => repo.deleteWallet(any()));
verify(() => settings.removeCurrentWalletId()).called(1);
});
});
Expand Down
6 changes: 6 additions & 0 deletions test/packages/storage/secure_storage_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ void main() {
verify(() => mockStorage.delete(key: 'pin.salt')).called(1);
});

test('deleteMnemonicKey deletes the mnemonic encryption key', () async {
await secureStorage.deleteMnemonicKey();

verify(() => mockStorage.delete(key: 'wallet.mnemonic.encryption.key')).called(1);
});

test('getPinSalt returns null when no salt is stored', () async {
when(
() => mockStorage.read(key: 'pin.salt'),
Expand Down
Loading