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.
PIN Storage
Section titled “PIN Storage”PINs are never stored in plaintext. When you set a PIN, ZestSSH:
- Generates a 16-byte random salt using
Random.secure(). - Hashes the PIN with Argon2id using the salt.
- Stores the result as
base64(salt):base64(hash)in Flutter Secure Storage.
Argon2id Parameters (PIN)
Section titled “Argon2id Parameters (PIN)”| Parameter | Value | Notes |
|---|---|---|
| Memory | 4,096 KB (4 MB) | Tuned for mobile — tested on 2019-era mid-range Android |
| Iterations | 4 | Number of passes |
| Parallelism | 2 | Lanes of computation |
| Hash length | 32 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 sync Argon2id parameters (128 MB / 3 iter / 4 par), which are tuned for stronger passwords on more capable hardware.
Verification
Section titled “Verification”When the user enters their PIN:
- The stored
salt:hashpair is read from secure storage. - The entered PIN is hashed with Argon2id using the stored salt.
- The derived hash is compared to the stored hash using constant-time comparison to prevent timing attacks.
Constant-Time Comparison
Section titled “Constant-Time Comparison”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.
Legacy Migration
Section titled “Legacy Migration”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.
Brute-Force Protection
Section titled “Brute-Force Protection”Escalating Lockout
Section titled “Escalating Lockout”After consecutive failed PIN attempts, ZestSSH enforces escalating lockout periods:
| Failed Attempts | Lockout Duration |
|---|---|
| 10 | 30 seconds |
| 15 | 60 seconds |
| 20+ | 300 seconds (5 minutes) |
The maximum attempts before the first lockout is 10 (internally _maxAttempts; it was 5 before 1.8). The lock screen shows a live countdown of the remaining lockout time.
Lockout Behavior
Section titled “Lockout Behavior”- During a lockout, all PIN verification attempts are immediately rejected without checking the hash.
- The remaining lockout time is exposed via
remainingLockoutSecondsfor 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.
Persistence Across Restarts
Section titled “Persistence Across Restarts”Lockout state is persisted to secure storage so that restarting the app does not bypass the lockout:
pin_failed_attempts— Current failed attempt countpin_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.
Lock Methods
Section titled “Lock Methods”| Method | Behavior |
|---|---|
pin | PIN only |
both | Biometric first, PIN as fallback |
These are the only two methods the setup flow produces: a PIN is always set. both means the PIN plus the device factor (fingerprint or Face ID on a phone, Windows Hello on Windows, Touch ID on macOS).
When the lock method is both, the phone app shows the biometric prompt first; if it fails or is cancelled, the PIN entry screen appears. The desktop app shows the PIN field first and offers the device factor through a Use Biometrics button.
Auto-Lock Timeout
Section titled “Auto-Lock Timeout”The auto-lock timeout (in minutes) controls how long the app can be backgrounded before requiring re-authentication:
- 0 (Immediately): Lock as soon as the app goes to the background
- 1: The default on Android and iOS
- 10: The default on Windows, macOS and Linux
- Also available: 5, 15, 30 minutes, or Never
- Go to Settings > Security (mobile: App Lock & Encryption; desktop: the App Lock section) and turn on App Lock.
- Enter a PIN of 4 to 32 digits.
- If the device offers a factor, a switch on the same step reads Also enable biometrics / Also enable Windows Hello / Also enable Touch ID. Leave it on for
both, or turn it off for a PIN-only lock. - Confirm the PIN. It is hashed and stored securely.
Changing the PIN later (Change PIN) requires the current PIN first, and turning the lock off requires the current PIN on mobile.
Adding Windows Hello, Touch ID or a fingerprint on top of the PIN
Section titled “Adding Windows Hello, Touch ID or a fingerprint on top of the PIN”The PIN is always the lock. The device factor is an optional shortcut past it, enabled with the “Also enable …” switch during PIN setup and stored as the both method. On Windows this is Windows Hello (Hello PIN, face or fingerprint, whatever Windows has set up); on macOS it is Touch ID; on Android and iOS it is the fingerprint or face sensor. It is not offered when Encrypt data at rest is on, because only the PIN can unwrap the encryption key. Full details, requirements and the unlock flow are on the Biometric Lock page under “Windows Hello unlock on the Windows desktop app”.
Clearing the Lock
Section titled “Clearing the Lock”Going to Settings > Security and selecting None as the lock method calls clearLock(), which:
- Deletes the PIN hash from secure storage.
- Deletes the lock method preference.
- Deletes the auto-lock timeout.
- Resets the failed attempt counter and lockout state.