Files
sepot/app/lib/features/auth/change_pin_screen.dart
T
Julius Joska 2f6b542f24 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.
2026-10-23 10:50:16 +02:00

199 lines
5.7 KiB
Dart

/// Změna PINu ve třech krocích (starý → nový → potvrzení).
///
/// PIN se neověřuje proti hashi, ale unwrapem DEK (Argon2id). Každý pokus
/// proto stojí ~2 s, což je zároveň jediná potřebná ochrana proti hádání.
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:sepot/core/crypto/dek_wrap.dart';
import 'package:sepot/core/l10n/error_messages.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';
enum _Krok { stary, novy, potvrzeni }
class ChangePinScreen extends ConsumerStatefulWidget {
const ChangePinScreen({super.key});
@override
ConsumerState<ChangePinScreen> createState() => _ChangePinScreenState();
}
class _ChangePinScreenState extends ConsumerState<ChangePinScreen> {
_Krok _krok = _Krok.stary;
String _pin = '';
String? _staryPin;
String? _novyPin;
bool _pracuje = false;
String? _chyba;
(String, String) _titulky(AppLocalizations l10n) => switch (_krok) {
_Krok.stary =>
(l10n.pinChangeCurrentTitle, l10n.pinChangeCurrentSubtitle),
_Krok.novy => (l10n.pinChangeNewTitle, l10n.pinChangeNewSubtitle),
_Krok.potvrzeni =>
(l10n.pinChangeConfirmTitle, l10n.pinChangeConfirmSubtitle),
};
void _reset(String chyba) {
setState(() {
_chyba = chyba;
_pin = '';
_staryPin = null;
_novyPin = null;
_krok = _Krok.stary;
_pracuje = false;
});
}
void _klavesa(String k) {
if (_pracuje) return;
setState(() {
_chyba = null;
if (k == 'DEL') {
if (_pin.isNotEmpty) _pin = _pin.substring(0, _pin.length - 1);
return;
}
if (_pin.length < 6) _pin += k;
});
if (_pin.length == 6) _hotovoSest();
}
Future<void> _hotovoSest() async {
final storage = ref.read(storageProvider);
switch (_krok) {
case _Krok.stary:
setState(() => _pracuje = true);
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);
return;
}
setState(() {
_staryPin = _pin;
_pin = '';
_krok = _Krok.novy;
_pracuje = false;
});
case _Krok.novy:
if (_pin == _staryPin) {
setState(() {
_chyba = context.l10n.pinChangeMustDiffer;
_pin = '';
});
return;
}
setState(() {
_novyPin = _pin;
_pin = '';
_krok = _Krok.potvrzeni;
});
case _Krok.potvrzeni:
if (_pin != _novyPin) {
setState(() {
_chyba = context.l10n.pinMismatch;
_pin = '';
_novyPin = null;
_krok = _Krok.novy;
});
return;
}
setState(() => _pracuje = true);
try {
await storage.changePin(oldPin: _staryPin!, newPin: _novyPin!);
} on DecoyPinConflict catch (e) {
if (!mounted) return;
_reset(describeError(context.l10n, e));
return;
} on WrongPinException {
if (!mounted) return;
_reset(context.l10n.pinWrong);
return;
} catch (_) {
if (!mounted) return;
_reset(context.l10n.pinChangeFailed);
return;
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.pinChanged)),
);
context.pop();
}
}
@override
Widget build(BuildContext context) {
final (titulek, popis) = _titulky(context.l10n);
return Scaffold(
appBar: AppBar(title: Text(context.l10n.settingsChangePinTitle)),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
titulek,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 22,
fontWeight: FontWeight.w700,
color: SepotColors.textPrimary,
),
),
const SizedBox(height: 8),
Text(
popis,
style: const TextStyle(
fontSize: 14, color: SepotColors.textSecondary),
),
const SizedBox(height: 40),
PinDots(filled: _pin.length),
if (_chyba != null) ...[
const SizedBox(height: 16),
Text(
_chyba!,
textAlign: TextAlign.center,
style:
const TextStyle(color: SepotColors.error, fontSize: 14),
),
],
if (_pracuje) ...[
const SizedBox(height: 16),
// Argon2id (64 MiB, t=3) — pár sekund je záměr, ne prodleva.
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: SepotColors.ember),
),
],
const SizedBox(height: 40),
PinKeypad(onKey: _klavesa, enabled: !_pracuje),
],
),
),
),
);
}
}