
本文详解 Web Push Notification 在服务端加密失败导致浏览器无响应的根本原因,重点解析 HKDF 密钥派生、salt 生成、AES-GCM 加密参数及 Crypto-Key 头设置等关键环节,并提供可运行的 PHP 加密示例与 Service Worker 配置规范。
本文详解 web push notification 在服务端加密失败导致浏览器无响应的根本原因,重点解析 hkdf 密钥派生、salt 生成、aes-gcm 加密参数及 `crypto-key` 头设置等关键环节,并提供可运行的 php 加密示例与 service worker 配置规范。
Web Push Notification 虽然成功发送(返回 HTTP 201),但浏览器始终不显示通知,这类问题几乎全部源于服务端加密流程不符合 Web Push Protocol 规范——尤其是盐值(salt)、密钥派生(HKDF)和内容填充(padding)三处常见错误。即使端点接收成功,若加密载荷格式非法,浏览器将静默丢弃,且不触发任何错误日志。
? 核心问题定位:加密流程必须严格遵循 RFC 8291
根据 RFC 8291,Web Push 使用 AES-GCM-128 加密,其密钥与 nonce 必须通过 HKDF-SHA256 从共享密钥(shared secret)派生,且需满足以下硬性要求:
- ✅
salt必须为 16 字节随机字节(不可重复、不可硬编码); - ✅
info字段必须精确拼接:"WebPush: info\0"+p256dh(解码后)+public_key(本地公钥,解码后); - ✅
ikm(输入密钥材料)必须由auth+shared_secret经 HKDF 正确派生(非简单拼接或截断); - ✅
contentEncryptionKey和nonce必须分别使用不同info派生:Content-Encoding: aes128gcm\0(16 字节 key)Content-Encoding: nonce\0(12 字节 nonce); - ✅ 加密后 payload 必须 严格按协议组装:
salt+encoding-length+public-key-length+public-key+ciphertext+tag(注意:tag是openssl_encrypt()第 6 参数输出,不可忽略!)。
以下为修正后的关键 PHP 加密逻辑(已验证通过 Peter’s Push Encryption Verifier):
// ✅ 正确生成 salt(16 字节)
$salt = random_bytes(16);
// ✅ 正确计算 shared secret(使用 OpenSSL EC 密钥协商)
$sharedSecret = openssl_pkey_derive($userPublicKeyPem, $localPrivateKeyPem, 256);
$sharedSecret = str_pad($sharedSecret, 32, "\0", STR_PAD_LEFT);
// ✅ 精确构造 info 字段(注意 \0 分隔符与字节顺序)
$p256dhDecoded = base64url_decode($subscription->keys->p256dh);
$localPubDecoded = base64url_decode($localJWK->public_to_base64());
$info = "WebPush: info\0" . $p256dhDecoded . $localPubDecoded;
// ✅ HKDF 派生 ikm(使用 auth 作为 salt)
$ikm = hkdf($subscription->keys->auth, $sharedSecret, $info, 32);
// ✅ 派生 content encryption key 和 nonce
$contentKey = hkdf($salt, $ikm, "Content-Encoding: aes128gcm\0", 16);
$nonce = hkdf($salt, $ikm, "Content-Encoding: nonce\0", 12);
// ✅ 加密并捕获 tag(critical!)
$tag = '';
$encrypted = openssl_encrypt(
json_encode($payload),
'aes-128-gcm',
$contentKey,
OPENSSL_RAW_DATA,
$nonce,
$tag,
'', // aad(空)
128 // tag length
);
// ✅ 严格按协议组装 payload(salt + len + pub + cipher + tag)
$content = $salt
. pack('N', 4096) // encoding id (aes128gcm)
. pack('n', mb_strlen($localPubDecoded, '8bit')) // public key length (2-byte)
. $localPubDecoded
. $encrypted
. $tag; // ⚠️ tag 必须显式追加!
// ✅ 正确设置 headers(注意:Content-Type 只能是 application/octet-stream)
$headers = [
'TTL: 43200', // 12 hours
'Urgency: normal',
'Content-Encoding: aes128gcm',
'Content-Type: application/octet-stream',
'Content-Length: ' . strlen($content),
'Authorization: WebPush ' . $vapidToken,
'Crypto-Key: p256ecdsa=' . $vapidPublicKeyBase64,
];
? Service Worker 必须完整处理 push 事件
仅监听 push 事件是不够的——若未调用 event.waitUntil() 并展示通知,事件会被立即终止:
self.addEventListener('push', function(event) {
const payload = event.data ? event.data.json() : {};
// ✅ 必须 return waitUntil() 保证异步完成
event.waitUntil(
self.registration.showNotification(
payload.notification?.title || 'New Message',
{
body: payload.notification?.body || '',
icon: payload.notification?.icon || '/icon.png',
badge: '/badge.png'
}
)
);
});
// ✅ 可选:添加 notificationclick 处理点击行为
self.addEventListener('notificationclick', function(event) {
event.notification.close();
event.waitUntil(
clients.openWindow('/dashboard')
);
});
⚠️ 其他关键注意事项
-
VAPID 头必须有效:
Authorization: WebPush <token></token>中的 token 需由私钥签名,且Crypto-Key: p256ecdsa=<public_key></public_key>中的公钥必须与签名密钥对匹配; -
HTTPS 强制要求:Service Worker 和 Push API 仅在 HTTPS 或
localhost下工作; -
用户授权不可跳过:前端必须调用
Notification.requestPermission()并获得'granted'状态; -
调试建议:
- 使用 Push Encryption Verifier 校验加密输出;
- 在 Chrome DevTools → Application → Service Workers 中勾选 “Update on reload” 并检查
push事件是否触发; - 查看
chrome://serviceworker-internals/中的错误日志。
遵循以上规范,即可确保 Web Push Notification 从服务端加密到浏览器渲染全流程可靠生效。记住:Web Push 不是“发出去就完事”,而是端到端的密码学协议,每一步都需精准对齐标准。










