Skip to content

Commit c7cd2be

Browse files
committed
feat: wire telemetry checks into startup and backup flows, warnings UI
Wires the telemetry orchestration into the app and surfaces the alerts. Checks: - Cold launch: AppStartupBloc fires RecoverbullTelemetryCubit .checkOnColdLaunch() unawaited after Tor init — never blocking startup, a no-op when the flag is off, Tor is not ready, or the last check is still fresh. No background polling. - Backup flows: the recoverbull feature records this device's own operations (store, and fetch via the new status-aware usecase) and reports the targeted per-identifier lockout as an alarm signal. Global 429 / capacity 503 map to unavailability, never to a lockout alarm. Surfacing (all advisory, all dismissible): - RecoverbullTelemetryWarnings banner on the wallet home and the backup settings screen; strong warnings (suspicious activity, targeted lockout) also open a bottom sheet once per session. Acknowledgement is remembered so multi-device false positives do not train the user to ignore alerts. - Copy follows the trust model: 'unknown/suspicious activity', never 'confirmed attack', always paired with 'check whether it was you or another of your devices' and 'never enter your seed words or password because of this alert'. English and French localization. The feature stays behind is_recoverbull_telemetry_enabled (default false) until the server is deployed and the client pinned by SHA. Bloc test updated for the new dependencies.
1 parent 7b0ee85 commit c7cd2be

11 files changed

Lines changed: 391 additions & 24 deletions

File tree

lib/features/app_startup/app_startup_locator.dart

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'package:bb_mobile/core/recoverbull/data/repository/recoverbull_repository.dart';
12
import 'package:bb_mobile/core/seed/data/repository/seed_repository.dart';
23
import 'package:bb_mobile/core/settings/data/settings_repository.dart';
34
import 'package:bb_mobile/core/storage/migrations/004_legacy/migrate_v4_legacy_usecase.dart';
@@ -11,15 +12,18 @@ import 'package:bb_mobile/features/app_startup/domain/usecases/reset_app_data_us
1112
import 'package:bb_mobile/features/app_startup/presentation/bloc/app_startup_bloc.dart';
1213
import 'package:bb_mobile/features/app_unlock/domain/usecases/check_pin_code_exists_usecase.dart';
1314
import 'package:bb_mobile/features/pin_code/data/repositories/pin_code_repository.dart';
15+
import 'package:bb_mobile/features/recoverbull/presentation/telemetry/recoverbull_telemetry_cubit.dart';
1416
import 'package:bb_mobile/features/test_wallet_backup/domain/usecases/check_backup_usecase.dart';
1517
import 'package:get_it/get_it.dart';
1618

1719
class AppStartupLocator {
1820
static void setup(GetIt locator) {
1921
// Use cases
2022
locator.registerFactory<ResetAppDataUsecase>(
21-
() =>
22-
ResetAppDataUsecase(pinCodeRepository: locator<PinCodeRepository>()),
23+
() => ResetAppDataUsecase(
24+
pinCodeRepository: locator<PinCodeRepository>(),
25+
recoverBullRepository: locator<RecoverBullRepository>(),
26+
),
2327
);
2428
locator.registerFactory<CheckForExistingDefaultWalletsUsecase>(
2529
() => CheckForExistingDefaultWalletsUsecase(
@@ -42,6 +46,7 @@ class AppStartupLocator {
4246
checkBackupUsecase: locator<CheckBackupUsecase>(),
4347
isTorRequiredUsecase: locator<IsTorRequiredUsecase>(),
4448
initTorUsecase: locator<InitTorUsecase>(),
49+
recoverbullTelemetryCubit: locator<RecoverbullTelemetryCubit>(),
4550
),
4651
);
4752
}
Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
1+
import 'package:bb_mobile/core/recoverbull/data/repository/recoverbull_repository.dart';
12
import 'package:bb_mobile/features/pin_code/data/repositories/pin_code_repository.dart';
23

34
// On iOS especially, some secure storage data might still be there after the app is uninstalled.
45
// This use case is used to reset the app data when the app is installed again.
56
class ResetAppDataUsecase {
67
final PinCodeRepository _pinCodeRepository;
8+
final RecoverBullRepository _recoverBullRepository;
79

8-
ResetAppDataUsecase({required this._pinCodeRepository});
10+
ResetAppDataUsecase({
11+
required this._pinCodeRepository,
12+
required this._recoverBullRepository,
13+
});
914

1015
Future<void> execute() async {
1116
await _pinCodeRepository.deletePinCode();
17+
// The telemetry baseline reveals which backups are monitored: it must
18+
// not survive an app-data reset.
19+
await _recoverBullRepository.clearTelemetry();
1220
}
1321
}

lib/features/app_startup/presentation/bloc/app_startup_bloc.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import 'package:bb_mobile/core/utils/result.dart';
1111
import 'package:bb_mobile/features/app_startup/domain/usecases/check_for_existing_default_wallets_usecase.dart';
1212
import 'package:bb_mobile/features/app_startup/domain/usecases/reset_app_data_usecase.dart';
1313
import 'package:bb_mobile/features/app_unlock/domain/usecases/check_pin_code_exists_usecase.dart';
14+
import 'package:bb_mobile/features/recoverbull/presentation/telemetry/recoverbull_telemetry_cubit.dart';
1415
import 'package:bb_mobile/features/test_wallet_backup/domain/usecases/check_backup_usecase.dart';
1516
import 'package:flutter/foundation.dart';
1617
import 'package:flutter/widgets.dart'
@@ -35,6 +36,7 @@ class AppStartupBloc extends Bloc<AppStartupEvent, AppStartupState>
3536
required this._checkBackupUsecase,
3637
required this._isTorRequiredUsecase,
3738
required this._initTorUsecase,
39+
required this._recoverbullTelemetryCubit,
3840
}) : _migrateToV5HiveToSqliteUsecase = migrateHiveToSqliteUsecase,
3941
_migrateToV4LegacyUsecase = migrateLegacyToV04Usecase,
4042
super(const AppStartupState.initial()) {
@@ -52,6 +54,7 @@ class AppStartupBloc extends Bloc<AppStartupEvent, AppStartupState>
5254
final CheckBackupUsecase _checkBackupUsecase;
5355
final IsTorRequiredUsecase _isTorRequiredUsecase;
5456
final InitTorUsecase _initTorUsecase;
57+
final RecoverbullTelemetryCubit _recoverbullTelemetryCubit;
5558

5659
/// True while we're sitting on the splash because a startup step
5760
/// threw `KeychainLockedException` (iOS pre-first-unlock pre-warm).
@@ -161,6 +164,11 @@ class AppStartupBloc extends Bloc<AppStartupEvent, AppStartupState>
161164
);
162165
}
163166

167+
// Brute-force telemetry check: cold launch only, unawaited, never
168+
// blocking startup. The cubit is a no-op when the feature flag is
169+
// off, when Tor is not ready, or when the last check is still fresh.
170+
unawaited(_recoverbullTelemetryCubit.checkOnColdLaunch());
171+
164172
emit(
165173
AppStartupState.success(
166174
isPinCodeSet: isPinCodeSet,

lib/features/backup_settings/ui/screens/backup_settings_screen.dart

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import 'package:bb_mobile/features/labels/labels_facade.dart';
1010
import 'package:bb_mobile/features/transactions/ui/transactions_router.dart';
1111
import 'package:bb_mobile/features/recoverbull/presentation/bloc.dart';
1212
import 'package:bb_mobile/features/recoverbull/router.dart';
13+
import 'package:bb_mobile/features/recoverbull/ui/widgets/telemetry_warnings.dart';
1314
import 'package:bb_mobile/locator.dart';
1415
import 'package:bb_mobile/core/widgets/snackbar_utils.dart';
1516
import 'package:flutter/material.dart';
@@ -64,12 +65,14 @@ class _Screen extends StatelessWidget {
6465
padding: const EdgeInsets.symmetric(horizontal: 16),
6566
child: Column(
6667
crossAxisAlignment: .start,
67-
children: [
68-
const Padding(
69-
padding: EdgeInsets.symmetric(horizontal: 16),
70-
child: _BackupTestStatusWidget(),
71-
),
72-
const Gap(40),
68+
children: [
69+
const Padding(
70+
padding: EdgeInsets.symmetric(horizontal: 16),
71+
child: _BackupTestStatusWidget(),
72+
),
73+
const Gap(16),
74+
const RecoverbullTelemetryWarnings(),
75+
const Gap(24),
7376
const _StartBackupButton(),
7477
if (state.lastEncryptedBackup != null)
7578
const _ViewVaultKeyButton(),

lib/features/recoverbull/presentation/bloc.dart

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1+
import 'dart:async';
2+
13
import 'package:bb_mobile/core/recoverbull/domain/entity/decrypted_vault.dart';
24
import 'package:bb_mobile/core/recoverbull/domain/entity/encrypted_vault.dart';
35
import 'package:bb_mobile/core/recoverbull/domain/entity/vault_provider.dart';
46
import 'package:bb_mobile/core/recoverbull/domain/recoverbull_failure.dart';
57
import 'package:bb_mobile/core/recoverbull/domain/usecases/check_server_connection_usecase.dart';
68
import 'package:bb_mobile/core/recoverbull/domain/usecases/create_encrypted_vault_usecase.dart';
79
import 'package:bb_mobile/core/recoverbull/domain/usecases/decrypt_vault_usecase.dart';
8-
import 'package:bb_mobile/core/recoverbull/domain/usecases/fetch_vault_key_from_server_usecase.dart';
10+
import 'package:bb_mobile/core/recoverbull/domain/usecases/fetch_vault_key_with_status_from_server_usecase.dart';
911
import 'package:bb_mobile/core/recoverbull/domain/usecases/google_drive/connect_google_drive_usecase.dart';
1012
import 'package:bb_mobile/core/recoverbull/domain/usecases/google_drive/fetch_latest_google_drive_backup_usecase.dart';
1113
import 'package:bb_mobile/core/recoverbull/domain/usecases/google_drive/save_to_google_drive_usecase.dart';
@@ -21,6 +23,7 @@ import 'package:bb_mobile/core/tor/tor_status.dart';
2123
import 'package:bb_mobile/core/utils/logger.dart';
2224
import 'package:bb_mobile/core/utils/result.dart';
2325
import 'package:bb_mobile/features/recoverbull/domain/recoverbull_failure.dart';
26+
import 'package:bb_mobile/features/recoverbull/presentation/telemetry/recoverbull_telemetry_cubit.dart';
2427
import 'package:bb_mobile/features/wallet/presentation/bloc/wallet_bloc.dart';
2528
import 'package:flutter_bloc/flutter_bloc.dart';
2629
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -37,7 +40,6 @@ class RecoverBullBloc extends Bloc<RecoverBullEvent, RecoverBullState> {
3740
final CreateEncryptedVaultUsecase _createEncryptedVaultUsecase;
3841
final StoreVaultKeyIntoServerUsecase _storeVaultKeyIntoServerUsecase;
3942
final CheckServerConnectionUsecase _checkKeyServerConnectionUsecase;
40-
final FetchVaultKeyFromServerUsecase _fetchVaultKeyFromServerUsecase;
4143
final DecryptVaultUsecase _decryptVaultUsecase;
4244
final RestoreVaultUsecase _restoreVaultUsecase;
4345
final InitTorUsecase _initializeTorUsecase;
@@ -47,6 +49,9 @@ class RecoverBullBloc extends Bloc<RecoverBullEvent, RecoverBullState> {
4749
_updateLatestEncryptedVaultTestUsecase;
4850
final TorStatusUsecase _torStatusUsecase;
4951
final TorConfigPort _torConfigPort;
52+
final FetchVaultKeyWithStatusFromServerUsecase
53+
_fetchVaultKeyWithStatusFromServerUsecase;
54+
final RecoverbullTelemetryCubit _recoverbullTelemetryCubit;
5055

5156
RecoverBullBloc({
5257
required RecoverBullFlow flow,
@@ -56,7 +61,6 @@ class RecoverBullBloc extends Bloc<RecoverBullEvent, RecoverBullState> {
5661
required this._createEncryptedVaultUsecase,
5762
required this._storeVaultKeyIntoServerUsecase,
5863
required this._checkKeyServerConnectionUsecase,
59-
required this._fetchVaultKeyFromServerUsecase,
6064
required this._decryptVaultUsecase,
6165
required this._restoreVaultUsecase,
6266
required this._connectToGoogleDriveUsecase,
@@ -67,6 +71,8 @@ class RecoverBullBloc extends Bloc<RecoverBullEvent, RecoverBullState> {
6771
required this._updateLatestEncryptedVaultTestUsecase,
6872
required this._torStatusUsecase,
6973
required this._torConfigPort,
74+
required this._fetchVaultKeyWithStatusFromServerUsecase,
75+
required this._recoverbullTelemetryCubit,
7076
}) : super(RecoverBullState(flow: flow, vault: preSelectedVault)) {
7177
on<OnVaultProviderSelection>(_onVaultProviderSelection);
7278
on<OnVaultSelection>(_onVaultSelection);
@@ -318,6 +324,12 @@ class RecoverBullBloc extends Bloc<RecoverBullEvent, RecoverBullState> {
318324
return;
319325
}
320326

327+
// Telemetry: register this backup as monitored and count this device's
328+
// own store operation. Unawaited, never blocks the creation flow.
329+
unawaited(
330+
_recoverbullTelemetryCubit.recordLocalAttempt(backupIdHex: vault.id),
331+
);
332+
321333
emit(state.copyWith(vault: vault, vaultProvider: event.provider));
322334
log.fine('Vault created and key stored in server');
323335
} finally {
@@ -334,15 +346,36 @@ class RecoverBullBloc extends Bloc<RecoverBullEvent, RecoverBullState> {
334346

335347
emit(state.copyWith(isLoading: true, vaultKey: null));
336348

337-
switch (await _fetchVaultKeyFromServerUsecase.execute(
349+
switch (await _fetchVaultKeyWithStatusFromServerUsecase.execute(
338350
vault: event.vault,
339351
password: event.password,
340352
)) {
341353
case Ok(:final value):
342-
emit(state.copyWith(vaultKey: value));
354+
emit(state.copyWith(vaultKey: value.vaultKey));
343355
log.fine('Vault key fetched from server');
344-
await _onVaultDecryption(OnVaultDecryption(vaultKey: value), emit);
356+
// Telemetry: record this device's own operation and surface an
357+
// immediate suspicious-activity alert when the server's counters
358+
// exceed it. Unawaited, never blocks the recovery flow.
359+
unawaited(
360+
_recoverbullTelemetryCubit.recordLocalAttempt(
361+
backupIdHex: event.vault.id,
362+
attemptStatus: value.attemptStatus,
363+
),
364+
);
365+
await _onVaultDecryption(
366+
OnVaultDecryption(vaultKey: value.vaultKey),
367+
emit,
368+
);
345369
case Err(:final failure):
370+
// The targeted per-identifier lockout on the user's own fetch is
371+
// an alarm signal: someone may be probing or griefing this backup.
372+
if (failure is KeyServerTargetedRateLimitedFailure) {
373+
unawaited(
374+
_recoverbullTelemetryCubit.reportTargetedLockout(
375+
backupIdHex: event.vault.id,
376+
),
377+
);
378+
}
346379
emit(state.copyWith(failure: _fetchKeyFailure(failure)));
347380
}
348381
} finally {
@@ -459,6 +492,12 @@ class RecoverBullBloc extends Bloc<RecoverBullEvent, RecoverBullState> {
459492
KeyServerRateLimitedFailure(:final retryIn) => VaultRateLimitedFailure(
460493
retryIn: retryIn ?? Duration.zero,
461494
),
495+
KeyServerTargetedRateLimitedFailure(:final retryIn) =>
496+
VaultRateLimitedFailure(retryIn: retryIn ?? Duration.zero),
497+
// Global 429 / capacity 503 are service pressure, not an attack:
498+
// surface them as unavailability, never as a lockout alarm.
499+
KeyServerOverloadedFailure() => const VaultKeyFetchFailure(),
500+
KeyServerCapacityFailure() => const VaultKeyFetchFailure(),
462501
KeyServerUnavailableFailure() => const VaultKeyFetchFailure(),
463502
_ => RecoverBullUnexpectedFailure(failure.logMessage),
464503
};
@@ -475,6 +514,10 @@ class RecoverBullBloc extends Bloc<RecoverBullEvent, RecoverBullState> {
475514
KeyServerRateLimitedFailure(:final retryIn) => VaultRateLimitedFailure(
476515
retryIn: retryIn ?? Duration.zero,
477516
),
517+
KeyServerTargetedRateLimitedFailure(:final retryIn) =>
518+
VaultRateLimitedFailure(retryIn: retryIn ?? Duration.zero),
519+
KeyServerOverloadedFailure() => const KeyServerConnectionFailure(),
520+
KeyServerCapacityFailure() => const KeyServerConnectionFailure(),
478521
KeyServerUnavailableFailure() => const KeyServerConnectionFailure(),
479522
_ => const VaultCreationFailure(),
480523
};

lib/features/recoverbull/router.dart

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import 'package:bb_mobile/core/recoverbull/domain/entity/encrypted_vault.dart';
22
import 'package:bb_mobile/core/recoverbull/domain/usecases/check_server_connection_usecase.dart';
33
import 'package:bb_mobile/core/recoverbull/domain/usecases/create_encrypted_vault_usecase.dart';
44
import 'package:bb_mobile/core/recoverbull/domain/usecases/decrypt_vault_usecase.dart';
5-
import 'package:bb_mobile/core/recoverbull/domain/usecases/fetch_vault_key_from_server_usecase.dart';
5+
import 'package:bb_mobile/core/recoverbull/domain/usecases/fetch_vault_key_with_status_from_server_usecase.dart';
66
import 'package:bb_mobile/core/recoverbull/domain/usecases/google_drive/connect_google_drive_usecase.dart';
77
import 'package:bb_mobile/core/recoverbull/domain/usecases/google_drive/fetch_latest_google_drive_backup_usecase.dart';
88
import 'package:bb_mobile/core/recoverbull/domain/usecases/google_drive/save_to_google_drive_usecase.dart';
@@ -16,6 +16,7 @@ import 'package:bb_mobile/core/tor/data/usecases/tor_status_usecase.dart';
1616
import 'package:bb_mobile/core/tor/domain/ports/tor_config_port.dart';
1717
import 'package:bb_mobile/features/recoverbull/flow.dart';
1818
import 'package:bb_mobile/features/recoverbull/presentation/bloc.dart';
19+
import 'package:bb_mobile/features/recoverbull/presentation/telemetry/recoverbull_telemetry_cubit.dart';
1920
import 'package:bb_mobile/locator.dart';
2021
import 'package:flutter_bloc/flutter_bloc.dart';
2122
import 'package:go_router/go_router.dart';
@@ -53,8 +54,6 @@ class RecoverBullRouter {
5354
locator<StoreVaultKeyIntoServerUsecase>(),
5455
checkKeyServerConnectionUsecase:
5556
locator<CheckServerConnectionUsecase>(),
56-
fetchVaultKeyFromServerUsecase:
57-
locator<FetchVaultKeyFromServerUsecase>(),
5857
decryptVaultUsecase: locator<DecryptVaultUsecase>(),
5958
restoreVaultUsecase: locator<RestoreVaultUsecase>(),
6059
connectToGoogleDriveUsecase: locator<ConnectToGoogleDriveUsecase>(),
@@ -67,6 +66,9 @@ class RecoverBullRouter {
6766
locator<UpdateLatestEncryptedVaultTestUsecase>(),
6867
torStatusUsecase: locator<TorStatusUsecase>(),
6968
torConfigPort: locator<TorConfigPort>(),
69+
fetchVaultKeyWithStatusFromServerUsecase:
70+
locator<FetchVaultKeyWithStatusFromServerUsecase>(),
71+
recoverbullTelemetryCubit: locator<RecoverbullTelemetryCubit>(),
7072
),
7173
child: const RecoverBullFlowNavigator(),
7274
);

0 commit comments

Comments
 (0)