Blog

解密来自 Web Push 的 AES-GCM 消息

2023-12-19

#Web Push
#aesgcm
#aes128gcm

Web push 无处不在,纵观 rfc8291 的修订历史,前后共出现了 aesgcm128aesgcmaes128gcm 三种消息编码方式,但是如何解密看得我实在是一头雾水,被各种 rfc 文档折磨了几天后我再次写下这篇文章,希望能够加深印象,也希望能够帮助到大家

浏览器 & node.js

而众所周知,浏览器的 ArrayBuffer 只有一部分 Buffer 的功能,而且 Web Crypto API 也只有一部分 crypto 标准库的方法,为了便于移植,我的示例代码会尽量偏向浏览器

此外还有各种 buffer 与 base64url/base64/hex 互转的场景,我会使用诸如 base64url_to_buffer() 或者 buffer_to_base64url() 的伪函数表示转换,具体的转换代码请查看在线工具的源代码

ECC & Auth

在浏览器订阅某个网站时,service worker 会调用浏览器的 api,获得一个至少包含了 endpointp256dhauth 的对象,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 就是私钥
  • xy 分别是公钥的 1~3333~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

最终这个 p256dhauth 都会经过 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 中只会是 authsalt 中的其中一个
    • 实际上在处理 Web Push 的消息时这个额外抽象出来的 hkdf() 方法并不能减少多少代码行数,反而还让代码与 rfc 的描述有不同,导致更难理解了

nonce & CEK (ContentEncryptionKey)

理解了 ECDH 和 HKDF,那么就可以开始计算 nonce 和 CEK 了,它们将分别作为 iv 和密钥用于解密消息,不同的编码方式的处理方式略有不同

这里需要用到 dhsalt,这两者都是由服务器生成,对标上一节的 p256dhauth

生成的原理也是一样的,本质就是另一个 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

dhsalt 都能在 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 的所有信息都整合到载荷里面,所以不需要额外提供 dhsalt

首先得从载荷取得 saltdh

// 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
)

然后计算 noncecek

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 表示这是最后一个分组,如果后面还有分组就意味着这串消息不合法

其他

参考


评论区