Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
use OpenNIT\Core\LegacySecretCleanup;
use OpenNIT\Security\CredentialCrypto;
/**
* Haelt fest, dass hinterlegte Zugangsdaten nirgends im Klartext liegen.
*
* Drei Dinge gehoeren zu jedem Zugangsdatum und muessen zusammenpassen:
* verschluesselt schreiben, entschluesselt lesen und den Bestand nachziehen.
* Faellt eines davon aus, faellt es im Betrieb erst auf, wenn es zu spaet ist -
* entweder weil der Wert im Klartext auf der Platte liegt oder weil der
* Chiffretext als Passwort hinausgeht.
*/
return static function (): void {
$wurzel = dirname(__DIR__);
$src = static fn (string $datei): string => (string)file_get_contents($wurzel . $datei);
// ----------------------------------------------------------------
// LDAP-Bind-Passwort des Verzeichnisabgleichs
// ----------------------------------------------------------------
test('Zugangsdaten einer Verzeichnis-Anbindung werden verschluesselt gespeichert', function () use ($src) {
// Die Anbindungen loesten die einzelne Konfiguration ab; Bind-Passwort
// und Client-Secret liegen jetzt in `settings_json`.
$mgr = $src('/src/Directory/DirectoryConnectionManager.php');
assertTrue(str_contains($mgr, "public const SECRET_FIELDS = ['client_secret', 'bind_password']"), 'Die Geheimnisse sind nicht benannt');
$von = strpos($mgr, 'in_array($feld, self::SECRET_FIELDS, true)');
assertTrue($von !== false, 'Geheimnisse werden nicht gesondert behandelt');
$block = substr($mgr, (int)$von, 500);
assertTrue(str_contains($block, 'CredentialCrypto::encrypt('), 'Ein Geheimnis geht im Klartext in die Datenbank');
// Eine leere Eingabe darf den hinterlegten Wert nicht loeschen.
assertTrue(str_contains($block, "\$eingabe !== ''"), 'Leere Eingabe ueberschreibt den Bestand');
assertTrue(str_contains($block, 'elseif (isset($bisher[$feld]))'), 'Der hinterlegte Wert wird nicht behalten');
});
test('Das LDAP-Passwort wird vor der Anmeldung entschluesselt', function () use ($src) {
$code = $src('/src/Directory/DirectorySync.php');
$von = strpos($code, 'private function syncFromLdap');
$block = substr($code, (int)$von, 900);
assertTrue(str_contains($block, 'CredentialCrypto::decrypt('), 'Der Chiffretext geht als Passwort hinaus');
assertFalse(
(bool)preg_match("/\\\$bindPw\s*=\s*\\\$this->config\['ldap'\]\['bind_password'\]/", $block),
'Das Passwort wird weiterhin roh gelesen'
);
});
test('Zugangsdaten stehen nicht mehr im Formular', function () use ($src) {
// Bisher ging das Bind-Passwort bei jedem Aufruf der Einstellungsseite
// mit an den Browser. Das Formular der Anbindung zeigt nur noch an,
// *dass* etwas hinterlegt ist.
$tpl = $src('/templates/admin/directory/connection_form.php');
foreach (['bind_password', 'client_secret'] as $feld) {
assertTrue(str_contains($tpl, 'name="' . $feld . '"'), $feld . ': Das Feld fehlt');
assertFalse(
str_contains($tpl, 'name="' . $feld . '" class="form-control" value='),
$feld . ': Der Wert wird in die Seite geschrieben'
);
}
assertTrue(str_contains($tpl, '(hinterlegt)'), 'Es ist nicht erkennbar, ob etwas hinterlegt ist');
assertTrue(substr_count($tpl, 'type="password"') >= 2, 'Die Felder sind nicht als Passwort ausgezeichnet');
});
// ----------------------------------------------------------------
// VAPID-Signierschluessel (Web-Push)
// ----------------------------------------------------------------
test('Der Push-Signierschluessel wird verschluesselt abgelegt', function () use ($src) {
foreach ([
'/src/Controllers/Admin/SettingsController.php',
'/bin/generate-vapid-keys.php',
] as $datei) {
$code = $src($datei);
$von = strpos($code, "\$config['vapid']['private_key']");
assertTrue($von !== false, "Keine Schreibstelle in {$datei}");
$block = substr($code, (int)$von, 300);
assertTrue(str_contains($block, 'CredentialCrypto::encrypt('), "Klartext in {$datei}");
}
});
test('Der Push-Dienst entschluesselt vor dem Signieren', function () use ($src) {
$code = $src('/src/Push/PushService.php');
assertTrue(str_contains($code, 'CredentialCrypto::decrypt('), 'Der Chiffretext wird zum Signieren benutzt');
// Die Bereitschaftspruefung muss einen verwendbaren Schluessel meinen,
// nicht bloss einen vorhandenen Eintrag.
$entschluesselt = strpos($code, 'CredentialCrypto::decrypt(');
$bereit = strpos($code, '$this->enabled = !empty(');
assertTrue($entschluesselt !== false, 'Keine Entschluesselung gefunden');
assertTrue($bereit !== false, 'Keine Bereitschaftspruefung gefunden');
assertTrue($entschluesselt < $bereit, 'Erst pruefen, dann entschluesseln');
});
// ----------------------------------------------------------------
// NVD-API-Schluessel
// ----------------------------------------------------------------
test('Der NVD-Schluessel wird verschluesselt gespeichert und entschluesselt gelesen', function () use ($src) {
$ctrl = $src('/src/Controllers/Admin/CveAdminController.php');
assertTrue(str_contains($ctrl, 'CredentialCrypto::encrypt('), 'Der Schluessel geht im Klartext in die Datenbank');
// Entfernen bleibt moeglich, obwohl "leer" jetzt "unveraendert" heisst.
assertTrue(str_contains($ctrl, 'remove_nvd_api_key'), 'Der Schluessel laesst sich nicht mehr entfernen');
$mgr = $src('/src/Content/CveManager.php');
$von = strpos($mgr, 'private function getApiKey');
$block = substr($mgr, (int)$von, 600);
assertTrue(str_contains($block, 'CredentialCrypto::decrypt('), 'Der Chiffretext geht als Schluessel hinaus');
});
test('Der NVD-Schluessel steht nicht mehr im Formular', function () use ($src) {
$tpl = $src('/templates/admin/cve/settings.php');
assertFalse(str_contains($tpl, 'value="<?= $this->e($nvdApiKey) ?>"'), 'Der Schluessel wird in die Seite geschrieben');
assertTrue(str_contains($tpl, 'type="password"'), 'Der Schluessel steht sichtbar im Feld');
// Der Controller reicht nur die Tatsache durch, nicht den Wert.
$ctrl = $src('/src/Controllers/Admin/CveAdminController.php');
assertTrue(str_contains($ctrl, "'nvd_api_key_set'"), 'Es wird weiterhin der Wert uebergeben');
assertFalse(
(bool)preg_match("/'nvd_api_key'\s*=>\s*\\\$this->getSetting/", $ctrl),
'Der Wert geht weiterhin an die Seite'
);
});
// ----------------------------------------------------------------
// Bestand nachziehen
// ----------------------------------------------------------------
test('Jedes verschluesselte Feld wird auch im Bestand nachgezogen', function () use ($src) {
// config/ wird beim Update nie ersetzt: Ein einmal im Klartext
// geschriebener Wert bliebe ohne diese Liste fuer immer stehen.
$liste = (new ReflectionClass(LegacySecretCleanup::class))
->getConstant('RUNTIME_SECRET_PATHS');
foreach ([['ldap', 'bind_password'], ['vapid', 'private_key']] as $pfad) {
assertTrue(in_array($pfad, $liste, true), 'Nicht nachgezogen: ' . implode('.', $pfad));
}
// Und die Werte in den Einstellungstabellen.
$db = (new ReflectionClass(LegacySecretCleanup::class))->getConstant('DB_SECRET_SOURCES');
$schluessel = array_column($db, 3);
assertTrue(in_array('nvd_api_key', $schluessel, true), 'NVD-Schluessel wird nicht nachgezogen');
});
test('Beide Nachzieh-Laeufe haengen am Update', function () use ($src) {
// Der Datenbank-Lauf hing zuvor allein am einmaligen Erzeugen eines
// APP_KEY - auf einer eingerichteten Instanz lief er nie wieder, und ein
// spaeter hinzugekommener Wert waere nie umgestellt worden.
$upd = $src('/src/Content/UpdateManager.php');
$von = strpos($upd, 'private function cleanupLegacySecrets');
$block = substr($upd, (int)$von, 2600);
assertTrue(str_contains($block, 'LegacySecretCleanup::encryptRuntimeSecrets('), 'Datei-Lauf fehlt');
assertTrue(str_contains($block, 'LegacySecretCleanup::encryptDatabaseSecrets('), 'Datenbank-Lauf fehlt');
});
test('Die Liste der Werte wird nur an einer Stelle gefuehrt', function () use ($src) {
// Zwei Listen laufen auseinander; die Oberflaeche ruft dieselbe Stelle auf.
$health = $src('/src/Controllers/Admin/SystemHealthController.php');
$von = strpos($health, 'private function encryptDbSecrets');
$block = substr($health, (int)$von, 500);
assertTrue(str_contains($block, 'LegacySecretCleanup::encryptDatabaseSecrets('), 'Eigene Umsetzung statt gemeinsamer');
assertFalse(str_contains($block, "'system_settings'"), 'Zweite Liste im Controller');
});
test('Ein Klartext-Altwert bleibt nutzbar, ein Chiffretext wird erkannt', function () {
$appKey = str_repeat('s', 32);
// Bestandsinstanzen duerfen durch die Umstellung nicht kaputtgehen.
assertSame('altes-passwort', CredentialCrypto::decrypt('altes-passwort', $appKey));
$chiffre = CredentialCrypto::encrypt('neues-passwort', $appKey);
assertTrue(CredentialCrypto::isEncrypted($chiffre), 'Chiffretext nicht als solcher erkennbar');
assertFalse(CredentialCrypto::isEncrypted('altes-passwort'), 'Klartext faelschlich als Chiffre erkannt');
assertSame('neues-passwort', CredentialCrypto::decrypt($chiffre, $appKey));
});
};