329 lines
11 KiB
PHP
329 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
final class WebPushService
|
|
{
|
|
private NotificationRepository $notifications;
|
|
|
|
public function __construct(NotificationRepository $notifications)
|
|
{
|
|
$this->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;
|
|
}
|
|
}
|