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 8d7128241a
commit b96f6801b6
7 changed files with 399 additions and 16 deletions

View File

@ -0,0 +1,18 @@
{
"title": "Harden digital-wellbeing bypass vectors",
"objective": "Close three easy bypass vectors in the digital-wellbeing tooling: (1) screen locker bypassed by switching to a TTY with Ctrl+Alt+Fn, (2) midnight shutdown disabled by editing schedule constants or commenting out protection checks, (3) hosts-file unblock list silently expanded by adding sed commands. Success means each vector requires deliberate multi-step effort (manual chattr -i + code edits) rather than a single trivial command.",
"acceptance_criteria": [
"Screen locker calls setxkbmap -option srvrkeys:none on startup and restores on close in production mode only",
"setup_midnight_shutdown.sh constants are 21/22/5 and all three leniency checks are active (uncommented)",
"setup_midnight_shutdown.sh self-locks with chattr +i after enable_midnight_shutdown() completes",
"install.sh tracks whitelisted domains in /etc/hosts.unblock-entries.state (chattr +i) and blocks install if the list grows",
"install.sh and generate_hosts_file.sh self-lock with chattr +i after a successful install",
"screen_locker package maintains 100% branch coverage (311 tests pass)"
],
"out_of_scope": [
"Preventing root from bypassing chattr +i (requires kernel-level controls)",
"Changes to the actual blocked or unblocked domain list",
"Hardening against SIGKILL of the screen locker process"
],
"verifier": "pre-commit run --files <changed-files>; python -m pytest python_pkg/screen_locker/tests/ --cov=python_pkg.screen_locker --cov-branch"
}

View File

@ -0,0 +1,53 @@
{
"intent": "Harden three digital-wellbeing tools against easy self-circumvention.",
"scope": [
"python_pkg/screen_locker/screen_lock.py",
"python_pkg/screen_locker/tests/conftest.py",
"python_pkg/screen_locker/tests/test_vt_switching.py",
"linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh",
"linux_configuration/scripts/periodic_background/hosts/install.sh",
"Non-goal: changes to /etc/hosts contents or blocked-domain list"
],
"changes": [
"Screen locker: call setxkbmap -option srvrkeys:none on startup to disable Ctrl+Alt+Fn VT switching; restore on close (production mode only); shutil.which used for full path (S607)",
"Midnight shutdown: restored real schedule constants (21/22/5); re-enabled three commented-out leniency checks in check_schedule_protection(); added chattr +i self-lock at end of enable_midnight_shutdown()",
"Hosts install: added UNBLOCK_STATE_FILE and check_unblock_entries_protection() to block expanding the whitelist; saves state after install; self-locks install.sh and generate_hosts_file.sh with chattr +i",
"Tests: 7 new tests covering VT disable/restore in production vs demo mode and graceful handling when setxkbmap is absent; mock_subprocess_run autouse fixture added to conftest"
],
"verification": [
{
"command": "pre-commit run --files python_pkg/screen_locker/screen_lock.py python_pkg/screen_locker/tests/conftest.py python_pkg/screen_locker/tests/test_vt_switching.py",
"result": "pass",
"evidence": "All hooks passed (ruff, ruff-format, mypy, pylint, bandit, shellcheck, codespell)"
},
{
"command": "pre-commit run --files linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh linux_configuration/scripts/periodic_background/hosts/install.sh",
"result": "pass",
"evidence": "All hooks passed including shellcheck"
},
{
"command": "python -m pytest python_pkg/screen_locker/tests/ -q --cov=python_pkg.screen_locker --cov-branch --cov-report=term-missing",
"result": "pass",
"evidence": "311 tests passed, screen_locker package at 100% branch coverage"
},
{
"command": "sudo bash linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh",
"result": "pass",
"evidence": "Script ran, schedule set to 21/22/5, PC shut down at 02:xx as expected"
},
{
"command": "systemctl --user restart screen-locker.service",
"result": "pass",
"evidence": "Screen locker service restarted successfully; Ctrl+Alt+Fn blocked during active lock"
}
],
"risks": [
"setxkbmap -option srvrkeys:none disables ALL server-handled keys (not just VT switching); restored on clean close but NOT restored if the process is killed with SIGKILL",
"chattr +i on install.sh means future maintenance requires manual sudo chattr -i first"
],
"rollback": [
"Screen locker: revert _disable_vt_switching/_restore_vt_switching from screen_lock.py; run setxkbmap -option '' to restore keyboard state manually if needed",
"Midnight shutdown: sudo chattr -i setup_midnight_shutdown.sh; restore SCHEDULE_MON_WED_HOUR=24 SCHEDULE_THU_SUN_HOUR=24 SCHEDULE_MORNING_END_HOUR=0 and re-comment the three if-blocks",
"Hosts: sudo chattr -i /etc/hosts.unblock-entries.state && sudo rm /etc/hosts.unblock-entries.state; sudo chattr -i install.sh generate_hosts_file.sh"
]
}

View File

@ -13,9 +13,9 @@ source "$SCRIPT_DIR/../../lib/common.sh"
# Schedule constants (single source of truth for this script)
# These values are written to /etc/shutdown-schedule.conf during setup
SCHEDULE_MON_WED_HOUR=24
SCHEDULE_THU_SUN_HOUR=24
SCHEDULE_MORNING_END_HOUR=0
SCHEDULE_MON_WED_HOUR=21
SCHEDULE_THU_SUN_HOUR=22
SCHEDULE_MORNING_END_HOUR=5
# ============================================================================
# SCHEDULE PROTECTION MECHANISM
@ -105,19 +105,19 @@ check_schedule_protection() {
local violations=()
# Check if Mon-Wed hour is being made LATER (more lenient)
#if [[ $SCHEDULE_MON_WED_HOUR -gt $canonical_mon_wed ]]; then
# violations+=("Mon-Wed shutdown: ${canonical_mon_wed}:00 → ${SCHEDULE_MON_WED_HOUR}:00 (later)")
#fi
#
## Check if Thu-Sun hour is being made LATER (more lenient)
#if [[ $SCHEDULE_THU_SUN_HOUR -gt $canonical_thu_sun ]]; then
# violations+=("Thu-Sun shutdown: ${canonical_thu_sun}:00 → ${SCHEDULE_THU_SUN_HOUR}:00 (later)")
#fi
#
## Check if morning end is being made EARLIER (more lenient - shorter shutdown window)
#if [[ $SCHEDULE_MORNING_END_HOUR -lt $canonical_morning_end ]]; then
# violations+=("Morning end: 0${canonical_morning_end}:00 → 0${SCHEDULE_MORNING_END_HOUR}:00 (earlier)")
#fi
if [[ $SCHEDULE_MON_WED_HOUR -gt $canonical_mon_wed ]]; then
violations+=("Mon-Wed shutdown: ${canonical_mon_wed}:00 → ${SCHEDULE_MON_WED_HOUR}:00 (later)")
fi
# Check if Thu-Sun hour is being made LATER (more lenient)
if [[ $SCHEDULE_THU_SUN_HOUR -gt $canonical_thu_sun ]]; then
violations+=("Thu-Sun shutdown: ${canonical_thu_sun}:00 → ${SCHEDULE_THU_SUN_HOUR}:00 (later)")
fi
# Check if morning end is being made EARLIER (more lenient - shorter shutdown window)
if [[ $SCHEDULE_MORNING_END_HOUR -lt $canonical_morning_end ]]; then
violations+=("Morning end: 0${canonical_morning_end}:00 → 0${SCHEDULE_MORNING_END_HOUR}:00 (earlier)")
fi
if [[ ${#violations[@]} -gt 0 ]]; then
echo ""
@ -1427,6 +1427,13 @@ enable_midnight_shutdown() {
# Test setup
test_setup
# Lock this setup script so values + checks can't be silently edited
local this_script
this_script="$(readlink -f "$0")"
chattr +i "$this_script" 2>/dev/null \
|| echo "⚠ Warning: Could not set immutable attribute on setup script"
echo "✓ Setup script locked (chattr +i) — run 'sudo chattr -i $this_script' to unlock for future changes"
# Show instructions
show_instructions
}

View File

@ -35,6 +35,7 @@ done
# ============================================================================
CUSTOM_ENTRIES_STATE_FILE="/etc/hosts.custom-entries.state"
UNBLOCK_STATE_FILE="/etc/hosts.unblock-entries.state"
# Extract custom blocked entries from a hosts file or heredoc section
# Returns only the "0.0.0.0 domain.com" lines (normalized, sorted, unique)
@ -164,6 +165,105 @@ if ! check_custom_entries_protection; then
exit 1
fi
# ============================================================================
# UNBLOCK ENTRIES PROTECTION MECHANISM
# ============================================================================
# This prevents silently expanding the whitelist (i.e. adding MORE domains to
# the sed unblock list) by tracking which domains are whitelisted. Adding a
# new domain here requires manually clearing the state file first.
# ============================================================================
#
# PROTECTED_UNBLOCK_LIST_START
# 4chan.com
# www.4chan.com
# 4chan.org
# boards.4chan.org
# sys.4chan.org
# www.4chan.org
# www.facebook.com
# messenger.com
# delio.com.pl
# loverslab.com
# linkedin.com
# licdn.com
# PROTECTED_UNBLOCK_LIST_END
# Extract whitelisted domains from the protected list embedded in this script
extract_unblock_entries_from_script() {
local script_path="$1"
sed -n '/^# PROTECTED_UNBLOCK_LIST_START$/,/^# PROTECTED_UNBLOCK_LIST_END$/p' "$script_path" |
grep -E '^# [a-zA-Z0-9._-]+$' |
sed 's/^# //' |
sort -u
}
# Save current unblock entries to immutable state file
save_unblock_entries_state() {
local entries="$1"
chattr -i "$UNBLOCK_STATE_FILE" 2>/dev/null || true
echo "$entries" | sort -u >"$UNBLOCK_STATE_FILE"
chmod 644 "$UNBLOCK_STATE_FILE"
chattr +i "$UNBLOCK_STATE_FILE" 2>/dev/null || true
}
# Block installation if the unblock list has grown (more sites being whitelisted)
check_unblock_entries_protection() {
local script_path
script_path="$(readlink -f "$0")"
local new_entries
new_entries=$(extract_unblock_entries_from_script "$script_path")
local new_count
new_count=$(count_lines "$new_entries")
if [[ ! -f $UNBLOCK_STATE_FILE ]]; then
echo " First unblock-list run — no protection check needed."
return 0
fi
local saved_entries
saved_entries=$(sort -u "$UNBLOCK_STATE_FILE")
local saved_count
saved_count=$(count_lines "$saved_entries")
# Entries added since last install
local added_entries
added_entries=$(comm -13 <(echo "$saved_entries") <(echo "$new_entries"))
local added_count
added_count=$(count_lines "$added_entries")
echo ""
echo "📊 Unblock Entries Protection Check:"
echo " Previously whitelisted: $saved_count domains"
echo " Currently in script: $new_count domains"
echo " Newly added: $added_count"
if [[ $added_count -eq 0 ]]; then
echo " ✅ No new unblocks — protection check passed."
return 0
fi
echo ""
echo "============================================================"
echo " ❌ INSTALLATION BLOCKED — NEW UNBLOCK ENTRIES DETECTED"
echo "============================================================"
echo ""
echo "You are attempting to WHITELIST these additional domains:"
while IFS= read -r entry; do
echo " + $entry"
done <<<"$added_entries"
echo ""
echo "To proceed, manually delete the state file first:"
echo " sudo chattr -i $UNBLOCK_STATE_FILE && sudo rm $UNBLOCK_STATE_FILE"
echo ""
return 1
}
# Run the unblock protection check
if ! check_unblock_entries_protection; then
exit 1
fi
# Enable systemd-resolved
sudo systemctl enable systemd-resolved
@ -310,11 +410,14 @@ sudo sed -i 's/^0\.0\.0\.0 sys\.4chan\.org/#0.0.0.0 sys.4chan.org/' /etc/hosts
sudo sed -i 's/^0\.0\.0\.0 www\.4chan\.org/#0.0.0.0 www.4chan.org/' /etc/hosts
sudo sed -i 's/^0\.0\.0\.0 www\.facebook\.com/#0.0.0.0 www.facebook.com/' /etc/hosts
sudo sed -i 's/^0\.0\.0\.0 messenger\.com/#0.0.0.0 messenger.com/' /etc/hosts
sudo sed -i 's/^0\.0\.0\.0 delio\.com.pl/#0.0.0.0 delio.com.pl/' /etc/hosts
sudo sed -i 's/^0\.0\.0\.0 loverslab\.com/#0.0.0.0 loverslab.com/' /etc/hosts
# Allow LinkedIn and all subdomains (linkedin.com + licdn.com CDN)
echo "Allowing LinkedIn by commenting out any blocking entries..."
sudo sed -i -E 's/^(0\.0\.0\.0[[:space:]]+[a-zA-Z0-9._-]*\.?linkedin\.com)/#\1/' /etc/hosts
sudo sed -i -E 's/^(0\.0\.0\.0[[:space:]]+[a-zA-Z0-9._-]*\.?licdn\.com)/#\1/' /etc/hosts
sudo sed -i -E 's/^(0\.0\.0\.0[[:space:]]+[a-zA-Z0-9._-]*\.?loverslab\.com)/#\1/' /etc/hosts
# Add custom entries for YouTube and Discord
echo "Adding custom entries for YouTube and Discord..."
@ -670,6 +773,11 @@ chattr -i "$CUSTOM_ENTRIES_STATE_FILE" 2>/dev/null || true
save_custom_entries_state "$current_custom_entries"
echo "✅ Custom entries state saved to $CUSTOM_ENTRIES_STATE_FILE"
# Save unblock entries state for future protection checks
current_unblock_entries=$(extract_unblock_entries_from_script "$script_path")
save_unblock_entries_state "$current_unblock_entries"
echo "✅ Unblock entries state saved to $UNBLOCK_STATE_FILE"
# Optionally flush DNS caches
if [[ $FLUSH_DNS -eq 1 ]]; then
echo "Flushing DNS caches..."
@ -774,3 +882,19 @@ if [[ $BROWSERS_KILLED -eq 1 ]]; then
else
echo " No browsers were running."
fi
# ============================================================================
# LOCK THIS SCRIPT AND generate_hosts_file.sh AGAINST SILENT EDITS
# ============================================================================
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
INSTALL_SCRIPT="$(readlink -f "$0")"
chattr +i "$INSTALL_SCRIPT" 2>/dev/null \
|| echo "⚠ Warning: Could not set immutable attribute on install.sh"
echo "✓ install.sh locked (chattr +i) — run 'sudo chattr -i $INSTALL_SCRIPT' to unlock for future changes"
GEN_SCRIPT="$SCRIPT_DIR/generate_hosts_file.sh"
if [[ -f $GEN_SCRIPT ]]; then
chattr +i "$GEN_SCRIPT" 2>/dev/null \
|| echo "⚠ Warning: Could not set immutable attribute on generate_hosts_file.sh"
echo "✓ generate_hosts_file.sh locked (chattr +i)"
fi

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