Files
2026-11-05 21:21:59 +00:00

209 lines
13 KiB
PHP

<?php
declare(strict_types=1);
/**
* Prüft Passkeys (includes/webauthn.php) mit einem nachgebauten Authenticator (ES256 und RS256): Registrierung, Anmeldung und alle
* Ablehnungsfälle (falsche Challenge/Origin/Domain, fehlende Nutzerprüfung, falsche Signatur, Zähler, fremdes Konto, inaktives Konto),
* Entfernen durch den Mitarbeiter und durch Administratoren, „Das war ich nicht“.
* Nicht geprüft werden echte Geräte (Windows Hello, Handy, Sicherheitsschlüssel): Das Zusammenspiel mit dem Browser braucht einen Test von Hand.
*/
if (!extension_loaded('pdo_sqlite') || !extension_loaded('openssl')) { echo "Passkey-Test übersprungen: pdo_sqlite oder openssl fehlt.\n"; exit(0); }
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
require_once dirname(__DIR__) . '/includes/webauthn.php';
$failures = [];
$total = 0;
$check = function (bool $ok, string $what) use (&$failures, &$total): void {
$total++;
if (!$ok) $failures[] = $what;
};
$pdo = get_db();
set_setting('site_url', 'https://support.test');
$now = date('Y-m-d H:i:s');
$dept = (int) $pdo->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);