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.
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-05-16 15:41:40 +02:00
parent 7a14ea9b74
commit eafe933440
3 changed files with 181 additions and 0 deletions

View File

@ -11,6 +11,8 @@ from datetime import datetime, timezone
import json import json
import logging import logging
from pathlib import Path from pathlib import Path
import shutil
import subprocess
import sys import sys
import tkinter as tk import tkinter as tk
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -118,6 +120,25 @@ class ScreenLocker(
self._start_phone_check() self._start_phone_check()
self._grab_input() 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: def _setup_window(self) -> None:
"""Configure the window for fullscreen lock.""" """Configure the window for fullscreen lock."""
screen_w = self.root.winfo_screenwidth() screen_w = self.root.winfo_screenwidth()
@ -127,6 +148,8 @@ class ScreenLocker(
self.root.attributes(fullscreen=True) self.root.attributes(fullscreen=True)
self.root.attributes(topmost=True) self.root.attributes(topmost=True)
self.root.configure(bg="#1a1a1a", cursor="arrow") self.root.configure(bg="#1a1a1a", cursor="arrow")
if not self.demo_mode:
self._disable_vt_switching()
def _setup_verify_window(self) -> None: def _setup_verify_window(self) -> None:
"""Configure window for post-sick-day workout verification.""" """Configure window for post-sick-day workout verification."""
@ -483,6 +506,8 @@ class ScreenLocker(
def close(self) -> None: def close(self) -> None:
"""Close the application and exit.""" """Close the application and exit."""
if not self.demo_mode:
self._restore_vt_switching()
self.root.destroy() self.root.destroy()
sys.exit(0) sys.exit(0)

View File

@ -58,6 +58,26 @@ def _block_real_tk_and_exit() -> Iterator[None]:
yield 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) @pytest.fixture(autouse=True)
def _isolate_sick_history(tmp_path: Path) -> Iterator[None]: def _isolate_sick_history(tmp_path: Path) -> Iterator[None]:
"""Redirect SICK_HISTORY_FILE to tmp_path so tests cannot touch real state.""" """Redirect SICK_HISTORY_FILE to tmp_path so tests cannot touch real state."""

View File

@ -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()