OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

AuditFieldAnchorTest.php

9 KB
<?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']);
    });
};