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

View File

@ -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."""

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