fix(security): throttle app lock PIN attempts

Persist failed unlock attempts in the lock flag so restarting the desktop app cannot reset the limit. Block further attempts for 30 seconds after five failures and surface the remaining cooldown on the lock screen.
This commit is contained in:
digua committed 2026-07-18 11:49:09 +08:00
1 parent 10ead5a306
commit e7dd418642
8 files changed
+203 -26

No files matched your search

@@ -95,11 +95,39 @@ test('desktop app lock persists its canonical config and handles manual, idle, a
assert.deepEqual(lockManager.lockApp(), { success: true })
assert.equal(lockManager.getLockState(), 'locked')
assert.deepEqual(await lockManager.unlockApp('0000'), {
success: false,
error: 'wrong-password',
wrongPassword: true,
})
for (let attempt = 1; attempt < 5; attempt++) {
assert.deepEqual(await lockManager.unlockApp('0000'), {
success: false,
error: 'wrong-password',
wrongPassword: true,
})
}
const blockedResult = await lockManager.unlockApp('0000')
assert.equal(blockedResult.success, false)
assert.equal(blockedResult.error, 'too-many-attempts')
assert.equal(blockedResult.wrongPassword, true)
assert.ok(blockedResult.retryAfterSeconds && blockedResult.retryAfterSeconds > 0)
const blockedFlag = JSON.parse(fs.readFileSync(path.join(settingsDir, '.app-lock-flag'), 'utf-8')) as {
failureCount: number
cooldownUntil: number
}
assert.equal(blockedFlag.failureCount, 5)
assert.ok(blockedFlag.cooldownUntil > Date.now())
lockManager.cleanupLockManager()
lockManager.initLockManager(mainWindow as never)
assert.equal(lockManager.getLockState(), 'locked')
const blockedAfterRestart = await lockManager.unlockApp('1234')
assert.equal(blockedAfterRestart.error, 'too-many-attempts')
assert.ok(blockedAfterRestart.retryAfterSeconds && blockedAfterRestart.retryAfterSeconds > 0)
blockedFlag.cooldownUntil = Date.now() - 1
fs.writeFileSync(path.join(settingsDir, '.app-lock-flag'), JSON.stringify(blockedFlag), 'utf-8')
lockManager.cleanupLockManager()
lockManager.initLockManager(mainWindow as never)
assert.deepEqual(await lockManager.unlockApp('1234'), { success: true })
assert.deepEqual(mainWindow.sentMessages.slice(-2), [
['app-lock-state-changed', true],
+119 -12
View File
@@ -17,19 +17,31 @@ interface LockSettings {
lockOnStartup: boolean
}
interface UnlockAttemptState {
failureCount: number
cooldownUntil: number
}
const LOCK_CONFIG_FILE = 'app-lock.json'
const LOCK_FLAG_FILE = '.app-lock-flag'
const PIN_PATTERN = /^\d{4}$/
const IDLE_CHECK_INTERVAL_MS = 10000
const MAX_UNLOCK_FAILURES = 5
const UNLOCK_COOLDOWN_MS = 30000
const IDLE_TIMEOUT_OPTIONS = new Set([0, 1, 3, 5, 10, 15, 30, 60])
const DEFAULT_LOCK_SETTINGS: LockSettings = {
idleTimeoutMinutes: 0,
lockOnStartup: false,
}
const DEFAULT_UNLOCK_ATTEMPTS: UnlockAttemptState = {
failureCount: 0,
cooldownUntil: 0,
}
let lockState: AppLockState = 'unlocked'
let currentSettings: LockSettings = { ...DEFAULT_LOCK_SETTINGS }
let storedPasswordHash: PasswordHash | null = null
let unlockAttempts: UnlockAttemptState = { ...DEFAULT_UNLOCK_ATTEMPTS }
let idleTimer: ReturnType<typeof setInterval> | null = null
let isTransitioning = false
let mainWindowRef: BrowserWindow | null = null
@@ -113,12 +125,35 @@ function saveConfig(settings: LockSettings, passwordHash: PasswordHash | null):
}
}
function setLockFlag(): boolean {
function isUnlockAttemptState(value: unknown): value is UnlockAttemptState {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
const record = value as Record<string, unknown>
return (
Object.keys(record).every((key) => key === 'failureCount' || key === 'cooldownUntil') &&
Number.isInteger(record.failureCount) &&
(record.failureCount as number) >= 0 &&
(record.failureCount as number) <= MAX_UNLOCK_FAILURES &&
Number.isSafeInteger(record.cooldownUntil) &&
(record.cooldownUntil as number) >= 0
)
}
function saveLockFlag(attemptState: UnlockAttemptState = unlockAttempts): boolean {
const flagPath = getLockFlagPath()
const tempPath = `${flagPath}.tmp`
try {
fs.writeFileSync(getLockFlagPath(), Date.now().toString(), 'utf-8')
fs.mkdirSync(getSettingsDir(), { recursive: true })
fs.writeFileSync(tempPath, JSON.stringify(attemptState), 'utf-8')
fs.renameSync(tempPath, flagPath)
return true
} catch (error) {
logger.error(`Failed to write app lock flag: ${error instanceof Error ? error.message : error}`)
try {
fs.unlinkSync(tempPath)
} catch {
// 临时文件可能尚未创建。
}
return false
}
}
@@ -134,10 +169,23 @@ function clearLockFlag(): boolean {
}
}
function hasLockFlag(): boolean {
function loadLockFlag(): boolean {
unlockAttempts = { ...DEFAULT_UNLOCK_ATTEMPTS }
try {
return fs.existsSync(getLockFlagPath())
} catch {
const flagPath = getLockFlagPath()
if (!fs.existsSync(flagPath)) return false
try {
const data = JSON.parse(fs.readFileSync(flagPath, 'utf-8')) as unknown
if (isUnlockAttemptState(data)) unlockAttempts = data
else logger.warn('App lock flag contains invalid unlock attempt state')
} catch (error) {
logger.warn(`App lock flag contains invalid JSON: ${error instanceof Error ? error.message : error}`)
}
return true
} catch (error) {
logger.error(`Failed to read app lock flag: ${error instanceof Error ? error.message : error}`)
return false
}
}
@@ -156,14 +204,17 @@ export function initLockManager(mainWindow: BrowserWindow): void {
mainWindowRef = mainWindow
lockState = 'unlocked'
isTransitioning = false
unlockAttempts = { ...DEFAULT_UNLOCK_ATTEMPTS }
const loaded = loadConfig()
if (!loaded || !isEnabled()) clearLockFlag()
const wasLocked = isEnabled() && hasLockFlag()
const wasLocked = isEnabled() && loadLockFlag()
logger.info(`App lock initialized: enabled=${isEnabled()}, wasLocked=${wasLocked}`)
if (isEnabled() && (currentSettings.lockOnStartup || wasLocked)) {
if (wasLocked) {
enterLockedState()
} else if (isEnabled() && currentSettings.lockOnStartup) {
lockApp()
} else if (isEnabled() && currentSettings.idleTimeoutMinutes > 0) {
startIdleTimer()
@@ -177,21 +228,34 @@ export function lockApp(): AppLockResult {
isTransitioning = true
try {
if (!setLockFlag()) return { success: false, error: 'save-failed' }
lockState = 'locked'
stopIdleTimer()
logger.info('App locked')
notifyLockState()
unlockAttempts = { ...DEFAULT_UNLOCK_ATTEMPTS }
if (!saveLockFlag()) return { success: false, error: 'save-failed' }
enterLockedState()
return { success: true }
} finally {
isTransitioning = false
}
}
function enterLockedState(): void {
lockState = 'locked'
stopIdleTimer()
logger.info('App locked')
notifyLockState()
}
export async function unlockApp(password: unknown): Promise<AppLockUnlockResult> {
if (lockState === 'unlocked') return { success: true }
if (isTransitioning) return { success: false, error: 'busy' }
if (!storedPasswordHash) return { success: false, error: 'password-not-set' }
const cooldownRemaining = getUnlockCooldownRemaining()
if (cooldownRemaining > 0) {
return { success: false, error: 'too-many-attempts', retryAfterSeconds: cooldownRemaining }
}
if (unlockAttempts.cooldownUntil > 0 && !resetUnlockAttempts()) {
return { success: false, error: 'save-failed' }
}
if (typeof password !== 'string' || !PIN_PATTERN.test(password)) {
return { success: false, error: 'wrong-password', wrongPassword: true }
}
@@ -199,9 +263,20 @@ export async function unlockApp(password: unknown): Promise<AppLockUnlockResult>
isTransitioning = true
try {
if (!(await verifyPasswordHash(password, storedPasswordHash))) {
const retryAfterSeconds = recordUnlockFailure()
if (retryAfterSeconds === null) return { success: false, error: 'save-failed' }
if (retryAfterSeconds > 0) {
return {
success: false,
error: 'too-many-attempts',
wrongPassword: true,
retryAfterSeconds,
}
}
return { success: false, error: 'wrong-password', wrongPassword: true }
}
if (!resetUnlockAttempts()) return { success: false, error: 'save-failed' }
clearLockFlag()
lockState = 'unlocked'
logger.info('App unlocked')
@@ -213,6 +288,35 @@ export async function unlockApp(password: unknown): Promise<AppLockUnlockResult>
}
}
function getUnlockCooldownRemaining(): number {
if (unlockAttempts.cooldownUntil <= Date.now()) return 0
return Math.ceil((unlockAttempts.cooldownUntil - Date.now()) / 1000)
}
function recordUnlockFailure(): number | null {
const failureCount = Math.min(unlockAttempts.failureCount + 1, MAX_UNLOCK_FAILURES)
const nextAttempts: UnlockAttemptState = {
failureCount,
cooldownUntil: failureCount >= MAX_UNLOCK_FAILURES ? Date.now() + UNLOCK_COOLDOWN_MS : 0,
}
unlockAttempts = nextAttempts
if (!saveLockFlag(nextAttempts)) return null
if (nextAttempts.cooldownUntil > 0) {
logger.warn('App unlock temporarily blocked after repeated PIN failures')
}
return getUnlockCooldownRemaining()
}
function resetUnlockAttempts(): boolean {
if (unlockAttempts.failureCount === 0 && unlockAttempts.cooldownUntil === 0) return true
const nextAttempts = { ...DEFAULT_UNLOCK_ATTEMPTS }
if (!saveLockFlag(nextAttempts)) return false
unlockAttempts = nextAttempts
return true
}
export async function setPassword(newPassword: unknown): Promise<AppLockResult> {
if (lockState === 'locked') return { success: false, error: 'locked' }
if (isTransitioning) return { success: false, error: 'busy' }
@@ -226,6 +330,7 @@ export async function setPassword(newPassword: unknown): Promise<AppLockResult>
const passwordHash = await hashPassword(newPassword)
if (!saveConfig(currentSettings, passwordHash)) return { success: false, error: 'save-failed' }
storedPasswordHash = passwordHash
unlockAttempts = { ...DEFAULT_UNLOCK_ATTEMPTS }
if (currentSettings.idleTimeoutMinutes > 0) startIdleTimer()
logger.info('App lock password initialized')
return { success: true }
@@ -254,6 +359,7 @@ export async function changePassword(oldPassword: unknown, newPassword: unknown)
const passwordHash = await hashPassword(newPassword)
if (!saveConfig(currentSettings, passwordHash)) return { success: false, error: 'save-failed' }
storedPasswordHash = passwordHash
unlockAttempts = { ...DEFAULT_UNLOCK_ATTEMPTS }
logger.info('App lock password changed')
return { success: true }
} catch (error) {
@@ -270,6 +376,7 @@ export function resetAppLockPassword(): AppLockResult {
if (!saveConfig(currentSettings, null)) return { success: false, error: 'save-failed' }
storedPasswordHash = null
unlockAttempts = { ...DEFAULT_UNLOCK_ATTEMPTS }
stopIdleTimer()
clearLockFlag()
logger.info('App lock disabled and password cleared')
+2
View File
@@ -53,6 +53,7 @@ export type AppLockErrorCode =
| 'password-not-set'
| 'same-password'
| 'wrong-password'
| 'too-many-attempts'
| 'save-failed'
| 'invalid-config'
| 'internal-error'
@@ -75,6 +76,7 @@ export interface AppLockResult {
export interface AppLockUnlockResult extends AppLockResult {
wrongPassword?: boolean
retryAfterSeconds?: number
}
export interface SecurityApi {
+41 -5
View File
@@ -10,10 +10,15 @@ const isUnlocking = ref(false)
const password = ref('')
const showPassword = ref(false)
const errorMessage = ref('')
const retryAfterSeconds = ref(0)
const passwordInputRef = ref<HTMLInputElement | null>(null)
const canUnlock = computed(() => /^\d{4}$/.test(password.value))
const canUnlock = computed(() => /^\d{4}$/.test(password.value) && retryAfterSeconds.value === 0)
const cooldownMessage = computed(() =>
t('settings.security.lockScreen.errorTooManyAttempts', { seconds: retryAfterSeconds.value })
)
let removeLockListener: (() => void) | null = null
let cooldownTimer: ReturnType<typeof setInterval> | null = null
onMounted(async () => {
removeLockListener = window.securityApi.onLockStateChanged((locked) => {
@@ -27,7 +32,10 @@ onMounted(async () => {
}
})
onUnmounted(() => removeLockListener?.())
onUnmounted(() => {
removeLockListener?.()
clearCooldownTimer()
})
async function showOverlay(): Promise<void> {
visible.value = true
@@ -46,6 +54,8 @@ function resetForm(): void {
password.value = ''
showPassword.value = false
errorMessage.value = ''
retryAfterSeconds.value = 0
clearCooldownTimer()
}
async function handleUnlock(): Promise<void> {
@@ -55,6 +65,11 @@ async function handleUnlock(): Promise<void> {
try {
const result = await window.securityApi.unlock(password.value)
if (result.success) return
if (result.retryAfterSeconds && result.retryAfterSeconds > 0) {
password.value = ''
startCooldownTimer(result.retryAfterSeconds)
return
}
errorMessage.value = result.wrongPassword
? t('settings.security.lockScreen.errorWrongPassword')
: t('settings.security.lockScreen.errorService')
@@ -66,6 +81,24 @@ async function handleUnlock(): Promise<void> {
}
}
function startCooldownTimer(seconds: number): void {
clearCooldownTimer()
retryAfterSeconds.value = seconds
cooldownTimer = setInterval(() => {
retryAfterSeconds.value = Math.max(0, retryAfterSeconds.value - 1)
if (retryAfterSeconds.value > 0) return
clearCooldownTimer()
nextTick(() => passwordInputRef.value?.focus())
}, 1000)
}
function clearCooldownTimer(): void {
if (!cooldownTimer) return
clearInterval(cooldownTimer)
cooldownTimer = null
}
function handlePasswordInput(event: Event): void {
password.value = (event.target as HTMLInputElement).value.replace(/\D/g, '').slice(0, 4)
}
@@ -100,7 +133,7 @@ function handlePasswordInput(event: Event): void {
autocomplete="current-password"
:placeholder="t('settings.security.lockScreen.placeholderPassword')"
class="w-full rounded-xl border border-gray-200 bg-gray-100 py-3 pl-4 pr-12 text-sm text-gray-900 placeholder-gray-400 outline-none transition focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:opacity-40 dark:border-gray-700 dark:bg-gray-900 dark:text-white dark:placeholder-gray-500"
:disabled="isUnlocking"
:disabled="isUnlocking || retryAfterSeconds > 0"
maxlength="4"
@input="handlePasswordInput"
/>
@@ -108,13 +141,16 @@ function handlePasswordInput(event: Event): void {
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-300"
:aria-label="t('settings.security.lockScreen.placeholderPassword')"
:disabled="isUnlocking"
:disabled="isUnlocking || retryAfterSeconds > 0"
@click="showPassword = !showPassword"
>
<UIcon :name="showPassword ? 'i-heroicons-eye-slash' : 'i-heroicons-eye'" class="h-5 w-5" />
</button>
</div>
<p v-if="errorMessage" class="text-center text-xs text-red-500">
<p v-if="retryAfterSeconds > 0" class="text-center text-xs text-amber-500">
{{ cooldownMessage }}
</p>
<p v-else-if="errorMessage" class="text-center text-xs text-red-500">
{{ errorMessage }}
</p>
<UButton
+2 -1
View File
@@ -644,7 +644,8 @@
"btnUnlock": "Unlock",
"verifying": "Verifying...",
"errorService": "Unlock failed, please try again",
"errorWrongPassword": "Incorrect PIN"
"errorWrongPassword": "Incorrect PIN",
"errorTooManyAttempts": "Too many attempts. Try again in {seconds} seconds"
}
}
}
+2 -1
View File
@@ -644,7 +644,8 @@
"btnUnlock": "ロック解除",
"verifying": "検証中...",
"errorService": "ロック解除に失敗しました。再試行してください",
"errorWrongPassword": "PINが正しくありません"
"errorWrongPassword": "PINが正しくありません",
"errorTooManyAttempts": "試行回数が多すぎます。{seconds} 秒後に再試行してください"
}
}
}
+2 -1
View File
@@ -644,7 +644,8 @@
"btnUnlock": "解锁",
"verifying": "验证中...",
"errorService": "解锁失败,请重试",
"errorWrongPassword": "密码错误"
"errorWrongPassword": "密码错误",
"errorTooManyAttempts": "尝试次数过多,请在 {seconds} 秒后重试"
}
}
}
+2 -1
View File
@@ -644,7 +644,8 @@
"btnUnlock": "解鎖",
"verifying": "驗證中...",
"errorService": "解鎖失敗,請重試",
"errorWrongPassword": "密碼錯誤"
"errorWrongPassword": "密碼錯誤",
"errorTooManyAttempts": "嘗試次數過多,請在 {seconds} 秒後重試"
}
}
}