query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn(); $pdo->exec('DELETE FROM staff'); $ins = $pdo->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)'); $ins->execute(['Eva', 'eva@test.de', 'x', 'agent', $dept, $now]); $eva = (int) $pdo->lastInsertId(); $ins->execute(['Max', 'max@test.de', 'x', 'agent', $dept, $now]); $max = (int) $pdo->lastInsertId(); $staffRow = fn(int $id) => $pdo->query('SELECT * FROM staff WHERE id = ' . $id)->fetch(); require_once __DIR__ . '/fixtures/fake_authenticator.php'; $GLOBALS['fa_rp'] = 'support.test'; $GLOBALS['fa_origin'] = 'https://support.test'; $bin = fn(string $b) => "\0BIN" . $b; $check(webauthn_cbor_decode(hex2bin('a201020326')) === [1 => 2, 3 => -7], 'CBOR-Leser liest eine COSE-Tabelle'); $m = webauthn_cbor_decode(t_cbor(['fmt' => 'none', 'n' => 300, 'neg' => -5, 'b' => $bin("\x01\x02")])); $check($m['fmt'] === 'none' && $m['n'] === 300 && $m['neg'] === -5 && $m['b'] === "\x01\x02", 'CBOR: Text, Zahlen, negative Zahlen und Bytes werden gelesen'); $bad = false; try { webauthn_cbor_decode("\x5f\x41"); } catch (Throwable $e) { $bad = true; } $check($bad, 'CBOR: unbestimmte Länge und abgeschnittene Daten werden abgelehnt'); $check(webauthn_b64u_decode(webauthn_b64u_encode("\xfb\xff\x00abc")) === "\xfb\xff\x00abc", 'base64url hin und zurück'); $rp = webauthn_rp(); $check($rp['id'] === 'support.test' && $rp['origin'] === 'https://support.test' && $rp['secure'] === true, 'Relying Party stammt aus der Website-URL'); $register = function (FakeKey $k, int $sid, array $o = [], string $name = 'Laptop') use ($pdo, $staffRow): array { $opt = webauthn_creation_options($pdo, $staffRow($sid)); return webauthn_register_finish($pdo, $staffRow($sid), t_register($k, $opt, $o), $name); }; $login = function (FakeKey $k, int $sid, array $o = []) use ($pdo): array { $opt = webauthn_request_options(); return webauthn_login_finish($pdo, t_login($k, $opt, $sid, $o)); }; // Subdomain: Wird DeskHub unter einer Subdomain der Website-URL aufgerufen, gilt diese Adresse (der Browser verlangt genau sie) $oldHost = $_SERVER['HTTP_HOST'] ?? null; set_setting('site_url', 'https://windelweb.org'); foreach ([ ['support.windelweb.org', 'support.windelweb.org', 'https://support.windelweb.org'], ['SUPPORT.WindelWeb.org:8443', 'support.windelweb.org', 'https://support.windelweb.org:8443'], ['support.windelweb.org:443', 'support.windelweb.org', 'https://support.windelweb.org'], ['windelweb.org', 'windelweb.org', 'https://windelweb.org'], ['evil.example', 'windelweb.org', 'https://windelweb.org'], ['windelweb.org.evil.example', 'windelweb.org', 'https://windelweb.org'], ['evilwindelweb.org', 'windelweb.org', 'https://windelweb.org'], ['support.windelweb.org/../x', 'windelweb.org', 'https://windelweb.org'], ['', 'windelweb.org', 'https://windelweb.org'], ] as [$h, $id, $origin]) { $_SERVER['HTTP_HOST'] = $h; $r = webauthn_rp(); $check($r['id'] === $id && $r['origin'] === $origin, "Domain bei Host „{$h}“: {$r['id']} / {$r['origin']}"); } // Reverse-Proxy verändert den Host-Kopf: der Origin-Kopf des Browsers entscheidet $_SERVER['HTTP_HOST'] = 'deskhub-intern:8080'; $_SERVER['HTTP_ORIGIN'] = 'https://support.windelweb.org'; $r = webauthn_rp(); $check($r['id'] === 'support.windelweb.org' && $r['origin'] === 'https://support.windelweb.org', 'Origin-Kopf des Browsers gilt, wenn der Host-Kopf intern umgeschrieben wurde'); $_SERVER['HTTP_ORIGIN'] = 'https://evil.example'; $r = webauthn_rp(); $check($r['id'] === 'windelweb.org', 'Fremder Origin-Kopf wird ignoriert'); unset($_SERVER['HTTP_ORIGIN']); if ($oldHost === null) unset($_SERVER['HTTP_HOST']); else $_SERVER['HTTP_HOST'] = $oldHost; set_setting('site_url', 'https://support.test'); // ── Optionen ─────────────────────────────────────────────────────────────────── $opt = webauthn_creation_options($pdo, $staffRow($eva)); $check($opt['rp']['id'] === 'support.test' && $opt['authenticatorSelection']['userVerification'] === 'required' && $opt['authenticatorSelection']['residentKey'] === 'required', 'Registrierung verlangt Nutzerprüfung und einen auffindbaren Schlüssel'); $check(strlen(webauthn_b64u_decode($opt['challenge'])) === 32 && $opt['user']['id'] !== '' && $opt['user']['name'] === 'eva@test.de' && $opt['user']['displayName'] === 'Eva', 'Challenge hat 32 Bytes, die Benutzerkennung ist gesetzt'); $check(webauthn_user_handle($eva) !== webauthn_user_handle($max) && strlen(webauthn_user_handle($eva)) === 32, 'Benutzerkennungen sind je Mitarbeiter verschieden'); // ── Registrierung ────────────────────────────────────────────────────────────── $ec = new FakeKey('ec'); $r = $register($ec, $eva); $check(!empty($r['ok']), 'Registrierung mit ES256 klappt: ' . json_encode($r)); $check(webauthn_count_passkeys($pdo, $eva) === 1 && webauthn_count_passkeys($pdo, $max) === 0, 'Der Passkey gehört nur Eva'); $row = webauthn_staff_passkeys($pdo, $eva)[0]; $check($row['name'] === 'Laptop' && (int) $row['alg'] === -7 && str_contains($row['public_key'], 'BEGIN PUBLIC KEY'), 'Name, Algorithmus und Schlüssel sind gespeichert'); $r = $register($ec, $max); $check(empty($r['ok']) && str_contains((string) $r['error'], 'schon registriert'), 'Derselbe Passkey lässt sich nicht für ein zweites Konto eintragen'); foreach ([ 'falsche Challenge' => ['challenge' => webauthn_b64u_encode(random_bytes(32))], 'falscher Origin' => ['origin' => 'https://evil.test'], 'falsche Domain im Passkey' => ['rpId' => 'evil.test'], 'ohne Nutzerprüfung' => ['flags' => 0x41], 'ohne Anwesenheit' => ['flags' => 0x44], 'falscher Vorgang' => ['type' => 'webauthn.get'], ] as $label => $o) { $k = new FakeKey('ec'); $r = $register($k, $max, $o); $check(empty($r['ok']), 'Registrierung abgelehnt: ' . $label); } $check(webauthn_count_passkeys($pdo, $max) === 0, 'Abgelehnte Registrierungen hinterlassen nichts'); // Challenge nur einmal verwendbar $opt = webauthn_creation_options($pdo, $staffRow($max)); $k = new FakeKey('ec'); $cred = t_register($k, $opt); $first = webauthn_register_finish($pdo, $staffRow($max), $cred, 'Handy'); $k2 = new FakeKey('ec'); $second = webauthn_register_finish($pdo, $staffRow($max), t_register($k2, $opt), 'Zweit'); $check(!empty($first['ok']) && empty($second['ok']), 'Eine Challenge gilt nur für einen Versuch'); $opt = webauthn_creation_options($pdo, $staffRow($eva)); $check(count($opt['excludeCredentials']) === 1, 'Schon eingetragene Passkeys werden beim Registrieren ausgeschlossen'); // Challenge eines anderen Mitarbeiters $opt = webauthn_creation_options($pdo, $staffRow($eva)); $r = webauthn_register_finish($pdo, $staffRow($max), t_register(new FakeKey('ec'), $opt), 'Fremd'); $check(empty($r['ok']), 'Challenge eines anderen Mitarbeiters wird nicht angenommen'); $_SESSION['webauthn_challenge'] = ['v' => 'x', 'type' => 'create', 'exp' => time() - 1, 'staff' => $eva]; $check(webauthn_challenge_take('create', $eva) === null, 'Abgelaufene Challenge wird abgelehnt'); // ── Anmeldung ────────────────────────────────────────────────────────────────── $r = $login($ec, $eva); $check(!empty($r['ok']) && (int) $r['staff']['id'] === $eva, 'Anmeldung mit ES256 klappt und liefert das richtige Konto: ' . json_encode($r['error'] ?? '')); $check(webauthn_staff_passkeys($pdo, $eva)[0]['last_used_at'] !== null && (int) webauthn_staff_passkeys($pdo, $eva)[0]['sign_count'] === 1, 'Zähler und „zuletzt benutzt“ werden gespeichert'); $r = $login($ec, $eva, ['count' => 1]); $check(empty($r['ok']) && str_contains((string) $r['error'], 'kopiert'), 'Zähler, der nicht wächst, wird als Kopie abgelehnt'); $r = $login($ec, $eva); $check(!empty($r['ok']), 'Danach klappt es mit wachsendem Zähler wieder'); $login($ec, $eva, ['count' => 50]); foreach ([ 'falsche Challenge' => ['challenge' => webauthn_b64u_encode(random_bytes(32))], 'falscher Origin' => ['origin' => 'https://evil.test'], 'falsche Domain' => ['rpId' => 'evil.test'], 'ohne Nutzerprüfung' => ['flags' => 0x01], 'fremde Signatur' => ['signWith' => (new FakeKey('ec'))->res], 'fremde Benutzerkennung' => ['userHandle' => webauthn_b64u_encode(webauthn_user_handle($max))], 'falscher Vorgang' => ['type' => 'webauthn.create'], ] as $label => $o) { $r = $login($ec, $eva, $o + ['count' => (int) $ec->count + 5]); $check(empty($r['ok']), 'Anmeldung abgelehnt: ' . $label); } $r = $login(new FakeKey('ec'), $eva); $check(empty($r['ok']), 'Unbekannter Passkey wird abgelehnt'); $opt = webauthn_request_options(); $cred = t_login($ec, $opt, $eva, ['count' => 200]); $r1 = webauthn_login_finish($pdo, $cred); $r2 = webauthn_login_finish($pdo, $cred); $check(!empty($r1['ok']) && empty($r2['ok']), 'Dieselbe Antwort lässt sich nicht noch einmal verwenden'); // RS256 $rsa = new FakeKey('rsa'); $r = $register($rsa, $max, [], 'Sicherheitsschlüssel'); $check(!empty($r['ok']), 'Registrierung mit RS256 klappt: ' . json_encode($r)); $r = $login($rsa, $max); $check(!empty($r['ok']) && (int) $r['staff']['id'] === $max, 'Anmeldung mit RS256 klappt'); // Zähler 0 bei Geräten ohne Zähler (Sicherheitsschlüssel/iCloud): bleibt erlaubt $zero = new FakeKey('ec'); $register($zero, $max, [], 'Ohne Zähler'); $r = $login($zero, $max, ['count' => 0]); $r2 = $login($zero, $max, ['count' => 0]); $check(!empty($r['ok']) && !empty($r2['ok']), 'Geräte, die immer 0 melden, bleiben anmeldbar'); // Inaktives Konto $pdo->prepare('UPDATE staff SET is_active = 0 WHERE id = ?')->execute([$max]); $r = $login($rsa, $max); $check(empty($r['ok']), 'Ein deaktiviertes Konto kann sich nicht mit Passkey anmelden'); $pdo->prepare('UPDATE staff SET is_active = 1 WHERE id = ?')->execute([$max]); // Nicht unterstützter Schlüssel $bad = false; try { webauthn_cose_to_pem([1 => 1, 3 => -8]); } catch (Throwable $e) { $bad = true; } $check($bad, 'Nicht unterstützte Schlüsselarten werden abgelehnt'); // ── Entfernen ────────────────────────────────────────────────────────────────── $n = webauthn_remove_all($pdo, $max); $check($n === 3 && webauthn_count_passkeys($pdo, $max) === 0 && webauthn_count_passkeys($pdo, $eva) === 1, 'Administrator-Entfernen löscht alle Passkeys des Mitarbeiters und nur seine'); $r = $login($rsa, $max); $check(empty($r['ok']), 'Nach dem Entfernen ist die Anmeldung mit diesem Passkey nicht mehr möglich'); $check(webauthn_remove_all($pdo, $max) === 0, 'Nochmaliges Entfernen ohne Passkeys ist harmlos'); // „Das war ich nicht“ entfernt Passkeys ebenfalls $pdo->prepare('INSERT INTO login_events (event, staff_id, email, channel, ip, device, place, created_at, token_hash, token_used) VALUES (?,?,?,?,?,?,?,?,?,0)') ->execute(['new_device', $eva, 'eva@test.de', 'web', '203.0.113.5', 'Test', 'Testort', $now, hash('sha256', 'x')]); $evt = $pdo->query('SELECT * FROM login_events ORDER BY id DESC LIMIT 1')->fetch(); login_guard_revoke($pdo, $evt); $check(webauthn_count_passkeys($pdo, $eva) === 0, '„Das war ich nicht“ entfernt auch die Passkeys'); // Protokoll-Beschriftungen $labels = login_guard_event_labels(); $check(isset($labels['passkey_added'], $labels['passkey_removed']), 'Protokoll kennt „Passkey hinzugefügt/entfernt“'); echo $failures ? "FEHLGESCHLAGEN:\n - " . implode("\n - ", $failures) . "\n" : ''; echo "Passkey-Test: " . ($total - count($failures)) . " von {$total} Prüfungen in Ordnung.\n"; exit($failures ? 1 : 0);