['public_key' => '...', 'private_key' => '...'] * 3. Service Worker erweitert (sw-staff.js) – bereits erledigt */ // ── VAPID Key Generator (CLI) ───────────────────────────────────────────────── if (php_sapi_name() === 'cli' && in_array('--generate-keys', $argv ?? [])) { $key = openssl_pkey_new(['curve_name' => 'prime256v1', 'private_key_type' => OPENSSL_KEYTYPE_EC]); $details = openssl_pkey_get_details($key); $pub = base64url_encode("\x04" . $details['ec']['x'] . $details['ec']['y']); openssl_pkey_export($key, $privPem); $priv = base64url_encode($details['ec']['d']); echo "VAPID Keys generiert – in config/config.php eintragen:\n\n"; echo "'push' => [\n"; echo " 'public_key' => '$pub',\n"; echo " 'private_key' => '$priv',\n"; echo "],\n\n"; echo "Public Key für Frontend (push_subscribe.php):\n$pub\n"; exit(0); } // ── Hilfsfunktionen ─────────────────────────────────────────────────────────── function base64url_encode(string $data): string { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); } function base64url_decode(string $data): string { return base64_decode(strtr($data, '-_', '+/') . str_repeat('=', (4 - strlen($data) % 4) % 4)); } /** * Sendet eine Web-Push-Benachrichtigung an einen Subscriber. * * @param array $sub ['endpoint'=>..., 'p256dh'=>..., 'auth'=>...] * @param string $title Titel der Benachrichtigung * @param string $body Nachrichtentext * @param string $url Ziel-URL beim Klick * @param string $icon Icon-URL (optional) */ /** * Gibt die VAPID-Keys zurück. Existieren sie noch nicht, werden sie * automatisch generiert und in der settings-Tabelle gespeichert. * Kein manueller Einrichtungsschritt nötig. */ function get_or_create_vapid_keys(): ?array { // 1. Config hat explizite Keys → Vorrang $cfg = config()['push'] ?? []; if (!empty($cfg['public_key']) && !empty($cfg['private_key'])) { return $cfg; } // 2. Keys aus DB laden try { $pub = get_setting('vapid_public_key'); $priv = get_setting('vapid_private_key'); if ($pub && $priv) { return ['public_key' => $pub, 'private_key' => $priv]; } // 3. Neu generieren und speichern if (!function_exists('openssl_pkey_new')) return null; $key = openssl_pkey_new(['curve_name' => 'prime256v1', 'private_key_type' => OPENSSL_KEYTYPE_EC]); if (!$key) return null; $details = openssl_pkey_get_details($key); $pubKey = base64url_encode("\x04" . $details['ec']['x'] . $details['ec']['y']); $privKey = base64url_encode($details['ec']['d']); set_setting('vapid_public_key', $pubKey); set_setting('vapid_private_key', $privKey); return ['public_key' => $pubKey, 'private_key' => $privKey]; } catch (Throwable $e) { return null; } } function send_push(array $sub, string $title, string $body, string $url = '/staff/', string $icon = '/assets/icons/icon-192.png'): bool { $keys = get_or_create_vapid_keys(); if (!$keys) return false; $payload = json_encode([ 'title' => $title, 'body' => $body, 'url' => $url, 'icon' => $icon, 'badge' => '/assets/icons/icon-192.png', ]); // Payload mit AES-128-GCM verschlüsseln (RFC 8291) $encrypted = encrypt_push_payload($payload, $sub['p256dh'], $sub['auth']); if (!$encrypted) return false; // VAPID JWT erstellen $endpoint = $sub['endpoint']; $parsedUrl = parse_url($endpoint); $audience = $parsedUrl['scheme'] . '://' . $parsedUrl['host']; $jwt = create_vapid_jwt($audience, $keys['public_key'], $keys['private_key']); $headers = [ 'Authorization: vapid t=' . $jwt . ',k=' . $keys['public_key'], 'Content-Type: application/octet-stream', 'Content-Encoding: aes128gcm', 'TTL: 86400', 'Content-Length: ' . strlen($encrypted['ciphertext']), ]; $ch = curl_init($endpoint); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $encrypted['ciphertext'], CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => true, ]); $resp = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); // 410 Gone = Subscription abgelaufen, aus DB löschen if ($status === 410) { try { get_db()->prepare('DELETE FROM push_subscriptions WHERE endpoint = ?')->execute([$endpoint]); } catch (Throwable $e) {} } return $status >= 200 && $status < 300; } /** * Sendet Push an alle Subscriptions eines Mitarbeiters. */ function push_to_staff(int $staffId, string $title, string $body, string $url = '/staff/'): void { if (!get_or_create_vapid_keys()) return; try { $pdo = get_db(); $stmt = $pdo->prepare('SELECT * FROM push_subscriptions WHERE staff_id = ?'); $stmt->execute([$staffId]); foreach ($stmt->fetchAll() as $sub) { send_push($sub, $title, $body, $url); } } catch (Throwable $e) {} } /** * Sendet Push an alle aktiven Mitarbeiter einer Abteilung (neues Ticket). */ function push_new_ticket(array $ticket): void { if (!get_or_create_vapid_keys()) return; try { $pdo = get_db(); $stmt = $pdo->prepare( "SELECT DISTINCT ps.staff_id, ps.endpoint, ps.p256dh, ps.auth FROM push_subscriptions ps JOIN staff s ON s.id = ps.staff_id LEFT JOIN staff_department sd ON sd.staff_id = s.id WHERE s.is_active = 1 AND (s.role = 'admin' OR sd.department_id = ?)" ); $stmt->execute([$ticket['department_id']]); foreach ($stmt->fetchAll() as $sub) { send_push( $sub, 'Neues Ticket: ' . mb_strimwidth($ticket['subject'], 0, 60, '…'), 'Von: ' . $ticket['requester_name'], '/staff/ticket_view.php?n=' . $ticket['ticket_number'] ); } } catch (Throwable $e) {} } /** * Sendet Push bei neuer Antwort an den zugewiesenen Mitarbeiter. */ function push_new_reply(array $ticket, string $authorName): void { if (empty($ticket['assigned_staff_id']) || empty(config()['push']['public_key'])) return; push_to_staff( (int)$ticket['assigned_staff_id'], 'Neue Antwort: #' . $ticket['ticket_number'], 'Von: ' . $authorName . ' – ' . mb_strimwidth($ticket['subject'], 0, 50, '…'), '/staff/ticket_view.php?n=' . $ticket['ticket_number'] ); } /** * Push bei neuer Live-Chat-Nachricht: an den zugewiesenen Mitarbeiter, oder – solange * der Chat noch niemandem zugewiesen ist – an alle aktiven Mitarbeiter der Abteilung * (wie bei einem neuen Ticket), damit ein wartender Chat niemals unbemerkt bleibt. */ function push_new_chat_message(int $departmentId, ?int $assignedStaffId, string $ticketNumber, string $requesterName): void { if (!get_or_create_vapid_keys()) return; $title = 'Neue Chat-Nachricht: #' . $ticketNumber; $body = 'Von: ' . $requesterName; $url = '/staff/live_chat.php?n=' . urlencode($ticketNumber); if ($assignedStaffId) { push_to_staff($assignedStaffId, $title, $body, $url); return; } try { $pdo = get_db(); $stmt = $pdo->prepare( "SELECT DISTINCT ps.staff_id, ps.endpoint, ps.p256dh, ps.auth FROM push_subscriptions ps JOIN staff s ON s.id = ps.staff_id LEFT JOIN staff_department sd ON sd.staff_id = s.id WHERE s.is_active = 1 AND (s.role = 'admin' OR sd.department_id = ?)" ); $stmt->execute([$departmentId]); foreach ($stmt->fetchAll() as $sub) { send_push($sub, $title, $body, $url); } } catch (Throwable $e) {} } // ── Crypto ──────────────────────────────────────────────────────────────────── function create_vapid_jwt(string $audience, string $publicKey, string $privateKeyB64): string { $header = base64url_encode(json_encode(['typ' => 'JWT', 'alg' => 'ES256'])); $payload = base64url_encode(json_encode([ 'aud' => $audience, 'exp' => time() + 43200, 'sub' => 'mailto:' . (get_setting('admin_email') ?: 'admin@localhost'), ])); $signing = $header . '.' . $payload; // Private Key aus base64url laden $privDer = base64url_decode($privateKeyB64); // Reconstruct PEM from raw EC private key bytes $privPem = ec_private_key_to_pem($privDer, base64url_decode($publicKey)); $key = openssl_pkey_get_private($privPem); openssl_sign($signing, $sig, $key, OPENSSL_ALGO_SHA256); // DER-Signatur in r||s umwandeln (P-256 = 32 Bytes je) $sig = der_to_raw_ec_sig($sig); return $signing . '.' . base64url_encode($sig); } function ec_private_key_to_pem(string $d, string $pubPoint): string { // SEC1 EC private key DER für prime256v1 $oid = "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07"; // prime256v1 $pub = "\x03" . chr(strlen($pubPoint) + 1) . "\x00" . $pubPoint; $prv = "\x04" . chr(strlen($d)) . $d; $seq = "\x02\x01\x01" . $prv . "\xa1" . chr(strlen($pub)) . $pub; $inner = "\x30" . chr(strlen($seq)) . $seq; $wrap = "\x30\x77\x02\x01\x00" . "\x30" . chr(strlen($oid)) . $oid . "\x04" . chr(strlen($inner)) . $inner; return "-----BEGIN PRIVATE KEY-----\n" . chunk_split(base64_encode($wrap), 64) . "-----END PRIVATE KEY-----\n"; } function der_to_raw_ec_sig(string $der): string { // Parse DER SEQUENCE { INTEGER r, INTEGER s } $pos = 2; // skip SEQUENCE tag + length $rLen = ord($der[$pos + 1]); $r = substr($der, $pos + 2, $rLen); $pos += 2 + $rLen; $sLen = ord($der[$pos + 1]); $s = substr($der, $pos + 2, $sLen); // Pad to 32 bytes return str_pad(ltrim($r, "\x00"), 32, "\x00", STR_PAD_LEFT) . str_pad(ltrim($s, "\x00"), 32, "\x00", STR_PAD_LEFT); } function encrypt_push_payload(string $payload, string $p256dhB64, string $authB64): ?array { // RFC 8291 aes128gcm content encoding $recipientPubKey = base64url_decode($p256dhB64); $authSecret = base64url_decode($authB64); // Generate ephemeral key pair $eph = openssl_pkey_new(['curve_name' => 'prime256v1', 'private_key_type' => OPENSSL_KEYTYPE_EC]); if (!$eph) return null; $ephDetails = openssl_pkey_get_details($eph); $ephPublicKey = "\x04" . $ephDetails['ec']['x'] . $ephDetails['ec']['y']; // Recipient public key as EC key $recipientKey = ec_public_key_from_uncompressed($recipientPubKey); if (!$recipientKey) return null; // ECDH if (!openssl_dh_compute_key($sharedSecret, $recipientKey, $eph)) return null; // HKDF to derive content encryption key and nonce (RFC 8291) $salt = random_bytes(16); $prk = hkdf_sha256($authSecret, $sharedSecret, "WebPush: info\x00" . $recipientPubKey . $ephPublicKey, 32); $cek = hkdf_sha256($salt, $prk, "Content-Encoding: aes128gcm\x00", 16); $nonce = hkdf_sha256($salt, $prk, "Content-Encoding: nonce\x00", 12); // Encrypt with AES-128-GCM $tag = ''; $ciphertext = openssl_encrypt($payload . "\x02", 'aes-128-gcm', $cek, OPENSSL_RAW_DATA, $nonce, $tag, '', 16); if ($ciphertext === false) return null; // RFC 8291 header: salt(16) + rs(4) + keyid_len(1) + keyid $keyId = $ephPublicKey; $rs = pack('N', strlen($payload) + 16 + 2); $header = $salt . $rs . chr(strlen($keyId)) . $keyId; return ['ciphertext' => $header . $ciphertext . $tag]; } function ec_public_key_from_uncompressed(string $point): mixed { // Build PEM from uncompressed EC point $oid = "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07"; $bitStr = "\x03" . chr(strlen($point) + 1) . "\x00" . $point; $algId = "\x30" . chr(strlen($oid) + 2) . "\x06\x07\x2a\x86\x48\xce\x3d\x02\x01" . $oid; $spki = "\x30" . chr(strlen($algId) + strlen($bitStr)) . $algId . $bitStr; $pem = "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($spki), 64) . "-----END PUBLIC KEY-----\n"; return openssl_pkey_get_public($pem); } function hkdf_sha256(string $salt, string $ikm, string $info, int $length): string { $prk = hash_hmac('sha256', $ikm, $salt, true); $t = ''; $okm = ''; for ($i = 1; strlen($okm) < $length; $i++) { $t = hash_hmac('sha256', $t . $info . chr($i), $prk, true); $okm .= $t; } return substr($okm, 0, $length); }