You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Opening the app with PIN entry (no biometrics enabled) consistently takes ~10 seconds from the moment the correct PIN is entered until the dashboard is shown. This delay was reported as a regression — it was noticeably faster previously.
The delay reproduces on every unlock, not just the first one after an update, so the legacy-iteration rehash path (secure_storage.dart:93-98) is not the cause.
Reproduction
Open the app cold (or after sufficient background time to trigger the lockout — see lockoutDuration)
Enter the correct 6-digit PIN
Observe time from last digit entered → dashboard rendered
Expected: < 2 s
Actual: ~10 s
Root-cause analysis
The wallclock from "last digit entered" to "dashboard visible" is the sum of two sequential blocking phases on the user-facing critical path. Neither runs in parallel with the other.
Phase 1 — PBKDF2-SHA256 with 600'000 iterations (isolate, but real wallclock)
lib/packages/storage/secure_storage.dart:44
staticconst _pinHashIterations =600000;
verifyPin() (secure_storage.dart:86-101) calls hashPinAsync(), which dispatches to a compute isolate (secure_storage.dart:57-64). The UI is not frozen visually, but the cryptographic work still consumes several seconds of wallclock on mobile devices — the in-code comment acknowledges this explicitly:
/// PBKDF2 with 600k iterations takes/// several seconds on a phone and freezes the UI when run synchronously
This iteration count was introduced by PR #290 ("Increase PBKDF2 iterations to 600k with transparent rehash (NEW-2)"), commit 5a998bb. Previously the iteration count was 10'000 (60× faster), so this PR alone is expected to add ~4-6 s wallclock on a mid-range Android device for the PBKDF2 phase.
The fact that the user experiences a consistent ~10 s — not just a one-time double-cost from the legacy rehash path — means we are paying the full 600k cost on every unlock, every time.
await _secureStorage.getOrCreateMnemonicKey() — FlutterSecureStorage.read(). On Android this triggers Keystore unlock (RSA + AES on encrypted SharedPreferences) and runs on the platform-channel main thread.
SecureStorage.decryptSeed() (secure_storage.dart:155-162) — synchronous AES-GCM. Small payload, but blocking.
wallet_account.dart:21-28 — WalletAccount constructor calls _getPrivateKeyAt(root, 0, 0) which runs root.derivePath(\"m/44'/60'/0'/0/0\") (5× HMAC-SHA512 child key derivation) and EthPrivateKey.fromHex(...) (sync)
The address itself (EthereumAddress) is lazily computed via secp256k1 scalar multiplication on first access via _appStore.primaryAddress (app_store.dart:24), called from home_bloc.dart:76-78 after the wallet is set. This kicks off an ECC point multiplication in pointycastle (Dart-only, no native acceleration), which is comparatively slow on phones.
None of this runs in an isolate. Anything blocking on the main thread directly contributes to wallclock UX delay between successful PIN entry and the dashboard render.
perf: hash app PIN off the main thread #306 (45eaa24) — Hash PIN off the main thread via compute(). Mitigates the frozen-UI symptom but cannot reduce the wallclock cost. The 600k iterations still need to be computed somewhere; the user still waits.
PR #290 was the regression-causing change with respect to perceived unlock speed; PR #306 made it less janky but not faster.
Suggested mitigations (ordered, non-exhaustive)
Tune iteration count vs. UX — Re-evaluate the 600k iteration count against the OWASP-recommended floor (currently 600k for PBKDF2-SHA256). It is technically defensible, but for a 6-digit PIN with strict in-app lockout (verify_pin_cubit.dart:97-104) — 5/6/7/8/permanent lockout cascade — the brute-force window is bounded. A lower iteration count (e.g. 200-300k) combined with a permanent device-bound lockout may yield acceptable security at substantially better UX. Decision should involve security review.
Migrate to Argon2id — Argon2id is purpose-built for password hashing and is memory-hard, offering equivalent attack resistance at a fraction of the wallclock cost on user devices. Drop-in via cryptography package or argon2_ffi_base. Worth benchmarking against current 600k PBKDF2.
Pre-warm wallet load in parallel with PIN hashing — Currently the wallet decryption (Phase 2) only begins after PIN verification completes. Since the mnemonic key in SecureStorage is independent of the PIN hash and gated by OS biometry/keystore (not by the PIN itself), getOrCreateMnemonicKey() and the BIP32 derivation can in principle be started speculatively in parallel with hashPinAsync() and either committed on PIN success or discarded on failure. This would hide Phase 2 entirely behind the PBKDF2 wallclock.
Move BIP32 derivation off the main thread — SoftwareWallet constructor and WalletAccount._getPrivateKeyAt block the UI thread. Wrap in compute() similar to the PIN hashing fix.
Instrument with timing — Add developer.Timeline markers or simple Stopwatch logging around each phase (PIN hash, secure storage read, wallet decryption, BIP32 derivation, first frame after dashboard route) to confirm where the 10 s actually splits on the affected device(s) before tuning. Without device-level numbers this is informed inference, not a precise breakdown.
Symptom
Opening the app with PIN entry (no biometrics enabled) consistently takes ~10 seconds from the moment the correct PIN is entered until the dashboard is shown. This delay was reported as a regression — it was noticeably faster previously.
The delay reproduces on every unlock, not just the first one after an update, so the legacy-iteration rehash path (
secure_storage.dart:93-98) is not the cause.Reproduction
lockoutDuration)Expected: < 2 s
Actual: ~10 s
Root-cause analysis
The wallclock from "last digit entered" to "dashboard visible" is the sum of two sequential blocking phases on the user-facing critical path. Neither runs in parallel with the other.
Phase 1 — PBKDF2-SHA256 with 600'000 iterations (isolate, but real wallclock)
lib/packages/storage/secure_storage.dart:44verifyPin()(secure_storage.dart:86-101) callshashPinAsync(), which dispatches to a compute isolate (secure_storage.dart:57-64). The UI is not frozen visually, but the cryptographic work still consumes several seconds of wallclock on mobile devices — the in-code comment acknowledges this explicitly:This iteration count was introduced by PR #290 ("Increase PBKDF2 iterations to 600k with transparent rehash (NEW-2)"), commit
5a998bb. Previously the iteration count was 10'000 (60× faster), so this PR alone is expected to add ~4-6 s wallclock on a mid-range Android device for the PBKDF2 phase.The fact that the user experiences a consistent ~10 s — not just a one-time double-cost from the legacy rehash path — means we are paying the full 600k cost on every unlock, every time.
Phase 2 — Sequential wallet decryption and BIP32 derivation (main thread)
After
VerifyPinSuccess, the flow chains synchronously through:main.dart:91-98—PinAuthCubitstate listener triggers_loadWalletIfNeeded()home_bloc.dart:55-79—_onLoadCurrentWallet:_walletService.getCurrentWallet()(wallet_service.dart:55-58) →_repository.getWalletById()wallet_repository.dart:29-33—_decryptWalletInfo():await _secureStorage.getOrCreateMnemonicKey()—FlutterSecureStorage.read(). On Android this triggers Keystore unlock (RSA + AES on encrypted SharedPreferences) and runs on the platform-channel main thread.SecureStorage.decryptSeed()(secure_storage.dart:155-162) — synchronous AES-GCM. Small payload, but blocking.wallet.dart:39-44—SoftwareWalletconstructor (synchronous):mnemonicToSeed(seed)— BIP39 PBKDF2-HMAC-SHA512 with 2048 iterationsBIP32.fromSeed(seedBytes)+WalletAccount(_bip32, 0)wallet_account.dart:21-28—WalletAccountconstructor calls_getPrivateKeyAt(root, 0, 0)which runsroot.derivePath(\"m/44'/60'/0'/0/0\")(5× HMAC-SHA512 child key derivation) andEthPrivateKey.fromHex(...)(sync)EthereumAddress) is lazily computed via secp256k1 scalar multiplication on first access via_appStore.primaryAddress(app_store.dart:24), called fromhome_bloc.dart:76-78after the wallet is set. This kicks off an ECC point multiplication inpointycastle(Dart-only, no native acceleration), which is comparatively slow on phones.None of this runs in an isolate. Anything blocking on the main thread directly contributes to wallclock UX delay between successful PIN entry and the dashboard render.
Why this is a regression
Two relevant PRs landed:
5a998bb) — Increase PBKDF2 iterations to 600k. Adds ~4-6 s of cryptographic work to the unlock path on mid-range mobile devices. Intentional security hardening (NEW-2 follow-up).45eaa24) — Hash PIN off the main thread viacompute(). Mitigates the frozen-UI symptom but cannot reduce the wallclock cost. The 600k iterations still need to be computed somewhere; the user still waits.PR #290 was the regression-causing change with respect to perceived unlock speed; PR #306 made it less janky but not faster.
Suggested mitigations (ordered, non-exhaustive)
Tune iteration count vs. UX — Re-evaluate the 600k iteration count against the OWASP-recommended floor (currently 600k for PBKDF2-SHA256). It is technically defensible, but for a 6-digit PIN with strict in-app lockout (
verify_pin_cubit.dart:97-104) — 5/6/7/8/permanent lockout cascade — the brute-force window is bounded. A lower iteration count (e.g. 200-300k) combined with a permanent device-bound lockout may yield acceptable security at substantially better UX. Decision should involve security review.Migrate to Argon2id — Argon2id is purpose-built for password hashing and is memory-hard, offering equivalent attack resistance at a fraction of the wallclock cost on user devices. Drop-in via
cryptographypackage orargon2_ffi_base. Worth benchmarking against current 600k PBKDF2.Pre-warm wallet load in parallel with PIN hashing — Currently the wallet decryption (Phase 2) only begins after PIN verification completes. Since the mnemonic key in
SecureStorageis independent of the PIN hash and gated by OS biometry/keystore (not by the PIN itself),getOrCreateMnemonicKey()and the BIP32 derivation can in principle be started speculatively in parallel withhashPinAsync()and either committed on PIN success or discarded on failure. This would hide Phase 2 entirely behind the PBKDF2 wallclock.Move BIP32 derivation off the main thread —
SoftwareWalletconstructor andWalletAccount._getPrivateKeyAtblock the UI thread. Wrap incompute()similar to the PIN hashing fix.Instrument with timing — Add
developer.Timelinemarkers or simpleStopwatchlogging around each phase (PIN hash, secure storage read, wallet decryption, BIP32 derivation, first frame after dashboard route) to confirm where the 10 s actually splits on the affected device(s) before tuning. Without device-level numbers this is informed inference, not a precise breakdown.References
lib/packages/storage/secure_storage.dart:44-101— PIN hashing & verifylib/screens/pin/bloc/verify_pin/verify_pin_cubit.dart:35-49— verify pathlib/main.dart:91-111— post-verify listener → wallet load triggerlib/screens/home/bloc/home_bloc.dart:55-79— wallet load handlerlib/packages/repository/wallet_repository.dart:20-38— decryptionlib/packages/wallet/wallet.dart:39-44&lib/packages/wallet/wallet_account.dart:21-28— sync BIP32 derivation5a998bb) — iteration-count regression45eaa24) — isolate fix (necessary but not sufficient)Environment
develop(HEAD:59a2010at time of writing)