screen-locker/screen_locker/_log_integrity.py
Krzysztof kuhy Rudnicki 4cdfce5fe3 chore: set up as standalone repo
Extracted from testsAndMisc monorepo. Changes:
- Rewrote imports from python_pkg.screen_locker.* → screen_locker.*
- Vendored python_pkg.shared.log_integrity → screen_locker._log_integrity
- Vendored wake_alarm constants (ALARM_DAYS, WAKE_AFTER_HOURS, RTCWAKE_BIN) into _constants.py
- Extracted has_workout_skip_today into new screen_locker._wake_state module
- Added tests for _wake_state.py (392 tests, 100% branch coverage)
- Moved scripts/service files to repo root
- Added standalone pyproject.toml, requirements.txt, .pre-commit-config.yaml, .gitignore
- Added GitHub Actions CI workflows

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 07:43:06 +02:00

81 lines
2.3 KiB
Python

"""HMAC-based integrity checking for signed state entries."""
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)
HMAC_KEY_FILE = Path("/etc/workout-locker/hmac.key")
def _load_hmac_key() -> bytes | None:
"""Load HMAC key from the root-owned key file.
Returns the key bytes, or None if the file cannot be read.
"""
try:
return HMAC_KEY_FILE.read_bytes().strip()
except OSError:
_logger.warning("Cannot read HMAC key from %s", HMAC_KEY_FILE)
return None
def _generate_hmac_key() -> bytes | None:
"""Generate a new HMAC key and write it to the key file.
The key file must be writable (requires root or setup script).
Returns the new key bytes, or None on failure.
"""
key = secrets.token_bytes(32)
try:
HMAC_KEY_FILE.parent.mkdir(parents=True, exist_ok=True)
HMAC_KEY_FILE.write_bytes(key)
except OSError:
_logger.warning("Cannot write HMAC key to %s", HMAC_KEY_FILE)
return None
return key
def compute_entry_hmac(entry_data: dict[str, object]) -> str | None:
"""Compute HMAC-SHA256 for a state entry.
Args:
entry_data: The entry dict (without the 'hmac' field).
Returns:
Hex-encoded HMAC string, or None if the key is unavailable.
"""
key = _load_hmac_key()
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]) -> bool:
"""Verify HMAC signature of a state entry.
Args:
entry: The full entry dict including the 'hmac' field.
Returns:
True if the HMAC is valid, False if invalid or key unavailable.
"""
stored_hmac = entry.get("hmac")
if not isinstance(stored_hmac, str):
return False
key = _load_hmac_key()
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)