Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
use OpenNIT\Audit\HashChain;
use OpenNIT\Core\Database;
/**
* Deckt die Integritätsprüfung des Audit-Logs ab.
*
* Schwerpunkte: echte Manipulation wird erkannt, parallel geschriebene
* Einträge (eigener Verkettungspunkt im Feld prev_checksum) gelten als gültig,
* und ein Verweis auf einen nicht mehr vorhandenen Vorgänger schlägt fehl.
*/
return static function (): void {
$_ENV['APP_KEY'] = 'test-key-für-audit-kette';
/** Baut eine Eintragszeile inklusive gültiger HMAC-Prüfsumme. */
$makeEntry = function (int $id, string $prevChecksum, array $overrides = [], ?string $key = null) : array {
$entry = array_merge([
'id' => $id,
'event_id' => sprintf('%08d-0000-4000-8000-000000000000', $id),
'event_timestamp' => sprintf('2026-08-12 10:%02d:00.000000', $id),
'user_id' => 1,
'action_category' => 'auth',
'action_type' => 'login',
'object_type' => 'user',
'object_id' => 1,
'object_name' => 'Administrator',
'details_json' => null,
'old_value_json' => null,
'new_value_json' => null,
'result' => 'success',
'risk_level' => 'low',
], $overrides);
$data = implode('|', [
$entry['event_id'], $entry['event_timestamp'], $entry['user_id'] ?? '',
$entry['action_category'], $entry['action_type'], $entry['object_type'] ?? '',
$entry['object_id'] ?? '', $entry['details_json'] ?? '', $entry['result'],
$entry['object_name'] ?? '', $entry['old_value_json'] ?? '',
$entry['new_value_json'] ?? '', $entry['risk_level'], $prevChecksum,
]);
$entry['checksum'] = hash_hmac('sha256', $data, $key ?? $_ENV['APP_KEY']);
$entry['prev_checksum'] = $prevChecksum;
return $entry;
};
/** Datenbank-Attrappe, die die Prüfung mit festen Zeilen bedient. */
$db = function (array $rows): Database {
return new class($rows) extends Database {
/** @param array<int,array<string,mixed>> $rows */
public function __construct(private array $rows) { parent::__construct([]); }
public function fetchAll(string $sql, array $params = []): array
{
if (str_contains($sql, "action_type = 'setup_encryption'")) {
$hits = array_filter($this->rows, fn($r) => ($r['action_type'] ?? '') === 'setup_encryption');
return array_reverse(array_values($hits));
}
if (str_contains($sql, 'FROM audit_log') && str_contains($sql, 'ORDER BY id ASC')) {
return $this->rows;
}
if (str_contains($sql, 'FROM audit_log') && str_contains($sql, 'ORDER BY id DESC')) {
$before = array_filter($this->rows, fn($r) => $r['id'] < ($params[0] ?? PHP_INT_MAX));
return array_map(fn($r) => ['checksum' => $r['checksum']], array_reverse($before));
}
return [];
}
public function fetchOne(string $sql, array $params = []): ?array
{
// Anker-Tabellen sind in der Attrappe nicht vorhanden
if (str_contains($sql, 'audit_chain_anchor') || str_contains($sql, 'audit_hmac_anchor')) {
return null;
}
foreach ($this->rows as $r) {
if (str_contains($sql, 'WHERE id = ?') && $r['id'] === ($params[0] ?? null)) {
return $r;
}
}
if (str_contains($sql, 'id < ?')) {
$before = array_filter($this->rows, fn($r) => $r['id'] < ($params[0] ?? 0));
return $before ? end($before) : null;
}
return null;
}
public function fetchColumn(string $sql, array $params = [], int $column = 0): mixed
{
if (str_contains($sql, 'COUNT(*)')) {
return count($this->rows);
}
if (str_contains($sql, 'WHERE checksum = ?')) {
foreach ($this->rows as $r) {
if ($r['checksum'] === ($params[0] ?? null)) { return 1; }
}
return false;
}
return false;
}
};
};
test('Unveränderte Kette gilt als integer', function () use ($makeEntry, $db) {
$a = $makeEntry(1, 'GENESIS');
$b = $makeEntry(2, $a['checksum']);
$result = (new HashChain($db([$a, $b])))->verify();
assertTrue($result['valid']);
assertSame(0, count($result['invalid']));
assertSame(0, count($result['forks']));
});
test('Nachträglich geänderter Inhalt wird als Integritätsfehler erkannt', function () use ($makeEntry, $db) {
$a = $makeEntry(1, 'GENESIS');
$b = $makeEntry(2, $a['checksum']);
$b['object_name'] = 'Manipuliert';
$result = (new HashChain($db([$a, $b])))->verify();
assertTrue($result['valid'] === false);
assertSame(2, (int)$result['invalid'][0]['id']);
});
test('Parallel geschriebener Eintrag gilt als gültig, nicht als Manipulation', function () use ($makeEntry, $db) {
$a = $makeEntry(1, 'GENESIS');
$b = $makeEntry(2, $a['checksum']);
// Dritter Eintrag verkettet auf denselben Vorgänger wie der zweite
$c = $makeEntry(3, $a['checksum']);
$result = (new HashChain($db([$a, $b, $c])))->verify();
assertTrue($result['valid']);
assertSame(0, count($result['invalid']));
assertSame(1, count($result['forks']));
assertSame(3, (int)$result['forks'][0]['id']);
});
test('Verweis auf fehlenden Vorgänger bleibt ein Integritätsfehler', function () use ($makeEntry, $db) {
$a = $makeEntry(1, 'GENESIS');
$missing = hash('sha256', 'nie-vorhanden');
$c = $makeEntry(3, $missing);
$result = (new HashChain($db([$a, $c])))->verify();
assertTrue($result['valid'] === false);
assertSame(3, (int)$result['invalid'][0]['id']);
});
test('Einträge von vor der Schlüssel-Einrichtung gelten nicht als Manipulation', function () use ($makeEntry, $db) {
// Ohne Anwendungsschlüssel signiert (leerer Schlüssel)
$a = $makeEntry(1, 'GENESIS', [], '');
$b = $makeEntry(2, $a['checksum'], [], '');
// Die Einrichtung selbst trägt bereits den aktuellen Schlüssel
$setup = $makeEntry(3, $b['checksum'], [
'action_category' => 'admin',
'action_type' => 'setup_encryption',
'object_type' => 'system',
'object_id' => 0,
'object_name' => 'APP_KEY erzeugt',
'risk_level' => 'high',
]);
$after = $makeEntry(4, $setup['checksum']);
$result = (new HashChain($db([$a, $b, $setup, $after])))->verify();
assertTrue($result['valid']);
assertSame(0, count($result['invalid']));
assertSame(2, count($result['pre_key']));
});
test('Ohne Schlüssel signierte Einträge NACH der Einrichtung bleiben ungültig', function () use ($makeEntry, $db) {
$a = $makeEntry(1, 'GENESIS', [], '');
$setup = $makeEntry(2, $a['checksum'], [
'action_category' => 'admin',
'action_type' => 'setup_encryption',
'object_name' => 'APP_KEY erzeugt',
]);
// Nachträglich ohne Schlüssel eingeschobener Eintrag
$forged = $makeEntry(3, $setup['checksum'], [], '');
$result = (new HashChain($db([$a, $setup, $forged])))->verify();
assertTrue($result['valid'] === false);
assertSame(3, (int)$result['invalid'][0]['id']);
});
test('Einzelprüfung meldet parallel geschriebenen Eintrag als gültig', function () use ($makeEntry, $db) {
$a = $makeEntry(1, 'GENESIS');
$b = $makeEntry(2, $a['checksum']);
$c = $makeEntry(3, $a['checksum']);
$single = (new HashChain($db([$a, $b, $c])))->verifyEntry(3);
assertTrue($single['valid']);
assertTrue($single['fork']);
});
};