notifications = $notifications; } public function isAvailable(): bool { return function_exists('openssl_pkey_new') && function_exists('openssl_sign') && function_exists('openssl_encrypt') && function_exists('openssl_pkey_derive') && function_exists('curl_init'); } public function publicKey(): ?string { $keys = $this->keys(); return $keys['public'] ?? null; } public function cronToken(): string { return (string) ($this->notifications->systemConfig()['cron_token'] ?? ''); } public function send(array $subscription, array $message): array { if (!$this->isAvailable()) { throw new RuntimeException('Web Push ist auf diesem Server aktuell nicht verfügbar.'); } $endpoint = trim((string) ($subscription['endpoint'] ?? '')); $p256dh = trim((string) ($subscription['keys']['p256dh'] ?? '')); $auth = trim((string) ($subscription['keys']['auth'] ?? '')); if ($endpoint === '' || $p256dh === '' || $auth === '') { throw new RuntimeException('Die Push-Subscription ist unvollständig.'); } $payload = json_encode([ 'title' => (string) ($message['title'] ?? 'Mood-Board'), 'body' => (string) ($message['body'] ?? 'Zeit für deinen Eintrag.'), 'icon' => '/assets/branding/logo-mark.svg', 'badge' => '/assets/branding/favicon.svg', 'url' => (string) ($message['url'] ?? '/track'), 'tag' => (string) ($message['tag'] ?? 'mood-reminder'), ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if (!is_string($payload)) { throw new RuntimeException('Die Push-Nachricht konnte nicht kodiert werden.'); } $encrypted = $this->encrypt($payload, $p256dh, $auth); $audience = $this->audienceForEndpoint($endpoint); $authorization = $this->authorizationHeader($audience); $headers = [ 'TTL: 3600', 'Urgency: normal', 'Content-Encoding: aes128gcm', 'Content-Type: application/octet-stream', 'Authorization: ' . $authorization['header'], 'Crypto-Key: p256ecdsa=' . $authorization['public'], ]; $handle = curl_init($endpoint); if ($handle === false) { throw new RuntimeException('Die Verbindung zum Push-Endpunkt konnte nicht vorbereitet werden.'); } curl_setopt_array($handle, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $encrypted['body'], CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => false, CURLOPT_TIMEOUT => 12, ]); $responseBody = curl_exec($handle); $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); $error = curl_error($handle); curl_close($handle); return [ 'ok' => $status >= 200 && $status < 300, 'status' => $status, 'remove' => in_array($status, [404, 410], true), 'error' => $error !== '' ? $error : null, 'response' => is_string($responseBody) ? $responseBody : null, ]; } private function keys(): array { $config = $this->notifications->systemConfig(); $public = trim((string) ($config['vapid_public_key'] ?? '')); $private = trim((string) ($config['vapid_private_key'] ?? '')); if ($public !== '' && $private !== '') { return ['public' => $public, 'private' => $private]; } if (!$this->isAvailable()) { return ['public' => null, 'private' => null]; } $resource = openssl_pkey_new([ 'private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1', ]); if ($resource === false) { throw new RuntimeException('Die VAPID-Schlüssel konnten nicht erzeugt werden.'); } $details = openssl_pkey_get_details($resource); if (!is_array($details) || !isset($details['ec']['x'], $details['ec']['y'], $details['ec']['d'])) { throw new RuntimeException('Die VAPID-Schlüsseldetails konnten nicht gelesen werden.'); } $publicKey = "\x04" . $details['ec']['x'] . $details['ec']['y']; $privateKey = $details['ec']['d']; $encodedPublic = base64url_encode($publicKey); $encodedPrivate = base64url_encode($privateKey); $this->notifications->saveVapidKeys($encodedPublic, $encodedPrivate); return ['public' => $encodedPublic, 'private' => $encodedPrivate]; } private function encrypt(string $payload, string $userPublicKey, string $authSecret): array { $salt = random_bytes(16); $userPublicRaw = base64url_decode($userPublicKey); $authRaw = base64url_decode($authSecret); $localKey = openssl_pkey_new([ 'private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1', ]); if ($localKey === false) { throw new RuntimeException('Der temporäre Push-Schlüssel konnte nicht erzeugt werden.'); } $localDetails = openssl_pkey_get_details($localKey); if (!is_array($localDetails) || !isset($localDetails['ec']['x'], $localDetails['ec']['y'])) { throw new RuntimeException('Die temporären Push-Schlüsseldetails fehlen.'); } $localPublicRaw = "\x04" . $localDetails['ec']['x'] . $localDetails['ec']['y']; $userPem = $this->publicKeyPemFromRaw($userPublicRaw); $sharedSecret = openssl_pkey_derive($userPem, $localKey, 32); if (!is_string($sharedSecret) || $sharedSecret === '') { throw new RuntimeException('Das gemeinsame Push-Geheimnis konnte nicht abgeleitet werden.'); } $context = "WebPush: info\0" . $userPublicRaw . $localPublicRaw; $keyMaterial = $this->hkdfExpand( $this->hkdfExtract($authRaw, $sharedSecret), $context, 32 ); $contentPrk = $this->hkdfExtract($salt, $keyMaterial); $contentKey = $this->hkdfExpand($contentPrk, "Content-Encoding: aes128gcm\0", 16); $nonce = $this->hkdfExpand($contentPrk, "Content-Encoding: nonce\0", 12); $recordSize = 4096; $plaintext = $payload . "\x02"; $tag = ''; $ciphertext = openssl_encrypt($plaintext, 'aes-128-gcm', $contentKey, OPENSSL_RAW_DATA, $nonce, $tag); if (!is_string($ciphertext)) { throw new RuntimeException('Die Push-Nachricht konnte nicht verschlüsselt werden.'); } $body = $salt . pack('N', $recordSize) . chr(strlen($localPublicRaw)) . $localPublicRaw . $ciphertext . $tag; return [ 'body' => $body, 'local_public' => $localPublicRaw, ]; } private function authorizationHeader(string $audience): array { $keys = $this->keys(); $header = base64url_encode((string) json_encode([ 'typ' => 'JWT', 'alg' => 'ES256', ], JSON_UNESCAPED_SLASHES)); $payload = base64url_encode((string) json_encode([ 'aud' => $audience, 'exp' => time() + 3600, 'sub' => (string) ($this->notifications->systemConfig()['subject'] ?? 'mailto:hello@localhost'), ], JSON_UNESCAPED_SLASHES)); $signingInput = $header . '.' . $payload; $signatureDer = ''; $privatePem = $this->privateKeyPemFromRaw(base64url_decode((string) $keys['private'])); if (!openssl_sign($signingInput, $signatureDer, $privatePem, OPENSSL_ALGO_SHA256)) { throw new RuntimeException('Die VAPID-Signatur konnte nicht erstellt werden.'); } $jwt = $signingInput . '.' . base64url_encode($this->derSignatureToJose($signatureDer, 64)); return [ 'header' => 'vapid t=' . $jwt . ', k=' . $keys['public'], 'public' => (string) $keys['public'], ]; } private function audienceForEndpoint(string $endpoint): string { $parts = parse_url($endpoint); if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) { throw new RuntimeException('Der Push-Endpunkt ist ungültig.'); } return $parts['scheme'] . '://' . $parts['host']; } private function hkdfExtract(string $salt, string $ikm): string { return hash_hmac('sha256', $ikm, $salt, true); } private function hkdfExpand(string $prk, string $info, int $length): string { $output = ''; $block = ''; $counter = 1; while (strlen($output) < $length) { $block = hash_hmac('sha256', $block . $info . chr($counter), $prk, true); $output .= $block; $counter++; } return substr($output, 0, $length); } private function publicKeyPemFromRaw(string $raw): string { $der = hex2bin('3059301306072A8648CE3D020106082A8648CE3D030107034200') . $raw; return "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode((string) $der), 64, "\n") . "-----END PUBLIC KEY-----\n"; } private function privateKeyPemFromRaw(string $raw): string { $der = hex2bin('30770201010420') . $raw . hex2bin('A00A06082A8648CE3D030107A14403420004') . substr(base64url_decode((string) $this->keys()['public']), 1); return "-----BEGIN EC PRIVATE KEY-----\n" . chunk_split(base64_encode((string) $der), 64, "\n") . "-----END EC PRIVATE KEY-----\n"; } private function derSignatureToJose(string $der, int $partLength): string { $offset = 0; if (ord($der[$offset]) !== 0x30) { throw new RuntimeException('Ungültige DER-Signatur.'); } $offset++; $this->readAsnLength($der, $offset); if (ord($der[$offset]) !== 0x02) { throw new RuntimeException('Ungültiger DER-R-Teil.'); } $offset++; $rLength = $this->readAsnLength($der, $offset); $r = substr($der, $offset, $rLength); $offset += $rLength; if (ord($der[$offset]) !== 0x02) { throw new RuntimeException('Ungültiger DER-S-Teil.'); } $offset++; $sLength = $this->readAsnLength($der, $offset); $s = substr($der, $offset, $sLength); return str_pad(ltrim($r, "\x00"), $partLength / 2, "\x00", STR_PAD_LEFT) . str_pad(ltrim($s, "\x00"), $partLength / 2, "\x00", STR_PAD_LEFT); } private function readAsnLength(string $der, int &$offset): int { $length = ord($der[$offset]); $offset++; if (($length & 0x80) === 0) { return $length; } $numberOfBytes = $length & 0x7F; $length = 0; for ($index = 0; $index < $numberOfBytes; $index++) { $length = ($length << 8) | ord($der[$offset]); $offset++; } return $length; } }