utils/gatelock/gatelock/log_integrity.py
Krzysztof kuhy Rudnicki 5f7553de91 Initial gatelock: shared lock-window + HMAC log-integrity backend
Extracted from wake_alarm, screen-locker, and diet_guard, which each
independently implemented fullscreen-lock, input-grab, VT-disable, and
HMAC-signed-state mechanics at different levels of maturity (the HMAC
module was already a hand-copied duplicate between two of them).
LockConfig exposes overrideredirect/grab/disable_vt as independent
axes so one LockWindow can reproduce all three projects' exact prior
behavior, plus screen-locker's confirmed upgrade to retry-forever grab.
2026-06-19 17:39:44 +02:00

111 lines
3.6 KiB
Python

"""HMAC-based integrity checking for signed state entries.
Ported from the byte-for-byte-duplicated copies in testsAndMisc's
``python_pkg/shared/log_integrity.py`` and screen-locker's
``_log_integrity.py``. ``DEFAULT_HMAC_KEY_FILE`` keeps the exact same literal
path both copies used -- changing it would invalidate every already-signed
entry in wake_alarm's, screen-locker's, and diet_guard's existing state files.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
from pathlib import Path
import secrets
_logger = logging.getLogger(__name__)
# HMAC key for signing state entries (root-owned, 0600).
DEFAULT_HMAC_KEY_FILE = Path("/etc/workout-locker/hmac.key")
def _load_hmac_key(key_file: Path) -> bytes | None:
"""Load the HMAC key from ``key_file``.
Returns the key bytes, or None if the file cannot be read.
"""
try:
return key_file.read_bytes().strip()
except OSError:
_logger.warning("Cannot read HMAC key from %s", key_file)
return None
def generate_hmac_key(key_file: Path | None = None) -> bytes | None:
"""Generate a new HMAC key and write it to ``key_file``.
The key file's parent must be writable (requires root or a setup script).
Args:
key_file: Where to write the new key. None (the default) targets the
shared, root-owned key location all three lockers expect -- read
fresh from :data:`DEFAULT_HMAC_KEY_FILE` on every call, so tests
(or callers) can repoint it by patching that module attribute.
Returns:
The new key bytes, or None on failure.
"""
target = key_file if key_file is not None else DEFAULT_HMAC_KEY_FILE
key = secrets.token_bytes(32)
try:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(key)
except OSError:
_logger.warning("Cannot write HMAC key to %s", target)
return None
return key
def compute_entry_hmac(
entry_data: dict[str, object],
*,
key_file: Path | None = None,
) -> str | None:
"""Compute HMAC-SHA256 for a state entry.
Args:
entry_data: The entry dict (without the 'hmac' field).
key_file: Where to read the signing key from. None (the default)
reads :data:`DEFAULT_HMAC_KEY_FILE` fresh on every call.
Returns:
Hex-encoded HMAC string, or None if the key is unavailable.
"""
target = key_file if key_file is not None else DEFAULT_HMAC_KEY_FILE
key = _load_hmac_key(target)
if key is None:
return None
payload = json.dumps(entry_data, sort_keys=True, separators=(",", ":"))
return hmac.new(key, payload.encode(), hashlib.sha256).hexdigest()
def verify_entry_hmac(
entry: dict[str, object],
*,
key_file: Path | None = None,
) -> bool:
"""Verify the HMAC signature of a state entry.
Args:
entry: The full entry dict, including the 'hmac' field.
key_file: Where to read the signing key from. None (the default)
reads :data:`DEFAULT_HMAC_KEY_FILE` fresh on every call.
Returns:
True if the HMAC is valid, False if invalid or the key is unavailable.
"""
stored_hmac = entry.get("hmac")
if not isinstance(stored_hmac, str):
return False
target = key_file if key_file is not None else DEFAULT_HMAC_KEY_FILE
key = _load_hmac_key(target)
if key is None:
return False
entry_without_hmac = {k: v for k, v in entry.items() if k != "hmac"}
payload = json.dumps(entry_without_hmac, sort_keys=True, separators=(",", ":"))
expected = hmac.new(key, payload.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(stored_hmac, expected)