feat: add alot
This commit is contained in:
@@ -1,24 +1,45 @@
|
||||
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,
|
||||
String saltKey = _defaultSaltKey,
|
||||
String hashKey = _defaultHashKey,
|
||||
this._saltKey = _defaultSaltKey,
|
||||
this._hashKey = _defaultHashKey,
|
||||
DateTime Function()? clock,
|
||||
}) : _storage = storage ?? const FlutterSecureStorage(),
|
||||
_saltKey = saltKey,
|
||||
_hashKey = hashKey;
|
||||
_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);
|
||||
@@ -27,24 +48,82 @@ class PinLockService {
|
||||
|
||||
Future<void> setPin(String pin) async {
|
||||
final salt = _generateSalt();
|
||||
final hash = _hash(pin, salt);
|
||||
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;
|
||||
|
||||
return _hash(pin, salt) == storedHash;
|
||||
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]) {
|
||||
@@ -53,8 +132,52 @@ class PinLockService {
|
||||
return base64UrlEncode(bytes);
|
||||
}
|
||||
|
||||
String _hash(String pin, String salt) {
|
||||
final bytes = utf8.encode('$salt:$pin');
|
||||
return sha256.convert(bytes).toString();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user