OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

WebhookSecretAtRestTest.php

8 KB
<?php

declare(strict_types=1);

use OpenNIT\Core\Database;
use OpenNIT\Cron\Handlers\WorkflowWebhookHandler;
use OpenNIT\Security\CredentialCrypto;

/**
 * Deckt die verschlüsselte Ablage des Signatur-Geheimnisses persönlicher
 * Webhooks ab.
 *
 * Der Wert signiert die Nutzlast an der hinterlegten Zieladresse. Im Klartext
 * abgelegt, liefert ein Datenbank-Abzug den Schlüssel gleich mit.
 */
return static function (): void {

    $wurzel = dirname(__DIR__);

    /** Datenbank-Attrappe, die Aktualisierungen mitschreibt. */
    $db = static function (): Database {
        return new class extends Database {
            /** @var list<array{table:string,data:array}> */
            public array $updates = [];

            public function __construct()
            {
                parent::__construct([]);
            }

            public function update(string $table, array $data, string $where = '', array $params = []): int
            {
                $this->updates[] = ['table' => $table, 'data' => $data];
                return 1;
            }
        };
    };

    /** Ruft die private Auflösung des Geheimnisses auf. */
    $secretOf = static function (Database $datenbank, ?string $gespeichert): ?string {
        $klasse  = new ReflectionClass(WorkflowWebhookHandler::class);
        $methode = $klasse->getMethod('secretOf');
        $methode->setAccessible(true);
        return $methode->invoke($klasse->newInstanceWithoutConstructor(), $datenbank, 7, $gespeichert);
    };

    // ----------------------------------------------------------------
    // Ablage
    // ----------------------------------------------------------------

    test('Ein neues Geheimnis wird verschlüsselt abgelegt', function () use ($wurzel) {
        $quelle = (string)file_get_contents($wurzel . '/src/Controllers/WorkflowController.php');
        $von    = strpos($quelle, 'public function addWebhook');
        $rumpf  = substr($quelle, (int)$von, (int)strpos($quelle, "\n    }\n", (int)$von) - (int)$von);

        assertTrue(str_contains($rumpf, 'CredentialCrypto::encrypt'), 'Das Geheimnis landet im Klartext');

        // In der Einfügung steht der abgelegte Wert, nicht der Klartext.
        $von   = strpos($rumpf, "insert('user_webhooks'");
        $block = substr($rumpf, (int)$von, 400);
        assertFalse(
            (bool)preg_match("/'secret'\s*=>\s*\\\$secret\b/", $block),
            'Der Klartext wird unverändert gespeichert'
        );
    });

    test('Ohne Anwendungsschlüssel wird nichts gespeichert und nichts abgestuerzt', function () use ($wurzel) {
        // CredentialCrypto wirft bewusst, statt Klartext abzulegen. Ungefangen
        // waere daraus ein Serverfehler statt einer Meldung geworden.
        $quelle = (string)file_get_contents($wurzel . '/src/Controllers/WorkflowController.php');
        $von    = strpos($quelle, 'public function addWebhook');
        $rumpf  = substr($quelle, (int)$von, (int)strpos($quelle, "\n    }\n", (int)$von) - (int)$von);

        assertTrue(str_contains($rumpf, 'catch (\Throwable'), 'Der Fehlschlag endet im Serverfehler');
        assertTrue(str_contains($rumpf, 'Anwendungsschlüssel'), 'Die Meldung nennt die Ursache nicht');
        assertTrue(
            strpos($rumpf, 'catch (\Throwable') < strpos($rumpf, "insert('user_webhooks'"),
            'Die Absicherung greift erst nach dem Speichern'
        );
    });

    test('Die Spalte fasst die verschlüsselte Form', function () use ($wurzel) {
        // Der Blob ist länger als der Klartext; VARCHAR(255) lehnte ihn unter
        // STRICT_TRANS_TABLES mit einem Fehler ab.
        $sql = (string)file_get_contents($wurzel . '/database/migrations/208_webhook_secret_encrypted.sql');

        assertTrue(str_contains($sql, 'MODIFY COLUMN `secret` TEXT'), 'Die Spaltenbreite bleibt zu klein');
        assertTrue(str_contains($sql, 'user_webhooks'), 'Falsche Tabelle');
        assertFalse(str_contains($sql, 'UPDATE '), 'Die Migration fasst Daten an – der Schlüssel liegt nur der Anwendung vor');
    });

    // ----------------------------------------------------------------
    // Zustellung
    // ----------------------------------------------------------------

    test('Beim Zustellen wird wieder entschlüsselt', function () use ($db, $secretOf) {
        $key = bin2hex(random_bytes(16));
        $alt = $_ENV['APP_KEY'] ?? null;
        $_ENV['APP_KEY'] = $key;

        $datenbank = $db();
        $klartext  = $secretOf($datenbank, CredentialCrypto::encrypt('mein-geheimnis', $key));

        assertSame('mein-geheimnis', $klartext, 'Die Signatur würde mit dem Blob gebildet');
        assertSame([], $datenbank->updates, 'Ein bereits verschlüsselter Wert wird erneut geschrieben');

        if ($alt === null) { unset($_ENV['APP_KEY']); } else { $_ENV['APP_KEY'] = $alt; }
    });

    test('Ein Altbestand wird beim Zustellen nachgezogen', function () use ($db, $secretOf) {
        // Die Signatur muss gültig bleiben – der Wert selbst ändert sich nicht,
        // nur seine Ablage.
        $key = bin2hex(random_bytes(16));
        $alt = $_ENV['APP_KEY'] ?? null;
        $_ENV['APP_KEY'] = $key;

        $datenbank = $db();
        $klartext  = $secretOf($datenbank, 'alt-im-klartext');

        assertSame('alt-im-klartext', $klartext, 'Die Signatur bricht beim Nachziehen');
        assertSame(1, count($datenbank->updates), 'Der Altbestand bleibt im Klartext liegen');
        assertSame('user_webhooks', $datenbank->updates[0]['table']);
        assertTrue(
            CredentialCrypto::isEncrypted((string)$datenbank->updates[0]['data']['secret']),
            'Zurückgeschrieben wurde erneut Klartext'
        );

        if ($alt === null) { unset($_ENV['APP_KEY']); } else { $_ENV['APP_KEY'] = $alt; }
    });

    test('Ohne Anwendungsschlüssel bleibt der Altbestand unangetastet', function () use ($db, $secretOf) {
        // Lieber Klartext behalten als mit untauglichem Schlüssel unlesbar machen.
        $alt = $_ENV['APP_KEY'] ?? null;
        $_ENV['APP_KEY'] = '';

        $datenbank = $db();
        assertSame('alt-im-klartext', $secretOf($datenbank, 'alt-im-klartext'));
        assertSame([], $datenbank->updates, 'Es wurde ohne brauchbaren Schlüssel geschrieben');

        if ($alt === null) { unset($_ENV['APP_KEY']); } else { $_ENV['APP_KEY'] = $alt; }
    });

    test('Ein Webhook ohne Geheimnis bleibt ohne Signatur', function () use ($db, $secretOf) {
        $datenbank = $db();
        assertTrue($secretOf($datenbank, null) === null);
        assertTrue($secretOf($datenbank, '') === null);
        assertSame([], $datenbank->updates);
    });

    test('Die Oberfläche zeigt das Geheimnis nicht an', function () use ($wurzel) {
        // Nach der Umstellung stünde dort sonst der Blob.
        $tpl = (string)file_get_contents($wurzel . '/templates/workflows/webhooks.php');

        assertFalse((bool)preg_match("/\\\$h\['secret'\]\s*\)?\s*\?>/", $tpl), 'Das Geheimnis wird ausgegeben');
        assertTrue(str_contains($tpl, "!empty(\$h['secret'])"), 'Der Hinweis auf die Signatur fehlt');
    });

    // ----------------------------------------------------------------
    // Installationsassistent im Webroot
    // ----------------------------------------------------------------

    test('Ein liegengebliebener Installationsassistent wird gemeldet', function () use ($wurzel) {
        $quelle = (string)file_get_contents($wurzel . '/src/Controllers/Admin/SystemHealthController.php');

        assertTrue(str_contains($quelle, "public/install.php"), 'Keine Prüfung');
        $von   = strpos($quelle, "'Installationsassistent'");
        $block = substr($quelle, (int)$von, 700);
        assertTrue(str_contains($block, "'warning'"), 'Der Fund bleibt ohne Warnung');
        assertTrue(str_contains($block, 'rm public/install.php'), 'Ohne Angabe, was zu tun ist');
    });
};