Skip to content

PIN Lock

ZestSSH supports PIN-based app locking as an alternative or supplement to biometric authentication. This is a free feature — security is not paywalled.

PINs are never stored in plaintext. When you set a PIN, ZestSSH:

  1. Generates a 16-byte random salt using Random.secure().
  2. Hashes the PIN with Argon2id using the salt.
  3. Stores the result as base64(salt):base64(hash) in Flutter Secure Storage.
ParameterValueNotes
Memory4,096 KB (4 MB)Tuned for mobile — tested on 2019-era mid-range Android
Iterations4Number of passes
Parallelism2Lanes of computation
Hash length32 bytes (256 bits)Output size

These parameters produce a ~120—180 ms hash time on mid-range hardware. This is slow enough to defeat offline brute force on a short PIN while fast enough to feel instant to the user. Higher memory (16 MB) was tested but caused jank on low-RAM devices and OOM on Android Go targets.

Note: These are different from the Cloud Sync Argon2id parameters (64 MB / 3 iter / 4 par), which are tuned for stronger passwords on more capable hardware.

When the user enters their PIN:

  1. The stored salt:hash pair is read from secure storage.
  2. The entered PIN is hashed with Argon2id using the stored salt.
  3. The derived hash is compared to the stored hash using constant-time comparison to prevent timing attacks.
var diff = 0;
for (var i = 0; i < derivedHash.length; i++) {
diff |= derivedHash[i] ^ expectedHash[i];
}
return diff == 0;

The XOR/OR accumulation ensures the comparison takes the same amount of time regardless of how many bytes match, preventing an attacker from deducing correct digits based on verification timing.

PINs set before the Argon2id migration were stored as unsalted SHA-256 hex strings. On successful verification of a legacy PIN, ZestSSH automatically re-hashes it with Argon2id and overwrites the stored value. This migration is transparent to the user.

After consecutive failed PIN attempts, ZestSSH enforces escalating lockout periods:

Failed AttemptsLockout Duration
530 seconds
1060 seconds
15+300 seconds (5 minutes)

The maximum attempts before the first lockout is 5 (configurable internally as _maxAttempts).

  • During a lockout, all PIN verification attempts are immediately rejected without checking the hash.
  • The remaining lockout time is exposed via remainingLockoutSeconds for the UI countdown display.
  • Failed attempt count is not reset when a lockout expires — it continues to escalate. Only a successful PIN entry resets the counter to zero.

Lockout state is persisted to secure storage so that restarting the app does not bypass the lockout:

  • pin_failed_attempts — Current failed attempt count
  • pin_lockout_until — ISO 8601 timestamp when the lockout expires

On app launch, the lockout state is lazily loaded before the first verification check. Expired lockouts are cleared, but the failed attempt count is preserved to maintain escalation.

MethodBehavior
pinPIN only
bothBiometric first, PIN as fallback

When the lock method is both, the user sees the biometric prompt first. If biometric authentication fails or is cancelled, the PIN entry screen appears.

The auto-lock timeout (in minutes) controls how long the app can be backgrounded before requiring re-authentication:

  • 0 — Immediate lock on background
  • 1 (default) — Lock after 1 minute
  1. Go to Settings > Security.
  2. Under App Lock, select PIN or Biometric + PIN.
  3. Enter and confirm a PIN.
  4. The PIN is hashed and stored securely.

Going to Settings > Security and selecting None as the lock method calls clearLock(), which:

  1. Deletes the PIN hash from secure storage.
  2. Deletes the lock method preference.
  3. Deletes the auto-lock timeout.
  4. Resets the failed attempt counter and lockout state.