generate_and_store_keys(); } public function generate_and_store_keys() { if (!function_exists('openssl_pkey_new')) { throw new \RuntimeException('OpenSSL is required for VAPID key generation.'); } $is_windows = (PHP_OS_FAMILY === 'Windows') || (DIRECTORY_SEPARATOR === '\\'); $shipped_cnf = __DIR__ . DIRECTORY_SEPARATOR . 'openssl.cnf'; $shipped_cnf_norm = ($is_windows && file_exists($shipped_cnf)) ? str_replace('\\', '/', $shipped_cnf) : ''; $original_env = getenv('OPENSSL_CONF'); if ($shipped_cnf_norm !== '') { putenv('OPENSSL_CONF=' . $shipped_cnf_norm); } $config = array( 'curve_name' => 'prime256v1', 'private_key_type' => OPENSSL_KEYTYPE_EC, ); if ($shipped_cnf_norm !== '') { $config['config'] = $shipped_cnf_norm; } $res = openssl_pkey_new($config); if ($shipped_cnf_norm !== '') { putenv($original_env === false ? 'OPENSSL_CONF' : 'OPENSSL_CONF=' . $original_env); } if ($res === false) { $errors = array(); while ($e = openssl_error_string()) { $errors[] = $e; } throw new \RuntimeException('openssl_pkey_new failed: ' . implode(' | ', $errors)); } $private_pem = ''; $export_config = array(); if (isset($config['config'])) { $export_config['config'] = $config['config']; } if (!openssl_pkey_export($res, $private_pem, null, $export_config)) { $errors = array(); while ($e = openssl_error_string()) { $errors[] = $e; } throw new \RuntimeException('openssl_pkey_export failed: ' . implode(' | ', $errors)); } $details = openssl_pkey_get_details($res); if (!isset($details['ec']['x'], $details['ec']['y'])) { throw new \RuntimeException('Could not read EC public key details.'); } $x = self::lpad($details['ec']['x'], 32); $y = self::lpad($details['ec']['y'], 32); $public_raw = "\x04" . $x . $y; $public_b64 = self::b64url($public_raw); $payload = array( 'private_pem' => $private_pem, 'public_b64url' => $public_b64, 'subject' => self::default_subject(), 'generated_at' => time(), ); update_option(self::OPTION_VAPID, $payload, false); return $payload; } public static function default_subject() { $admin_email = (string) get_option('admin_email', ''); if ($admin_email !== '' && is_email($admin_email)) { return 'mailto:' . $admin_email; } return (string) home_url('/'); } public function send_empty($subscription, $ttl = 0) { if (class_exists('AIEO_DMM_Web_Push') && !\AIEO_DMM_Web_Push::is_engine_live()) { return array('ok' => false, 'status' => 0, 'error' => 'engine_paused'); } if (empty($subscription['endpoint'])) { return array('ok' => false, 'status' => 0, 'error' => 'missing-endpoint'); } try { $keys = $this->get_keys(); $auth_hdr = $this->vapid_authorization_header($subscription['endpoint'], $keys); } catch (\Throwable $e) { return array('ok' => false, 'status' => 0, 'error' => 'vapid-' . $e->getMessage()); } $resp = wp_remote_post($subscription['endpoint'], array( 'timeout' => 10, 'headers' => array( 'TTL' => (string) max(0, (int) $ttl), 'Content-Length' => '0', 'Authorization' => $auth_hdr, ), 'body' => '', )); if (is_wp_error($resp)) { return array('ok' => false, 'status' => 0, 'error' => $resp->get_error_message()); } $status = (int) wp_remote_retrieve_response_code($resp); if ($status >= 200 && $status < 300) { return array('ok' => true, 'status' => $status, 'error' => ''); } return array( 'ok' => false, 'status' => $status, 'error' => trim((string) wp_remote_retrieve_body($resp)), ); } public function send($subscription, $payload, $ttl = 0) { if (class_exists('AIEO_DMM_Web_Push') && !\AIEO_DMM_Web_Push::is_engine_live()) { return array('ok' => false, 'status' => 0, 'error' => 'engine_paused'); } if (empty($subscription['endpoint'])) { return array('ok' => false, 'status' => 0, 'error' => 'missing-endpoint'); } if (empty($subscription['p256dh']) || empty($subscription['auth_secret'])) { return array('ok' => false, 'status' => 0, 'error' => 'missing-keys'); } if (!function_exists('openssl_pkey_derive') || !function_exists('hash_hkdf')) { return array('ok' => false, 'status' => 0, 'error' => 'crypto-unavailable'); } try { $keys = $this->get_keys(); $auth_hdr = $this->vapid_authorization_header($subscription['endpoint'], $keys); $body = $this->encrypt_payload_aes128gcm( (string) $payload, self::b64url_decode((string) $subscription['p256dh']), self::b64url_decode((string) $subscription['auth_secret']) ); } catch (\Throwable $e) { return array('ok' => false, 'status' => 0, 'error' => 'encrypt-' . $e->getMessage()); } $resp = wp_remote_post($subscription['endpoint'], array( 'timeout' => 10, 'headers' => array( 'TTL' => (string) max(0, (int) $ttl), 'Content-Type' => 'application/octet-stream', 'Content-Encoding' => 'aes128gcm', 'Content-Length' => (string) strlen($body), 'Authorization' => $auth_hdr, ), 'body' => $body, )); if (is_wp_error($resp)) { return array('ok' => false, 'status' => 0, 'error' => $resp->get_error_message()); } $status = (int) wp_remote_retrieve_response_code($resp); if ($status >= 200 && $status < 300) { return array('ok' => true, 'status' => $status, 'error' => ''); } return array( 'ok' => false, 'status' => $status, 'error' => trim((string) wp_remote_retrieve_body($resp)), ); } private function encrypt_payload_aes128gcm($payload, $ua_pub_raw, $auth_secret) { if (strlen($ua_pub_raw) !== 65 || ord($ua_pub_raw[0]) !== 0x04) { throw new \RuntimeException('Invalid p256dh — expected uncompressed P-256 point (65B starting 0x04).'); } if (strlen($auth_secret) !== 16) { throw new \RuntimeException('Invalid auth_secret — expected 16 bytes.'); } if (strlen($payload) > 3993) { throw new \RuntimeException('Payload too large (max 3993 bytes after framing).'); } $is_windows = (PHP_OS_FAMILY === 'Windows') || (DIRECTORY_SEPARATOR === '\\'); $shipped_cnf = __DIR__ . DIRECTORY_SEPARATOR . 'openssl.cnf'; $cnf_norm = ($is_windows && file_exists($shipped_cnf)) ? str_replace('\\', '/', $shipped_cnf) : ''; $orig_env = getenv('OPENSSL_CONF'); if ($cnf_norm !== '') { putenv('OPENSSL_CONF=' . $cnf_norm); } $kcfg = array('curve_name' => 'prime256v1', 'private_key_type' => OPENSSL_KEYTYPE_EC); if ($cnf_norm !== '') { $kcfg['config'] = $cnf_norm; } $as_res = openssl_pkey_new($kcfg); if ($cnf_norm !== '') { putenv($orig_env === false ? 'OPENSSL_CONF' : 'OPENSSL_CONF=' . $orig_env); } if ($as_res === false) { throw new \RuntimeException('openssl_pkey_new (ephemeral) failed.'); } $as_details = openssl_pkey_get_details($as_res); if (!isset($as_details['ec']['x'], $as_details['ec']['y'])) { throw new \RuntimeException('Could not read ephemeral EC public key.'); } $as_pub_raw = "\x04" . self::lpad($as_details['ec']['x'], 32) . self::lpad($as_details['ec']['y'], 32); $ua_pem = self::raw_p256_to_pem($ua_pub_raw); $shared = openssl_pkey_derive($ua_pem, $as_res, 32); if ($shared === false) { throw new \RuntimeException('openssl_pkey_derive (ECDH) failed.'); } $info_key = "WebPush: info\x00" . $ua_pub_raw . $as_pub_raw; $prk_key = hash_hkdf('sha256', $shared, 32, $info_key, $auth_secret); $salt = random_bytes(16); $cek = hash_hkdf('sha256', $prk_key, 16, "Content-Encoding: aes128gcm\x00", $salt); $nonce = hash_hkdf('sha256', $prk_key, 12, "Content-Encoding: nonce\x00", $salt); $plaintext = $payload . "\x02"; $tag = ''; $ciphertext = openssl_encrypt($plaintext, 'aes-128-gcm', $cek, OPENSSL_RAW_DATA, $nonce, $tag, '', 16); if ($ciphertext === false) { throw new \RuntimeException('openssl_encrypt (aes-128-gcm) failed.'); } $rs = 4096; $idlen = 65; $header = $salt . pack('N', $rs) . chr($idlen) . $as_pub_raw; return $header . $ciphertext . $tag; } private static function raw_p256_to_pem($raw65) { $prefix = "\x30\x59\x30\x13\x06\x07\x2a\x86\x48\xce\x3d\x02\x01" . "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07\x03\x42\x00"; $der = $prefix . $raw65; $pem = "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($der), 64, "\n") . "-----END PUBLIC KEY-----\n"; return $pem; } private function vapid_authorization_header($endpoint, array $keys) { $aud = $this->endpoint_origin($endpoint); $exp = time() + 12 * HOUR_IN_SECONDS; $header_b64 = self::b64url(wp_json_encode(array('typ' => 'JWT', 'alg' => 'ES256'))); $payload_b64 = self::b64url(wp_json_encode(array( 'aud' => $aud, 'exp' => $exp, 'sub' => (string) $keys['subject'], ))); $signing_input = $header_b64 . '.' . $payload_b64; $der_sig = ''; if (!openssl_sign($signing_input, $der_sig, $keys['private_pem'], OPENSSL_ALGO_SHA256)) { throw new \RuntimeException('openssl_sign failed for VAPID JWT.'); } $jose_sig = self::ecdsa_der_to_jose($der_sig); $jwt = $signing_input . '.' . self::b64url($jose_sig); return 'vapid t=' . $jwt . ',k=' . $keys['public_b64url']; } private static function ecdsa_der_to_jose($der) { $offset = 0; if (substr($der, $offset, 1) !== "\x30") { throw new \RuntimeException('ECDSA DER: expected SEQUENCE'); } $offset++; $seq_len = ord($der[$offset]); $offset++; if ($seq_len & 0x80) { $n = $seq_len & 0x7f; $offset += $n; } if (substr($der, $offset, 1) !== "\x02") { throw new \RuntimeException('ECDSA DER: expected INTEGER for R'); } $offset++; $r_len = ord($der[$offset]); $offset++; $r = substr($der, $offset, $r_len); $offset += $r_len; if (substr($der, $offset, 1) !== "\x02") { throw new \RuntimeException('ECDSA DER: expected INTEGER for S'); } $offset++; $s_len = ord($der[$offset]); $offset++; $s = substr($der, $offset, $s_len); $r = ltrim($r, "\x00"); $s = ltrim($s, "\x00"); return self::lpad($r, 32) . self::lpad($s, 32); } private function endpoint_origin($url) { $parts = wp_parse_url($url); if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) { return (string) $url; } $port = isset($parts['port']) ? ':' . (int) $parts['port'] : ''; return $parts['scheme'] . '://' . $parts['host'] . $port; } public static function b64url($bytes) { return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); } public static function b64url_decode($str) { $pad = strlen($str) % 4; if ($pad) { $str .= str_repeat('=', 4 - $pad); } return (string) base64_decode(strtr($str, '-_', '+/')); } private static function lpad($bytes, $n) { $len = strlen($bytes); if ($len >= $n) { return $bytes; } return str_repeat("\x00", $n - $len) . $bytes; } }