Move staff PINs out of the shipped binary into hashed database rows

Three StaffUser constants carried plaintext PINs (1234/2345/3456) in
auth_controller.dart. Every build shipped every till's credentials, readable
by anyone who unzipped the APK. Across 100 deployed devices that is one
credential, not a hundred.

- Schema v5 adds a staff table. Only a PBKDF2-HMAC-SHA256 hash and a per-user
  random salt are stored; the PIN itself exists nowhere, including there.
  12,000 iterations, tuned so one sign-in is imperceptible while working
  through all 10,000 four-digit PINs against a stolen database takes ~15
  minutes per account instead of milliseconds.
- Verification is constant-time. String == returns at the first differing
  byte, and that timing leaks how much of a guess was right.
- StaffUser no longer has a pin field at all, so the credential cannot drift
  back into memory, into widgets, or into a const declaration.
- Weak PINs are refused: under four digits, non-numeric, repeated digits, and
  sequences. Two staff cannot share a PIN — the till identifies a cashier by
  PIN alone, so a shared one would attribute bills to whichever row was
  checked first.
- The last admin cannot be demoted or deactivated. A till with no admin cannot
  be administered, including to appoint one, and recovering means editing the
  database by hand.
- Staff are deactivated, never deleted, so bills already rung keep naming a
  real person.

Seed accounts are now 4821/5093/6274 rather than 1234/2345/3456 — the weak-PIN
rule refuses the old ones, and a default the rule itself would reject is not a
defensible default. All three are flagged must-change-pin so they get a shop
trading on day one without becoming permanent.

Store details are now editable data, not compile-time constants. Name,
address, GSTIN and phone persist to the database and are read back rather than
falling through to the build's constants, which would silently undo a failed
save. GSTIN is format-validated including the state code — it prints on every
invoice as a legal requirement, so a typo is a compliance problem across
hundreds of bills before anyone notices.

Tests: 141 -> 160. Includes a test that reads every column of every staff row
and asserts no seed PIN appears anywhere in the database.

Migration test now asserts v5 and that an upgraded terminal comes up with the
staff table present but empty — seeding is the store's job on first open, so
an existing shop is never handed accounts it did not create.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Suriya
2026-08-01 13:00:12 +05:30
parent 174fdddb8a
commit e17937e8f1
18 changed files with 984 additions and 34 deletions

View File

@@ -0,0 +1,102 @@
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
/// Turns a staff PIN into something safe to store.
///
/// PINs used to be string literals in `auth_controller.dart`, which meant every
/// shipped build carried every till's credentials — readable by anyone who
/// unzipped the APK. Storing them as plain rows in SQLite would be no better:
/// the database file sits on a shop-floor machine.
///
/// So: PBKDF2-HMAC-SHA256, per-user random salt, and only the derived key is
/// kept. A four-digit PIN has 10,000 possibilities, so the iteration count is
/// doing the real work — it makes checking all of them slow enough to matter.
class PinHasher {
const PinHasher._();
/// Chosen so one verification costs roughly a tenth of a second on terminal
/// hardware. A cashier signing in never notices; someone working through all
/// 10,000 PINs against a stolen database is looking at ~15 minutes per
/// account rather than milliseconds.
static const int iterations = 12000;
static const int _keyLength = 32;
static const int _saltLength = 16;
static final Random _random = Random.secure();
/// A fresh salt. Random.secure draws from the OS, not a seeded PRNG.
static String newSalt() {
final bytes = Uint8List.fromList(
List.generate(_saltLength, (_) => _random.nextInt(256)),
);
return base64Encode(bytes);
}
static String hash(String pin, String salt) {
final derived = _pbkdf2(
utf8.encode(pin),
base64Decode(salt),
iterations,
_keyLength,
);
return base64Encode(derived);
}
/// Constant-time comparison.
///
/// `==` on strings returns as soon as it finds a difference, and the timing
/// of that leaks how much of the guess was right.
static bool verify(String pin, {required String salt, required String hash}) {
final candidate = base64Decode(PinHasher.hash(pin, salt));
final expected = base64Decode(hash);
if (candidate.length != expected.length) return false;
var difference = 0;
for (var i = 0; i < candidate.length; i++) {
difference |= candidate[i] ^ expected[i];
}
return difference == 0;
}
/// PBKDF2 as specified in RFC 8018, with HMAC-SHA256 as the pseudorandom
/// function.
static Uint8List _pbkdf2(
List<int> password,
List<int> salt,
int iterations,
int keyLength,
) {
final hmac = Hmac(sha256, password);
final blocks = (keyLength / 32).ceil();
final output = BytesBuilder();
for (var block = 1; block <= blocks; block++) {
// U1 = PRF(password, salt || INT_32_BE(block))
final input = <int>[
...salt,
(block >> 24) & 0xff,
(block >> 16) & 0xff,
(block >> 8) & 0xff,
block & 0xff,
];
var u = Uint8List.fromList(hmac.convert(input).bytes);
final accumulator = Uint8List.fromList(u);
for (var i = 1; i < iterations; i++) {
u = Uint8List.fromList(hmac.convert(u).bytes);
for (var j = 0; j < accumulator.length; j++) {
accumulator[j] ^= u[j];
}
}
output.add(accumulator);
}
return Uint8List.fromList(output.toBytes().sublist(0, keyLength));
}
}