Files
ssh-jumphost/app/security/audit.py
2026-09-02 20:30:44 +02:00

102 lines
4.0 KiB
Python

"""
Manipulationssicheres, hash-verkettetes Audit-Log (siehe Konzept 4.7 / 6.1).
Jeder Eintrag verkettet sich kryptographisch mit seinem Vorgaenger:
entry_hash = sha256(prev_hash || ts || event_type || details_json)
Nachtraegliches Aendern oder Herausloeschen eines Eintrags bricht die Kette
ab dieser Stelle - erkennbar durch verify_chain(). Zusaetzlich verhindern
DB-Trigger (0001_initial.sql) UPDATE/DELETE auf Anwendungsebene.
"""
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from typing import Any
import asyncio
import aiosqlite
GENESIS_HASH = "0" * 64
def _entry_hash(prev_hash: str, ts: str, event_type: str, details_json: str) -> str:
payload = f"{prev_hash}|{ts}|{event_type}|{details_json}".encode()
return hashlib.sha256(payload).hexdigest()
# E1 (Umsetzungsauftrag_Sonnet5.md Teil E.1): Lesen von prev_hash und das
# nachfolgende INSERT muessen atomar sein. Ohne Schutz koennen zwei
# gleichzeitig laufende Aufrufe (z.B. zwei Sitzungen, die im selben Moment
# starten/enden) denselben prev_hash lesen und beide darauf aufbauen -- die
# Kette bricht dann dauerhaft, weil UPDATE/DELETE auf audit_log durch
# DB-Trigger verboten sind (0001_initial.sql). Zwei Schutzschichten:
# 1. Ein prozessweiter asyncio.Lock serialisiert alle Aufrufe innerhalb
# dieses (einzigen) Event-Loops -- ausreichend, solange die Anwendung in
# einem uvicorn-Worker mit einer globalen Verbindung laeuft (E.0/E.5).
# 2. BEGIN IMMEDIATE/COMMIT als zweite, von der Prozesstopologie
# unabhaengige Schutzschicht: falls doch einmal mehrere Verbindungen auf
# dieselbe Datei schreiben, sorgt die IMMEDIATE-Schreibsperre dafuer,
# dass SELECT und INSERT nicht auseinandergerissen werden.
_write_lock = asyncio.Lock()
async def write_audit_event(
conn: aiosqlite.Connection,
*,
event_type: str,
user_id: int | None,
client_ip: str | None,
details: dict[str, Any],
) -> None:
"""Schreibt einen Audit-Eintrag; haengt ihn an die bestehende Hash-Chain an.
Muss innerhalb derselben Transaktion wie die fachliche Aktion laufen (oder
zumindest unmittelbar danach), damit kein Ereignis unauditiert bleibt.
Lesen des prev_hash und das INSERT sind durch einen Lock plus eine
BEGIN-IMMEDIATE-Transaktion atomar (siehe E1 in Umsetzungsauftrag_Sonnet5.md).
"""
async with _write_lock:
await conn.execute("BEGIN IMMEDIATE")
try:
cursor = await conn.execute(
"SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1"
)
row = await cursor.fetchone()
prev_hash = row[0] if row else GENESIS_HASH
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
details_json = json.dumps(details, sort_keys=True, ensure_ascii=False)
entry_hash = _entry_hash(prev_hash, ts, event_type, details_json)
await conn.execute(
"INSERT INTO audit_log (ts, user_id, client_ip, event_type, details_json, prev_hash, entry_hash) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(ts, user_id, client_ip, event_type, details_json, prev_hash, entry_hash),
)
except BaseException:
await conn.execute("ROLLBACK")
raise
else:
await conn.execute("COMMIT")
async def verify_chain(conn: aiosqlite.Connection) -> tuple[bool, int | None]:
"""Prueft die gesamte Audit-Log-Kette. Rueckgabe: (intakt?, erste kaputte id)."""
prev_hash = GENESIS_HASH
cursor = await conn.execute(
"SELECT id, ts, event_type, details_json, prev_hash, entry_hash FROM audit_log ORDER BY id ASC"
)
async for row in cursor:
entry_id, ts, event_type, details_json, stored_prev, stored_entry = row
if stored_prev != prev_hash:
return False, entry_id
expected = _entry_hash(prev_hash, ts, event_type, details_json)
if expected != stored_entry:
return False, entry_id
prev_hash = stored_entry
return True, None