From f215c640856a0ae99c1f2c21470a9bdb45bc67e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 15:26:59 +0000 Subject: [PATCH 1/3] 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 Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm --- Readme.md | 8 +- kiosk-app/lockscreen.html | 165 ++++++++++++++++++++++++++++++++++++++ kiosk-app/main.js | 124 +++++++++------------------- lib/config.sh | 18 ++++- menus/lockout.sh | 133 ++++++++++++++++++++++++------ webui/lib/config.js | 19 +++++ webui/public/app.js | 42 +++++++++- webui/public/index.html | 39 ++++++++- webui/server.js | 43 +++++++++- webui/test/api.test.js | 79 ++++++++++++++++++ 10 files changed, 554 insertions(+), 116 deletions(-) create mode 100644 kiosk-app/lockscreen.html diff --git a/Readme.md b/Readme.md index 77af9a2..30977ae 100644 --- a/Readme.md +++ b/Readme.md @@ -262,7 +262,10 @@ The kiosk machine still needs a working internet connection (ethernet, or WiFi c - **Session lockout** after configured inactivity - **Scheduled lockout** at specific time daily - **Display wake lockout** - Require password after display schedule -- **Boot password** option - Require password on system startup +- **Boot password** option - Require password on system startup, with the unlock code or **its own separate code** +- **Password or PIN** - each code (unlock, boot) can be a password or a **4-8 digit PIN** entered on an on-screen number pad, so it works on a touch-only kiosk +- **The kiosk's own codes** - set in Core Settings → Password Protection & Lockout or the Web UI; not the admin (sudo) password, which never needs to be shared +- **Wrong-code slowdown** - after 5 wrong codes in a row the lock screen refuses input for 30 seconds, doubling up to 5 minutes, so a PIN can't be guessed through - **Full screen blocking** during lockout (no content visible) ### Navigation Security @@ -1047,7 +1050,7 @@ When "Are you still here?" prompt appears (on manual or hidden sites): **During Lockout:** - Full black screen (no content visible) - All browser views detached for security -- Password prompt displayed +- Password field, or number pad for a PIN (the boot screen uses the boot code if one is set) - Limited power menu (no Reload option to prevent bypass) - Rotation and timers paused @@ -1525,6 +1528,7 @@ full migration pass. - **Local files as sites:** an absolute path (`/home/kiosk/docs/menu.pdf`) shows a local page, PDF or image; a folder (`/home/kiosk/photos/`) plays as a full-screen **image slideshow** that picks up added/removed images within a minute. Works in both the Sites menu and the Web UI. - **Fix: the first-boot kiosk setup never started on 26.04.** Its service was ordered after cloud-init's final stage, which itself runs after `multi-user.target` - an ordering cycle that systemd broke by dropping the kiosk setup. It also "conflicted" with the tty1 login prompt, which systemd can resolve the same way. Both removed; checked with `systemd-analyze verify` against 26.04's own units. Rebuild the ISO to pick this up. - **WiFi tools in the base install:** `wpasupplicant` and `iw` are now installed (and bundled offline). A default Ubuntu Server install only has them if WiFi was set up in the installer; without them, Core Settings → WiFi couldn't connect an offline kiosk. The WiFi menu also installs them itself if missing. +- **Lock screen PINs and a separate boot code:** the unlock code and the boot code can each be a password or a 4-8 digit PIN (on-screen number pad, works on touch screens), and the boot lock can have its own code. Repeated wrong codes now pause the lock screen (30s, doubling to 5 min). Set in Core Settings → Password Protection & Lockout or the Web UI's Lockout page. Existing settings keep working unchanged (password, same code at boot). - **New: WiFi from the kiosk screen** - hold Shift+W+S+F+H (or Ctrl+Alt+Shift+Super+W) to scan for and join a WiFi network right on the kiosk, PIN-protected, for kiosks with the consoles turned off. Reverts to the previous WiFi if the new one doesn't connect. See "WiFi from the kiosk screen". - **New: `iso/e2b-contig.sh`** - makes a file on an Easy2Boot (or any NTFS/FAT32/exFAT) USB drive contiguous from Linux, so ISOs that need to be in one piece boot. - **Offline addons:** the `--offline` bundle now also carries LMS (Lyrion) + Squeezelite, Asterisk Intercom, VNC, WireGuard and the Emergency Hotspot; their normal installs use it automatically when there's no internet. diff --git a/kiosk-app/lockscreen.html b/kiosk-app/lockscreen.html new file mode 100644 index 0000000..71ac07b --- /dev/null +++ b/kiosk-app/lockscreen.html @@ -0,0 +1,165 @@ + + + + + Locked + + + +
+

Session Locked

+ + + + + +
+
+ + + diff --git a/kiosk-app/main.js b/kiosk-app/main.js index cf8908d..11cb9c3 100644 --- a/kiosk-app/main.js +++ b/kiosk-app/main.js @@ -66,9 +66,15 @@ let lockoutAtTime=""; let lockoutActiveStart=""; let lockoutActiveEnd=""; let requirePasswordOnBoot=false; +let lockoutCodeType='password'; // 'password' or 'pin' (4-8 digits, keypad) +let bootPassword=''; // separate boot code hash; '' = use lockoutPassword +let bootCodeType='password'; // Password lockout state let isLockedOut=false; +let lockoutMode='unlock'; // 'boot' while the boot lock screen is up +let lockoutFailures=0; +let lockoutBlockedUntil=0; let lockoutWindow=null; let lockoutTimer=null; let lockoutActivityTime=Date.now(); @@ -103,6 +109,9 @@ function loadConfig(){ lockoutActiveStart=config.lockoutActiveStart||""; lockoutActiveEnd=config.lockoutActiveEnd||""; requirePasswordOnBoot=(config.requirePasswordOnBoot===true); + lockoutCodeType=(config.lockoutCodeType==='pin')?'pin':'password'; + bootPassword=config.bootPassword||''; + bootCodeType=(config.bootCodeType==='pin')?'pin':'password'; autheliaURL=config.autheliaURL||''; autheliaUsername=config.autheliaUsername||''; autheliaEncryptedPassword=config.autheliaEncryptedPassword||''; @@ -118,6 +127,7 @@ function loadConfig(){ if(lockoutAtTime)console.log('[CONFIG] Lock at time:',lockoutAtTime); if(lockoutActiveStart&&lockoutActiveEnd)console.log('[CONFIG] Active hours:',lockoutActiveStart,'-',lockoutActiveEnd); console.log('[CONFIG] Require password on boot:',requirePasswordOnBoot); + console.log('[CONFIG] Unlock code:',lockoutCodeType,'| boot code:',bootPassword?('separate '+bootCodeType):'same as unlock'); console.log('[CONFIG] Sites:',config.tabs?.length||0); console.log('[CONFIG] ╚═══════════════════════════════╝'); @@ -154,10 +164,11 @@ function markKeyboardActivity(){ } // Password lockout functions -function showLockoutScreen(){ +function showLockoutScreen(mode){ if(isLockedOut||!enablePasswordProtection||!lockoutPassword)return; isLockedOut=true; + lockoutMode=(mode==='boot')?'boot':'unlock'; console.log('[LOCKOUT] Showing lockout screen'); closeWifiWindow(); @@ -193,81 +204,9 @@ function showLockoutScreen(){ } }); - lockoutWindow.loadURL('data:text/html;charset=utf-8,'+encodeURIComponent(` - - - - - - - -
-

Session Locked

- - -
Incorrect password
-
- - - - `)); + // The boot screen takes the separate boot code when one is set. + const codeType=(lockoutMode==='boot'&&bootPassword)?bootCodeType:lockoutCodeType; + lockoutWindow.loadFile(path.join(__dirname,'lockscreen.html'),{query:{type:codeType,mode:lockoutMode}}); lockoutWindow.on('closed',()=>{ lockoutWindow=null; @@ -1585,7 +1524,7 @@ async function createWindow(){ if(enablePasswordProtection&&lockoutPassword&&requirePasswordOnBoot&&fs.existsSync(bootFlag)){ console.log('[LOCKOUT] Boot detected, requiring password BEFORE showing sites'); fs.unlinkSync(bootFlag); - showLockoutScreen(); + showLockoutScreen('boot'); }else{ attachView(startIndex); startMasterTimer(); @@ -1609,14 +1548,31 @@ async function createWindow(){ ipcMain.on('close-keyboard',()=>{closeHTMLKeyboard();}); ipcMain.on('keyboard-activity',()=>{markKeyboardActivity();}); ipcMain.on('show-pause-dialog',()=>{showPauseDialog();}); - ipcMain.on('check-lockout-password',(event,hash)=>{ - if(hash===lockoutPassword){ - unlockScreen(); - }else{ - if(lockoutWindow&&!lockoutWindow.isDestroyed()){ - lockoutWindow.webContents.send('password-incorrect'); - } + // The lock screen sends the code as typed; compared here as a SHA-256 + // hash (how the menu and Web UI store it). After every 5 wrong codes in + // a row the screen refuses input for a while - 30s, doubling up to 5 + // minutes - so a 4-digit PIN can't simply be tried through. + ipcMain.on('check-lockout-password',(event,code)=>{ + if(!lockoutWindow||lockoutWindow.isDestroyed()||event.sender!==lockoutWindow.webContents)return; + if(Date.now(){ diff --git a/lib/config.sh b/lib/config.sh index 3b9fd50..cf3d7d1 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -81,6 +81,12 @@ LOCKOUT_AT_TIME="" LOCKOUT_ACTIVE_START="" LOCKOUT_ACTIVE_END="" REQUIRE_PASSWORD_ON_BOOT="false" +# "password" or "pin" (4-8 digits, unlocked with an on-screen keypad). +LOCKOUT_CODE_TYPE="password" +# A separate code for the boot lock screen - empty means the unlock code +# above is used at boot too. Hashed like LOCKOUT_PASSWORD. +BOOT_PASSWORD="" +BOOT_CODE_TYPE="password" AUTHELIA_URL="" AUTHELIA_USERNAME="" AUTHELIA_ENCRYPTED_PASSWORD="" @@ -214,6 +220,13 @@ load_existing_config() { boot_password=$(sudo -u "$KIOSK_USER" jq -r '.requirePasswordOnBoot // false' "$CONFIG_PATH" 2>/dev/null) [[ "$boot_password" == "true" ]] && REQUIRE_PASSWORD_ON_BOOT="true" || REQUIRE_PASSWORD_ON_BOOT="false" + local code_type + code_type=$(sudo -u "$KIOSK_USER" jq -r '.lockoutCodeType // "password"' "$CONFIG_PATH" 2>/dev/null) + [[ "$code_type" == "pin" ]] && LOCKOUT_CODE_TYPE="pin" || LOCKOUT_CODE_TYPE="password" + BOOT_PASSWORD=$(sudo -u "$KIOSK_USER" jq -r '.bootPassword // ""' "$CONFIG_PATH" 2>/dev/null || echo "") + code_type=$(sudo -u "$KIOSK_USER" jq -r '.bootCodeType // "password"' "$CONFIG_PATH" 2>/dev/null) + [[ "$code_type" == "pin" ]] && BOOT_CODE_TYPE="pin" || BOOT_CODE_TYPE="password" + AUTHELIA_URL=$(sudo -u "$KIOSK_USER" jq -r '.autheliaURL // ""' "$CONFIG_PATH" 2>/dev/null || echo "") AUTHELIA_USERNAME=$(sudo -u "$KIOSK_USER" jq -r '.autheliaUsername // ""' "$CONFIG_PATH" 2>/dev/null || echo "") AUTHELIA_ENCRYPTED_PASSWORD=$(sudo -u "$KIOSK_USER" jq -r '.autheliaEncryptedPassword // ""' "$CONFIG_PATH" 2>/dev/null || echo "") @@ -290,10 +303,13 @@ save_config() { --arg lockoutActiveStart "${LOCKOUT_ACTIVE_START:-}" \ --arg lockoutActiveEnd "${LOCKOUT_ACTIVE_END:-}" \ --argjson requirePasswordOnBoot "$boot_password_json" \ + --arg lockoutCodeType "${LOCKOUT_CODE_TYPE:-password}" \ + --arg bootPassword "${BOOT_PASSWORD:-}" \ + --arg bootCodeType "${BOOT_CODE_TYPE:-password}" \ --arg autheliaURL "${AUTHELIA_URL:-}" \ --arg autheliaUsername "${AUTHELIA_USERNAME:-}" \ --arg autheliaEncryptedPassword "${AUTHELIA_ENCRYPTED_PASSWORD:-}" \ - '. + {autoswitch:$autoswitch,enableTouch:$enableTouch,dualSwipe:$dualSwipe,swipeMode:$swipeMode,allowNavigation:$allowNavigation,homeTabIndex:$homeTabIndex,inactivityTimeout:$inactivityTimeout,enablePauseButton:$enablePauseButton,enableKeyboardButton:$enableKeyboardButton,enableNavButton:$enableNavButton,enablePasswordProtection:$enablePasswordProtection,lockoutPassword:$lockoutPassword,lockoutTimeout:$lockoutTimeout,lockoutAtTime:$lockoutAtTime,lockoutActiveStart:$lockoutActiveStart,lockoutActiveEnd:$lockoutActiveEnd,requirePasswordOnBoot:$requirePasswordOnBoot,autheliaURL:$autheliaURL,autheliaUsername:$autheliaUsername,autheliaEncryptedPassword:$autheliaEncryptedPassword,tabs:[]}' > "$tmp" + '. + {autoswitch:$autoswitch,enableTouch:$enableTouch,dualSwipe:$dualSwipe,swipeMode:$swipeMode,allowNavigation:$allowNavigation,homeTabIndex:$homeTabIndex,inactivityTimeout:$inactivityTimeout,enablePauseButton:$enablePauseButton,enableKeyboardButton:$enableKeyboardButton,enableNavButton:$enableNavButton,enablePasswordProtection:$enablePasswordProtection,lockoutPassword:$lockoutPassword,lockoutTimeout:$lockoutTimeout,lockoutAtTime:$lockoutAtTime,lockoutActiveStart:$lockoutActiveStart,lockoutActiveEnd:$lockoutActiveEnd,requirePasswordOnBoot:$requirePasswordOnBoot,lockoutCodeType:$lockoutCodeType,bootPassword:$bootPassword,bootCodeType:$bootCodeType,autheliaURL:$autheliaURL,autheliaUsername:$autheliaUsername,autheliaEncryptedPassword:$autheliaEncryptedPassword,tabs:[]}' > "$tmp" if [[ ${#URLS[@]} -gt 0 ]]; then for idx in "${!URLS[@]}"; do diff --git a/menus/lockout.sh b/menus/lockout.sh index 9810c1a..805e02a 100644 --- a/menus/lockout.sh +++ b/menus/lockout.sh @@ -2,6 +2,11 @@ ################################################################################ # menus/lockout.sh - "Password Protection & Lockout" menu. # +# The unlock code and the (optional, separate) boot code are each either +# a password or a 4-8 digit PIN - the kiosk's own codes, independent of +# the admin account's sudo password. A PIN gets an on-screen number pad +# on the lock screen, so it works on a touch-only kiosk. +# # Fifth menu migrated. Back to config.json (like Display), but with a # sensitive field: the lockout password is SHA-256 hashed before it's # ever written to disk (matching the Electron app's comparison logic in @@ -31,7 +36,11 @@ lockout_status() { else echo "Daily lock time: not set" fi - echo "Password on boot: $(onoff "$REQUIRE_PASSWORD_ON_BOOT")" + echo "Unlock code: $(lockout_code_label "$LOCKOUT_CODE_TYPE")" + echo "Lock on boot: $(onoff "$REQUIRE_PASSWORD_ON_BOOT")" + if [[ "$REQUIRE_PASSWORD_ON_BOOT" == "true" ]]; then + echo "Boot code: $(lockout_boot_code_label)" + fi else echo "Password protection: disabled" fi @@ -40,10 +49,11 @@ lockout_status() { lockout_menu_builder() { if [[ "$ENABLE_PASSWORD_PROTECTION" == "true" ]]; then MENU_LABELS=( - "Change lockout password" + "Change unlock password/PIN (currently: $(lockout_code_label "$LOCKOUT_CODE_TYPE"))" "Change inactivity lockout timeout (currently: ${LOCKOUT_TIMEOUT}m)" "Set/clear daily lock time (currently: ${LOCKOUT_AT_TIME:-not set})" - "Toggle require password on boot (currently: $(onoff "$REQUIRE_PASSWORD_ON_BOOT"))" + "Toggle lock on boot (currently: $(onoff "$REQUIRE_PASSWORD_ON_BOOT"))" + "Set boot password/PIN (currently: $(lockout_boot_code_label))" "Disable password protection" ) MENU_HANDLERS=( @@ -51,6 +61,7 @@ lockout_menu_builder() { action_change_timeout action_change_daily_lock action_toggle_boot_password + action_set_boot_code action_disable_protection ) else @@ -68,31 +79,74 @@ lockout_menu() { # Shared helpers ################################################################################ -# Prompts for a new password twice, hashes it, and assigns to -# LOCKOUT_PASSWORD. Returns 1 (without saving) if the user gives up. -prompt_and_hash_password() { - local pass1 pass2 +# "PIN" / "password" for menus and status lines. +lockout_code_label() { + [[ "$1" == "pin" ]] && echo "PIN" || echo "password" +} + +lockout_boot_code_label() { + if [[ -z "$BOOT_PASSWORD" ]]; then + echo "same as unlock" + else + echo "separate $(lockout_code_label "$BOOT_CODE_TYPE")" + fi +} + +# True if $1 is a valid kiosk PIN: 4-8 digits. +lockout_valid_pin() { + [[ "$1" =~ ^[0-9]{4,8}$ ]] +} + +# prompt_code WHAT - asks whether WHAT (e.g. "unlock") is a password or a +# 4-8 digit PIN, then for the code itself twice. Sets CODE_TYPE ("password" +# or "pin") and CODE_HASH (SHA-256 hex, matching the Electron app's check +# in main.js - never plaintext). These are the kiosk's own codes, not +# any Linux account's password. +prompt_code() { + local what="$1" choice + echo "How should the $what code be entered?" + echo " 1) Password (letters, numbers, anything - needs a keyboard)" + echo " 2) PIN (4-8 digits - on-screen number pad, works on a touch screen)" while true; do - read -r -s -p "Enter password: " pass1 - echo - read -r -s -p "Confirm password: " pass2 - echo + read -r -p "Choose [1-2]: " choice + case "$choice" in + 1) CODE_TYPE="password"; break ;; + 2) CODE_TYPE="pin"; break ;; + *) echo "❌ Enter 1 or 2" ;; + esac + done + local label pass1 pass2 + label=$(lockout_code_label "$CODE_TYPE") + while true; do + read -r -s -p "Enter $what $label: " pass1 + echo if [[ -z "$pass1" ]]; then - echo "❌ Password cannot be empty" + echo "❌ The $label cannot be empty" continue fi - + if [[ "$CODE_TYPE" == "pin" ]] && ! lockout_valid_pin "$pass1"; then + echo "❌ A PIN is 4-8 digits (0-9 only)" + continue + fi + read -r -s -p "Confirm $what $label: " pass2 + echo if [[ "$pass1" != "$pass2" ]]; then - echo "❌ Passwords don't match, try again" + echo "❌ They don't match, try again" continue fi - - LOCKOUT_PASSWORD=$(echo -n "$pass1" | sha256sum | cut -d' ' -f1) + CODE_HASH=$(echo -n "$pass1" | sha256sum | cut -d' ' -f1) return 0 done } +# Prompts for the unlock code into LOCKOUT_PASSWORD/LOCKOUT_CODE_TYPE. +prompt_and_hash_password() { + prompt_code "unlock" + LOCKOUT_CODE_TYPE="$CODE_TYPE" + LOCKOUT_PASSWORD="$CODE_HASH" +} + ################################################################################ # Actions ################################################################################ @@ -101,13 +155,14 @@ action_enable_protection() { echo echo "Add password protection with automatic lockout:" echo " • Blank screen after an inactivity period" - echo " • Password required to unlock" - echo " • Password required after display schedule wake-up" + echo " • Password or PIN required to unlock" + echo " • Required after display schedule wake-up" echo " • Optional: lock at a specific time daily" - echo " • Optional: require password on system boot" + echo " • Optional: locked on system boot, with the same or its own code" + echo + echo "These are the kiosk's own codes - not your admin (sudo) password." echo - echo "Set lockout password:" prompt_and_hash_password echo @@ -123,8 +178,16 @@ action_enable_protection() { fi echo - if ask_yes_no "Require password on system boot/power on?" "y"; then + BOOT_PASSWORD="" + BOOT_CODE_TYPE="password" + if ask_yes_no "Lock the kiosk on system boot/power on?" "y"; then REQUIRE_PASSWORD_ON_BOOT="true" + echo + if ask_yes_no "Use a different password/PIN at boot than for unlocking?" "n"; then + prompt_code "boot" + BOOT_CODE_TYPE="$CODE_TYPE" + BOOT_PASSWORD="$CODE_HASH" + fi else REQUIRE_PASSWORD_ON_BOOT="false" fi @@ -140,6 +203,9 @@ action_disable_protection() { LOCKOUT_TIMEOUT=0 LOCKOUT_AT_TIME="" REQUIRE_PASSWORD_ON_BOOT="false" + LOCKOUT_CODE_TYPE="password" + BOOT_PASSWORD="" + BOOT_CODE_TYPE="password" log_success "Password protection disabled" save_config } @@ -147,7 +213,7 @@ action_disable_protection() { action_change_password() { echo prompt_and_hash_password - log_success "Password updated" + log_success "Unlock $(lockout_code_label "$LOCKOUT_CODE_TYPE") updated" save_config } @@ -178,10 +244,29 @@ action_change_daily_lock() { action_toggle_boot_password() { if [[ "$REQUIRE_PASSWORD_ON_BOOT" == "true" ]]; then REQUIRE_PASSWORD_ON_BOOT="false" - log_warning "Password on boot disabled" + log_warning "Lock on boot disabled" else REQUIRE_PASSWORD_ON_BOOT="true" - log_success "Password on boot enabled" + log_success "Lock on boot enabled (boot code: $(lockout_boot_code_label))" + fi + save_config +} + +action_set_boot_code() { + echo + echo "The boot lock screen can use its own password/PIN, or the unlock one." + echo "Currently: $(lockout_boot_code_label)" + echo + if ask_yes_no "Use a separate password/PIN at boot?" "y"; then + prompt_code "boot" + BOOT_CODE_TYPE="$CODE_TYPE" + BOOT_PASSWORD="$CODE_HASH" + REQUIRE_PASSWORD_ON_BOOT="true" + log_success "Boot $(lockout_code_label "$BOOT_CODE_TYPE") set (lock on boot: on)" + else + BOOT_PASSWORD="" + BOOT_CODE_TYPE="password" + log_success "Boot uses the unlock $(lockout_code_label "$LOCKOUT_CODE_TYPE")" fi save_config } diff --git a/webui/lib/config.js b/webui/lib/config.js index e61f63b..ba293c0 100644 --- a/webui/lib/config.js +++ b/webui/lib/config.js @@ -44,6 +44,10 @@ const SCALAR_DEFAULTS = { lockoutTimeout: 0, lockoutAtTime: '', requirePasswordOnBoot: false, + // 'password' or 'pin' (4-8 digits, entered on the lock screen's + // number pad) - for the unlock code and the optional separate boot code. + lockoutCodeType: 'password', + bootCodeType: 'password', }; function readExisting() { @@ -94,6 +98,8 @@ function loadConfig() { out[key] = key in existing ? existing[key] : def; } out.hasLockoutPassword = typeof existing.lockoutPassword === 'string' && existing.lockoutPassword.length > 0; + // A boot code of its own (empty bootPassword = the unlock code is used at boot). + out.hasBootPassword = typeof existing.bootPassword === 'string' && existing.bootPassword.length > 0; out.dualSwipe = out.swipeMode === 'dual'; return out; } @@ -122,11 +128,24 @@ function saveConfig(patch) { if (typeof patch.newLockoutPassword === 'string' && patch.newLockoutPassword.length > 0) { merged.lockoutPassword = hashPassword(patch.newLockoutPassword); } + // Separate boot code: a new one is hashed in, clearBootPassword goes + // back to "boot uses the unlock code". + if (!('bootPassword' in merged)) merged.bootPassword = ''; + if (typeof patch.newBootPassword === 'string' && patch.newBootPassword.length > 0) { + merged.bootPassword = hashPassword(patch.newBootPassword); + } + if (patch.clearBootPassword === true) { + merged.bootPassword = ''; + merged.bootCodeType = 'password'; + } if (patch.enablePasswordProtection === false) { merged.lockoutPassword = ''; merged.lockoutTimeout = 0; merged.lockoutAtTime = ''; merged.requirePasswordOnBoot = false; + merged.lockoutCodeType = 'password'; + merged.bootPassword = ''; + merged.bootCodeType = 'password'; } if (Array.isArray(patch.tabs)) { diff --git a/webui/public/app.js b/webui/public/app.js index 705c859..0d6a939 100644 --- a/webui/public/app.js +++ b/webui/public/app.js @@ -196,10 +196,18 @@ const dailyLockEnabled = document.getElementById('daily-lock-enabled'); const lockoutAtTime = document.getElementById('lockout-at-time'); const passwordLabel = document.getElementById('password-label'); +const bootLockEnabled = document.getElementById('boot-lock-enabled'); +const bootCodeMode = document.getElementById('boot-code-mode'); +const PIN_RE = /^[0-9]{4,8}$/; + function refreshLockoutFieldVisibility() { lockoutFields.hidden = !lockoutEnabled.checked; + document.getElementById('boot-code-fields').hidden = !bootLockEnabled.checked; + document.getElementById('boot-separate-fields').hidden = bootCodeMode.value !== 'separate'; } lockoutEnabled.addEventListener('change', refreshLockoutFieldVisibility); +bootLockEnabled.addEventListener('change', refreshLockoutFieldVisibility); +bootCodeMode.addEventListener('change', refreshLockoutFieldVisibility); dailyLockEnabled.addEventListener('change', () => { lockoutAtTime.disabled = !dailyLockEnabled.checked; @@ -221,6 +229,23 @@ lockoutForm.addEventListener('submit', async (e) => { showMessage('Set a lockout password before enabling password protection', true); return; } + const codeType = fd.get('lockoutCodeType'); + if (enable && newPassword && codeType === 'pin' && !PIN_RE.test(newPassword)) { + showMessage('A PIN is 4-8 digits', true); + return; + } + const bootSeparate = fd.get('requirePasswordOnBoot') === 'on' && fd.get('bootCodeMode') === 'separate'; + const newBootPassword = fd.get('newBootPassword') || ''; + if (enable && bootSeparate) { + if (newBootPassword !== document.getElementById('boot-password-confirm').value) { + showMessage("Boot passwords don't match", true); + return; + } + if (newBootPassword && fd.get('bootCodeType') === 'pin' && !PIN_RE.test(newBootPassword)) { + showMessage('A boot PIN is 4-8 digits', true); + return; + } + } const patch = { enablePasswordProtection: enable }; if (enable) { @@ -228,6 +253,14 @@ lockoutForm.addEventListener('submit', async (e) => { patch.lockoutTimeoutMinutes = parseInt(fd.get('lockoutTimeoutMinutes'), 10); patch.lockoutAtTime = dailyLockEnabled.checked ? fd.get('lockoutAtTime') : ''; patch.requirePasswordOnBoot = fd.get('requirePasswordOnBoot') === 'on'; + patch.lockoutCodeType = codeType; + if (bootSeparate) { + patch.bootCodeMode = 'separate'; + patch.bootCodeType = fd.get('bootCodeType'); + if (newBootPassword) patch.newBootPassword = newBootPassword; + } else if (patch.requirePasswordOnBoot) { + patch.bootCodeMode = 'same'; + } } try { @@ -236,6 +269,8 @@ lockoutForm.addEventListener('submit', async (e) => { populateAll(currentConfig); lockoutForm.querySelector('[name=newLockoutPassword]').value = ''; document.getElementById('lockout-password-confirm').value = ''; + lockoutForm.querySelector('[name=newBootPassword]').value = ''; + document.getElementById('boot-password-confirm').value = ''; } catch (err) { showMessage(err.message, true); } @@ -252,12 +287,17 @@ function populateAll(config) { displayForm.elements.inactivityTimeoutMinutes.value = Math.round(config.inactivityTimeout / 60); lockoutEnabled.checked = !!config.enablePasswordProtection; - passwordLabel.textContent = config.hasLockoutPassword ? 'New password (leave blank to keep the current one)' : 'Set lockout password'; + passwordLabel.textContent = config.hasLockoutPassword ? 'New password/PIN (leave blank to keep the current one)' : 'Set unlock password/PIN'; lockoutForm.elements.lockoutTimeoutMinutes.value = config.lockoutTimeout; dailyLockEnabled.checked = !!config.lockoutAtTime; lockoutAtTime.disabled = !config.lockoutAtTime; lockoutAtTime.value = config.lockoutAtTime || ''; lockoutForm.elements.requirePasswordOnBoot.checked = !!config.requirePasswordOnBoot; + lockoutForm.elements.lockoutCodeType.value = config.lockoutCodeType === 'pin' ? 'pin' : 'password'; + bootCodeMode.value = config.hasBootPassword ? 'separate' : 'same'; + lockoutForm.elements.bootCodeType.value = config.bootCodeType === 'pin' ? 'pin' : 'password'; + document.getElementById('boot-password-label').textContent = config.hasBootPassword + ? 'New boot password/PIN (leave blank to keep the current one)' : 'Set boot password/PIN'; refreshLockoutFieldVisibility(); } diff --git a/webui/public/index.html b/webui/public/index.html index 48d6670..d898f4a 100644 --- a/webui/public/index.html +++ b/webui/public/index.html @@ -115,19 +115,26 @@
+ @@ -142,7 +149,33 @@ - + +
+ +
+ + + +
+
diff --git a/webui/server.js b/webui/server.js index 45f3157..7fa80e8 100644 --- a/webui/server.js +++ b/webui/server.js @@ -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'); } } diff --git a/webui/test/api.test.js b/webui/test/api.test.js index 87c2513..03b3c94 100644 --- a/webui/test/api.test.js +++ b/webui/test/api.test.js @@ -141,6 +141,85 @@ async function main() { assert.strictEqual(JSON.stringify(body).includes('hunter2'), false); }); + const put = (body) => fetch(`${base}/api/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const onDisk = () => JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8')); + const sha = (v) => require('crypto').createHash('sha256').update(v).digest('hex'); + + await check('PUT rejects switching to a PIN without a new code', async () => { + const res = await put({ lockoutCodeType: 'pin' }); + assert.strictEqual(res.status, 400); + assert.strictEqual(onDisk().lockoutCodeType || 'password', 'password'); + }); + + await check('PUT rejects a PIN that is not 4-8 digits', async () => { + for (const pin of ['123', '123456789', '12a4']) { + const res = await put({ lockoutCodeType: 'pin', newLockoutPassword: pin }); + assert.strictEqual(res.status, 400, pin); + } + }); + + await check('PUT switches the unlock code to a 4-digit PIN', async () => { + const res = await put({ lockoutCodeType: 'pin', newLockoutPassword: '4821' }); + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.strictEqual(body.lockoutCodeType, 'pin'); + assert.strictEqual(onDisk().lockoutPassword, sha('4821')); + }); + + await check('PUT sets a separate boot password, never echoed back', async () => { + let res = await put({ requirePasswordOnBoot: true, bootCodeMode: 'separate', bootCodeType: 'password' }); + assert.strictEqual(res.status, 400, 'separate boot code without one entered'); + res = await put({ requirePasswordOnBoot: true, bootCodeMode: 'separate', bootCodeType: 'password', newBootPassword: 'boot pass!' }); + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.strictEqual(body.hasBootPassword, true); + assert.strictEqual(body.bootCodeType, 'password'); + assert.strictEqual(JSON.stringify(body).includes('boot pass!'), false); + assert.strictEqual(onDisk().bootPassword, sha('boot pass!')); + assert.strictEqual(onDisk().lockoutPassword, sha('4821'), 'unlock PIN untouched'); + }); + + await check('PUT keeps the boot code when saved again without a new one', async () => { + const res = await put({ bootCodeMode: 'separate', bootCodeType: 'password' }); + assert.strictEqual(res.status, 200); + assert.strictEqual(onDisk().bootPassword, sha('boot pass!')); + }); + + await check('PUT requires a new boot code when its type changes, and checks PIN format', async () => { + let res = await put({ bootCodeMode: 'separate', bootCodeType: 'pin' }); + assert.strictEqual(res.status, 400); + res = await put({ bootCodeMode: 'separate', bootCodeType: 'pin', newBootPassword: '12' }); + assert.strictEqual(res.status, 400); + res = await put({ bootCodeMode: 'separate', bootCodeType: 'pin', newBootPassword: '90210777' }); + assert.strictEqual(res.status, 200); + assert.strictEqual(onDisk().bootCodeType, 'pin'); + assert.strictEqual(onDisk().bootPassword, sha('90210777')); + }); + + await check('PUT bootCodeMode "same" clears the separate boot code', async () => { + const res = await put({ bootCodeMode: 'same' }); + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.strictEqual(body.hasBootPassword, false); + assert.strictEqual(onDisk().bootPassword, ''); + }); + + await check('PUT disabling protection clears PIN type and boot code too', async () => { + await put({ bootCodeMode: 'separate', bootCodeType: 'pin', newBootPassword: '5555' }); + const res = await put({ enablePasswordProtection: false }); + assert.strictEqual(res.status, 200); + const d = onDisk(); + assert.strictEqual(d.lockoutPassword, ''); + assert.strictEqual(d.lockoutCodeType, 'password'); + assert.strictEqual(d.bootPassword, ''); + // back to the state the next test expects + await put({ enablePasswordProtection: true, newLockoutPassword: 'hunter2', lockoutTimeoutMinutes: 15 }); + }); + await check('PUT rejects a malformed lockoutAtTime', async () => { const res = await fetch(`${base}/api/config`, { method: 'PUT', From d6769d5207c5893c1740f2989c091dd42277f4ca Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 15:33:05 +0000 Subject: [PATCH 2/3] Hash the hidden-sites PIN and slow down repeated wrong PINs .jitsi-pin now holds "sha256:" (or NOPIN) instead of the digits. The menu writes the hashed form. A plain-digits file still works and is converted by the kiosk app at startup. The PIN is now checked in the main process instead of the PIN pad's renderer, using the lock screen's slowdown, now factored out (createCodeThrottle): 5 wrong in a row refuse entry for 30s, doubling up to 5 minutes. The F10 PIN pad and the WiFi screen share one count. The WiFi screen's close-after-5-tries is replaced by this. The PIN pad no longer shows the default PIN on screen, and the menu no longer echoes the PIN as it's typed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cjz8a58eB5P9sUweqd3NHm --- Readme.md | 10 ++- kiosk-app/main.js | 133 +++++++++++++++++++++++++++------------ kiosk-app/pin-entry.html | 52 +++++++++------ menus/hidden_pin.sh | 23 +++++-- 4 files changed, 152 insertions(+), 66 deletions(-) diff --git a/Readme.md b/Readme.md index 30977ae..3075a09 100644 --- a/Readme.md +++ b/Readme.md @@ -903,11 +903,14 @@ The PIN file controls access to hidden sites (duration = -1): - **Default:** `1234` - **Configure via:** Main Menu → Core Settings → Sites → Configure Hidden Sites PIN - **Disable PIN:** Set content to `NOPIN` to allow any entry -- **Custom PIN:** 4-8 digits +- **Custom PIN:** 4-8 digits, independent of the lock screen's password/PIN (same or different, as you like) +- **Stored hashed:** the file holds `sha256:`, never the digits. A file with plain digits (set by hand, or from before this) still works and is converted to the hashed form when the kiosk app starts. +- **Wrong-PIN slowdown:** after 5 wrong PINs in a row, PIN entry is refused for 30 seconds, doubling up to 5 minutes. The hidden-sites PIN box and the WiFi screen share this count. +- Also guards the WiFi screen (Shift+W+S+F+H). ```bash -# Set custom PIN -echo "5678" | sudo -u kiosk tee /home/kiosk/kiosk-app/.jitsi-pin +# Set custom PIN (the menu does this for you) +echo "sha256:$(echo -n 5678 | sha256sum | cut -d' ' -f1)" | sudo -u kiosk tee /home/kiosk/kiosk-app/.jitsi-pin # Disable PIN protection echo "NOPIN" | sudo -u kiosk tee /home/kiosk/kiosk-app/.jitsi-pin @@ -1528,6 +1531,7 @@ full migration pass. - **Local files as sites:** an absolute path (`/home/kiosk/docs/menu.pdf`) shows a local page, PDF or image; a folder (`/home/kiosk/photos/`) plays as a full-screen **image slideshow** that picks up added/removed images within a minute. Works in both the Sites menu and the Web UI. - **Fix: the first-boot kiosk setup never started on 26.04.** Its service was ordered after cloud-init's final stage, which itself runs after `multi-user.target` - an ordering cycle that systemd broke by dropping the kiosk setup. It also "conflicted" with the tty1 login prompt, which systemd can resolve the same way. Both removed; checked with `systemd-analyze verify` against 26.04's own units. Rebuild the ISO to pick this up. - **WiFi tools in the base install:** `wpasupplicant` and `iw` are now installed (and bundled offline). A default Ubuntu Server install only has them if WiFi was set up in the installer; without them, Core Settings → WiFi couldn't connect an offline kiosk. The WiFi menu also installs them itself if missing. +- **Hidden-sites PIN hashed, with the same slowdown:** the F10 PIN is now stored as a SHA-256 hash (existing PIN files convert automatically) and repeated wrong PINs pause entry (30s, doubling to 5 min), shared with the WiFi screen. The PIN pad no longer shows "Default PIN: 1234" on screen. - **Lock screen PINs and a separate boot code:** the unlock code and the boot code can each be a password or a 4-8 digit PIN (on-screen number pad, works on touch screens), and the boot lock can have its own code. Repeated wrong codes now pause the lock screen (30s, doubling to 5 min). Set in Core Settings → Password Protection & Lockout or the Web UI's Lockout page. Existing settings keep working unchanged (password, same code at boot). - **New: WiFi from the kiosk screen** - hold Shift+W+S+F+H (or Ctrl+Alt+Shift+Super+W) to scan for and join a WiFi network right on the kiosk, PIN-protected, for kiosks with the consoles turned off. Reverts to the previous WiFi if the new one doesn't connect. See "WiFi from the kiosk screen". - **New: `iso/e2b-contig.sh`** - makes a file on an Easy2Boot (or any NTFS/FAT32/exFAT) USB drive contiguous from Linux, so ISOs that need to be in one piece boot. diff --git a/kiosk-app/main.js b/kiosk-app/main.js index 11cb9c3..b82f89c 100644 --- a/kiosk-app/main.js +++ b/kiosk-app/main.js @@ -73,8 +73,7 @@ let bootCodeType='password'; // Password lockout state let isLockedOut=false; let lockoutMode='unlock'; // 'boot' while the boot lock screen is up -let lockoutFailures=0; -let lockoutBlockedUntil=0; +const lockoutThrottle=createCodeThrottle(); let lockoutWindow=null; let lockoutTimer=null; let lockoutActivityTime=Date.now(); @@ -163,6 +162,75 @@ function markKeyboardActivity(){ keyboardClosePending=false; } +// Wrong-code slowdown, shared by the lock screen and the hidden-sites PIN +// (one throttle per code): after every 5 wrong codes in a row, input is +// refused for 30s, doubling up to 5 minutes - so a 4-digit PIN can't +// simply be tried through. A right code resets it. +function createCodeThrottle(){ + return{failures:0,blockedUntil:0}; +} +function throttleWaitSeconds(t){ + return Math.max(0,Math.ceil((t.blockedUntil-Date.now())/1000)); +} +// Counts a wrong code; returns the seconds input is now refused for (0 = none). +function throttleFail(t){ + t.failures++; + if(t.failures%5!==0)return 0; + const wait=Math.min(300,30*Math.pow(2,t.failures/5-1)); + t.blockedUntil=Date.now()+wait*1000; + return wait; +} + +function sha256Hex(v){ + return crypto.createHash('sha256').update(String(v==null?'':v)).digest('hex'); +} + +// Hidden-sites PIN (.jitsi-pin, set in Core Settings -> Hidden Sites PIN): +// "NOPIN" (no PIN), "sha256:", or - written before the PIN was +// hashed - the plain digits, which migrateHiddenPin() rewrites hashed at +// startup. No file = the default PIN 1234. Read on every check, so a +// change from the menu applies without a restart. Guards the hidden +// sites (F10) and the WiFi screen, with one shared slowdown. +const HIDDEN_PIN_FILE=path.join(__dirname,'.jitsi-pin'); +const hiddenPinThrottle=createCodeThrottle(); + +function readHiddenPinEntry(){ + try{ + const stored=fs.readFileSync(HIDDEN_PIN_FILE,'utf8').trim(); + if(stored==='NOPIN')return{none:true}; + if(stored.startsWith('sha256:'))return{hash:stored.slice(7)}; + if(stored)return{hash:sha256Hex(stored)}; + }catch(e){} + return{hash:sha256Hex('1234')}; +} + +function migrateHiddenPin(){ + try{ + const stored=fs.readFileSync(HIDDEN_PIN_FILE,'utf8').trim(); + if(/^[0-9]{4,8}$/.test(stored)){ + fs.writeFileSync(HIDDEN_PIN_FILE,'sha256:'+sha256Hex(stored)+'\n',{mode:0o600}); + console.log('[PIN] Hidden-sites PIN converted to hashed form'); + } + }catch(e){} +} + +function hiddenPinRequired(){ + return!readHiddenPinEntry().none; +} + +function checkHiddenPin(pin){ + const blocked=throttleWaitSeconds(hiddenPinThrottle); + if(blocked)return{ok:false,waitSeconds:blocked,error:'Too many wrong tries - wait '+blocked+'s'}; + const entry=readHiddenPinEntry(); + if(entry.none||sha256Hex(pin)===entry.hash){ + hiddenPinThrottle.failures=0; + return{ok:true}; + } + const wait=throttleFail(hiddenPinThrottle); + console.log('[PIN] Wrong hidden-sites PIN, attempt',hiddenPinThrottle.failures); + return wait?{ok:false,waitSeconds:wait,error:'Too many wrong tries - wait '+wait+'s'}:{ok:false,error:'Incorrect PIN'}; +} + // Password lockout functions function showLockoutScreen(mode){ if(isLockedOut||!enablePasswordProtection||!lockoutPassword)return; @@ -1131,23 +1199,13 @@ function showHiddenTab(index){ // WiFi screen (hold Shift+W+S+F+H, or Ctrl+Alt+Shift+Super+W): join a WiFi network from the kiosk // itself, for when the console (Ctrl+Alt+F1..F6) is switched off and the -// admin menu can't be reached. Gated by the hidden-tab PIN (.jitsi-pin, -// same rules as pin-entry.html: no file = 1234, NOPIN = none). The root +// admin menu can't be reached. Gated by the hidden-sites PIN +// (checkHiddenPin - same PIN and slowdown as F10). The root // work is done by /usr/local/bin/kiosk-wifi-helper, which the kiosk user // may run through sudo and nothing else (/etc/sudoers.d/kiosk-wifi). const WIFI_HELPER='/usr/local/bin/kiosk-wifi-helper'; const WIFI_IDLE_TIMEOUT=5*60000; // closes after 5 minutes untouched -const WIFI_MAX_PIN_TRIES=5; -let wifiWindow=null,wifiUnlocked=false,wifiPinTries=0,wifiIdleTimer=null; - -function readHiddenPin(){ - try{ - const stored=fs.readFileSync(path.join(__dirname,'.jitsi-pin'),'utf8').trim(); - if(stored==='NOPIN')return null; - if(stored)return stored; - }catch(e){} - return '1234'; -} +let wifiWindow=null,wifiUnlocked=false,wifiIdleTimer=null; function runWifiHelper(action,input){ return new Promise(resolve=>{ @@ -1199,8 +1257,7 @@ function showWifiWindow(){ wifiWindow.focus(); return; } - wifiUnlocked=(readHiddenPin()===null); - wifiPinTries=0; + wifiUnlocked=!hiddenPinRequired(); let width=720,height=820; if(mainWindow&&!mainWindow.isDestroyed()){ const[w,h]=mainWindow.getContentSize(); @@ -1268,17 +1325,9 @@ ipcMain.handle('wifi-request',async(event,req)=>{ if(action==='needs-pin')return{ok:true,needsPin:!wifiUnlocked}; if(action==='unlock'){ if(wifiUnlocked)return{ok:true}; - if(String(req.pin||'')===readHiddenPin()){ - wifiUnlocked=true; - return{ok:true}; - } - wifiPinTries++; - console.log('[WIFI] Wrong PIN, attempt',wifiPinTries); - if(wifiPinTries>=WIFI_MAX_PIN_TRIES){ - setTimeout(closeWifiWindow,1500); - return{ok:false,error:'Too many wrong PINs'}; - } - return{ok:false,error:'Incorrect PIN'}; + const result=checkHiddenPin(req.pin); + if(result.ok)wifiUnlocked=true; + return result; } if(!wifiUnlocked)return{ok:false,error:'Enter the PIN first'}; if(action==='status'||action==='scan')return runWifiHelper(action); @@ -1394,6 +1443,7 @@ async function autheliaAuthenticate(){ async function createWindow(){ tabs=loadConfig(); + migrateHiddenPin(); await autheliaAuthenticate(); mainWindow=new BrowserWindow({ @@ -1552,29 +1602,32 @@ async function createWindow(){ // hash (how the menu and Web UI store it). After every 5 wrong codes in // a row the screen refuses input for a while - 30s, doubling up to 5 // minutes - so a 4-digit PIN can't simply be tried through. + // The lock screen sends the code as typed; compared here as a SHA-256 + // hash (how the menu and Web UI store it), with the wrong-code slowdown + // (see createCodeThrottle). ipcMain.on('check-lockout-password',(event,code)=>{ if(!lockoutWindow||lockoutWindow.isDestroyed()||event.sender!==lockoutWindow.webContents)return; - if(Date.now(){ + if(!pinWindow||pinWindow.isDestroyed()||event.sender!==pinWindow.webContents)return{ok:false,error:'Not allowed'}; + return checkHiddenPin(pin); + }); + ipcMain.on('get-config',(event)=>{ try{ if(fs.existsSync(CONFIG_FILE)){ diff --git a/kiosk-app/pin-entry.html b/kiosk-app/pin-entry.html index f638219..120565a 100644 --- a/kiosk-app/pin-entry.html +++ b/kiosk-app/pin-entry.html @@ -78,20 +78,13 @@
-
Default PIN: 1234 (4-8 digits)
+
4-8 digits