Lock screen: password or 4-8 digit PIN, optional separate boot code

The unlock code and the boot lock code can each be a password or a
4-8 digit PIN. A PIN gets an on-screen number pad, so the lock works on
a touch-only kiosk. The boot lock can use the unlock code or its own.
These are the kiosk's own codes, set in Core Settings -> Password
Protection & Lockout or on the Web UI's Lockout page, never the admin
(sudo) password.

The lock screen moves from an inline data: URL to lockscreen.html. The
code is now hashed and compared in the main process, which also slows
down guessing: after every 5 wrong codes in a row, input is refused for
30s, doubling up to 5 minutes. Existing configs behave as before:
missing fields mean a password, and the same code at boot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm
This commit is contained in:
Claude
2026-10-10 15:26:59 +00:00
parent 600f5174d8
commit f215c64085
10 changed files with 554 additions and 116 deletions
+42 -1
View File
@@ -114,18 +114,59 @@ app.put('/api/config', (req, res) => {
if (body.requirePasswordOnBoot !== undefined) patch.requirePasswordOnBoot = !!body.requirePasswordOnBoot;
const CODE_TYPES = ['password', 'pin'];
const PIN_RE = /^[0-9]{4,8}$/;
const current = loadConfig();
if (body.lockoutCodeType !== undefined) {
if (!CODE_TYPES.includes(body.lockoutCodeType)) return badRequest(res, 'lockoutCodeType must be "password" or "pin"');
patch.lockoutCodeType = body.lockoutCodeType;
}
const lockoutType = patch.lockoutCodeType || current.lockoutCodeType;
if (body.newLockoutPassword !== undefined) {
if (typeof body.newLockoutPassword !== 'string' || body.newLockoutPassword.length === 0) {
return badRequest(res, 'Password cannot be empty');
}
if (lockoutType === 'pin' && !PIN_RE.test(body.newLockoutPassword)) {
return badRequest(res, 'A PIN is 4-8 digits');
}
patch.newLockoutPassword = body.newLockoutPassword;
}
// Switching between password and PIN changes which entry screen the
// kiosk shows - the old code can't be kept across that.
if (patch.lockoutCodeType && patch.lockoutCodeType !== current.lockoutCodeType && current.hasLockoutPassword
&& !patch.newLockoutPassword && patch.enablePasswordProtection !== false) {
return badRequest(res, `Enter a new ${lockoutType === 'pin' ? 'PIN' : 'password'} when switching between password and PIN`);
}
// Boot code: "same" (use the unlock code) or "separate" (its own
// password/PIN).
if (body.bootCodeMode !== undefined) {
if (body.bootCodeMode === 'same') {
patch.clearBootPassword = true;
} else if (body.bootCodeMode === 'separate') {
const bootType = body.bootCodeType !== undefined ? body.bootCodeType : current.bootCodeType;
if (!CODE_TYPES.includes(bootType)) return badRequest(res, 'bootCodeType must be "password" or "pin"');
patch.bootCodeType = bootType;
const newBoot = body.newBootPassword;
if (newBoot !== undefined && newBoot !== '') {
if (typeof newBoot !== 'string') return badRequest(res, 'Boot password must be text');
if (bootType === 'pin' && !PIN_RE.test(newBoot)) return badRequest(res, 'A boot PIN is 4-8 digits');
patch.newBootPassword = newBoot;
} else if (!current.hasBootPassword || bootType !== current.bootCodeType) {
return badRequest(res, `Enter the boot ${bootType === 'pin' ? 'PIN' : 'password'}`);
}
} else {
return badRequest(res, 'bootCodeMode must be "same" or "separate"');
}
}
// Mirrors action_enable_protection() always requiring a password up
// front - lockout.sh has no path that enables protection without one.
if (patch.enablePasswordProtection === true) {
const hasNewPassword = typeof patch.newLockoutPassword === 'string' && patch.newLockoutPassword.length > 0;
if (!hasNewPassword && !loadConfig().hasLockoutPassword) {
if (!hasNewPassword && !current.hasLockoutPassword) {
return badRequest(res, 'Set a lockout password before enabling password protection');
}
}