OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

VaultSessionKeyTest.php

7 KB
<?php

declare(strict_types=1);

use OpenNIT\Core\Database;
use OpenNIT\Core\Session;
use OpenNIT\Vault\VaultCrypto;
use OpenNIT\Vault\VaultManager;

/**
 * Hält fest, dass der Vault-Master-Key die Sitzungsdatei nie im Klartext
 * erreicht: Er liegt dort verschlüsselt, und der Schlüssel dazu stammt aus
 * einem Begleit-Cookie. Wer nur die Sitzungsdatei hat, kommt nicht an den
 * Tresor.
 */
return static function (): void {

    /**
     * Sitzung ohne PHP-Sitzungsverwaltung: hält Werte und Cookie im Speicher.
     */
    $sitzung = static function (): Session {
        return new class (['path' => '/', 'domain' => '', 'secure' => true, 'samesite' => 'Lax']) extends Session {
            /** @var array<string,mixed> */
            public array $werte = [];
            /** @var array<string,string> */
            public array $cookies = [];

            public function __construct(private array $cfg) {}

            public function set(string $key, mixed $value): void { $this->werte[$key] = $value; }
            public function get(string $key, mixed $default = null): mixed { return $this->werte[$key] ?? $default; }
            public function has(string $key): bool { return isset($this->werte[$key]); }
            public function remove(string $key): void { unset($this->werte[$key]); }

            public function begleitGeheimnis(string $name, bool $anlegen = false, int $laenge = 32): ?string
            {
                if (isset($this->cookies[$name])) {
                    return base64_decode(strtr($this->cookies[$name], '-_', '+/'), true) ?: null;
                }
                if (!$anlegen) {
                    return null;
                }
                $bytes = random_bytes($laenge);
                $this->cookies[$name] = rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
                return $bytes;
            }

            public function begleitGeheimnisLoeschen(string $name): void { unset($this->cookies[$name]); }
        };
    };

    /** Datenbank-Doppel: liefert für Leerlauf-Abfragen nichts Brauchbares. */
    $db = static function (): Database {
        return new class extends Database {
            public function __construct() {}
            public function fetchOne(string $sql, array $params = []): ?array { return null; }
            public function fetchAll(string $sql, array $params = []): array { return []; }
        };
    };

    /** Ruft eine private Methode des VaultManagers auf. */
    $ruf = static function (VaultManager $vm, string $methode, mixed ...$args): mixed {
        $m = new ReflectionMethod(VaultManager::class, $methode);
        $m->setAccessible(true);
        return $m->invoke($vm, ...$args);
    };

    $vmkSchluessel = 'vault_vmk_7';

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

    test('Der VMK liegt nicht im Klartext in der Sitzung', function () use ($sitzung, $db, $ruf, $vmkSchluessel) {
        $s   = $sitzung();
        $vm  = new VaultManager($db(), $s, 'app-key');
        $vmk = VaultCrypto::generateKey();

        $ruf($vm, 'writeSessionVmk', 7, $vmk);

        $abgelegt = (string)$s->werte[$vmkSchluessel];
        assertFalse(str_contains($abgelegt, base64_encode($vmk)));
        assertFalse(str_contains((string)base64_decode($abgelegt, true), $vmk));
    });

    test('Mit dem Begleit-Cookie kommt derselbe VMK zurück', function () use ($sitzung, $db, $ruf) {
        $s   = $sitzung();
        $vm  = new VaultManager($db(), $s, 'app-key');
        $vmk = VaultCrypto::generateKey();

        $ruf($vm, 'writeSessionVmk', 7, $vmk);
        assertSame($vmk, $ruf($vm, 'readSessionVmk', 7));
    });

    test('Ohne Begleit-Cookie bleibt der Tresor verschlossen', function () use ($sitzung, $db, $ruf) {
        $s   = $sitzung();
        $vm  = new VaultManager($db(), $s, 'app-key');
        $vmk = VaultCrypto::generateKey();

        $ruf($vm, 'writeSessionVmk', 7, $vmk);
        // Genau der Fall „jemand hat nur die Sitzungsdatei".
        $s->cookies = [];

        assertSame(null, $ruf($vm, 'readSessionVmk', 7));
        assertFalse($vm->isUnlocked(7));
    });

    test('Ein fremdes Begleit-Cookie öffnet den Tresor nicht', function () use ($sitzung, $db, $ruf) {
        $s   = $sitzung();
        $vm  = new VaultManager($db(), $s, 'app-key');
        $vmk = VaultCrypto::generateKey();

        $ruf($vm, 'writeSessionVmk', 7, $vmk);
        foreach (array_keys($s->cookies) as $name) {
            $s->cookies[$name] = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
        }

        assertSame(null, $ruf($vm, 'readSessionVmk', 7));
    });

    test('Jede Ablage benutzt eine eigene Nonce', function () use ($sitzung, $db, $ruf, $vmkSchluessel) {
        $s   = $sitzung();
        $vm  = new VaultManager($db(), $s, 'app-key');
        $vmk = VaultCrypto::generateKey();

        $ruf($vm, 'writeSessionVmk', 7, $vmk);
        $erste = (string)$s->werte[$vmkSchluessel];
        $ruf($vm, 'writeSessionVmk', 7, $vmk);

        assertFalse($erste === (string)$s->werte[$vmkSchluessel]);
    });

    // ----------------------------------------------------------------
    // Übergang und Sperren
    // ----------------------------------------------------------------

    test('Eine Sitzung von vor der Umstellung wird gelesen und aufgerüstet', function () use ($sitzung, $db, $ruf, $vmkSchluessel) {
        $s   = $sitzung();
        $vm  = new VaultManager($db(), $s, 'app-key');
        $vmk = VaultCrypto::generateKey();

        // Alte Form: der VMK selbst, nur base64-kodiert.
        $s->werte[$vmkSchluessel] = base64_encode($vmk);

        assertSame($vmk, $ruf($vm, 'readSessionVmk', 7));
        // Danach liegt er in der neuen Form da.
        assertFalse(base64_encode($vmk) === (string)$s->werte[$vmkSchluessel]);
        assertSame($vmk, $ruf($vm, 'readSessionVmk', 7));
    });

    test('Sperren entfernt Chiffrat und Begleit-Cookie', function () use ($sitzung, $db, $ruf, $vmkSchluessel) {
        $s  = $sitzung();
        $vm = new VaultManager($db(), $s, 'app-key');

        $ruf($vm, 'writeSessionVmk', 7, VaultCrypto::generateKey());
        assertTrue(isset($s->werte[$vmkSchluessel]));
        assertTrue($s->cookies !== []);

        $vm->lock(7);

        assertFalse(isset($s->werte[$vmkSchluessel]));
        assertSame([], $s->cookies);
    });

    test('Ohne Ablage liefert das Lesen nichts', function () use ($sitzung, $db, $ruf) {
        $s  = $sitzung();
        $vm = new VaultManager($db(), $s, 'app-key');
        assertSame(null, $ruf($vm, 'readSessionVmk', 7));
    });

    // ----------------------------------------------------------------
    // Das Begleit-Cookie selbst
    // ----------------------------------------------------------------

    test('Das Begleit-Cookie endet mit dem Browser und ist nicht per Skript lesbar', function () {
        $quelle = (string)file_get_contents(dirname(__DIR__) . '/src/Core/Session.php');
        $anfang = strpos($quelle, 'public function begleitGeheimnis(');
        assertTrue($anfang !== false);
        $rumpf = substr($quelle, $anfang, 1400);

        assertTrue(str_contains($rumpf, "'expires'  => 0"));
        assertTrue(str_contains($rumpf, "'httponly' => true"));
        // Ohne die Möglichkeit, das Cookie zu senden, darf kein Geheimnis entstehen.
        assertTrue(str_contains($rumpf, 'headers_sent()'));
    });
};