Quelltext des aktuellen Standes
<?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');
});
};