Files

311 lines
9.1 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart' show AppLifecycleState, Rect;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:local_auth/local_auth.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../utils/logger.dart';
class AppLock {
AppLock._();
static final AppLock instance = AppLock._();
static const int pinLength = 4;
static const int maxAttempts = 5;
static const Duration lockout = Duration(minutes: 5);
static const List<int> idleOptions = [0, 1, 5, 10, 15, 30];
static const Duration _idleCheck = Duration(seconds: 15);
static const int _iterations = 20000;
static const String _kPin = 'app_lock_pin';
static const String _kEnabled = 'app_lock_enabled';
static const String _kBiometric = 'app_lock_biometric';
static const String _kIdle = 'app_lock_idle_minutes';
static const String _kFailed = 'app_lock_failed';
static const String _kBlockedUntil = 'app_lock_blocked_until';
static const FlutterSecureStorage _secure = FlutterSecureStorage(
aOptions: AndroidOptions(),
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
synchronizable: false,
),
mOptions: MacOsOptions(usesDataProtectionKeychain: false),
);
final ValueNotifier<bool> enabled = ValueNotifier(false);
final ValueNotifier<bool> biometric = ValueNotifier(false);
final ValueNotifier<int> idleMinutes = ValueNotifier(5);
final ValueNotifier<bool> locked = ValueNotifier(false);
final ValueNotifier<DateTime?> blockedUntil = ValueNotifier(null);
final LocalAuthentication _auth = LocalAuthentication();
int _failed = 0;
int _externalDepth = 0;
Rect? _origin;
DateTime _lastInteraction = DateTime.now();
Timer? _idleTimer;
bool? _biometricAvailable;
bool _checkingPin = false;
int get attemptsLeft => maxAttempts - _failed;
Rect? takeOrigin() {
final origin = _origin;
_origin = null;
return origin;
}
Future<void> load() async {
final prefs = await SharedPreferences.getInstance();
var on = prefs.getBool(_kEnabled) ?? false;
if (on) {
try {
// A missing or temporarily unavailable key must never disable a lock.
await _secure.read(key: _kPin);
} catch (e) {
logger.w('AppLock: не удалось прочитать код: $e');
}
}
enabled.value = on;
biometric.value = on && (prefs.getBool(_kBiometric) ?? false);
idleMinutes.value = prefs.getInt(_kIdle) ?? 5;
_failed = prefs.getInt(_kFailed) ?? 0;
final until = prefs.getInt(_kBlockedUntil);
blockedUntil.value = until == null
? null
: DateTime.fromMillisecondsSinceEpoch(until);
_expireLockout();
locked.value = on;
_syncIdleTimer();
}
Future<void> setPin(String pin) async {
if (!RegExp(r'^\d{4}$').hasMatch(pin)) {
throw ArgumentError('A passcode must contain four digits');
}
await _secure.write(key: _kPin, value: await _hash(pin));
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kEnabled, true);
enabled.value = true;
await _resetAttempts();
noteInteraction();
_syncIdleTimer();
}
Future<void> disable() async {
await _secure.delete(key: _kPin);
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kEnabled, false);
await prefs.setBool(_kBiometric, false);
enabled.value = false;
biometric.value = false;
locked.value = false;
await _resetAttempts();
_syncIdleTimer();
}
Future<void> setBiometric(bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kBiometric, value);
biometric.value = value;
}
Future<void> setIdleMinutes(int minutes) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_kIdle, minutes);
idleMinutes.value = minutes;
noteInteraction();
_syncIdleTimer();
}
Future<bool> checkPin(String pin) async {
try {
final stored = await _secure.read(key: _kPin);
return stored != null && await _matches(pin, stored);
} catch (_) {
return false;
}
}
Future<bool> unlockWithPin(String pin) async {
if (_checkingPin) return false;
_checkingPin = true;
try {
return await _tryUnlockWithPin(pin);
} finally {
_checkingPin = false;
}
}
Future<bool> _tryUnlockWithPin(String pin) async {
_expireLockout();
if (blockedUntil.value != null) return false;
if (await checkPin(pin)) {
await _resetAttempts();
unlock();
return true;
}
_failed++;
final prefs = await SharedPreferences.getInstance();
if (_failed >= maxAttempts) {
_failed = 0;
final until = DateTime.now().add(lockout);
blockedUntil.value = until;
await prefs.setInt(_kBlockedUntil, until.millisecondsSinceEpoch);
}
await prefs.setInt(_kFailed, _failed);
return false;
}
void _expireLockout() {
final until = blockedUntil.value;
if (until != null && !DateTime.now().isBefore(until)) {
blockedUntil.value = null;
unawaited(
SharedPreferences.getInstance().then((p) => p.remove(_kBlockedUntil)),
);
}
}
void refreshLockout() => _expireLockout();
Future<void> _resetAttempts() async {
_failed = 0;
blockedUntil.value = null;
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_kFailed);
await prefs.remove(_kBlockedUntil);
}
void lock({Rect? origin}) {
if (!enabled.value || locked.value) return;
_origin = origin;
locked.value = true;
}
void unlock() {
locked.value = false;
noteInteraction();
}
Future<T> external<T>(Future<T> Function() action) async {
_externalDepth++;
try {
return await action();
} finally {
_externalDepth--;
}
}
void onLifecycle(AppLifecycleState state) {
final background =
state == AppLifecycleState.paused || state == AppLifecycleState.hidden;
if (background && _externalDepth == 0) lock();
if (state == AppLifecycleState.resumed) _expireLockout();
}
void noteInteraction() => _lastInteraction = DateTime.now();
void _syncIdleTimer() {
_idleTimer?.cancel();
_idleTimer = null;
if (!enabled.value || idleMinutes.value <= 0) return;
_idleTimer = Timer.periodic(_idleCheck, (_) {
if (locked.value || _externalDepth > 0) return;
final idle = DateTime.now().difference(_lastInteraction);
if (idle >= Duration(minutes: idleMinutes.value)) lock();
});
}
Future<bool> biometricAvailable() async {
final known = _biometricAvailable;
if (known != null) return known;
try {
final available =
await _auth.isDeviceSupported() &&
await _auth.canCheckBiometrics &&
(await _auth.getAvailableBiometrics()).isNotEmpty;
return _biometricAvailable = available;
} catch (_) {
return _biometricAvailable = false;
}
}
Future<List<BiometricType>> biometricTypes() async {
if (!await biometricAvailable()) return const [];
try {
return await _auth.getAvailableBiometrics();
} catch (_) {
return const [];
}
}
Future<bool> authenticateBiometric(String reason) async {
if (!await biometricAvailable()) return false;
try {
return await _auth.authenticate(
localizedReason: reason,
biometricOnly: true,
sensitiveTransaction: false,
);
} catch (e) {
logger.w('AppLock: биометрия не прошла: $e');
return false;
}
}
Future<bool> unlockWithBiometric(String reason) async {
if (!biometric.value) return false;
final ok = await authenticateBiometric(reason);
if (ok) {
await _resetAttempts();
unlock();
}
return ok;
}
static Future<String> _hash(String pin) async {
final random = Random.secure();
final salt = Uint8List.fromList(
List<int>.generate(16, (_) => random.nextInt(256)),
);
final digest = await compute(_derive, (pin, salt));
return 'v1:${base64Encode(salt)}:${base64Encode(digest)}';
}
static Future<bool> _matches(String pin, String stored) async {
final parts = stored.split(':');
if (parts.length != 3 || parts[0] != 'v1') return false;
final salt = base64Decode(parts[1]);
final expected = base64Decode(parts[2]);
final actual = await compute(_derive, (pin, salt));
if (actual.length != expected.length) return false;
var diff = 0;
for (var i = 0; i < actual.length; i++) {
diff |= actual[i] ^ expected[i];
}
return diff == 0;
}
static Uint8List _derive((String, Uint8List) input) {
final (pin, salt) = input;
final hmac = Hmac(sha256, utf8.encode(pin));
var block = hmac.convert([...salt, 0, 0, 0, 1]).bytes;
final result = Uint8List.fromList(block);
for (var i = 1; i < _iterations; i++) {
block = hmac.convert(block).bytes;
for (var j = 0; j < result.length; j++) {
result[j] ^= block[j];
}
}
return result;
}
}