187 lines
5.5 KiB
Dart
187 lines
5.5 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,
|
|
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<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;
|
|
}
|
|
}
|