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

346 lines
18 KiB
PHP

<?php
/**
* Passkeys (WebAuthn) für die Anmeldung der Mitarbeiter, ohne Fremdbibliothek: CBOR lesen, Registrierung und Anmeldung prüfen.
*
* Unterstützt: ES256 (ECDSA P-256) und RS256, Attestation „none“ (bei Passkeys üblich, die Herkunft des Geräts wird nicht geprüft).
* Verlangt wird immer Nutzerprüfung (Fingerabdruck, Gesicht oder PIN): Ein Passkey ersetzt deshalb Passwort und 2FA-Code.
* Relying-Party-ID und Origin kommen aus der eingestellten Website-URL (Einstellungen → Anzeigename & URL); Passkeys gelten nur dafür.
*/
/** Passkeys eingeschaltet? (Adminportal → Anmelde-Wächter) */
function webauthn_enabled(): bool
{
return get_setting('passkeys_enabled', '1') === '1';
}
function webauthn_b64u_encode(string $raw): string
{
return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
}
function webauthn_b64u_decode(string $s): string
{
$s = strtr($s, '-_', '+/');
$pad = strlen($s) % 4;
if ($pad) $s .= str_repeat('=', 4 - $pad);
$r = base64_decode($s, true);
return $r === false ? '' : $r;
}
/** Minimaler CBOR-Leser (genug für Attestation-Objekt und COSE-Schlüssel). @return mixed */
function webauthn_cbor_decode(string $d, int &$o = 0, int $depth = 0)
{
if ($depth > 8 || $o >= strlen($d)) throw new RuntimeException('CBOR ungültig');
$b = ord($d[$o++]);
$major = $b >> 5;
$info = $b & 31;
if ($major === 7) {
if ($info === 20) return false;
if ($info === 21) return true;
if ($info === 22 || $info === 23) return null;
throw new RuntimeException('CBOR: nicht unterstützter Wert');
}
if ($info < 24) {
$n = $info;
} elseif ($info === 24) {
if ($o + 1 > strlen($d)) throw new RuntimeException('CBOR zu kurz');
$n = ord($d[$o++]);
} elseif ($info === 25) {
if ($o + 2 > strlen($d)) throw new RuntimeException('CBOR zu kurz');
$n = unpack('n', substr($d, $o, 2))[1]; $o += 2;
} elseif ($info === 26) {
if ($o + 4 > strlen($d)) throw new RuntimeException('CBOR zu kurz');
$n = unpack('N', substr($d, $o, 4))[1]; $o += 4;
} elseif ($info === 27) {
if ($o + 8 > strlen($d)) throw new RuntimeException('CBOR zu kurz');
$n = unpack('J', substr($d, $o, 8))[1]; $o += 8;
if ($n < 0) throw new RuntimeException('CBOR: Zahl zu groß');
} else {
throw new RuntimeException('CBOR: unbestimmte Länge nicht unterstützt');
}
switch ($major) {
case 0: return $n;
case 1: return -1 - $n;
case 2:
case 3:
if ($n > strlen($d) - $o) throw new RuntimeException('CBOR zu kurz');
$s = substr($d, $o, $n); $o += $n;
return $s;
case 4:
if ($n > 1000) throw new RuntimeException('CBOR: Liste zu lang');
$a = [];
for ($i = 0; $i < $n; $i++) $a[] = webauthn_cbor_decode($d, $o, $depth + 1);
return $a;
case 5:
if ($n > 1000) throw new RuntimeException('CBOR: Tabelle zu lang');
$m = [];
for ($i = 0; $i < $n; $i++) {
$k = webauthn_cbor_decode($d, $o, $depth + 1);
$m[is_int($k) ? $k : (string) $k] = webauthn_cbor_decode($d, $o, $depth + 1);
}
return $m;
case 6:
return webauthn_cbor_decode($d, $o, $depth + 1);
}
throw new RuntimeException('CBOR ungültig');
}
function webauthn_der_len(int $n): string
{
if ($n < 128) return chr($n);
$b = ltrim(pack('N', $n), "\0");
return chr(0x80 | strlen($b)) . $b;
}
function webauthn_der(int $tag, string $content): string
{
return chr($tag) . webauthn_der_len(strlen($content)) . $content;
}
function webauthn_der_int(string $raw): string
{
$raw = ltrim($raw, "\0");
if ($raw === '' || (ord($raw[0]) & 0x80)) $raw = "\0" . $raw;
return webauthn_der(0x02, $raw);
}
function webauthn_pem(string $der): string
{
return "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($der), 64, "\n") . "-----END PUBLIC KEY-----\n";
}
/** COSE-Schlüssel (ES256 oder RS256) in eine PEM-Datei für OpenSSL umwandeln. @return array{0:string,1:int} PEM und Algorithmus */
function webauthn_cose_to_pem(array $cose): array
{
$kty = $cose[1] ?? null;
$alg = $cose[3] ?? null;
if ($kty === 2 && $alg === -7 && ($cose[-1] ?? null) === 1 && strlen((string) ($cose[-2] ?? '')) === 32 && strlen((string) ($cose[-3] ?? '')) === 32) {
$spki = hex2bin('3059301306072a8648ce3d020106082a8648ce3d030107034200') . "\x04" . $cose[-2] . $cose[-3];
return [webauthn_pem($spki), -7];
}
if ($kty === 3 && $alg === -257 && is_string($cose[-1] ?? null) && is_string($cose[-2] ?? null) && strlen($cose[-1]) >= 128) {
$rsaKey = webauthn_der(0x30, webauthn_der_int($cose[-1]) . webauthn_der_int($cose[-2]));
$alg = hex2bin('300d06092a864886f70d0101010500');
$spki = webauthn_der(0x30, $alg . webauthn_der(0x03, "\0" . $rsaKey));
return [webauthn_pem($spki), -257];
}
throw new RuntimeException('Dieser Passkey-Typ wird nicht unterstützt (erlaubt: ES256, RS256).');
}
/**
* Relying Party: ['id' => Domain, 'origin' => Schema://Domain[:Port], 'secure' => bool, 'name' => Anzeigename].
* Grundlage ist die Website-URL (Einstellungen → Anzeigename & URL). Wird DeskHub unter einer Subdomain dieser Adresse aufgerufen
* (Website-URL https://beispiel.de, aufgerufen unter https://support.beispiel.de), gilt die Adresse, unter der die Seite gerade aufgerufen wird:
* Der Browser verlangt genau diese Domain. Fremde Domains werden ignoriert, dann gilt die Website-URL.
*/
function webauthn_rp(): array
{
$u = parse_url(base_url());
$siteHost = strtolower((string) ($u['host'] ?? ''));
$scheme = strtolower((string) ($u['scheme'] ?? 'https'));
$host = $siteHost;
$port = isset($u['port']) ? (int) $u['port'] : null;
// Mögliche Adressen, unter denen die Seite aufgerufen wird. Der Origin-Kopf stammt vom Browser (nicht fälschbar durch die Seite) und ist
// zuverlässig, auch wenn ein Reverse-Proxy den Host-Kopf verändert; X-Forwarded-Host zählt nur von einem vertrauenswürdigen Proxy.
$candidates = [];
$originHdr = trim((string) ($_SERVER['HTTP_ORIGIN'] ?? ''));
if ($originHdr !== '' && ($oh = parse_url($originHdr)) && !empty($oh['host'])) $candidates[] = strtolower((string) $oh['host']) . (isset($oh['port']) ? ':' . (int) $oh['port'] : '');
if (function_exists('request_from_trusted_proxy') && request_from_trusted_proxy() && !empty($_SERVER['HTTP_X_FORWARDED_HOST'])) {
$candidates[] = strtolower(trim(explode(',', (string) $_SERVER['HTTP_X_FORWARDED_HOST'])[0]));
}
$candidates[] = strtolower(trim((string) ($_SERVER['HTTP_HOST'] ?? '')));
foreach ($candidates as $req) {
if ($req === '' || !preg_match('/^([a-z0-9]([a-z0-9.-]*[a-z0-9])?)(?::(\d{1,5}))?$/', $req, $m)) continue;
$reqHost = $m[1];
if ($siteHost !== '' && ($reqHost === $siteHost || str_ends_with($reqHost, '.' . $siteHost))) {
$host = $reqHost;
$port = isset($m[3]) && $m[3] !== '' ? (int) $m[3] : null;
if ($port !== null && (($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80))) $port = null;
break;
}
}
$origin = $scheme . '://' . $host . ($port !== null ? ':' . $port : '');
$secure = $scheme === 'https' || $host === 'localhost';
return ['id' => $host, 'origin' => $origin, 'secure' => $secure, 'name' => (string) get_setting('display_name', config()['site']['name'] ?? 'DeskHub')];
}
/** Feste Kennung des Mitarbeiters im Passkey (keine Personendaten). */
function webauthn_user_handle(int $staffId): string
{
return hash_hmac('sha256', 'deskhub-passkey-user-' . $staffId, (string) (config()['app_key'] ?? 'deskhub'), true);
}
function webauthn_challenge_new(string $type, ?int $staffId = null): string
{
$c = random_bytes(32);
$_SESSION['webauthn_challenge'] = ['v' => webauthn_b64u_encode($c), 'type' => $type, 'exp' => time() + 300, 'staff' => $staffId];
return webauthn_b64u_encode($c);
}
/** Merkt die Challenge nur einmal: Ein zweiter Versuch mit derselben Challenge scheitert. */
function webauthn_challenge_take(string $type, ?int $staffId = null): ?string
{
$c = $_SESSION['webauthn_challenge'] ?? null;
unset($_SESSION['webauthn_challenge']);
if (!is_array($c) || ($c['type'] ?? '') !== $type || (int) ($c['exp'] ?? 0) < time()) return null;
if ($staffId !== null && (int) ($c['staff'] ?? 0) !== $staffId) return null;
return (string) $c['v'];
}
/** @return list<array<string,mixed>> */
function webauthn_staff_passkeys(PDO $pdo, int $staffId): array
{
try {
$s = $pdo->prepare('SELECT * FROM staff_passkeys WHERE staff_id = ? ORDER BY id');
$s->execute([$staffId]);
return $s->fetchAll();
} catch (Throwable $e) {
return [];
}
}
function webauthn_count_passkeys(PDO $pdo, int $staffId): int
{
return count(webauthn_staff_passkeys($pdo, $staffId));
}
/** Optionen für die Registrierung (navigator.credentials.create). */
function webauthn_creation_options(PDO $pdo, array $staff): array
{
$rp = webauthn_rp();
$exclude = [];
foreach (webauthn_staff_passkeys($pdo, (int) $staff['id']) as $k) $exclude[] = ['type' => 'public-key', 'id' => $k['credential_id']];
return [
'challenge' => webauthn_challenge_new('create', (int) $staff['id']),
'rp' => ['id' => $rp['id'], 'name' => $rp['name']],
'user' => ['id' => webauthn_b64u_encode(webauthn_user_handle((int) $staff['id'])), 'name' => (string) $staff['email'], 'displayName' => (string) $staff['name']],
'pubKeyCredParams' => [['type' => 'public-key', 'alg' => -7], ['type' => 'public-key', 'alg' => -257]],
'timeout' => 120000,
'attestation' => 'none',
'excludeCredentials' => $exclude,
'authenticatorSelection' => ['residentKey' => 'required', 'requireResidentKey' => true, 'userVerification' => 'required'],
];
}
/** Optionen für die Anmeldung ohne Eingabe der Adresse (navigator.credentials.get). */
function webauthn_request_options(): array
{
return [
'challenge' => webauthn_challenge_new('get'),
'rpId' => webauthn_rp()['id'],
'timeout' => 120000,
'userVerification' => 'required',
'allowCredentials' => [],
];
}
/** @return array{0:bool,1:string} gemeinsame Prüfung von clientDataJSON */
function webauthn_check_client_data(string $json, string $type, string $challenge): array
{
$cd = json_decode($json, true);
if (!is_array($cd)) return [false, 'Antwort des Geräts ist ungültig.'];
if (($cd['type'] ?? '') !== $type) return [false, 'Falscher Vorgang.'];
if (!hash_equals($challenge, (string) ($cd['challenge'] ?? ''))) return [false, 'Die Anfrage ist abgelaufen. Bitte noch einmal versuchen.'];
if (($cd['origin'] ?? '') !== webauthn_rp()['origin']) return [false, 'Die Adresse stimmt nicht mit der Website-URL aus den Einstellungen überein (' . webauthn_rp()['origin'] . ').'];
if (!empty($cd['crossOrigin'])) return [false, 'Eingebettete Seiten sind nicht erlaubt.'];
return [true, ''];
}
/**
* Registrierung abschließen. $cred = ['id' => …, 'response' => ['clientDataJSON' => b64u, 'attestationObject' => b64u]].
* @return array{ok:bool,error?:string,id?:int}
*/
function webauthn_register_finish(PDO $pdo, array $staff, array $cred, string $name): array
{
$challenge = webauthn_challenge_take('create', (int) $staff['id']);
if ($challenge === null) return ['ok' => false, 'error' => 'Die Anfrage ist abgelaufen. Bitte noch einmal versuchen.'];
try {
$clientData = webauthn_b64u_decode((string) ($cred['response']['clientDataJSON'] ?? ''));
[$ok, $err] = webauthn_check_client_data($clientData, 'webauthn.create', $challenge);
if (!$ok) return ['ok' => false, 'error' => $err];
$att = webauthn_cbor_decode(webauthn_b64u_decode((string) ($cred['response']['attestationObject'] ?? '')));
if (!is_array($att) || !is_string($att['authData'] ?? null)) return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.'];
$ad = $att['authData'];
if (strlen($ad) < 55) return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.'];
if (!hash_equals(hash('sha256', webauthn_rp()['id'], true), substr($ad, 0, 32))) return ['ok' => false, 'error' => 'Der Passkey gehört zu einer anderen Domain.'];
$flags = ord($ad[32]);
if (!($flags & 0x01)) return ['ok' => false, 'error' => 'Anwesenheit nicht bestätigt.'];
if (!($flags & 0x04)) return ['ok' => false, 'error' => 'Das Gerät hat Sie nicht geprüft (Fingerabdruck, Gesicht oder PIN nötig).'];
if (!($flags & 0x40)) return ['ok' => false, 'error' => 'Das Gerät hat keinen Schlüssel geliefert.'];
$signCount = unpack('N', substr($ad, 33, 4))[1];
$idLen = unpack('n', substr($ad, 53, 2))[1];
if ($idLen < 16 || $idLen > 1023 || strlen($ad) < 55 + $idLen) return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.'];
$credId = substr($ad, 55, $idLen);
$o = 55 + $idLen;
$cose = webauthn_cbor_decode($ad, $o);
if (!is_array($cose)) return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.'];
[$pem, $alg] = webauthn_cose_to_pem($cose);
if (!openssl_pkey_get_public($pem)) return ['ok' => false, 'error' => 'Der öffentliche Schlüssel ist ungültig.'];
$idText = webauthn_b64u_encode($credId);
if (hash_equals($idText, (string) ($cred['id'] ?? '')) === false) return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.'];
$dup = $pdo->prepare('SELECT COUNT(*) FROM staff_passkeys WHERE credential_id = ?');
$dup->execute([$idText]);
if ((int) $dup->fetchColumn() > 0) return ['ok' => false, 'error' => 'Dieser Passkey ist schon registriert.'];
$name = trim(mb_substr($name, 0, 80));
if ($name === '') $name = 'Passkey ' . date('d.m.Y');
$pdo->prepare('INSERT INTO staff_passkeys (staff_id, credential_id, public_key, alg, sign_count, name, created_at) VALUES (?,?,?,?,?,?,?)')
->execute([(int) $staff['id'], $idText, $pem, $alg, $signCount, $name, db_now()]);
return ['ok' => true, 'id' => (int) $pdo->lastInsertId()];
} catch (Throwable $e) {
return ['ok' => false, 'error' => $e instanceof RuntimeException ? $e->getMessage() : 'Der Passkey konnte nicht geprüft werden.'];
}
}
/**
* Anmeldung prüfen. $cred = ['id', 'response' => ['clientDataJSON', 'authenticatorData', 'signature', 'userHandle']] (alles base64url).
* @return array{ok:bool,error?:string,staff?:array<string,mixed>,passkey?:array<string,mixed>}
*/
function webauthn_login_finish(PDO $pdo, array $cred): array
{
$challenge = webauthn_challenge_take('get');
if ($challenge === null) return ['ok' => false, 'error' => 'Die Anfrage ist abgelaufen. Bitte noch einmal versuchen.'];
try {
$st = $pdo->prepare('SELECT * FROM staff_passkeys WHERE credential_id = ?');
$st->execute([(string) ($cred['id'] ?? '')]);
$key = $st->fetch();
if (!$key) return ['ok' => false, 'error' => 'Dieser Passkey ist hier nicht registriert.'];
$clientData = webauthn_b64u_decode((string) ($cred['response']['clientDataJSON'] ?? ''));
[$ok, $err] = webauthn_check_client_data($clientData, 'webauthn.get', $challenge);
if (!$ok) return ['ok' => false, 'error' => $err];
$ad = webauthn_b64u_decode((string) ($cred['response']['authenticatorData'] ?? ''));
$sig = webauthn_b64u_decode((string) ($cred['response']['signature'] ?? ''));
if (strlen($ad) < 37 || $sig === '') return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.'];
if (!hash_equals(hash('sha256', webauthn_rp()['id'], true), substr($ad, 0, 32))) return ['ok' => false, 'error' => 'Der Passkey gehört zu einer anderen Domain.'];
$flags = ord($ad[32]);
if (!($flags & 0x01) || !($flags & 0x04)) return ['ok' => false, 'error' => 'Das Gerät hat Sie nicht geprüft (Fingerabdruck, Gesicht oder PIN nötig).'];
$pub = openssl_pkey_get_public((string) $key['public_key']);
if (!$pub) return ['ok' => false, 'error' => 'Der gespeicherte Schlüssel ist ungültig.'];
$data = $ad . hash('sha256', $clientData, true);
if (openssl_verify($data, $sig, $pub, OPENSSL_ALGO_SHA256) !== 1) return ['ok' => false, 'error' => 'Die Signatur des Passkeys ist ungültig.'];
$handle = (string) ($cred['response']['userHandle'] ?? '');
if ($handle !== '' && !hash_equals(webauthn_b64u_encode(webauthn_user_handle((int) $key['staff_id'])), $handle)) {
return ['ok' => false, 'error' => 'Der Passkey gehört zu einem anderen Konto.'];
}
// Zähler gegen kopierte Passkeys: Er muss wachsen, wenn das Gerät überhaupt zählt
$count = unpack('N', substr($ad, 33, 4))[1];
$old = (int) $key['sign_count'];
if (($count !== 0 || $old !== 0) && $count <= $old) return ['ok' => false, 'error' => 'Der Passkey wurde möglicherweise kopiert und ist abgelehnt worden. Bitte einen Administrator informieren.'];
$sd = $pdo->prepare('SELECT * FROM staff WHERE id = ? AND is_active = 1');
$sd->execute([(int) $key['staff_id']]);
$staff = $sd->fetch();
if (!$staff) return ['ok' => false, 'error' => 'Das Konto ist nicht aktiv.'];
$pdo->prepare('UPDATE staff_passkeys SET sign_count = ?, last_used_at = ? WHERE id = ?')->execute([$count, db_now(), (int) $key['id']]);
return ['ok' => true, 'staff' => $staff, 'passkey' => $key];
} catch (Throwable $e) {
return ['ok' => false, 'error' => 'Der Passkey konnte nicht geprüft werden.'];
}
}
/** Alle Passkeys eines Mitarbeiters löschen. @return int Anzahl */
function webauthn_remove_all(PDO $pdo, int $staffId): int
{
$n = webauthn_count_passkeys($pdo, $staffId);
if ($n > 0) $pdo->prepare('DELETE FROM staff_passkeys WHERE staff_id = ?')->execute([$staffId]);
return $n;
}