Web push 无处不在,纵观 rfc8291 的修订历史,前后共出现了 aesgcm128
、aesgcm
和 aes128gcm
三种消息编码方式,但是如何解密看得我实在是一头雾水,被各种 rfc 文档折磨了几天后我再次写下这篇文章,希望能够加深印象,也希望能够帮助到大家
浏览器 & node.js
而众所周知,浏览器的 ArrayBuffer
只有一部分 Buffer
的功能,而且 Web Crypto API 也只有一部分 crypto
标准库的方法,为了便于移植,我的示例代码会尽量偏向浏览器
此外还有各种 buffer 与 base64url/base64/hex 互转的场景,我会使用诸如 base64url_to_buffer()
或者 buffer_to_base64url()
的伪函数表示转换,具体的转换代码请查看在线工具的源代码
ECC & Auth
在浏览器订阅某个网站时,service worker 会调用浏览器的 api,获得一个至少包含了 endpoint、p256dh、auth 的对象,endpoint 很好理解,发布端要往这里提交内容
ECC
p256dh 是一个使用的曲线 prime256v1
即时生成的 ECC 公钥,然后浏览器会保存私钥
// generate
let keyCurve = await crypto.subtle.generateKey(
{
name: 'ECDH',
namedCurve: 'P-256'
},
true,
['deriveKey', 'deriveBits']
)
// import
const jwk = {
"crv": "P-256",
"d": "9FWl15_QUQAWDaD3k3l50ZBZQJ4au27F1V4F0uLSD_M",
"ext": true,
"key_ops": [
"deriveKey",
"deriveBits"
],
"kty": "EC",
"x": "ISQGPMvxncL6iLZDugTm3Y2n6nuiyMYuD3epQ_TC-pE",
"y": "T21EEWyf0cQDQcakQMqz4hQKYOQ3il2nNZct4HgAUQU"
}
const keyCurve = Object.fromEntries(await Promise.all([
['privateKey', await crypto.subtle.importKey("jwk", jwk, {
name: 'ECDH',
namedCurve: jwk.crv
},
true,
jwk.key_ops
)],
['publicKey', await crypto.subtle.importKey("jwk", (jwk => {
delete jwk.d
return jwk
})(JSON.parse(JSON.stringify(jwk))), {
name: 'ECDH',
namedCurve: jwk.crv
},
true,
[]
)]
]))
const p256dh = await crypto.subtle.exportKey('raw', keyCurve.publicKey) // e.g. BCEkBj...
// const privateKey = (await crypto.subtle.exportKey('jwk', keyCurve.privateKey)).d // e.g. 9FWl15...
crypto.subtle.importKey
/crypto.subtle.exportKey
只能导入或导出 JWK 格式的私钥(公钥没有限制)。上面示例密钥的 JWK 就长下面这样:
{
"crv": "P-256",
"d": "9FWl15_QUQAWDaD3k3l50ZBZQJ4au27F1V4F0uLSD_M",
"ext": true,
"key_ops": [
"deriveKey",
"deriveBits"
],
"kty": "EC",
"x": "ISQGPMvxncL6iLZDugTm3Y2n6nuiyMYuD3epQ_TC-pE",
"y": "T21EEWyf0cQDQcakQMqz4hQKYOQ3il2nNZct4HgAUQU"
}
d
就是私钥x
和y
分别是公钥的1~33
和33~66
位,完整公钥的第0
位\x04
仅用于表示未压缩,所以在x
y
表示中需要去掉ext
设置为true
使这个密钥可导出
由于本文不会涉及剩下的部分的相关细节,所以我就不展开细说了
Auth
auth 是另一个即时生成的毫不相关的随机 16
位 buffer(对于部分发布端来说,即使 auth
是空 buffer 也能使用)
const auth = crypto.getRandomValues(new Uint8Array(16)).buffer// e.g. FC7ixOIxIKWCuR0Q-xSNKg
最终这个 p256dh
和 auth
都会经过 base64url
编码处理,与 endpoint
一并被提交到发布端
ECDH & HKDF
- ECDH 就是一个 ECC 公钥与另一个 ECC 私钥协商出一个新的公共密钥
ecdh_secret
的过程const ecdh = async (publicKey, privateKey) => { const ecdh_secret_CryptoKey = await crypto.subtle.deriveKey( { name: "ECDH", public: publicKey }, privateKey, { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"] ) const ecdh_secret = await crypto.subtle.exportKey('raw', ecdh_secret_CryptoKey) return ecdh_secret }
- 公钥
publicKey
需要传ArrayBuffer
- 私钥
privateKey
则需要CryptoKey
- 公钥
- HKDF 是先以 auth/salt 为密钥算一遍特定内容的 hmac SHA256,得到一个中间值
tmpKey
,再以这个中间值为密钥算一段内容的 hmac SHA256,再截取特定长度的这个过程const hmac_sha_256 = async (key, data) => { const keyData = await crypto.subtle.importKey("raw", key, { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"],) return new Uint8Array(await crypto.subtle.sign("HMAC", keyData, data)) } const hkdf = async (key, ikm, info, length = -1) => { const tmpKey = await hmac_sha_256(key, ikm) return (await hmac_sha_256(tmpKey, info)).slice(0, length < 0 ? undefined : length) }
key
是第一阶段的密钥,是一个ArrayBuffer
,在 Web Push 中只会是auth
和salt
中的其中一个- 实际上在处理 Web Push 的消息时这个额外抽象出来的
hkdf()
方法并不能减少多少代码行数,反而还让代码与 rfc 的描述有不同,导致更难理解了
nonce & CEK (ContentEncryptionKey)
理解了 ECDH 和 HKDF,那么就可以开始计算 nonce 和 CEK 了,它们将分别作为 iv 和密钥用于解密消息,不同的编码方式的处理方式略有不同
这里需要用到 dh
和 salt
,这两者都是由服务器生成,对标上一节的 p256dh
和 auth
生成的原理也是一样的,本质就是另一个 ECC 公钥和另一个随机 16
位 buffer
由于推送服务商都对消息的字符数做了限制,理论上接收到的消息长度都不会超过 4096
字节
- FCM: 4000, 控制台限制 1024
- Autopush: 4096, GCM/FCM bridge may be limited to only 2744 bytes instead of the normal 4096 bytes.
aesgcm128
- draft-01 ~ draft-03
我没看明白这玩意跟下面的 aesgcm 有什么区别,另外这古早玩意还有人在用吗……所以先跳过等我看明白了再补充
aesgcm
- draft-04
这是目前比较常用的方式,我的下一篇文章 通过 Web Push 接收最新的推文 里面的 Twitter 用的就是 aesgcm
dh
和 salt
都能在 headers 找到,然后就能计算了
const dh = 'BCEkBjzL8Z3C-oi2Q7oE5t2Np-p7osjGLg93qUP0wvqRT21EEWyf0cQDQcakQMqz4hQKYOQ3il2nNZct4HgAUQU'
const ecdh_secret = await ecdh(base64url_to_buffer(dh), keyCurve.privateKey)
然后计算 context
,由于公钥的长度恒定为 65
,可以直接硬编码上去
//https://gist.github.com/72lions/4528834
const concatBuffer = (...buffer) => {
const length = buffer.reduce((acc, cur) => acc + cur.byteLength, 0)
let tmp = new Uint8Array(length)
buffer.reduce((acc, cur) => {
tmp.set(new Uint8Array(cur), acc)
return acc + cur.byteLength
}, 0)
return tmp
}
const context = concatBuffer(
new TextEncoder().encode('P-256\0'),
new Uint8Array([0, 65]),
base64url_to_buffer(p256dh),
new Uint8Array([0, 65]),
base64url_to_buffer(dh),
)
然后继续照着文档计算 nonce 和 cek
const auth_info = new TextEncoder().encode("Content-Encoding: auth\0")
const PRK_combine = await hmac_sha_256(auth, ecdh_secret)
const IKM = await hmac_sha_256(PRK_combine, concatBuffer(auth_info, new Uint8Array([1])))
const PRK = await hmac_sha_256(salt, IKM)
const cek_info = concatBuffer(new TextEncoder().encode("Content-Encoding: aesgcm\0"), context)
let CEK = (await hmac_sha_256(PRK, concatBuffer(cek_info, new Uint8Array([1])))).slice(0, 16)
const nonce_info = concatBuffer(new TextEncoder().encode("Content-Encoding: nonce\0"), context)
let NONCE = (await hmac_sha_256(PRK, concatBuffer(nonce_info, new Uint8Array([1])))).slice(0, 12)
aes128gcm
- draft-05 ~ rfc8291
这是 rfc8291 规定的编码方式,也是最新的方式
与前面两者都不一样,aes128gcm
的所有信息都整合到载荷里面,所以不需要额外提供 dh
和 salt
首先得从载荷取得 salt
和 dh
// from rfc8291
// https://datatracker.ietf.org/doc/html/rfc8291#section-5
const payload = base64url_to_buffer('DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml' +
'mlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPT' +
'pK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN')
let salt = payload.slice(0, 16)
let rs = new DataView(new Uint8Array(payload.slice(16, 20)).buffer).getUint32(0, false)
let idlen = new DataView(payload.slice(20, 21).buffer).getUint8(0)// 65
let dh = payload.slice(21, 21 + idlen)
let plaintext = payload.slice(21 + idlen)
在 aes128gcm
这里类似 context
的东西又换了个名字,叫 key_info
const key_info = concatBuffer(
new TextEncoder().encode('WebPush: info\0'),
base64url_to_buffer(p256dh),
dh
)
然后计算 nonce
和 cek
const PRK_key = await hmac_sha_256(auth, ecdh_secret)
const IKM = await hmac_sha_256(PRK_key, concatBuffer(key_info, new Uint8Array([1])))
const PRK = await hmac_sha_256(salt, IKM)
const cek_info = new TextEncoder().encode("Content-Encoding: aes128gcm\0")
let CEK = (await hmac_sha_256(PRK, concatBuffer(cek_info, new Uint8Array([1])))).slice(0, 16)
const nonce_info = new TextEncoder().encode("Content-Encoding: nonce\0")
let NONCE = (await hmac_sha_256(PRK, concatBuffer(nonce_info, new Uint8Array([1])))).slice(0, 12)
*RS
rs(record size)是一个不大于 4096
的非负整数,且小于 18
时可直接忽略
它用于表示分段加密中每个区块的最大长度,比如 rs 为 18
, 总长 24 byte
的 buffer 就会拆分成长度分别为 18(SEQ=0
) 和 6(SEQ=1
) 的两个 buffer 进行加密
正常情况下 Web Push 不会用到这个值,所以理论上它设置成什么都没太大影响
但如果很不凑巧有这个值就意味着我们需要分段处理,这时 nonce 也需要另外处理
// NONCE = HMAC-SHA-256(PRK, nonce_info || 0x01) XOR SEQ
const getNonce = (nonce, SEQ) => {
if (SEQ > 0) {
nonce = new Uint8Array(nonce)
return nonce.map((byte, index) => {
if (index < 6) {
return byte
} else {
return byte ^ ((SEQ / Math.pow(256, 12 - 1 - index)) & 0xff)
}
})
}
return nonce
}
SEQ
就是内容所在的分组的顺序数,只有 SEQ > 0
时才有必要重新计算 nonce
,所以对于解密 Web Push 消息来说这段基本可以忽略
decrypt
nonce(iv)和 cek(key)都拿到了,那么就是简单的 AES-GCM 解密
const splitData = (data, size) => {
const result = []
for (let i = 0; i < data.byteLength; i += size) {
result.push(data.slice(i, i + size))
}
return result
}
const decrypt = async (nonce, contentEncryptionKey, content, rs = 0, encoding = 'aesgcm') => {
const cek = await crypto.subtle.importKey('raw', contentEncryptionKey, 'AES-GCM', true, ['encrypt', 'decrypt'])
let bufferChunk = []
if (rs < 18) {
bufferChunk.push(content)
} else {
bufferChunk.push(...splitData(content, rs))
}
decodedChunk = await Promise.all(bufferChunk.map(async (chunk, index) => {
let decodedBuffer = await crypto.subtle.decrypt({ name: "AES-GCM", iv: getNonce(nonce, index) }, cek, chunk)
let paddingLength = 0
if (encoding === 'aes128gcm') {
let i = decodedBuffer.byteLength - 1
let tmpDecodedBuffer = new Uint8Array(decodedBuffer)
while (tmpDecodedBuffer[i--] === 0) {
paddingLength++
}
decodedBuffer = decodedBuffer.slice(0, decodedBuffer.byteLength - paddingLength - 1)
} else {
paddingLength = new DataView(decodedBuffer.slice(0, 2)).getUint8()
decodedBuffer = decodedBuffer.slice(2 + paddingLength)
}
//const padding = decodedBuffer.slice(2, 2 + paddingLength)
return { data: decodedBuffer, padding: { length: paddingLength } }
}))
return { data: concatBuffer(...decodedChunk.map(chunk => chunk.data)), padding: { length: decodedChunk[0].padding.length }, chunk: decodedChunk }
}
padding
在 aesgcm
的开头或 aes128gcm
的结尾部分会加上一些 \x00
补齐长度,这些就是 padding
- aesgcm 可以从解密后的数据的前两位获得 padding 的总长
paddingLength
,去掉0 ~ 2 + paddingLength
部分即可得到原始内容 - aes128gcm 从末尾逐位去掉
\x00
,直到遇到\x01
或\x02
标记,再把这个标记也去掉就得到原始内容\x01
意味着还没到最后一个分组,后面还有内容\x02
表示这是最后一个分组,如果后面还有分组就意味着这串消息不合法
其他
- 我建议阅读 Firefox 的源码 /dom/push/PushCrypto.sys.mjs 帮助理解。请注意相关代码是以 MPL 协议开源的,可能需要规避可能的风险
- web.dev 关于上下文的部分给的示例代码有 bug……
// ❌ const localPublicKeyLength = new Uint8Array(2); subscriptionPubKeyLength[0] = 0; subscriptionPubKeyLength[1] = localPublicKey.length; // ✅ const localPublicKeyLength = new Uint8Array(2); localPublicKeyLength[0] = 0; localPublicKeyLength[1] = localPublicKey.length;
- 本文用到的示例
- 测试代码期间我除了使用 rfc 的示例外,还找到其他测试样例
- 使用前面提到的在线小工具请点这里
- 除了 Twitter 以外,YouTube 和 Instagram 也支持 Web Push