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, this.maxLockout = const Duration(minutes: 15), }) : _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); final FlutterSecureStorage _storage; final String _saltKey; final String _hashKey; final DateTime Function() _clock; /// Kept short for the bar PIN so a bartender is never locked out for long. final Duration maxLockout; String get _failuresKey => '${_hashKey}_failures'; String get _lockedUntilKey => '${_hashKey}_locked_until'; Future hasPin() async { final hash = await _storage.read(key: _hashKey); return hash != null && hash.isNotEmpty; } Future 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 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 clearPin() async { await _storage.delete(key: _saltKey); await _storage.delete(key: _hashKey); await _clearFailures(); } Future _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 _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 _clearFailures() async { await _storage.delete(key: _failuresKey); await _storage.delete(key: _lockedUntilKey); } String _generateSalt([int length = 16]) { final random = Random.secure(); final bytes = List.generate(length, (_) => random.nextInt(256)); return base64UrlEncode(bytes); } int _iterationsOf(String hash) { return int.parse(hash.split('\$')[1]); } /// Hashes are stored as `pbkdf2-sha256$$`, so the /// iteration count can be raised later without breaking existing PINs. /// /// Static so the isolate closure can't capture `this`. static Future _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 _pbkdf2(List password, List salt, int iterations) { final hmac = Hmac(sha256, password); var block = hmac.convert([...salt, 0, 0, 0, 1]).bytes; final key = List.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; } }