Skip to content

Commit 6509b1b

Browse files
committed
fix(test_wallet_backup): keep the mnemonic out of the bloc state
The freezed state held the full mnemonic and passphrase, and its generated toString() exposed both in clear text to any state log, crash report or debug session. Secrets now stay ephemeral: screens read them at the point of use through a bloc method that never stores them, the word-order game lives in widget state, and VerifyPhysicalBackupUsecase re-reads the seed to compare, returning only a bool. This also corrects two pre-existing flaws made visible by the rewrite: verification compared against the default mainnet wallet instead of the selected one, and the success screen opened before the verification result was known.
1 parent 5885ae4 commit 6509b1b

11 files changed

Lines changed: 724 additions & 341 deletions

lib/features/test_wallet_backup/domain/usecases/verify_physical_backup_usecase.dart

Lines changed: 14 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,27 @@
11
import 'package:bb_mobile/core/seed/data/models/seed_model.dart'
22
show MnemonicSeedModel, SeedModel;
33
import 'package:bb_mobile/core/seed/data/repository/seed_repository.dart';
4-
import 'package:bb_mobile/core/settings/domain/settings_entity.dart';
54
import 'package:bb_mobile/core/utils/logger.dart';
6-
import 'package:bb_mobile/core/wallet/data/repositories/wallet_repository.dart';
75

86
class VerifyPhysicalBackupUsecase {
9-
final WalletRepository _walletRepository;
107
final SeedRepository _seedRepository;
11-
VerifyPhysicalBackupUsecase({
12-
required this._walletRepository,
13-
required this._seedRepository,
14-
});
158

16-
Future<bool> execute(List<String> mnemonic) async {
17-
try {
18-
final defaultWallets = await _walletRepository.getWallets(
19-
onlyDefaults: true,
20-
onlyBitcoin: true,
21-
environment: Environment.mainnet,
22-
);
23-
if (defaultWallets.isEmpty) {
24-
throw Exception('No default wallet found');
25-
}
26-
final defaultWallet = defaultWallets.first;
27-
final defaultFingerprint = defaultWallet.masterFingerprint;
28-
final defaultSeed = await _seedRepository.get(defaultFingerprint);
9+
VerifyPhysicalBackupUsecase({required this._seedRepository});
2910

30-
final defaultSeedModel = SeedModel.fromEntity(defaultSeed);
31-
final mnemonicWords = switch (defaultSeedModel) {
11+
/// Compares [mnemonic] against the seed stored for [fingerprint].
12+
///
13+
/// The stored secret is read at the point of use and never leaves this
14+
/// method; only the comparison result is returned.
15+
Future<bool> execute({
16+
required String fingerprint,
17+
required List<String> mnemonic,
18+
}) async {
19+
try {
20+
final seed = await _seedRepository.get(fingerprint);
21+
final seedModel = SeedModel.fromEntity(seed);
22+
final mnemonicWords = switch (seedModel) {
3223
MnemonicSeedModel(:final mnemonicWords) => mnemonicWords,
33-
_ => throw Exception('Default seed is not a mnemonic seed'),
24+
_ => throw Exception('Selected seed is not a mnemonic seed'),
3425
};
3526

3627
return mnemonic.length == mnemonicWords.length &&

lib/features/test_wallet_backup/presentation/bloc/test_wallet_backup_bloc.dart

Lines changed: 69 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart';
44
import 'package:bb_mobile/features/onboarding/complete_physical_backup_verification_usecase.dart';
55
import 'package:bb_mobile/features/test_wallet_backup/domain/usecases/get_mnemonic_from_fingerprint_usecase.dart';
66
import 'package:bb_mobile/features/test_wallet_backup/domain/usecases/load_wallets_for_network_usecase.dart';
7+
import 'package:bb_mobile/features/test_wallet_backup/domain/usecases/verify_physical_backup_usecase.dart';
78
import 'package:flutter_bloc/flutter_bloc.dart';
89
import 'package:freezed_annotation/freezed_annotation.dart';
910

@@ -13,146 +14,111 @@ part 'test_wallet_backup_state.dart';
1314

1415
class TestWalletBackupBloc
1516
extends Bloc<TestWalletBackupEvent, TestWalletBackupState> {
17+
final CompletePhysicalBackupVerificationUsecase
18+
_completePhysicalBackupVerificationUsecase;
19+
final LoadWalletsForNetworkUsecase _loadWalletsForNetworkUsecase;
20+
final GetMnemonicFromFingerprintUsecase _getMnemonicFromFingerprintUsecase;
21+
final VerifyPhysicalBackupUsecase _verifyPhysicalBackupUsecase;
22+
1623
TestWalletBackupBloc({
1724
required this._completePhysicalBackupVerificationUsecase,
1825
required this._loadWalletsForNetworkUsecase,
1926
required this._getMnemonicFromFingerprintUsecase,
27+
required this._verifyPhysicalBackupUsecase,
2028
}) : super(const TestWalletBackupState()) {
21-
on<OnWordsSelected>(_onWordsSelected);
22-
on<VerifyPhysicalBackup>(_verifyPhysicalBackup);
23-
on<StartPhysicalBackupVerification>((event, emit) {});
2429
on<LoadWallets>(_onLoadWallets);
25-
on<LoadMnemonicForWallet>(_onLoadMnemonicForWallet);
26-
on<ClearError>((event, emit) => emit(state.copyWith(statusError: '')));
27-
}
28-
29-
final CompletePhysicalBackupVerificationUsecase
30-
_completePhysicalBackupVerificationUsecase;
31-
final LoadWalletsForNetworkUsecase _loadWalletsForNetworkUsecase;
32-
final GetMnemonicFromFingerprintUsecase _getMnemonicFromFingerprintUsecase;
33-
34-
/// Handles word selection during backup verification
35-
/// Validates word order and updates test state
36-
Future<void> _onWordsSelected(
37-
OnWordsSelected event,
38-
Emitter<TestWalletBackupState> emit,
39-
) async {
40-
final mnemonic = state.mnemonic;
41-
final reorderedMnemonic = List<String>.from(
42-
state.reorderedMnemonic + [event.word],
43-
);
44-
45-
final isCorrect = mnemonic
46-
.join(' ')
47-
.startsWith(reorderedMnemonic.join(' '));
48-
49-
if (isCorrect) {
50-
emit(
30+
on<WalletSelected>(_onWalletSelected);
31+
on<VerifyPhysicalBackup>(_verifyPhysicalBackup);
32+
on<ClearError>(
33+
(event, emit) => emit(
5134
state.copyWith(
52-
reorderedMnemonic: [...state.reorderedMnemonic, event.word],
5335
statusError: '',
54-
selectedMnemonicWords: [...state.selectedMnemonicWords, event.index],
36+
verificationStatus: BackupVerificationStatus.idle,
5537
),
56-
);
57-
} else {
58-
final shuffled = List<String>.from(mnemonic)..shuffle();
59-
emit(
60-
state.copyWith(
61-
shuffledMnemonic: shuffled,
62-
reorderedMnemonic: [],
63-
selectedMnemonicWords: [],
64-
statusError: 'Incorrect word order. Please try again.',
65-
),
66-
);
67-
}
38+
),
39+
);
6840
}
6941

70-
Future<void> _verifyPhysicalBackup(
71-
VerifyPhysicalBackup event,
72-
Emitter<TestWalletBackupState> emit,
73-
) async {
74-
try {
75-
if (state.mnemonic.isEmpty) {
76-
emit(state.copyWith(statusError: 'No mnemonic loaded'));
77-
return;
78-
}
79-
80-
if (state.reorderedMnemonic.length != state.mnemonic.length) {
81-
emit(state.copyWith(statusError: 'Please select all words'));
82-
return;
83-
}
84-
85-
// Compare with original mnemonic
86-
final isCorrect =
87-
state.mnemonic.join(' ') == state.reorderedMnemonic.join(' ');
88-
89-
if (isCorrect) {
90-
await _completePhysicalBackupVerificationUsecase.execute();
91-
} else {
92-
// Reset test state when wrong
93-
final shuffled = state.mnemonic.toList()..shuffle();
94-
emit(
95-
state.copyWith(
96-
statusError: 'Incorrect word order. Please try again.',
97-
shuffledMnemonic: shuffled,
98-
reorderedMnemonic: [],
99-
selectedMnemonicWords: [],
100-
),
101-
);
102-
}
103-
} catch (e) {
104-
emit(state.copyWith(statusError: 'Verification failed: $e'));
42+
/// Reads the selected wallet's secret at the point of use.
43+
///
44+
/// The mnemonic and passphrase are returned directly to the caller and are
45+
/// never held in bloc state: secrets must stay ephemeral and must never
46+
/// appear in the freezed `toString()` of the state.
47+
Future<(List<String>, String?)> loadSelectedWalletMnemonic() {
48+
final wallet = state.selectedWallet;
49+
if (wallet == null) {
50+
throw Exception('No wallet selected');
10551
}
52+
return _getMnemonicFromFingerprintUsecase.execute(wallet.masterFingerprint);
10653
}
10754

10855
Future<void> _onLoadWallets(
10956
LoadWallets event,
11057
Emitter<TestWalletBackupState> emit,
11158
) async {
11259
try {
113-
emit(state.copyWith(selectedWallet: null));
114-
11560
final wallets = await _loadWalletsForNetworkUsecase.execute();
11661
if (wallets.isEmpty) throw Exception('No wallets found');
11762
final Wallet selected = wallets.firstWhere(
11863
(w) => w.isDefault,
11964
orElse: () => wallets.first,
12065
);
121-
emit(state.copyWith(wallets: wallets, selectedWallet: selected));
122-
123-
add(LoadMnemonicForWallet(wallet: selected));
66+
emit(
67+
state.copyWith(
68+
wallets: wallets,
69+
selectedWallet: selected,
70+
verificationStatus: BackupVerificationStatus.idle,
71+
),
72+
);
12473
} catch (e) {
12574
emit(state.copyWith(statusError: 'Failed to load wallets: $e'));
12675
}
12776
}
12877

129-
Future<void> _onLoadMnemonicForWallet(
130-
LoadMnemonicForWallet event,
78+
Future<void> _onWalletSelected(
79+
WalletSelected event,
80+
Emitter<TestWalletBackupState> emit,
81+
) async {
82+
emit(
83+
state.copyWith(
84+
selectedWallet: event.wallet,
85+
statusError: '',
86+
verificationStatus: BackupVerificationStatus.idle,
87+
),
88+
);
89+
}
90+
91+
Future<void> _verifyPhysicalBackup(
92+
VerifyPhysicalBackup event,
13193
Emitter<TestWalletBackupState> emit,
13294
) async {
13395
try {
134-
emit(state.copyWith(selectedWallet: null));
96+
final wallet = state.selectedWallet;
97+
if (wallet == null) {
98+
emit(state.copyWith(statusError: 'No wallet selected'));
99+
return;
100+
}
135101

136-
final wallet = event.wallet;
137-
final (
138-
mnemonicWords,
139-
passphrase,
140-
) = await _getMnemonicFromFingerprintUsecase.execute(
141-
wallet.masterFingerprint,
102+
final isCorrect = await _verifyPhysicalBackupUsecase.execute(
103+
fingerprint: wallet.masterFingerprint,
104+
mnemonic: event.reorderedWords,
142105
);
143106

144-
emit(
145-
state.copyWith(
146-
selectedWallet: wallet,
147-
mnemonic: mnemonicWords,
148-
passphrase: passphrase ?? '',
149-
shuffledMnemonic: mnemonicWords.toList()..shuffle(),
150-
reorderedMnemonic: [],
151-
selectedMnemonicWords: [],
152-
),
153-
);
107+
if (isCorrect) {
108+
await _completePhysicalBackupVerificationUsecase.execute();
109+
emit(
110+
state.copyWith(
111+
verificationStatus: BackupVerificationStatus.success,
112+
statusError: '',
113+
),
114+
);
115+
} else {
116+
emit(
117+
state.copyWith(verificationStatus: BackupVerificationStatus.failure),
118+
);
119+
}
154120
} catch (e) {
155-
emit(state.copyWith(statusError: 'Failed to load mnemonic: $e'));
121+
emit(state.copyWith(statusError: 'Verification failed: $e'));
156122
}
157123
}
158124
}

lib/features/test_wallet_backup/presentation/bloc/test_wallet_backup_event.dart

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,20 @@ sealed class TestWalletBackupEvent {
44
const TestWalletBackupEvent();
55
}
66

7-
class OnWordsSelected extends TestWalletBackupEvent {
8-
const OnWordsSelected({required this.word, required this.index});
9-
final String word;
10-
final int index;
11-
}
12-
13-
class StartPhysicalBackupVerification extends TestWalletBackupEvent {
14-
const StartPhysicalBackupVerification();
15-
}
16-
17-
class VerifyPhysicalBackup extends TestWalletBackupEvent {
18-
const VerifyPhysicalBackup();
19-
}
20-
217
class LoadWallets extends TestWalletBackupEvent {
228
const LoadWallets();
239
}
2410

25-
class LoadMnemonicForWallet extends TestWalletBackupEvent {
26-
const LoadMnemonicForWallet({required this.wallet});
11+
class WalletSelected extends TestWalletBackupEvent {
12+
const WalletSelected({required this.wallet});
2713
final Wallet wallet;
2814
}
2915

16+
class VerifyPhysicalBackup extends TestWalletBackupEvent {
17+
const VerifyPhysicalBackup({required this.reorderedWords});
18+
final List<String> reorderedWords;
19+
}
20+
3021
class ClearError extends TestWalletBackupEvent {
3122
const ClearError();
3223
}
Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
11
part of 'test_wallet_backup_bloc.dart';
22

3+
enum BackupVerificationStatus { idle, success, failure }
4+
35
@freezed
46
abstract class TestWalletBackupState with _$TestWalletBackupState {
57
const factory TestWalletBackupState({
6-
@Default([]) List<String> mnemonic,
7-
@Default('') String passphrase,
8-
@Default([]) List<String> shuffledMnemonic,
9-
@Default([]) List<String> reorderedMnemonic,
10-
@Default([]) List<int> selectedMnemonicWords,
118
@Default('') String statusError,
129
@Default([]) List<Wallet> wallets,
1310
@Default(null) Wallet? selectedWallet,
11+
@Default(BackupVerificationStatus.idle)
12+
BackupVerificationStatus verificationStatus,
1413
}) = _TestWalletBackupState;
1514
const TestWalletBackupState._();
1615
}

lib/features/test_wallet_backup/test_wallet_backup_locator.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:bb_mobile/core/wallet/data/repositories/wallet_repository.dart';
44
import 'package:bb_mobile/features/test_wallet_backup/domain/usecases/check_backup_usecase.dart';
55
import 'package:bb_mobile/features/test_wallet_backup/domain/usecases/get_mnemonic_from_fingerprint_usecase.dart';
66
import 'package:bb_mobile/features/test_wallet_backup/domain/usecases/load_wallets_for_network_usecase.dart';
7+
import 'package:bb_mobile/features/test_wallet_backup/domain/usecases/verify_physical_backup_usecase.dart';
78
import 'package:get_it/get_it.dart';
89

910
class TestWalletBackupLocator {
@@ -19,6 +20,11 @@ class TestWalletBackupLocator {
1920
seedRepository: locator<SeedRepository>(),
2021
),
2122
);
23+
locator.registerLazySingleton<VerifyPhysicalBackupUsecase>(
24+
() => VerifyPhysicalBackupUsecase(
25+
seedRepository: locator<SeedRepository>(),
26+
),
27+
);
2228
locator.registerFactory<CheckBackupUsecase>(
2329
() => CheckBackupUsecase(
2430
walletRepository: locator<WalletRepository>(),

lib/features/test_wallet_backup/ui/app_bar_widget.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ class AppBarWidget extends StatelessWidget {
4848
);
4949

5050
if (selectedWalletId != null) {
51-
bloc.add(LoadMnemonicForWallet(wallet: selectedWallet!));
51+
bloc.add(WalletSelected(wallet: selectedWallet!));
5252
}
5353
},
5454
),
@@ -119,7 +119,7 @@ Future<String?> _showWalletPicker({
119119
onPressed: () {
120120
final wallet = wallets[controller.selectedItem];
121121
context.read<TestWalletBackupBloc>().add(
122-
LoadMnemonicForWallet(wallet: wallet),
122+
WalletSelected(wallet: wallet),
123123
);
124124
Navigator.of(context).pop();
125125
},

0 commit comments

Comments
 (0)