OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

PlaintextSecretsTest.php

10 KB
<?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));
    });
};