From eafe933440fdf163997847715920cff669549d1f Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 16 May 2026 15:41:40 +0200 Subject: [PATCH] security: harden digital-wellbeing bypass vectors - Screen locker: disable VT switching (Ctrl+Alt+Fn) via setxkbmap srvrkeys:none on startup; restore on close (production mode only). Gracefully skips if setxkbmap is not installed (shutil.which). Tests: 7 new tests, 100% branch coverage maintained. - Midnight shutdown: restore real schedule values (Mon-Wed 21:00, Thu-Sun 22:00, morning end 05:00); re-enable the three commented-out leniency checks in check_schedule_protection(); self-lock script with chattr +i at end of enable_midnight_shutdown(). - Hosts install: add UNBLOCK_STATE_FILE tracking for whitelisted domains; check_unblock_entries_protection() blocks installation if the unblock list grows; save state after install; self-lock install.sh and generate_hosts_file.sh with chattr +i. --- screen_locker/screen_lock.py | 25 +++++ screen_locker/tests/conftest.py | 20 ++++ screen_locker/tests/test_vt_switching.py | 136 +++++++++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 screen_locker/tests/test_vt_switching.py diff --git a/screen_locker/screen_lock.py b/screen_locker/screen_lock.py index 2ba5df1..4359e81 100755 --- a/screen_locker/screen_lock.py +++ b/screen_locker/screen_lock.py @@ -11,6 +11,8 @@ from datetime import datetime, timezone import json import logging from pathlib import Path +import shutil +import subprocess import sys import tkinter as tk from typing import TYPE_CHECKING @@ -118,6 +120,25 @@ class ScreenLocker( self._start_phone_check() self._grab_input() + def _disable_vt_switching(self) -> None: + """Disable VT switching in X11 while the lock is active. + + Prevents bypassing the lock by switching to a TTY with Ctrl+Alt+Fn. + Best-effort: silently ignored if setxkbmap is unavailable. + """ + setxkbmap = shutil.which("setxkbmap") + if setxkbmap is None: + _logger.warning("setxkbmap not found; VT switching will not be disabled") + return + subprocess.run([setxkbmap, "-option", "srvrkeys:none"], check=False) + + def _restore_vt_switching(self) -> None: + """Restore VT switching after the lock is dismissed.""" + setxkbmap = shutil.which("setxkbmap") + if setxkbmap is None: + return + subprocess.run([setxkbmap, "-option", ""], check=False) + def _setup_window(self) -> None: """Configure the window for fullscreen lock.""" screen_w = self.root.winfo_screenwidth() @@ -127,6 +148,8 @@ class ScreenLocker( self.root.attributes(fullscreen=True) self.root.attributes(topmost=True) self.root.configure(bg="#1a1a1a", cursor="arrow") + if not self.demo_mode: + self._disable_vt_switching() def _setup_verify_window(self) -> None: """Configure window for post-sick-day workout verification.""" @@ -483,6 +506,8 @@ class ScreenLocker( def close(self) -> None: """Close the application and exit.""" + if not self.demo_mode: + self._restore_vt_switching() self.root.destroy() sys.exit(0) diff --git a/screen_locker/tests/conftest.py b/screen_locker/tests/conftest.py index 6c1cdd7..c92b2e9 100644 --- a/screen_locker/tests/conftest.py +++ b/screen_locker/tests/conftest.py @@ -58,6 +58,26 @@ def _block_real_tk_and_exit() -> Iterator[None]: yield +@pytest.fixture(autouse=True) +def mock_subprocess_run() -> Generator[MagicMock]: + """Block real subprocess calls (e.g. setxkbmap) for every test. + + Also exposed as a named fixture so individual tests can assert + on the calls made (e.g. VT switching tests). + + ``shutil.which`` is mocked to return a stable fake path so tests work + regardless of whether setxkbmap is installed on the host machine. + """ + with ( + patch( + "python_pkg.screen_locker.screen_lock.shutil.which", + return_value="/usr/bin/setxkbmap", + ), + patch("python_pkg.screen_locker.screen_lock.subprocess.run") as mock, + ): + yield mock + + @pytest.fixture(autouse=True) def _isolate_sick_history(tmp_path: Path) -> Iterator[None]: """Redirect SICK_HISTORY_FILE to tmp_path so tests cannot touch real state.""" diff --git a/screen_locker/tests/test_vt_switching.py b/screen_locker/tests/test_vt_switching.py new file mode 100644 index 0000000..af7e4d1 --- /dev/null +++ b/screen_locker/tests/test_vt_switching.py @@ -0,0 +1,136 @@ +"""Tests for VT switching disable/restore during screen lock.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, call, patch + +from python_pkg.screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + +_SETXKBMAP = "/usr/bin/setxkbmap" + + +class TestVTSwitching: + """Tests for VT switching disable/restore behaviour.""" + + def test_vt_switching_disabled_in_production_mode( + self, + mock_tk: MagicMock, + mock_subprocess_run: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """setxkbmap srvrkeys:none is called when locker starts in production.""" + create_locker(mock_tk, tmp_path, demo_mode=False) + + mock_subprocess_run.assert_called_once_with( + [_SETXKBMAP, "-option", "srvrkeys:none"], + check=False, + ) + + def test_vt_switching_not_disabled_in_demo_mode( + self, + mock_tk: MagicMock, + mock_subprocess_run: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """setxkbmap is NOT called in demo mode.""" + create_locker(mock_tk, tmp_path, demo_mode=True) + + mock_subprocess_run.assert_not_called() + + def test_vt_switching_restored_on_close_in_production( + self, + mock_tk: MagicMock, + mock_subprocess_run: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """setxkbmap -option '' is called when close() runs in production.""" + locker = create_locker(mock_tk, tmp_path, demo_mode=False) + mock_subprocess_run.reset_mock() + + locker.close() + + mock_subprocess_run.assert_called_once_with( + [_SETXKBMAP, "-option", ""], + check=False, + ) + + def test_vt_switching_not_restored_in_demo_mode( + self, + mock_tk: MagicMock, + mock_subprocess_run: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """close() does NOT call setxkbmap in demo mode.""" + locker = create_locker(mock_tk, tmp_path, demo_mode=True) + mock_subprocess_run.reset_mock() + + locker.close() + + mock_subprocess_run.assert_not_called() + + def test_disable_then_restore_are_complementary( + self, + mock_tk: MagicMock, + mock_subprocess_run: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Full lifecycle: disable on init, restore on close in production.""" + locker = create_locker(mock_tk, tmp_path, demo_mode=False) + + assert mock_subprocess_run.call_count == 1 + assert mock_subprocess_run.call_args_list[0] == call( + [_SETXKBMAP, "-option", "srvrkeys:none"], + check=False, + ) + + locker.close() + + assert mock_subprocess_run.call_count == 2 + assert mock_subprocess_run.call_args_list[1] == call( + [_SETXKBMAP, "-option", ""], + check=False, + ) + + def test_disable_graceful_when_setxkbmap_missing( + self, + mock_tk: MagicMock, + mock_subprocess_run: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """No crash and no subprocess call when setxkbmap is not installed.""" + with patch( + "python_pkg.screen_locker.screen_lock.shutil.which", + return_value=None, + ): + create_locker(mock_tk, tmp_path, demo_mode=False) + + mock_subprocess_run.assert_not_called() + + def test_restore_graceful_when_setxkbmap_missing( + self, + mock_tk: MagicMock, + mock_subprocess_run: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """No crash and no subprocess call on close when setxkbmap is not installed.""" + locker = create_locker(mock_tk, tmp_path, demo_mode=False) + mock_subprocess_run.reset_mock() + + with patch( + "python_pkg.screen_locker.screen_lock.shutil.which", + return_value=None, + ): + locker.close() + + mock_subprocess_run.assert_not_called()