Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
use OpenNIT\Audit\ChecksumPayload;
use OpenNIT\Audit\HashChain;
use OpenNIT\Core\Database;
/**
* Deckt die signierte Grenze zwischen den beiden Serialisierungen ab.
*
* Bestehende Einträge wurden über die Verkettung mit `|` signiert, neue über
* die längenpräfixierte Form. Die Grenze steht in `audit_field_anchor` und ist
* mit dem Anwendungsschlüssel signiert: Oberhalb gilt ausschließlich das neue
* Verfahren – ein Eintrag lässt sich also nicht nachträglich auf das frühere
* zurückstufen, um dessen verschiebbare Feldgrenzen auszunutzen.
*/
return static function (): void {
$_ENV['APP_KEY'] = 'test-key-für-format-grenze';
/**
* Baut eine Eintragszeile mit Prüfsumme in der gewünschten Serialisierung.
*/
$makeEntry = static function (int $id, string $prev, bool $lengthPrefixed, array $overrides = []): array {
$entry = array_merge([
'id' => $id,
'event_id' => sprintf('%08d-0000-4000-8000-000000000000', $id),
'event_timestamp' => sprintf('2026-08-30 09:%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);
$felder = [
(string)$entry['event_id'],
(string)$entry['event_timestamp'],
(string)($entry['user_id'] ?? ''),
(string)$entry['action_category'],
(string)$entry['action_type'],
(string)($entry['object_type'] ?? ''),
(string)($entry['object_id'] ?? ''),
(string)($entry['details_json'] ?? ''),
(string)$entry['result'],
(string)($entry['object_name'] ?? ''),
(string)($entry['old_value_json'] ?? ''),
(string)($entry['new_value_json'] ?? ''),
(string)$entry['risk_level'],
$prev,
];
$data = $lengthPrefixed
? ChecksumPayload::lengthPrefixed($felder)
: ChecksumPayload::pipeJoined($felder);
$entry['checksum'] = hash_hmac('sha256', $data, $_ENV['APP_KEY']);
$entry['prev_checksum'] = $prev;
return $entry;
};
/**
* Datenbank-Attrappe mit Einträgen und – optional – einer signierten
* Format-Grenze.
*/
$db = static function (array $rows, ?int $boundary = null, bool $gueltigeSignatur = true): Database {
return new class($rows, $boundary, $gueltigeSignatur) extends Database {
public function __construct(
private array $rows,
private ?int $boundary,
private bool $gueltigeSignatur
) {
parent::__construct([]);
}
public function fetchAll(string $sql, array $params = []): array
{
// Die Suche nach der Schlüssel-Einrichtung liefert vollständige
// Zeilen – vor den Zweigen, die nur Prüfsummen zurückgeben.
if (str_contains($sql, "action_type = 'setup_encryption'")) {
return [];
}
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')) {
$vorher = array_filter($this->rows, fn($r) => $r['id'] < ($params[0] ?? PHP_INT_MAX));
return array_map(fn($r) => ['checksum' => $r['checksum']], array_reverse($vorher));
}
return [];
}
public function fetchOne(string $sql, array $params = []): ?array
{
if (str_contains($sql, 'audit_field_anchor')) {
if ($this->boundary === null) {
return null;
}
$sig = hash_hmac(
'sha256',
'audit_field_anchor|' . $this->boundary,
$this->gueltigeSignatur ? ($_ENV['APP_KEY'] ?? '') : 'anderer-schluessel'
);
return ['boundary_id' => $this->boundary, 'signature' => $sig];
}
if (str_contains($sql, '_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 < ?')) {
$vorher = array_filter($this->rows, fn($r) => $r['id'] < ($params[0] ?? 0));
return $vorher ? end($vorher) : 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;
}
};
};
// ----------------------------------------------------------------
// Ohne Anker: beide Serialisierungen gelten
// ----------------------------------------------------------------
test('Ohne Grenze bleiben Einträge im früheren Verfahren gültig', function () use ($makeEntry, $db) {
$a = $makeEntry(1, 'GENESIS', false);
$b = $makeEntry(2, $a['checksum'], false);
assertTrue((new HashChain($db([$a, $b])))->verify()['valid']);
});
test('Ohne Grenze gelten auch längenpräfixierte Einträge', function () use ($makeEntry, $db) {
// Der Fall zwischen Programm- und Datenbank-Aktualisierung: Es wird
// bereits neu signiert, die Ankertabelle existiert aber noch nicht.
$a = $makeEntry(1, 'GENESIS', true);
$b = $makeEntry(2, $a['checksum'], true);
assertTrue((new HashChain($db([$a, $b])))->verify()['valid']);
});
// ----------------------------------------------------------------
// Mit signierter Grenze
// ----------------------------------------------------------------
test('Unterhalb der Grenze gilt das frühere Verfahren', function () use ($makeEntry, $db) {
$a = $makeEntry(1, 'GENESIS', false);
$b = $makeEntry(2, $a['checksum'], false);
assertTrue((new HashChain($db([$a, $b], 2)))->verify()['valid']);
});
test('Unterhalb der Grenze gilt auch die längenpräfixierte Form', function () use ($makeEntry, $db) {
// Die Grenze entsteht erst beim ersten Schreibvorgang nach dem Anlegen
// der Ankertabelle. Einträge, die zwischen Programm- und
// Datenbank-Aktualisierung entstehen, sind bereits längenpräfixiert,
// liegen aber noch darunter – sie dürfen nicht als verändert gelten.
$a = $makeEntry(1, 'GENESIS', false);
$b = $makeEntry(2, $a['checksum'], true);
assertTrue((new HashChain($db([$a, $b], 2)))->verify()['valid']);
});
test('Oberhalb der Grenze gilt die längenpräfixierte Form', function () use ($makeEntry, $db) {
$a = $makeEntry(1, 'GENESIS', false);
$b = $makeEntry(2, $a['checksum'], true);
assertTrue((new HashChain($db([$a, $b], 1)))->verify()['valid']);
});
test('Oberhalb der Grenze wird das frühere Verfahren nicht mehr anerkannt', function () use ($makeEntry, $db) {
// Kern der Grenze: Ein Eintrag lässt sich nicht auf das Verfahren mit
// verschiebbaren Feldgrenzen zurückstufen.
$a = $makeEntry(1, 'GENESIS', false);
$b = $makeEntry(2, $a['checksum'], false);
$ergebnis = (new HashChain($db([$a, $b], 1)))->verify();
assertFalse($ergebnis['valid']);
});
test('Eine unsignierte Grenze lässt beide Verfahren zu', function () use ($makeEntry, $db) {
// Nach einem Schlüsselwechsel ist die Grenze nicht mehr belegbar; sie
// darf dann keinen Eintrag fälschlich als verändert melden.
$a = $makeEntry(1, 'GENESIS', false);
$b = $makeEntry(2, $a['checksum'], false);
assertTrue((new HashChain($db([$a, $b], 1, false)))->verify()['valid']);
});
};