345 lines
13 KiB
PHP
345 lines
13 KiB
PHP
<?php
|
||
/**
|
||
* includes/push.php
|
||
*
|
||
* Browser Push Notifications via Web Push API (VAPID).
|
||
*
|
||
* Kein externer Composer-Paket nötig – minimale VAPID-Implementierung
|
||
* mit PHP-OpenSSL und JWT.
|
||
*
|
||
* Einrichtung:
|
||
* 1. VAPID-Keys einmalig generieren: php includes/push.php --generate-keys
|
||
* 2. Keys in config/config.php eintragen:
|
||
* 'push' => ['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);
|
||
}
|