7.1 VAPID Key Setup
VAPID (Voluntary Application Server Identification) uses an EC P-256 key pair:
- Generate —
OPWA_Push::generate_vapid_keys()callsopenssl_pkey_new(['curve_name' => 'prime256v1']). - Public key — The uncompressed 65-byte point (
0x04 || X || Y) base64url-encoded. This is sent to the browser during subscription viaapplicationServerKey. - Private key — Stored as PEM, used to sign the VAPID JWT.
7.2 Sending Notifications
OPWA_Push::send(object $subscriber, array $payload_data, string $vapid_subject):
- Build the VAPID JWT (ES256):
- Header: {"typ":"JWT","alg":"ES256"}
- Payload: {"aud":"https://fcm.googleapis.com", "exp":now+43200, "sub":"mailto:admin@site.com"}
- Sign with private key via openssl_sign() → convert DER signature to raw R‖S (32 bytes each)
- Encrypt payload with
OPWA_Push::encrypt_payload()(RFC 8188 / ECE aes128gcm) - POST to subscriber endpoint with headers:
`
Authorization: vapid t={jwt},k={public_key_b64u}
Content-Type: application/octet-stream
Content-Encoding: aes128gcm
TTL: 86400
`
- HTTP 410 response → delete subscriber (endpoint expired)
7.3 Encryption Details
The payload is encrypted using the Web Push Encryption spec (RFC 8188, ECE draft-03):
- Shared secret — ECDH between an ephemeral sender key pair and the subscriber's
p256dhkey.
- PHP 8.1+: openssl_pkey_derive()
- PHP < 8.1: pure-PHP double-and-add scalar multiplication on P-256 using GMP
- PRK —
HKDF-SHA256(salt=auth_secret, ikm=shared_secret, info="WebPush: info\x00" || recv_pub || sender_pub, len=32) - CEK —
HKDF-SHA256(salt=random_16_bytes, ikm=prk, info="Content-Encoding: aes128gcm\x00", len=16) - Nonce —
HKDF-SHA256(salt, prk, "Content-Encoding: nonce\x00", len=12) - Ciphertext —
AES-128-GCM(key=cek, iv=nonce, plaintext=payload + \x02 + zero_padding) - Body —
salt(16) || rs(4, big-endian) || idlen(1) || sender_pub(65) || ciphertext || gcm_tag(16)
