Files
kooltab/lib/services/pin_lock_service.dart
T
2026-10-08 04:12:36 +02:00

184 lines
5.4 KiB
Dart

import 'dart:convert';
import 'dart:isolate';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class PinLockedOutException implements Exception {
final Duration remaining;
const PinLockedOutException(this.remaining);
@override
String toString() => 'PIN locked for ${remaining.inSeconds}s';
}
class PinLockService {
PinLockService({
FlutterSecureStorage? storage,
this._saltKey = _defaultSaltKey,
this._hashKey = _defaultHashKey,
DateTime Function()? clock,
}) : _storage = storage ?? const FlutterSecureStorage(),
_clock = clock ?? DateTime.now;
static const _defaultSaltKey = 'pin_salt';
static const _defaultHashKey = 'pin_hash';
static const _hashPrefix = 'pbkdf2-sha256';
static const _iterations = 50000;
static const _freeAttempts = 5;
static const _baseLockout = Duration(seconds: 30);
static const _maxLockout = Duration(minutes: 15);
final FlutterSecureStorage _storage;
final String _saltKey;
final String _hashKey;
final DateTime Function() _clock;
String get _failuresKey => '${_hashKey}_failures';
String get _lockedUntilKey => '${_hashKey}_locked_until';
Future<bool> hasPin() async {
final hash = await _storage.read(key: _hashKey);
return hash != null && hash.isNotEmpty;
}
Future<void> setPin(String pin) async {
final salt = _generateSalt();
final hash = await _derive(pin, salt, _iterations);
await _storage.write(key: _saltKey, value: salt);
await _storage.write(key: _hashKey, value: hash);
await _clearFailures();
}
/// Throws [PinLockedOutException] after too many wrong attempts. The
/// lockout is persisted, so restarting the app doesn't reset it.
Future<bool> verifyPin(String pin) async {
await _throwIfLockedOut();
final salt = await _storage.read(key: _saltKey);
final storedHash = await _storage.read(key: _hashKey);
if (salt == null || storedHash == null) return false;
final isLegacy = !storedHash.startsWith('$_hashPrefix\$');
final candidate = isLegacy
? _legacyHash(pin, salt)
: await _derive(pin, salt, _iterationsOf(storedHash));
if (!_constantTimeEquals(candidate, storedHash)) {
await _registerFailure();
return false;
}
if (isLegacy) {
await setPin(pin);
} else {
await _clearFailures();
}
return true;
}
Future<void> clearPin() async {
await _storage.delete(key: _saltKey);
await _storage.delete(key: _hashKey);
await _clearFailures();
}
Future<void> _throwIfLockedOut() async {
final lockedUntil = int.tryParse(
await _storage.read(key: _lockedUntilKey) ?? '',
);
if (lockedUntil == null) return;
final remaining = DateTime.fromMillisecondsSinceEpoch(
lockedUntil,
).difference(_clock());
if (remaining > Duration.zero) throw PinLockedOutException(remaining);
}
/// Every failure past the free attempts doubles the lockout.
Future<void> _registerFailure() async {
final failures =
(int.tryParse(await _storage.read(key: _failuresKey) ?? '') ?? 0) + 1;
await _storage.write(key: _failuresKey, value: '$failures');
if (failures < _freeAttempts) return;
final exponent = min(failures - _freeAttempts, 10);
final lockout = _baseLockout * (1 << exponent);
final capped = lockout > _maxLockout ? _maxLockout : lockout;
await _storage.write(
key: _lockedUntilKey,
value: '${_clock().add(capped).millisecondsSinceEpoch}',
);
}
Future<void> _clearFailures() async {
await _storage.delete(key: _failuresKey);
await _storage.delete(key: _lockedUntilKey);
}
String _generateSalt([int length = 16]) {
final random = Random.secure();
final bytes = List<int>.generate(length, (_) => random.nextInt(256));
return base64UrlEncode(bytes);
}
int _iterationsOf(String hash) {
return int.parse(hash.split('\$')[1]);
}
/// Hashes are stored as `pbkdf2-sha256$<iterations>$<hex>`, so the
/// iteration count can be raised later without breaking existing PINs.
///
/// Static so the isolate closure can't capture `this`.
static Future<String> _derive(String pin, String salt, int iterations) async {
final key = await Isolate.run(
() => _pbkdf2(utf8.encode(pin), utf8.encode(salt), iterations),
);
final hex = key.map((byte) => byte.toRadixString(16).padLeft(2, '0'));
return '$_hashPrefix\$$iterations\$${hex.join()}';
}
/// Single-block PBKDF2-HMAC-SHA256 (a 32-byte key).
static List<int> _pbkdf2(List<int> password, List<int> salt, int iterations) {
final hmac = Hmac(sha256, password);
var block = hmac.convert([...salt, 0, 0, 0, 1]).bytes;
final key = List<int>.of(block);
for (var round = 1; round < iterations; round++) {
block = hmac.convert(block).bytes;
for (var index = 0; index < key.length; index++) {
key[index] ^= block[index];
}
}
return key;
}
/// The original single-SHA-256 format, only used to verify and upgrade
/// PINs set by older versions.
String _legacyHash(String pin, String salt) {
return sha256.convert(utf8.encode('$salt:$pin')).toString();
}
bool _constantTimeEquals(String a, String b) {
if (a.length != b.length) return false;
var difference = 0;
for (var index = 0; index < a.length; index++) {
difference |= a.codeUnitAt(index) ^ b.codeUnitAt(index);
}
return difference == 0;
}
}