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> */ 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,passkey?:array} */ 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; }