Skip to content

PIN unlock takes ~10s consistently on every app open (regression) #462

@Blume1977

Description

@Blume1977

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

  1. Open the app cold (or after sufficient background time to trigger the lockout — see lockoutDuration)
  2. Enter the correct 6-digit PIN
  3. 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

static const _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.

Phase 2 — Sequential wallet decryption and BIP32 derivation (main thread)

After VerifyPinSuccess, the flow chains synchronously through:

  1. main.dart:91-98PinAuthCubit state listener triggers _loadWalletIfNeeded()
  2. 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.
  3. wallet.dart:39-44SoftwareWallet constructor (synchronous):
    • mnemonicToSeed(seed) — BIP39 PBKDF2-HMAC-SHA512 with 2048 iterations
    • BIP32.fromSeed(seedBytes) + WalletAccount(_bip32, 0)
    • wallet_account.dart:21-28WalletAccount 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)
  4. 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.

Why this is a regression

Two relevant PRs landed:

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)

  1. 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.

  2. 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.

  3. 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.

  4. Move BIP32 derivation off the main threadSoftwareWallet constructor and WalletAccount._getPrivateKeyAt block the UI thread. Wrap in compute() similar to the PIN hashing fix.

  5. 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.

References

  • lib/packages/storage/secure_storage.dart:44-101 — PIN hashing & verify
  • lib/screens/pin/bloc/verify_pin/verify_pin_cubit.dart:35-49 — verify path
  • lib/main.dart:91-111 — post-verify listener → wallet load trigger
  • lib/screens/home/bloc/home_bloc.dart:55-79 — wallet load handler
  • lib/packages/repository/wallet_repository.dart:20-38 — decryption
  • lib/packages/wallet/wallet.dart:39-44 & lib/packages/wallet/wallet_account.dart:21-28 — sync BIP32 derivation
  • PR Increase PBKDF2 iterations to 600k with transparent rehash (NEW-2) #290 (5a998bb) — iteration-count regression
  • PR perf: hash app PIN off the main thread #306 (45eaa24) — isolate fix (necessary but not sufficient)

Environment

  • Reporter: jana.ruettimann@dfx.swiss
  • Branch analysed: develop (HEAD: 59a2010 at time of writing)
  • Device specifics not yet captured — should be filled in before mitigation work begins, since iteration-cost-per-device varies materially.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions