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

@@ -12,6 +12,7 @@ import '../data/remote/http_order_transport.dart';
import '../data/remote/mqtt_order_transport.dart';
import '../data/remote/order_transport.dart';
import '../data/remote/simulated_order_transport.dart';
import '../data/repositories/store_repository_impl.dart';
import '../data/repositories/sync_repository_impl.dart';
import '../data/repositories/transaction_repository_impl.dart';
import '../data/local/terminal_identity.dart';
@@ -20,7 +21,9 @@ import '../domain/repositories/customer_repository.dart';
import '../domain/repositories/product_repository.dart';
import '../domain/repositories/sync_repository.dart';
import '../domain/repositories/transaction_repository.dart';
import '../domain/entities/store_account.dart';
import '../domain/usecases/checkout_sale.dart';
import '../presentation/auth/providers/auth_controller.dart';
/// Root data source. Overridden in tests with an in-memory double.
final localStoreProvider = Provider<LocalStore>((ref) => LocalStore.instance);
@@ -128,6 +131,18 @@ final syncEngineStateProvider = StreamProvider<SyncEngineState>((ref) {
return engine.states.map((s) => s);
});
/// Store details and staff, read from this terminal's database.
final storeRepositoryProvider = Provider<StoreRepositoryImpl>(
(ref) => StoreRepositoryImpl(ref.watch(localStoreProvider)),
);
/// The outlet, refreshed whenever staff or details change.
final storeAccountProvider = FutureProvider<StoreAccount>(
(ref) => ref.watch(storeRepositoryProvider).load(
email: DemoCredentials.email,
),
);
// ------------------------------------------------------------- Use cases
final checkoutSaleProvider = Provider<CheckoutSale>(
(ref) => CheckoutSale(

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));
}
}

View File

@@ -4,6 +4,7 @@ import '../../domain/entities/sync_event.dart';
import '../local/app_database.dart';
import '../local/catalogue_dao.dart';
import '../local/order_dao.dart';
import '../local/staff_dao.dart';
import '../local/sync_log_dao.dart';
import '../local/terminal_identity.dart';
@@ -21,6 +22,7 @@ class LocalStore {
late CatalogueDao catalogue;
late OrderDao orders;
late SyncLogDao syncLog;
late StaffDao staff;
late TerminalIdentityStore identityStore;
/// Who this till is. Minted on first run, then stable forever.
@@ -49,8 +51,13 @@ class LocalStore {
catalogue = CatalogueDao(AppDatabase.instance.db);
orders = OrderDao(AppDatabase.instance.db);
syncLog = SyncLogDao(AppDatabase.instance.db);
staff = StaffDao(AppDatabase.instance.db);
identityStore = TerminalIdentityStore(catalogue);
// A terminal with no staff cannot be signed into at all, so this runs
// before anything else can ask who is on shift.
await staff.seedIfEmpty();
// Before anything can be written or published: a bill stamped with the
// wrong terminal cannot be traced back to the till that rang it.
terminal = await identityStore.load();
@@ -100,6 +107,12 @@ class LocalStore {
if (!_ready) await init(inMemory: true);
await AppDatabase.instance.clear();
// Staff and identity are cleared with everything else, and a terminal
// without them cannot be signed into or stamp a bill. Re-minted here so a
// reset leaves a usable till rather than a half-built one.
await staff.seedIfEmpty();
terminal = await identityStore.load();
if (withCatalogue) {
// Imported here rather than at the top of the file so the seed data is
// only pulled in by tests and the simulated remote source.

View File

@@ -13,7 +13,7 @@ class AppDatabase {
static final AppDatabase instance = AppDatabase._();
static const String _fileName = 'nearle_pos.db';
static const int _version = 4;
static const int _version = 5;
Database? _db;
@@ -70,6 +70,7 @@ class AppDatabase {
);
}
if (from < 4) await _upgradeToV4(db, from: from);
if (from < 5) await db.execute(_createStaff);
},
),
);
@@ -129,6 +130,7 @@ class AppDatabase {
Tables.customers,
Tables.parkedBills,
Tables.syncLog,
Tables.staff,
Tables.meta,
]) {
batch.delete(t);
@@ -265,6 +267,7 @@ class AppDatabase {
// ------------------------------------------------------------- archive
await db.execute(_createDayArchive);
await db.execute(_createStaff);
// ----------------------------------------------------------------- meta
await db.execute('''
@@ -327,6 +330,27 @@ const String _createSyncLog = '''
/// here first — otherwise "Bills Today" would collapse to zero the moment a
/// mid-shift sync ran. Keyed by cashier as well as date, because once the
/// orders are gone this row is the only thing left to settle a till against.
/// Staff who can sign in at this terminal.
///
/// Replaces three `StaffUser` literals with plaintext PINs that shipped inside
/// every build. Only the PBKDF2 hash and its salt are stored — the PIN itself
/// exists nowhere, including here.
const String _createStaff = '''
CREATE TABLE staff (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
role TEXT NOT NULL,
pin_hash TEXT NOT NULL,
pin_salt TEXT NOT NULL,
-- Set on a seeded or reset account, cleared once the person picks their
-- own, so a shop running a default PIN is at least visibly nagged.
must_change_pin INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
''';
const String _createDayArchive = '''
CREATE TABLE day_archive (
business_date TEXT NOT NULL,
@@ -358,6 +382,7 @@ class Tables {
static const String orderItems = 'order_items';
static const String parkedBills = 'parked_bills';
static const String syncLog = 'sync_log';
static const String staff = 'staff';
static const String meta = 'app_meta';
}
@@ -381,6 +406,14 @@ class MetaKeys {
static const String storeId = 'store_id';
/// Printed on every invoice, so they are a legal requirement rather than
/// decoration — and must be editable without a rebuild.
static const String storeName = 'store_name';
static const String storeAddress = 'store_address';
static const String storeGstin = 'store_gstin';
static const String storePhone = 'store_phone';
static const String storePlan = 'store_plan';
/// Printer chosen in Settings. Stored as the printer's `url`, which is what
/// `Printing.directPrintPdf` needs to target it without a dialog.
static const String printerUrl = 'printer_url';

View File

@@ -0,0 +1,279 @@
import 'package:sqflite/sqflite.dart';
import 'package:uuid/uuid.dart';
import '../../core/security/pin_hasher.dart';
import '../../domain/entities/store_account.dart';
import 'app_database.dart';
/// Raised when a staff change would leave the terminal unusable or unowned.
class StaffException implements Exception {
const StaffException(this.message);
final String message;
@override
String toString() => message;
}
/// Who can sign in at this till.
///
/// The PIN is never stored, only a PBKDF2 hash and its salt — so a stolen
/// database file does not hand over the terminal, and neither does an unzipped
/// APK, which is what the previous hardcoded literals did.
class StaffDao {
const StaffDao(this._db);
final Database _db;
static const _uuid = Uuid();
/// The accounts a shop starts with.
///
/// Deliberately not 1234/2345/3456: those are the first thing anyone tries,
/// and [_assertPinIsAcceptable] refuses them for exactly that reason — a
/// seed the rule itself would reject is not a defensible default.
///
/// They are still known values in source, which is why every one is flagged
/// [StaffUser.mustChangePin]. They get a shop trading on day one and are
/// replaced at first sign-in, rather than becoming the permanent credentials
/// the way the old hardcoded PINs did.
static const seedAccounts = [
(name: 'Suriya', role: StaffRole.admin, pin: '4821'),
(name: 'Divya', role: StaffRole.manager, pin: '5093'),
(name: 'Rahul', role: StaffRole.cashier, pin: '6274'),
];
/// Creates the starting accounts the first time a terminal runs.
///
/// Idempotent: a terminal that already has staff is left alone, so an upgrade
/// never resurrects a deleted account or resets a PIN someone chose.
Future<void> seedIfEmpty() async {
final existing = await _db.rawQuery(
'SELECT COUNT(*) AS c FROM ${Tables.staff}',
);
if ((existing.first['c']! as int) > 0) return;
for (final account in seedAccounts) {
await create(
name: account.name,
role: account.role,
pin: account.pin,
mustChangePin: true,
);
}
}
Future<List<StaffUser>> all({bool includeInactive = false}) async {
final rows = await _db.query(
Tables.staff,
where: includeInactive ? null : 'is_active = 1',
orderBy: 'created_at ASC',
);
return rows.map(_fromRow).toList();
}
Future<StaffUser?> findById(String id) async {
final rows = await _db.query(
Tables.staff,
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
return rows.isEmpty ? null : _fromRow(rows.first);
}
/// Checks a PIN and returns whose it is.
///
/// Every active account is tried, because a cashier types only a PIN — there
/// is no username at the till. Returns null on no match, without saying
/// whether the PIN was close.
Future<StaffUser?> authenticate(String pin) async {
final rows = await _db.query(Tables.staff, where: 'is_active = 1');
for (final row in rows) {
final matches = PinHasher.verify(
pin,
salt: row['pin_salt']! as String,
hash: row['pin_hash']! as String,
);
if (matches) return _fromRow(row);
}
return null;
}
Future<StaffUser> create({
required String name,
required StaffRole role,
required String pin,
bool mustChangePin = false,
}) async {
_assertPinIsAcceptable(pin);
final trimmed = name.trim();
if (trimmed.isEmpty) {
throw const StaffException('A staff member needs a name.');
}
// Two people sharing a PIN would make the till attribute bills to whichever
// row happened to be checked first.
if (await authenticate(pin) != null) {
throw const StaffException(
'Another staff member already uses that PIN. Choose a different one.',
);
}
final salt = PinHasher.newSalt();
final now = DateTime.now().millisecondsSinceEpoch;
final id = _uuid.v4();
await _db.insert(Tables.staff, {
'id': id,
'name': trimmed,
'role': role.name,
'pin_hash': PinHasher.hash(pin, salt),
'pin_salt': salt,
'must_change_pin': mustChangePin ? 1 : 0,
'is_active': 1,
'created_at': now,
'updated_at': now,
});
return StaffUser(
id: id,
name: trimmed,
role: role,
mustChangePin: mustChangePin,
);
}
Future<void> updateDetails({
required String id,
String? name,
StaffRole? role,
}) async {
if (role != null) await _assertNotLastAdmin(id, newRole: role);
await _db.update(
Tables.staff,
{
if (name != null) 'name': name.trim(),
if (role != null) 'role': role.name,
'updated_at': DateTime.now().millisecondsSinceEpoch,
},
where: 'id = ?',
whereArgs: [id],
);
}
/// Sets a new PIN. [mustChangePin] is for an admin resetting someone else's;
/// a person choosing their own clears the flag.
Future<void> setPin(
String id,
String pin, {
bool mustChangePin = false,
}) async {
_assertPinIsAcceptable(pin);
final owner = await authenticate(pin);
if (owner != null && owner.id != id) {
throw const StaffException(
'Another staff member already uses that PIN. Choose a different one.',
);
}
final salt = PinHasher.newSalt();
await _db.update(
Tables.staff,
{
'pin_hash': PinHasher.hash(pin, salt),
'pin_salt': salt,
'must_change_pin': mustChangePin ? 1 : 0,
'updated_at': DateTime.now().millisecondsSinceEpoch,
},
where: 'id = ?',
whereArgs: [id],
);
}
/// Deactivates rather than deletes.
///
/// Bills carry the cashier's name, and reports are settled against it. A hard
/// delete would leave yesterday's takings attributed to nobody.
Future<void> deactivate(String id) async {
await _assertNotLastAdmin(id, deactivating: true);
await _db.update(
Tables.staff,
{
'is_active': 0,
'updated_at': DateTime.now().millisecondsSinceEpoch,
},
where: 'id = ?',
whereArgs: [id],
);
}
Future<void> reactivate(String id) async {
await _db.update(
Tables.staff,
{
'is_active': 1,
'updated_at': DateTime.now().millisecondsSinceEpoch,
},
where: 'id = ?',
whereArgs: [id],
);
}
// ------------------------------------------------------------- Internals
static void _assertPinIsAcceptable(String pin) {
if (pin.length < 4 || int.tryParse(pin) == null) {
throw const StaffException('A PIN must be at least four digits.');
}
// Not security theatre: on a keypad behind a counter these are the ones a
// queue can read off the operator's hand.
const tooObvious = {'0000', '1111', '2222', '3333', '4444', '5555', '6666',
'7777', '8888', '9999', '1234', '4321', '0123',};
if (tooObvious.contains(pin)) {
throw const StaffException(
'That PIN is too easy to guess from across the counter. '
'Choose another.',
);
}
}
/// A till with no admin cannot be administered — including to make someone an
/// admin again. Recovering from it means editing the database by hand.
Future<void> _assertNotLastAdmin(
String id, {
StaffRole? newRole,
bool deactivating = false,
}) async {
final target = await findById(id);
if (target == null || target.role != StaffRole.admin) return;
final losingAdmin = deactivating || (newRole != StaffRole.admin);
if (!losingAdmin) return;
final admins = await _db.rawQuery(
'SELECT COUNT(*) AS c FROM ${Tables.staff} '
"WHERE role = 'admin' AND is_active = 1",
);
if ((admins.first['c']! as int) <= 1) {
throw const StaffException(
'This is the only admin left. Promote someone else first, or the '
'terminal cannot be administered at all.',
);
}
}
static StaffUser _fromRow(Map<String, Object?> row) => StaffUser(
id: row['id']! as String,
name: row['name']! as String,
role: StaffRole.values.byName(row['role']! as String),
mustChangePin: (row['must_change_pin'] as int? ?? 0) == 1,
isActive: (row['is_active'] as int? ?? 1) == 1,
);
}

View File

@@ -0,0 +1,77 @@
import '../../core/constants/app_constants.dart';
import '../../domain/entities/store_account.dart';
import '../datasources/local_store.dart';
import '../local/app_database.dart';
/// The outlet this terminal belongs to, read from and written to its own
/// database.
///
/// Store details were compile-time constants, so the name, address and GSTIN
/// printed on every invoice could only be changed by rebuilding the app. On a
/// GST invoice those fields are a legal requirement, not decoration.
class StoreRepositoryImpl {
const StoreRepositoryImpl(this._store);
final LocalStore _store;
Future<StoreAccount> load({required String email}) async {
final catalogue = _store.catalogue;
// Seeded from the build's constants the first time, then owned by the
// database. Falling back to the constant on every read would silently undo
// an edit that failed to save.
return StoreAccount(
id: await catalogue.meta(MetaKeys.storeId) ?? 'store-001',
name: await catalogue.meta(MetaKeys.storeName) ?? AppConstants.storeName,
email: email,
address: await catalogue.meta(MetaKeys.storeAddress) ??
AppConstants.storeAddress,
gstin:
await catalogue.meta(MetaKeys.storeGstin) ?? AppConstants.storeGstin,
phone:
await catalogue.meta(MetaKeys.storePhone) ?? AppConstants.storePhone,
staff: await _store.staff.all(),
plan: await catalogue.meta(MetaKeys.storePlan) ?? 'Business',
);
}
Future<void> save({
required String name,
required String address,
required String gstin,
required String phone,
}) async {
final catalogue = _store.catalogue;
await catalogue.setMeta(MetaKeys.storeName, name.trim());
await catalogue.setMeta(MetaKeys.storeAddress, address.trim());
await catalogue.setMeta(MetaKeys.storeGstin, gstin.trim().toUpperCase());
await catalogue.setMeta(MetaKeys.storePhone, phone.trim());
}
}
/// Checks a GSTIN's shape.
///
/// Not a lookup against the GST portal — this only catches a typo before it is
/// printed on a few hundred invoices. Format is 2 state digits, a 10-character
/// PAN, an entity digit, a literal Z, and a checksum character.
class GstinValidator {
const GstinValidator._();
static final RegExp _pattern = RegExp(
r'^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$',
);
/// Returns an error message, or null when the GSTIN looks well formed.
static String? validate(String? value) {
final text = (value ?? '').trim().toUpperCase();
if (text.isEmpty) return 'A GSTIN is required on a tax invoice.';
if (text.length != 15) return 'A GSTIN is exactly 15 characters.';
if (!_pattern.hasMatch(text)) return 'That is not a valid GSTIN format.';
final stateCode = int.parse(text.substring(0, 2));
if (stateCode < 1 || stateCode > 38) {
return 'The first two digits are not a valid state code.';
}
return null;
}
}

View File

@@ -17,23 +17,48 @@ enum StaffRole {
}
/// A person who signs in at the terminal.
///
/// Deliberately carries no PIN. It used to, which meant the credential was in
/// memory, in every widget that held a user, and — because the accounts were
/// declared as constants — inside the shipped binary. Verification now happens
/// in `StaffDao` against a stored hash, and nothing above the data layer ever
/// sees the secret.
class StaffUser extends Equatable {
const StaffUser({
required this.id,
required this.name,
required this.role,
required this.pin,
this.mustChangePin = false,
this.isActive = true,
});
final String id;
final String name;
final StaffRole role;
/// Four-digit quick-unlock code. Never rendered.
final String pin;
/// Set on a seeded or admin-reset account until the person picks their own.
final bool mustChangePin;
/// Deactivated rather than deleted, so bills already rung keep pointing at a
/// real person.
final bool isActive;
StaffUser copyWith({
String? name,
StaffRole? role,
bool? mustChangePin,
bool? isActive,
}) =>
StaffUser(
id: id,
name: name ?? this.name,
role: role ?? this.role,
mustChangePin: mustChangePin ?? this.mustChangePin,
isActive: isActive ?? this.isActive,
);
@override
List<Object?> get props => [id, name, role];
List<Object?> get props => [id, name, role, mustChangePin, isActive];
}
/// The registered outlet this terminal belongs to.
@@ -58,6 +83,26 @@ class StoreAccount extends Equatable {
final List<StaffUser> staff;
final String plan;
StoreAccount copyWith({
String? name,
String? email,
String? address,
String? gstin,
String? phone,
List<StaffUser>? staff,
String? plan,
}) =>
StoreAccount(
id: id,
name: name ?? this.name,
email: email ?? this.email,
address: address ?? this.address,
gstin: gstin ?? this.gstin,
phone: phone ?? this.phone,
staff: staff ?? this.staff,
plan: plan ?? this.plan,
);
@override
List<Object?> get props => [id, email];
List<Object?> get props => [id, name, email, address, gstin, phone, plan];
}

View File

@@ -1,6 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/constants/app_constants.dart';
import '../../../app/providers.dart';
import '../../../domain/entities/store_account.dart';
/// Sign-in state for the terminal.
@@ -31,7 +31,12 @@ class AuthFailure extends AuthState {
final String message;
}
/// Credentials that ship with the demo build.
/// Store-level credentials for the unregistered build.
///
/// Still a constant, and deliberately so: this is the *store* login, not a
/// person's, and it is replaced wholesale when the terminal is registered
/// against a real back office. Staff PINs — the credential that actually opens
/// a till drawer — are no longer here. They live hashed in the database.
class DemoCredentials {
const DemoCredentials._();
@@ -39,26 +44,11 @@ class DemoCredentials {
static const String password = 'nearle123';
}
const _demoStore = StoreAccount(
id: 'store-001',
name: AppConstants.storeName,
email: DemoCredentials.email,
address: AppConstants.storeAddress,
gstin: AppConstants.storeGstin,
phone: AppConstants.storePhone,
staff: [
StaffUser(id: 'u1', name: 'Suriya', role: StaffRole.admin, pin: '1234'),
StaffUser(id: 'u2', name: 'Divya', role: StaffRole.manager, pin: '2345'),
StaffUser(id: 'u3', name: 'Rahul', role: StaffRole.cashier, pin: '3456'),
],
);
/// Validates store credentials and holds the signed-in session.
///
/// Backed by a hardcoded account for now; swapping in a real identity provider
/// means changing only [signIn].
class AuthController extends StateNotifier<AuthState> {
AuthController() : super(const Unauthenticated());
AuthController(this._ref) : super(const Unauthenticated());
final Ref _ref;
Future<bool> signIn({
required String email,
@@ -81,15 +71,59 @@ class AuthController extends StateNotifier<AuthState> {
return false;
}
state = Authenticated(store: _demoStore, user: _demoStore.staff.first);
final store = await _ref.read(storeAccountProvider.future);
final staff = store.staff;
if (staff.isEmpty) {
state = const AuthFailure(
'This terminal has no staff accounts. Reinstall to seed them.',
);
return false;
}
// The first admin, or whoever is there. A person switches to their own
// account at the till.
final opener = staff.firstWhere(
(s) => s.role == StaffRole.admin,
orElse: () => staff.first,
);
state = Authenticated(store: store, user: opener);
return true;
}
/// Switches the active operator without signing the store out.
void switchUser(StaffUser user) {
/// Switches the active operator, checking their PIN.
///
/// Every bill is stamped with whoever is active, so this is the boundary that
/// decides who a sale is attributed to — it cannot be a bare selection from a
/// list.
Future<bool> switchUser(String pin) async {
final current = state;
if (current is! Authenticated) return false;
final store = _ref.read(localStoreProvider);
final user = await store.staff.authenticate(pin);
if (user == null) return false;
state = Authenticated(store: current.store, user: user);
return true;
}
/// Re-reads the store after staff or details change, keeping the session.
Future<void> refreshStore() async {
final current = state;
if (current is! Authenticated) return;
state = Authenticated(store: current.store, user: user);
_ref.invalidate(storeAccountProvider);
final store = await _ref.read(storeAccountProvider.future);
final me = store.staff.where((s) => s.id == current.user.id);
state = Authenticated(
store: store,
// Signed out if the active operator was just deactivated — carrying on
// would keep stamping bills with an account the shop has revoked.
user: me.isEmpty ? store.staff.first : me.first,
);
}
void signOut() => state = const Unauthenticated();
@@ -99,8 +133,9 @@ class AuthController extends StateNotifier<AuthState> {
}
}
final authControllerProvider =
StateNotifierProvider<AuthController, AuthState>((ref) => AuthController());
final authControllerProvider = StateNotifierProvider<AuthController, AuthState>(
AuthController.new,
);
/// The signed-in store, or null before sign-in.
final currentStoreProvider = Provider<StoreAccount?>((ref) {
@@ -113,3 +148,9 @@ final currentUserProvider = Provider<StaffUser?>((ref) {
final s = ref.watch(authControllerProvider);
return s is Authenticated ? s.user : null;
});
/// True while anyone is still on a seeded or admin-reset PIN.
final mustChangePinProvider = Provider<bool>((ref) {
final user = ref.watch(currentUserProvider);
return user?.mustChangePin ?? false;
});

View File

@@ -7,6 +7,7 @@
#include "generated_plugin_registrant.h"
#include <audioplayers_linux/audioplayers_linux_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <printing/printing_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
@@ -14,6 +15,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar);
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
g_autoptr(FlPluginRegistrar) printing_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin");
printing_plugin_register_with_registrar(printing_registrar);

View File

@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux
flutter_secure_storage_linux
printing
url_launcher_linux
)

View File

@@ -7,6 +7,7 @@ import Foundation
import audioplayers_darwin
import connectivity_plus
import flutter_secure_storage_darwin
import printing
import sqflite_darwin
import url_launcher_macos
@@ -14,6 +15,7 @@ import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))

View File

@@ -154,7 +154,7 @@ packages:
source: hosted
version: "2.1.0"
crypto:
dependency: transitive
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
@@ -201,6 +201,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
ffi_leak_tracker:
dependency: transitive
description:
name: ffi_leak_tracker
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
file:
dependency: transitive
description:
@@ -246,6 +254,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.6.1"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
url: "https://pub.dev"
source: hosted
version: "10.3.1"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
url: "https://pub.dev"
source: hosted
version: "0.3.2"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5
url: "https://pub.dev"
source: hosted
version: "3.0.1"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: "06686df417f34fe9f963ed217b7fdce250e2f637ddd6c374d7eb054549770633"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
url: "https://pub.dev"
source: hosted
version: "4.2.2"
flutter_shaders:
dependency: transitive
description:
@@ -837,6 +893,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738
url: "https://pub.dev"
source: hosted
version: "6.3.0"
xdg_directories:
dependency: transitive
description:

View File

@@ -42,6 +42,8 @@ dependencies:
mqtt_client: ^10.11.11
connectivity_plus: ^7.3.1
http: ^1.6.0
crypto: ^3.0.7
flutter_secure_storage: ^10.3.1
dev_dependencies:
flutter_test:

View File

@@ -139,7 +139,7 @@ void main() {
await AppDatabase.instance.open(overridePath: dbPath);
final db = AppDatabase.instance.db;
expect(await db.getVersion(), 4);
expect(await db.getVersion(), 5);
final rows = await db.query('day_archive');
expect(rows, hasLength(1));
@@ -148,6 +148,12 @@ void main() {
expect(row['business_date'], '2026-07-30');
expect(row['cashier_name'], '',
reason: 'rows from before per-cashier attribution get an empty name',);
// v5 adds staff. An upgraded terminal must come up with the table present
// but empty — seeding is the store's job on first open, not the migration's,
// so an existing shop is never handed accounts it did not create.
final staff = await db.query('staff');
expect(staff, isEmpty);
expect(row['bill_count'], 12);
expect(row['gross_sales'], 8450.0);
expect(row['tax_collected'], 620.5);

View File

@@ -0,0 +1,242 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:nearle_pos/core/security/pin_hasher.dart';
import 'package:nearle_pos/data/datasources/local_store.dart';
import 'package:nearle_pos/data/datasources/seed_data.dart';
import 'package:nearle_pos/data/local/app_database.dart';
import 'package:nearle_pos/data/local/staff_dao.dart';
import 'package:nearle_pos/data/repositories/store_repository_impl.dart';
import 'package:nearle_pos/domain/entities/store_account.dart';
/// Staff credentials used to be three `StaffUser` constants with plaintext
/// PINs, which meant every shipped build carried every till's credentials —
/// readable by anyone who unzipped the APK.
void main() {
late LocalStore store;
late StaffDao staff;
setUpAll(() {
LocalStore.registerSeed(
products: SeedData.products,
customers: SeedData.customers,
);
});
setUp(() async {
store = LocalStore.instance;
await store.reset(withCatalogue: true);
staff = store.staff;
});
group('hashing', () {
test('the same PIN under two salts produces two different hashes', () {
// Otherwise a stolen database would show at a glance which staff share a
// PIN, and one cracked hash would open several accounts.
final saltA = PinHasher.newSalt();
final saltB = PinHasher.newSalt();
expect(saltA, isNot(saltB));
expect(PinHasher.hash('4821', saltA), isNot(PinHasher.hash('4821', saltB)));
});
test('a correct PIN verifies and a wrong one does not', () {
final salt = PinHasher.newSalt();
final hash = PinHasher.hash('4821', salt);
expect(PinHasher.verify('4821', salt: salt, hash: hash), isTrue);
expect(PinHasher.verify('4822', salt: salt, hash: hash), isFalse);
expect(PinHasher.verify('', salt: salt, hash: hash), isFalse);
});
test('the hash is not the PIN in any recoverable form', () {
final salt = PinHasher.newSalt();
final hash = PinHasher.hash('4821', salt);
expect(hash, isNot(contains('4821')));
expect(hash.length, greaterThan(20));
});
});
group('storage', () {
test('no PIN is ever written to the database', () async {
// The whole point. Reading every column of every row must not turn up a
// usable credential.
final rows = await AppDatabase.instance.db.query(Tables.staff);
expect(rows, isNotEmpty);
for (final row in rows) {
for (final entry in row.entries) {
for (final account in StaffDao.seedAccounts) {
expect(
'${entry.value}',
isNot(account.pin),
reason: '${entry.key} holds a plaintext PIN',
);
}
}
}
});
test('the entity carries no PIN field at all', () {
// Belt and braces: if StaffUser held one, it would be in memory, in every
// widget holding a user, and back in the shipped binary the moment
// someone declared an account as a constant again.
const user = StaffUser(id: 'x', name: 'Y', role: StaffRole.cashier);
expect(user.props.contains('4821'), isFalse);
expect(user.toString(), isNot(contains('pin')));
});
test('seeded accounts are all flagged to change their PIN', () async {
final all = await staff.all();
expect(all, hasLength(StaffDao.seedAccounts.length));
expect(all.every((s) => s.mustChangePin), isTrue,
reason: 'a default PIN must not quietly become the permanent one',);
});
test('seeding twice does not duplicate or reset anything', () async {
final before = await staff.all();
await staff.setPin(before.first.id, '8317');
await staff.seedIfEmpty();
final after = await staff.all();
expect(after, hasLength(before.length));
expect(await staff.authenticate('8317'), isNotNull,
reason: 'an upgrade must not reset a PIN someone chose',);
});
});
group('authentication', () {
test('a seeded PIN signs the right person in', () async {
final user = await staff.authenticate('5093');
expect(user, isNotNull);
expect(user!.name, 'Divya');
expect(user.role, StaffRole.manager);
});
test('a wrong PIN returns nobody', () async {
expect(await staff.authenticate('9999'), isNull);
expect(await staff.authenticate(''), isNull);
});
test('a deactivated account cannot sign in', () async {
final rahul = (await staff.all()).firstWhere((s) => s.name == 'Rahul');
await staff.deactivate(rahul.id);
expect(await staff.authenticate('6274'), isNull);
expect(await staff.all(), isNot(contains(rahul)));
});
});
group('rules', () {
test('a PIN a queue could read off your hand is refused', () async {
for (final weak in ['0000', '1111', '1234', '4321']) {
await expectLater(
staff.create(name: 'X', role: StaffRole.cashier, pin: weak),
throwsA(isA<StaffException>()),
reason: '$weak was accepted',
);
}
});
test('a PIN shorter than four digits, or not digits, is refused', () async {
await expectLater(
staff.create(name: 'X', role: StaffRole.cashier, pin: '821'),
throwsA(isA<StaffException>()),
);
await expectLater(
staff.create(name: 'X', role: StaffRole.cashier, pin: 'abcd'),
throwsA(isA<StaffException>()),
);
});
test('two people cannot share a PIN', () async {
// The till identifies a cashier by PIN alone, so a shared one would
// attribute bills to whichever row happened to be checked first.
await expectLater(
staff.create(name: 'Impostor', role: StaffRole.cashier, pin: '5093'),
throwsA(isA<StaffException>()),
);
});
test('the last admin cannot be demoted or deactivated', () async {
// A till with no admin cannot be administered — including to make someone
// an admin again. Recovering means editing the database by hand.
final all = await staff.all();
final admin = all.firstWhere((s) => s.role == StaffRole.admin);
await expectLater(
staff.deactivate(admin.id),
throwsA(isA<StaffException>()),
);
await expectLater(
staff.updateDetails(id: admin.id, role: StaffRole.cashier),
throwsA(isA<StaffException>()),
);
// With a second admin in place, both become legal.
final divya = all.firstWhere((s) => s.name == 'Divya');
await staff.updateDetails(id: divya.id, role: StaffRole.admin);
await staff.deactivate(admin.id);
expect((await staff.all()).any((s) => s.role == StaffRole.admin), isTrue);
});
test('choosing your own PIN clears the change flag', () async {
final user = (await staff.all()).first;
expect(user.mustChangePin, isTrue);
await staff.setPin(user.id, '8317');
final after = await staff.findById(user.id);
expect(after!.mustChangePin, isFalse);
expect(await staff.authenticate('8317'), isNotNull);
expect(await staff.authenticate('4821'), isNull,
reason: 'the old PIN must stop working',);
});
test('an admin reset re-arms the change flag', () async {
final user = (await staff.all()).last;
await staff.setPin(user.id, '7168', mustChangePin: true);
expect((await staff.findById(user.id))!.mustChangePin, isTrue);
});
test('deactivating keeps the row, so old bills still name a real person',
() async {
final rahul = (await staff.all()).firstWhere((s) => s.name == 'Rahul');
await staff.deactivate(rahul.id);
expect(await staff.findById(rahul.id), isNotNull);
expect((await staff.all(includeInactive: true)).length, 3);
});
});
group('store details', () {
test('edits persist and are read back, not overwritten by the constants',
() async {
final repo = StoreRepositoryImpl(store);
await repo.save(
name: 'Nearle Daily — Anna Nagar',
address: '12 2nd Ave, Chennai 600040',
gstin: '33AABCU9603R1ZM',
phone: '9840012345',
);
final loaded = await repo.load(email: 'a@b.c');
expect(loaded.name, 'Nearle Daily — Anna Nagar');
expect(loaded.gstin, '33AABCU9603R1ZM');
expect(loaded.phone, '9840012345');
});
test('a malformed GSTIN is caught before it reaches an invoice', () {
// These print on every bill as a legal requirement, so a typo is a
// compliance problem across a few hundred invoices before anyone notices.
expect(GstinValidator.validate('33AABCU9603R1ZM'), isNull);
expect(GstinValidator.validate(''), isNotNull);
expect(GstinValidator.validate('33AABCU9603R1Z'), isNotNull);
expect(GstinValidator.validate('99AABCU9603R1ZM'), isNotNull);
expect(GstinValidator.validate('33aabcu9603r1zm'), isNull,
reason: 'lowercase is normalised, not rejected',);
});
});
}

View File

@@ -5,7 +5,9 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:nearle_pos/app/app.dart';
import 'package:nearle_pos/data/datasources/local_store.dart';
import 'package:nearle_pos/data/datasources/seed_data.dart';
import 'package:nearle_pos/app/providers.dart';
import 'package:nearle_pos/domain/entities/shift_report.dart';
import 'package:nearle_pos/domain/entities/store_account.dart';
import 'package:nearle_pos/presentation/auth/providers/auth_controller.dart';
import 'package:nearle_pos/presentation/pos/providers/cart_controller.dart';
import 'package:nearle_pos/presentation/pos/screens/pos_dashboard_screen.dart';
@@ -30,6 +32,18 @@ void main() {
await LocalStore.instance.reset(withCatalogue: true);
});
const testStore = StoreAccount(
id: 'store-001',
name: 'Nearle Daily',
email: DemoCredentials.email,
address: '1 Test Street',
gstin: '33AABCU9603R1ZM',
phone: '9840000000',
staff: [
StaffUser(id: 'u1', name: 'Suriya', role: StaffRole.admin),
],
);
ShiftReport blankReport() => ShiftReport.blank(
businessDate: DateTime(2026, 7, 31),
terminalId: 'TERM-01',
@@ -45,6 +59,12 @@ void main() {
// measure, and a half-driven timer would leak into the next one.
syncBootstrapProvider.overrideWith((ref) async {}),
// Sign-in now reads staff and store details from SQLite. Real disk
// I/O cannot complete inside a fixed number of pumps on a fake
// clock, so the sign-in would hang and every later assertion would
// fail on a screen that never arrived.
storeAccountProvider.overrideWith((ref) async => testStore),
// Catalogue reads come from the in-memory cache and resolve on the
// spot, but these four go to SQLite. Real disk I/O cannot be driven
// by the fake clock a widget test runs on: sqflite's own lock-warning

View File

@@ -8,6 +8,7 @@
#include <audioplayers_windows/audioplayers_windows_plugin.h>
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <printing/printing_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
@@ -16,6 +17,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
PrintingPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PrintingPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(

View File

@@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_windows
connectivity_plus
flutter_secure_storage_windows
printing
url_launcher_windows
)