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:
@@ -71,7 +71,14 @@ class _ChangePinScreenState extends ConsumerState<ChangePinScreen> {
|
||||
switch (_krok) {
|
||||
case _Krok.stary:
|
||||
setState(() => _pracuje = true);
|
||||
final ok = await storage.verifyPin(_pin);
|
||||
final bool ok;
|
||||
try {
|
||||
ok = await storage.verifyPin(_pin);
|
||||
} on PinLockedException catch (e) {
|
||||
if (!mounted) return;
|
||||
_reset(context.l10n.pinLockedFor(e.remaining.inSeconds));
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
if (!ok) {
|
||||
_reset(context.l10n.pinWrong);
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/// Čerstvé ověření před citlivou akcí v už odemčené appce.
|
||||
///
|
||||
/// Odemčený telefon v cizí ruce nesmí stačit na převzetí účtu: 24 slov,
|
||||
/// záloha, smazání účtu nebo oslabení zámku chtějí PIN (Argon2id unwrap,
|
||||
/// stejná brzda jako zámková obrazovka), případně skutečnou biometrii.
|
||||
/// Žádné okno „před chvílí jsem ověřil“ — každá akce se ptá znovu.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/storage/secure_storage.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
import 'package:sepot/widgets/pin_keypad.dart';
|
||||
|
||||
/// Systémová biometrická výzva. Provider kvůli testům — skutečný
|
||||
/// `local_auth` ve widget testu neběží.
|
||||
final biometricPromptProvider = Provider<Future<bool> Function(String reason)>((
|
||||
ref,
|
||||
) {
|
||||
return (reason) => LocalAuthentication().authenticate(
|
||||
localizedReason: reason,
|
||||
// biometricOnly: zámek telefonu (PIN/vzor zařízení) by obešel
|
||||
// Argon2id ochranu slabším faktorem, který zná i „půjčovatel“.
|
||||
options: const AuthenticationOptions(stickyAuth: true, biometricOnly: true),
|
||||
);
|
||||
});
|
||||
|
||||
/// Vrátí true, jen když uživatel právě teď prokázal, že zná PIN, nebo
|
||||
/// prošel biometrií. [reason] se ukáže ve výzvě.
|
||||
///
|
||||
/// [allowBiometrics] = false u akcí, které biometrii samy zakládají:
|
||||
/// zapnutí biometrie se nesmí potvrdit tím, co se teprve zapíná.
|
||||
Future<bool> confirmSensitiveAction(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
required String reason,
|
||||
bool allowBiometrics = true,
|
||||
}) async {
|
||||
final storage = ref.read(storageProvider);
|
||||
if (storage.isDecoyMode) return false;
|
||||
|
||||
if (!await storage.hasPin()) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.reauthPinRequired),
|
||||
action: SnackBarAction(
|
||||
label: context.l10n.reauthSetPin,
|
||||
onPressed: () => context.push('/pin-setup'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (allowBiometrics && await storage.isBiometricsEnabled()) {
|
||||
try {
|
||||
if (await ref.read(biometricPromptProvider)(reason)) return true;
|
||||
} catch (_) {
|
||||
// Biometrie nedostupná nebo zrušená — PIN je vždy cesta.
|
||||
}
|
||||
}
|
||||
|
||||
if (!context.mounted) return false;
|
||||
final ok = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
backgroundColor: SepotColors.background,
|
||||
builder: (_) => _ReauthPinSheet(reason: reason),
|
||||
);
|
||||
return ok ?? false;
|
||||
}
|
||||
|
||||
class _ReauthPinSheet extends ConsumerStatefulWidget {
|
||||
const _ReauthPinSheet({required this.reason});
|
||||
|
||||
final String reason;
|
||||
|
||||
@override
|
||||
ConsumerState<_ReauthPinSheet> createState() => _ReauthPinSheetState();
|
||||
}
|
||||
|
||||
class _ReauthPinSheetState extends ConsumerState<_ReauthPinSheet> {
|
||||
String _pin = '';
|
||||
String? _error;
|
||||
bool _busy = false;
|
||||
|
||||
void _onKey(String key) {
|
||||
if (_busy) return;
|
||||
setState(() {
|
||||
_error = null;
|
||||
if (key == 'DEL') {
|
||||
if (_pin.isNotEmpty) _pin = _pin.substring(0, _pin.length - 1);
|
||||
} else if (_pin.length < 6) {
|
||||
_pin += key;
|
||||
}
|
||||
});
|
||||
if (_pin.length == 6) _verify();
|
||||
}
|
||||
|
||||
Future<void> _verify() async {
|
||||
setState(() => _busy = true);
|
||||
final l10n = context.l10n;
|
||||
final storage = ref.read(storageProvider);
|
||||
String error;
|
||||
try {
|
||||
if (await storage.verifyPin(_pin)) {
|
||||
if (mounted) Navigator.of(context).pop(true);
|
||||
return;
|
||||
}
|
||||
final locked = await storage.pinLockRemaining();
|
||||
error = locked == null
|
||||
? l10n.pinWrong
|
||||
: l10n.pinLockedFor(locked.inSeconds);
|
||||
} on PinLockedException catch (e) {
|
||||
error = l10n.pinLockedFor(e.remaining.inSeconds);
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = error;
|
||||
_pin = '';
|
||||
_busy = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(32, 24, 32, 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
l10n.reauthTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.reason,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
PinDots(filled: _pin.length),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: SepotColors.error, fontSize: 14),
|
||||
),
|
||||
],
|
||||
if (_busy) ...[
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 32),
|
||||
PinKeypad(onKey: _onKey, enabled: !_busy),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: _busy ? null : () => Navigator.of(context).pop(false),
|
||||
child: Text(l10n.commonCancel),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import 'package:local_auth/local_auth.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/features/auth/reauth.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
|
||||
class BiometricsTile extends ConsumerStatefulWidget {
|
||||
@@ -48,6 +49,14 @@ class _BiometricsTileState extends ConsumerState<BiometricsTile> {
|
||||
_snack(l10n.biometricsStorageLocked);
|
||||
return;
|
||||
}
|
||||
// Biometrie dostane kopii DEK — zapnout ji smí jen ten, kdo zná PIN.
|
||||
// Otisk sám nestačí: v telefonu může být zapsaný i cizí prst.
|
||||
if (!mounted ||
|
||||
!await confirmSensitiveAction(context, ref,
|
||||
reason: l10n.reauthReasonBiometrics,
|
||||
allowBiometrics: false)) {
|
||||
return;
|
||||
}
|
||||
final auth = LocalAuthentication();
|
||||
if (!await auth.isDeviceSupported()) {
|
||||
_snack(l10n.biometricsNotAvailable);
|
||||
|
||||
@@ -24,6 +24,7 @@ import 'package:sepot/providers/identity_provider.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
import 'package:sepot/providers/settings_provider.dart';
|
||||
import 'package:sepot/features/auth/reauth.dart';
|
||||
import 'package:sepot/features/settings/biometrics_tile.dart';
|
||||
import 'package:sepot/features/settings/language_settings_tile.dart';
|
||||
import 'package:sepot/features/settings/push_settings_tile.dart';
|
||||
@@ -151,8 +152,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
subtitle: l10n.settingsScreenSecuritySubtitle,
|
||||
trailing: Switch(
|
||||
value: ref.watch(screenSecurityProvider).valueOrNull ?? false,
|
||||
onChanged: (v) =>
|
||||
ref.read(screenSecurityProvider.notifier).set(v),
|
||||
onChanged: (v) => _setScreenSecurity(context, ref, v),
|
||||
activeTrackColor: SepotColors.ember,
|
||||
),
|
||||
onTap: () {},
|
||||
@@ -319,9 +319,15 @@ class SettingsScreen extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
);
|
||||
if (vybrano != null && vybrano != ted) {
|
||||
await ref.read(autoLockProvider.notifier).set(vybrano);
|
||||
if (vybrano == null || vybrano == ted || !context.mounted) return;
|
||||
// Delší interval nebo „nikdy“ = slabší zámek. Kratší projde bez ptaní.
|
||||
final weakens = vybrano == 0 || (ted != 0 && vybrano > ted);
|
||||
if (weakens &&
|
||||
!await confirmSensitiveAction(context, ref,
|
||||
reason: context.l10n.reauthReasonAutoLock)) {
|
||||
return;
|
||||
}
|
||||
await ref.read(autoLockProvider.notifier).set(vybrano);
|
||||
}
|
||||
|
||||
/// Moje profilová fotka: vybrat z galerie (oříznutí a zmenšení běží
|
||||
@@ -518,6 +524,10 @@ class SettingsScreen extends ConsumerWidget {
|
||||
final l10n = context.l10n;
|
||||
final identity = ref.read(identityProvider).valueOrNull;
|
||||
if (identity == null) return;
|
||||
if (!await confirmSensitiveAction(context, ref,
|
||||
reason: l10n.reauthReasonBackupExport)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final bytes = await ref.read(backupServiceProvider).export(identity);
|
||||
final dir = await getTemporaryDirectory();
|
||||
@@ -547,6 +557,10 @@ class SettingsScreen extends ConsumerWidget {
|
||||
final l10n = context.l10n;
|
||||
final identity = ref.read(identityProvider).valueOrNull;
|
||||
if (identity == null) return;
|
||||
if (!await confirmSensitiveAction(context, ref,
|
||||
reason: l10n.reauthReasonBackupImport)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final res = await FilePicker.pickFiles(withData: true);
|
||||
final data = res?.files.firstOrNull?.bytes;
|
||||
@@ -570,6 +584,11 @@ class SettingsScreen extends ConsumerWidget {
|
||||
|
||||
Future<void> _setDecoyPin(BuildContext context, WidgetRef ref) async {
|
||||
final l10n = context.l10n;
|
||||
if (!await confirmSensitiveAction(context, ref,
|
||||
reason: l10n.reauthReasonDecoyPin)) {
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
final pinCtrl = TextEditingController();
|
||||
final confirmCtrl = TextEditingController();
|
||||
const numberField = TextInputType.number;
|
||||
@@ -645,6 +664,10 @@ class SettingsScreen extends ConsumerWidget {
|
||||
|
||||
Future<void> _disableDecoyPin(BuildContext context, WidgetRef ref) async {
|
||||
final l10n = context.l10n;
|
||||
if (!await confirmSensitiveAction(context, ref,
|
||||
reason: l10n.reauthReasonDecoyPin)) {
|
||||
return;
|
||||
}
|
||||
await ref.read(storageProvider).disableDecoyPin();
|
||||
ref.invalidate(hasDecoyPinProvider);
|
||||
if (context.mounted) {
|
||||
@@ -654,8 +677,15 @@ class SettingsScreen extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
void _showSeedPhrase(BuildContext context, WidgetRef ref) async {
|
||||
/// Fráze = celý účet navždy. Načte se z keystore až po čerstvém ověření
|
||||
/// a kopírovat ji nejde záměrně: schránku čtou klávesnice a synchronizace
|
||||
/// do cloudu, takže by 24 slov opustilo telefon mimo kontrolu uživatele.
|
||||
Future<void> _showSeedPhrase(BuildContext context, WidgetRef ref) async {
|
||||
final l10n = context.l10n;
|
||||
if (!await confirmSensitiveAction(context, ref,
|
||||
reason: l10n.reauthReasonSeed)) {
|
||||
return;
|
||||
}
|
||||
final storage = ref.read(storageProvider);
|
||||
final seed = await storage.loadSeedPhrase();
|
||||
|
||||
@@ -670,47 +700,64 @@ class SettingsScreen extends ConsumerWidget {
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(ctx.l10n.settingsSeedPhraseTitle),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
ctx.l10n.settingsSeedPhraseWarning,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: SepotColors.warning,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: List.generate(seed.length, (i) {
|
||||
return Chip(
|
||||
label: Text(
|
||||
ctx.l10n.settingsSeedWordChip(i + 1, seed[i]),
|
||||
style: SepotTheme.cryptoText,
|
||||
await ref.read(screenSecurityProvider.notifier).enforceWhile(
|
||||
() => showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(ctx.l10n.settingsSeedPhraseTitle),
|
||||
// 24 čipů se na malý displej nevejde — posouvat, ne přetéct.
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
ctx.l10n.settingsSeedPhraseWarning,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: SepotColors.warning,
|
||||
),
|
||||
backgroundColor: SepotColors.surfaceVariant,
|
||||
side: const BorderSide(color: SepotColors.border),
|
||||
);
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: List.generate(seed.length, (i) {
|
||||
return Chip(
|
||||
label: Text(
|
||||
ctx.l10n.settingsSeedWordChip(i + 1, seed[i]),
|
||||
style: SepotTheme.cryptoText,
|
||||
),
|
||||
backgroundColor: SepotColors.surfaceVariant,
|
||||
side: const BorderSide(color: SepotColors.border),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(ctx.l10n.commonClose),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(ctx.l10n.commonClose),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Vypnutí ochrany obrazovky pouští obsah (i frázi) do snímků a náhledu
|
||||
/// v přepínači aplikací — stejné oslabení jako delší auto-zámek.
|
||||
Future<void> _setScreenSecurity(
|
||||
BuildContext context, WidgetRef ref, bool enabled) async {
|
||||
if (!enabled &&
|
||||
!await confirmSensitiveAction(context, ref,
|
||||
reason: context.l10n.reauthReasonScreenSecurity)) {
|
||||
return;
|
||||
}
|
||||
await ref.read(screenSecurityProvider.notifier).set(enabled);
|
||||
}
|
||||
|
||||
void _showAbout(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
@@ -767,6 +814,10 @@ class SettingsScreen extends ConsumerWidget {
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
if (!await confirmSensitiveAction(ctx, ref,
|
||||
reason: ctx.l10n.reauthReasonDeleteAccount)) {
|
||||
return;
|
||||
}
|
||||
final messaging = ref.read(messagingServiceProvider);
|
||||
// Serverová deregistrace PŘED lokálním wipe — podpis
|
||||
// požadavku potřebuje identity klíč, který wipe zahodí.
|
||||
|
||||
Reference in New Issue
Block a user