fix(security): require a fresh PIN or biometric check for sensitive actions
An unlocked phone was enough to read the 24-word recovery phrase. The phrase, backup export and import, the duress PIN, a longer auto-lock, turning off screen security, enabling biometrics and deleting the account now ask for the PIN (or biometrics with biometricOnly) every time. The duress session never shows the phrase. PIN checks outside the lock screen count towards the same failure throttle.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
// Potřebuje nativní sqlcipher a Argon2id — na builderu se vynechává (--exclude-tags ffi).
|
||||
@Tags(['ffi'])
|
||||
// Argon2id (64 MiB) ~2 s na pokus, test jich dělá přes deset.
|
||||
@Timeout(Duration(minutes: 3))
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sepot/core/crypto/sepot_crypto.dart';
|
||||
import 'package:sepot/core/storage/secure_storage.dart';
|
||||
|
||||
/// Opětovné ověření PINu v odemčené appce (fráze, záloha, smazání účtu):
|
||||
/// stejná brzda jako zámek, nátlakový PIN ani nátlaková relace nic neodemknou.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
late Directory tmp;
|
||||
late SecureStorageService storage;
|
||||
|
||||
setUpAll(() async => SepotCrypto.init());
|
||||
|
||||
setUp(() async {
|
||||
FlutterSecureStorage.setMockInitialValues({});
|
||||
tmp = Directory.systemTemp.createTempSync('sepot_verify_pin');
|
||||
storage = SecureStorageService(dbPath: '${tmp.path}/app.db');
|
||||
await storage.unlockWithoutPin();
|
||||
await storage.enablePin('111111');
|
||||
await storage.enableDecoyPin('222222');
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
storage.lock();
|
||||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
test('skutečný PIN projde, nátlakový ne', () async {
|
||||
expect(await storage.verifyPin('111111'), isTrue);
|
||||
expect(await storage.verifyPin('222222'), isFalse);
|
||||
});
|
||||
|
||||
test('v nátlakové relaci neprojde ani skutečný PIN', () async {
|
||||
storage.lock();
|
||||
await storage.unlockWithPin('222222');
|
||||
expect(storage.isDecoyMode, isTrue);
|
||||
expect(await storage.verifyPin('111111'), isFalse);
|
||||
expect(await storage.loadSeedPhrase(), isNull);
|
||||
});
|
||||
|
||||
test('po pěti chybách zámek, hádání se nedá obejít', () async {
|
||||
for (var i = 0; i < 5; i++) {
|
||||
expect(await storage.verifyPin('99999$i'), isFalse);
|
||||
}
|
||||
expect(await storage.pinLockRemaining(), isNotNull);
|
||||
expect(() => storage.verifyPin('111111'),
|
||||
throwsA(isA<PinLockedException>()),
|
||||
reason: 'ani správný PIN během zámku');
|
||||
});
|
||||
|
||||
test('správný PIN vynuluje počítadlo chyb', () async {
|
||||
for (var i = 0; i < 4; i++) {
|
||||
await storage.verifyPin('99999$i');
|
||||
}
|
||||
expect(await storage.verifyPin('111111'), isTrue);
|
||||
expect(await storage.verifyPin('999990'), isFalse);
|
||||
expect(await storage.pinLockRemaining(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sepot/core/storage/secure_storage.dart';
|
||||
import 'package:sepot/features/auth/reauth.dart';
|
||||
import 'package:sepot/features/settings/settings_screen.dart';
|
||||
import 'package:sepot/l10n/generated/app_localizations.dart';
|
||||
import 'package:sepot/models/identity.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
import 'package:sepot/widgets/pin_keypad.dart';
|
||||
|
||||
/// 24 slov z odemčené appky jen po čerstvém PINu nebo biometrii; v nátlakové
|
||||
/// relaci vůbec. Úložiště je podvržené — Argon2id a keystore testuje
|
||||
/// `test/storage/verify_pin_test.dart`.
|
||||
class _FakeStorage extends SecureStorageService {
|
||||
_FakeStorage({this.pin = true, this.bio = false, this.decoy = false});
|
||||
|
||||
final bool pin;
|
||||
final bool bio;
|
||||
final bool decoy;
|
||||
int seedReads = 0;
|
||||
|
||||
static const correctPin = '123456';
|
||||
static final words = List.generate(24, (i) => 'slovo$i');
|
||||
|
||||
@override
|
||||
bool get isDecoyMode => decoy;
|
||||
|
||||
@override
|
||||
Future<bool> hasPin() async => pin;
|
||||
|
||||
@override
|
||||
Future<bool> hasDecoyPin() async => false;
|
||||
|
||||
@override
|
||||
Future<bool> isBiometricsEnabled() async => bio;
|
||||
|
||||
@override
|
||||
Future<Duration?> pinLockRemaining() async => null;
|
||||
|
||||
@override
|
||||
Future<bool> verifyPin(String pin) async => pin == correctPin;
|
||||
|
||||
@override
|
||||
Future<List<String>?> loadSeedPhrase() async {
|
||||
seedReads++;
|
||||
return decoy ? null : words;
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeIdentityNotifier extends IdentityNotifier {
|
||||
@override
|
||||
Future<LocalIdentity?> build() async => LocalIdentity(
|
||||
displayName: 'Alice',
|
||||
identityPrivateKey: Uint8List(32),
|
||||
identityPublicKey: Uint8List(33),
|
||||
createdAt: DateTime.utc(2026, 1, 1),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() => FlutterSecureStorage.setMockInitialValues({}));
|
||||
|
||||
Future<_FakeStorage> pump(
|
||||
WidgetTester tester, {
|
||||
bool pin = true,
|
||||
bool bio = false,
|
||||
bool bioResult = false,
|
||||
bool decoy = false,
|
||||
}) async {
|
||||
final storage = _FakeStorage(pin: pin, bio: bio, decoy: decoy);
|
||||
tester.view.physicalSize = const Size(1080, 2400);
|
||||
tester.view.devicePixelRatio = 2.5;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
overrides: [
|
||||
storageProvider.overrideWithValue(storage),
|
||||
identityProvider.overrideWith(_FakeIdentityNotifier.new),
|
||||
biometricPromptProvider.overrideWithValue((_) async => bioResult),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('cs'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const SettingsScreen(),
|
||||
),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
return storage;
|
||||
}
|
||||
|
||||
Future<void> openSeed(WidgetTester tester) async {
|
||||
final tile = find.text('Zálohovací fráze');
|
||||
await tester.scrollUntilVisible(tile, 200,
|
||||
scrollable: find.byType(Scrollable).first);
|
||||
await tester.tap(tile);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> typePin(WidgetTester tester, String pin) async {
|
||||
for (final d in pin.split('')) {
|
||||
await tester.tap(find.descendant(
|
||||
of: find.byType(PinKeypad), matching: find.text(d)));
|
||||
await tester.pump();
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
final firstWord = find.textContaining('slovo0');
|
||||
|
||||
testWidgets('fráze se bez PINu neukáže ani nenačte', (tester) async {
|
||||
final storage = await pump(tester);
|
||||
await openSeed(tester);
|
||||
expect(find.text('Potvrď, že jsi to ty'), findsOneWidget);
|
||||
expect(firstWord, findsNothing);
|
||||
expect(storage.seedReads, 0, reason: 'z keystore až po ověření');
|
||||
|
||||
await tester.tap(find.text('Zrušit'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(firstWord, findsNothing);
|
||||
expect(storage.seedReads, 0);
|
||||
});
|
||||
|
||||
testWidgets('špatný PIN frázi neukáže', (tester) async {
|
||||
final storage = await pump(tester);
|
||||
await openSeed(tester);
|
||||
await typePin(tester, '000000');
|
||||
expect(find.text('Špatný PIN.'), findsOneWidget);
|
||||
expect(firstWord, findsNothing);
|
||||
expect(storage.seedReads, 0);
|
||||
});
|
||||
|
||||
testWidgets('správný PIN frázi ukáže', (tester) async {
|
||||
final storage = await pump(tester);
|
||||
await openSeed(tester);
|
||||
await typePin(tester, _FakeStorage.correctPin);
|
||||
expect(firstWord, findsOneWidget);
|
||||
expect(storage.seedReads, 1);
|
||||
});
|
||||
|
||||
testWidgets('každé zobrazení chce PIN znovu', (tester) async {
|
||||
await pump(tester);
|
||||
await openSeed(tester);
|
||||
await typePin(tester, _FakeStorage.correctPin);
|
||||
await tester.tap(find.text('Zavřít'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await openSeed(tester);
|
||||
expect(find.text('Potvrď, že jsi to ty'), findsOneWidget);
|
||||
expect(firstWord, findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('úspěšná biometrie stačí', (tester) async {
|
||||
await pump(tester, bio: true, bioResult: true);
|
||||
await openSeed(tester);
|
||||
expect(firstWord, findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('zrušená biometrie spadne na PIN', (tester) async {
|
||||
await pump(tester, bio: true, bioResult: false);
|
||||
await openSeed(tester);
|
||||
expect(find.text('Potvrď, že jsi to ty'), findsOneWidget);
|
||||
expect(firstWord, findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('bez nastaveného PINu fráze nejde', (tester) async {
|
||||
final storage = await pump(tester, pin: false);
|
||||
await openSeed(tester);
|
||||
expect(find.textContaining('Nejdřív si ho nastav'), findsOneWidget);
|
||||
expect(firstWord, findsNothing);
|
||||
expect(storage.seedReads, 0);
|
||||
});
|
||||
|
||||
testWidgets('nátlaková relace frázi vůbec nenabízí', (tester) async {
|
||||
await pump(tester, decoy: true);
|
||||
expect(find.text('Zálohovací fráze', skipOffstage: false), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('smazání účtu bez PINu neproběhne', (tester) async {
|
||||
await pump(tester);
|
||||
final button = find.text('Smazat všechna data');
|
||||
await tester.scrollUntilVisible(button, 200,
|
||||
scrollable: find.byType(Scrollable).first);
|
||||
await tester.tap(button);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byType(ElevatedButton).last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Potvrď, že jsi to ty'), findsOneWidget);
|
||||
await typePin(tester, '999999');
|
||||
expect(find.text('Špatný PIN.'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user