OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

VaultPasskeysTest.php

10 KB
<?php

declare(strict_types=1);

use OpenNIT\Controllers\VaultApiController;
use OpenNIT\Core\Database;
use OpenNIT\Vault\VaultCrypto;
use OpenNIT\Vault\VaultPasskeys;

/**
 * Hält fest, was der Server als Passkey-Authenticator zusichert: gültige
 * WebAuthn-Strukturen (COSE-Schlüssel, Authenticator-Daten, Attestierung),
 * prüfbare Signaturen, Schlüsselmaterial nur verschlüsselt in der Ablage –
 * und die Adress- und Ordnerregeln der Erweiterungs-Schnittstelle.
 */
return static function (): void {

    /** Datenbank-Doppel, das Schreibvorgänge festhält und Leseabfragen beantwortet. */
    $dbDoppel = static function (): Database {
        return new class extends Database {
            /** @var array<int,array{table:string,data:array}> */
            public array $inserts = [];
            public array $updates = [];
            public function __construct()
            {
                parent::__construct([]);
            }
            public function insert(string $table, array $data): int
            {
                $this->inserts[] = ['table' => $table, 'data' => $data];
                return count($this->inserts);
            }
            public function update(string $table, array $data, string $where = '', array $params = []): int
            {
                $this->updates[] = ['table' => $table, 'data' => $data, 'where' => $where, 'params' => $params];
                return 1;
            }
        };
    };

    $key  = random_bytes(32);
    $rpId = 'Example.ORG';

    // ----------------------------------------------------------------
    // Kodierung
    // ----------------------------------------------------------------

    test('CBOR-Grundtypen entsprechen den Referenzwerten der RFC 8949', function () {
        assertSame("\x01", VaultPasskeys::cborInt(1));
        assertSame("\x18\x64", VaultPasskeys::cborInt(100));
        assertSame("\x19\x03\xe8", VaultPasskeys::cborInt(1000));
        assertSame("\x20", VaultPasskeys::cborInt(-1));
        assertSame("\x26", VaultPasskeys::cborInt(-7));
        assertSame("\x63fmt", VaultPasskeys::cborText('fmt'));
        assertSame("\x44\x01\x02\x03\x04", VaultPasskeys::cborBytes("\x01\x02\x03\x04"));
        assertSame("\xa0", VaultPasskeys::cborMap([]));
        assertSame("\x58\x20" . str_repeat('x', 32), VaultPasskeys::cborBytes(str_repeat('x', 32)));
    });

    test('Der COSE-Schlüssel hat die feste EC2/P-256/ES256-Struktur und lässt sich zurücklesen', function () {
        $x = random_bytes(32);
        $y = random_bytes(32);
        $cose = VaultPasskeys::coseEcKey($x, $y);
        assertSame(77, strlen($cose));
        assertSame("\xa5\x01\x02\x03\x26\x20\x01\x21\x58\x20", substr($cose, 0, 10));
        assertSame([$x, $y], VaultPasskeys::coseToPoint($cose));
    });

    test('Die Authenticator-Daten beginnen mit dem rpIdHash der kleingeschriebenen Domain, Flags und Zähler 0', function () use ($rpId) {
        $hdr = VaultPasskeys::authDataHeader($rpId, 0x1d);
        assertSame(37, strlen($hdr));
        assertSame(hash('sha256', 'example.org', true), substr($hdr, 0, 32));
        assertSame("\x1d", $hdr[32]);
        assertSame("\0\0\0\0", substr($hdr, 33, 4));
    });

    test('Base64URL ist ohne Padding und in beide Richtungen verlustfrei', function () {
        $raw = random_bytes(45);
        $enc = VaultPasskeys::base64url($raw);
        assertTrue(preg_match('/^[A-Za-z0-9_-]+$/', $enc) === 1);
        assertSame($raw, VaultPasskeys::base64urlDecode($enc));
        assertSame($raw, VaultPasskeys::base64urlDecode(base64_encode($raw)));
        assertSame(null, VaultPasskeys::base64urlDecode('nicht*base64'));
    });

    // ----------------------------------------------------------------
    // Anlegen
    // ----------------------------------------------------------------

    test('Anlegen liefert eine Registrierungsantwort ohne Attestierung und legt nur Chiffrate ab', function () use ($dbDoppel, $key, $rpId) {
        $db = $dbDoppel();
        $pk = new VaultPasskeys($db);
        $res = $pk->create(42, $key, [
            'rp_id' => $rpId, 'rp_name' => 'Example', 'user_handle' => 'uh-123',
            'user_name' => 'max@example.org', 'user_display' => 'Max',
        ], 7);

        // Authenticator-Daten: Kopf + AAGUID (16 Nullen) + Länge + Credential-ID + COSE
        $auth = $res['auth_data'];
        assertSame("\x5d", $auth[32]);                              // UP|UV|BE|BS|AT
        assertSame(str_repeat("\0", 16), substr($auth, 37, 16));
        $credLen = unpack('n', substr($auth, 53, 2))[1];
        assertSame(32, $credLen);
        assertSame($res['credential_id'], VaultPasskeys::base64url(substr($auth, 55, 32)));
        assertSame(77, strlen(substr($auth, 87)));

        // Attestierungsobjekt: {"fmt":"none","attStmt":{},"authData":<auth>}
        $erwartet = "\xa3" . "\x63fmt" . "\x64none" . "\x67attStmt" . "\xa0" . "\x68authData" . VaultPasskeys::cborBytes($auth);
        assertSame($erwartet, $res['attestation_object']);

        // Ablage
        assertSame(1, count($db->inserts));
        $row = $db->inserts[0]['data'];
        assertSame('vault_passkeys', $db->inserts[0]['table']);
        assertSame('example.org', $row['rp_id']);
        assertSame(42, $row['entry_id']);
        assertSame(-7, $row['alg']);
        assertSame(substr($auth, 87), $row['public_key']);
        foreach (['privkey', 'user_handle', 'user_name', 'user_display'] as $f) {
            assertTrue(isset($row[$f . '_enc'], $row[$f . '_nonce'], $row[$f . '_tag']), $f . ' verschlüsselt');
            assertFalse(str_contains((string)$row[$f . '_enc'], 'PRIVATE KEY'));
        }
        assertSame('uh-123', VaultCrypto::decrypt($row['user_handle_enc'], $row['user_handle_nonce'], $row['user_handle_tag'], $key));
        assertTrue(str_contains(VaultCrypto::decrypt($row['privkey_enc'], $row['privkey_nonce'], $row['privkey_tag'], $key), 'PRIVATE KEY'));
    });

    // ----------------------------------------------------------------
    // Anmelden
    // ----------------------------------------------------------------

    test('Die Signatur einer Anmeldung ist mit dem abgelegten öffentlichen Schlüssel prüfbar', function () use ($dbDoppel, $key, $rpId) {
        $db = $dbDoppel();
        $pk = new VaultPasskeys($db);
        $res = $pk->create(42, $key, ['rp_id' => $rpId, 'user_handle' => 'uh-1'], 7);
        $row = $db->inserts[0]['data'] + ['id' => 5];

        $clientDataHash = hash('sha256', '{"type":"webauthn.get"}', true);
        $out = $pk->assert($row, $key, $clientDataHash);

        assertSame("\x1d", $out['auth_data'][32]);                 // UP|UV|BE|BS ohne AT
        assertSame(37, strlen($out['auth_data']));
        assertSame('uh-1', $out['user_handle']);
        assertSame($res['credential_id'], $out['credential_id']);

        [$x, $y] = VaultPasskeys::coseToPoint($row['public_key']);
        $pub = openssl_pkey_get_public(VaultPasskeys::publicKeyPem($x, $y));
        assertTrue($pub !== false, 'öffentlicher Schlüssel lesbar');
        assertSame(1, openssl_verify($out['auth_data'] . $clientDataHash, $out['signature'], $pub, OPENSSL_ALGO_SHA256));
        assertSame(0, openssl_verify($out['auth_data'] . random_bytes(32), $out['signature'], $pub, OPENSSL_ALGO_SHA256));

        // Verwendung wird vermerkt
        assertSame('vault_passkeys', $db->updates[0]['table']);
        assertTrue(isset($db->updates[0]['data']['last_used_at']));
    });

    test('Ein falscher Kontext-Schlüssel oder ein falsch langer Hash führt zu einer Ausnahme', function () use ($dbDoppel, $key, $rpId) {
        $db = $dbDoppel();
        $pk = new VaultPasskeys($db);
        $pk->create(42, $key, ['rp_id' => $rpId, 'user_handle' => 'uh-1'], 7);
        $row = $db->inserts[0]['data'] + ['id' => 5];

        $fehler = 0;
        try { $pk->assert($row, random_bytes(32), hash('sha256', 'x', true)); } catch (\RuntimeException) { $fehler++; }
        try { $pk->assert($row, $key, 'zu kurz'); } catch (\RuntimeException) { $fehler++; }
        assertSame(2, $fehler);
    });

    // ----------------------------------------------------------------
    // Umschlüsselung beim Kontextwechsel
    // ----------------------------------------------------------------

    test('Der Kontextwechsel eines Eintrags schlüsselt auch seine Passkeys um', function () {
        $quelle = (string)file_get_contents(dirname(__DIR__) . '/src/Vault/VaultManager.php');
        foreach (['privkey', 'user_handle', 'user_name', 'user_display'] as $f) {
            assertTrue(str_contains($quelle, "['vault_passkeys',      '{$f}']"), 'Re-Key für ' . $f);
        }
    });

    // ----------------------------------------------------------------
    // Erweiterungs-Schnittstelle: Adressen und Ordner
    // ----------------------------------------------------------------

    $api = (new ReflectionClass(VaultApiController::class))->newInstanceWithoutConstructor();

    test('Mehrere Adressen werden zeilenweise bereinigt, Leerzeilen und Dubletten fallen weg', function () use ($api) {
        assertSame("https://a.example\nhttps://b.example", callPrivate($api, 'normalizeUrls', ["  https://a.example \r\n\n https://b.example\nhttps://a.example\n"]));
        assertSame('', callPrivate($api, 'normalizeUrls', ["\n  \n"]));
    });

    test('Das Favicon folgt der ersten Adresse, auch ohne Schema', function () use ($api) {
        assertSame('a.example', callPrivate($api, 'faviconDomainOf', ["https://a.example/login\nhttps://b.example"]));
        assertSame('b.example', callPrivate($api, 'faviconDomainOf', ["b.example/pfad"]));
        assertSame('', callPrivate($api, 'faviconDomainOf', ['']));
    });

    test('Der Server meldet der Erweiterung den Stand seiner Schnittstelle', function () {
        assertTrue(VaultApiController::API_VERSION >= 2);
        $quelle = (string)file_get_contents(dirname(__DIR__) . '/src/Controllers/VaultApiController.php');
        assertTrue(str_contains($quelle, "'api_version'   => self::API_VERSION"));
    });
};