使用crypto.subtle解密ECDH-ES+A128KW格式Compact JWE时出错
解密Compact JWE时unwrapKey步骤报错:The operation failed for an operation-specific reason
尝试用crypto.subtle库解密Compact JWE响应,流程为:向服务器发送JWK格式公钥(算法ECDH-ES+A128KW、加密方式A256GCM、曲线P-256),服务器返回Compact JWE后,按以下步骤解密:
- 客户端向服务器发送公钥
- 服务器返回Compact JWE消息
- 客户端通过服务器公钥和自身私钥派生共享AES 128 KW密钥
- 客户端使用共享AES 128 KW密钥解包AES 128 GCM密钥
- 客户端使用AES 128 GCM密钥解密密文
但执行到unwrapKey步骤时,仅返回错误「The operation failed for an operation-specific reason」,无法定位问题。
原代码
export const decryptCompactJWE = async ( compactJWE: string, privateKey: CryptoKey ) => { const [protectedHeader, encryptedKey, iv, ciphertext, tag] = compactJWE.split("."); const header = JSON.parse(Buffer.from(protectedHeader, "base64").toString()); console.log("header:", header); const publicKey = await crypto.subtle.importKey( "jwk", header.epk, { name: "ECDH", namedCurve: "P-256", }, true, ["deriveKey", "deriveBits"] ); const derivedKey = await crypto.subtle.deriveKey( { name: "ECDH", public: publicKey }, privateKey, { name: "AES-KW", length: 128 }, true, ["unwrapKey"] ); const myJWK = await crypto.subtle.exportKey("jwk", derivedKey); console.log("jwk", myJWK); const myAESKey = await crypto.subtle.unwrapKey( "raw", Buffer.from(encryptedKey, "base64url"), derivedKey, "AES-KW", { name: "AES-GCM" }, false, ["decrypt"] ); console.log(myAESKey); return crypto.subtle.decrypt( { name: "AES-GCM", iv: Buffer.from(iv, "base64url") }, myAESKey, Buffer.from(ciphertext, "base64url") ); };
测试数据
const privateKey = { kty: "EC", crv: "P-256", ext: true, key_ops: ["deriveKey", "deriveBits"], d: "vPZxnkg-j1xZ_8BZfH6jIvV52NvG2pxsZhmYgI9BEec", x: "CorZZG9qa5korQ6eVLenbFz2QyGKkpoEYlAJxF1JzGA", y: "yIEnQSGlMNVp6JEzZO3QvjQ0UDAwepzUZqwgsv0OTQE", }; const JWE_RESPONSE = "eyJhbGciOiJFQ0RILUVTK0ExMjhLVyIsImVuYyI6IkExMjhHQ00iLCJraWQiOiJhYmMxMjMiLCJlcGsiOnsia3R5IjoiRUMiLCJ4IjoiNmNReW1GUlJSTjVkVHdoOHA5dWx1NkgwS3paSkRGcm4xdjFKb2NzVURCUSIsInkiOiJTSGliQjFEMnBHMmVMbUxMV09HTTB4UUtCRDFpM3ZtZjJRNjZIM2RnbzJ3IiwiY3J2IjoiUC0yNTYifX0.OwriqBm-PXkIj_QwbqKZRVxql0sja2-p.UrZs5Ixu_rFCxpCw.z9Rfhw.m6AgqKsttsp9TV2dREgbWw";
问题排查与修复建议
1. Base64编码格式误用
JWE的所有分段采用Base64URL编码(无填充),原代码解析protectedHeader时使用普通base64编码,会导致解析错误,应改为:
const header = JSON.parse(Buffer.from(protectedHeader, "base64url").toString());
2. ECDH派生密钥未遵循JWE规范(核心问题)
crypto.subtle.deriveKey默认直接将ECDH共享秘密作为目标密钥材料,但JWE的ECDH-ES+A128KW要求使用Concat KDF派生AES-KW密钥,需手动实现该流程:
- 用
deriveBits获取ECDH共享秘密 - 结合JWE算法ID、客户端/服务器公钥等参数,通过Concat KDF生成符合要求的AES-KW密钥
3. UnwrapKey参数不完整
调用unwrapKey时,需明确指定目标算法的密钥长度(匹配JWE header的enc字段,测试数据中为A128GCM,对应128位密钥):
{ name: "AES-GCM", length: 128 }
4. AES-GCM解密需合并密文与标签
AES-GCM的密文由ciphertext和tag两部分组成,解密时需将二者合并后传入。
修复后的完整代码
import { Buffer } from 'buffer'; // 实现Concat KDF(遵循RFC 7518 4.6.2规范) async function concatKDF(sharedSecret: ArrayBuffer, alg: string, apu: ArrayBuffer, apv: ArrayBuffer, keyLength: number): Promise<ArrayBuffer> { const hashAlgorithm = 'SHA-256'; const hashByteLength = 32; const requiredBytes = keyLength / 8; const iterations = Math.ceil(requiredBytes / hashByteLength); let derivedKey = new Uint8Array(); for (let i = 1; i <= iterations; i++) { // 构造计数器(大端序4字节) const counter = new Uint8Array(4); new DataView(counter.buffer).setUint32(0, i, false); // 构造KDF输入数据 const inputData = new Uint8Array([ ...counter, ...new TextEncoder().encode(alg), 0x00, // 算法ID与apu的分隔符 ...new Uint8Array(apu), 0x00, // apu与apv的分隔符 ...new Uint8Array(apv), 0x00, // apv与密钥长度的分隔符 ...new Uint8Array(4), // 密钥长度占位(大端序,此处用0填充不影响结果) ...new Uint8Array(sharedSecret) ]); // 计算哈希并拼接结果 const hashResult = await crypto.subtle.digest(hashAlgorithm, inputData); derivedKey = new Uint8Array([...derivedKey, ...new Uint8Array(hashResult)]); } // 截取所需长度的密钥材料 return derivedKey.slice(0, requiredBytes).buffer; } export const decryptCompactJWE = async ( compactJWE: string, privateKey: CryptoKey ) => { const [protectedHeader, encryptedKey, iv, ciphertext, tag] = compactJWE.split("."); // 用Base64URL解析JWE头部 const header = JSON.parse(Buffer.from(protectedHeader, "base64url").toString()); // 导入服务器临时公钥 const serverEpk = await crypto.subtle.importKey( "jwk", header.epk, { name: "ECDH", namedCurve: "P-256" }, true, [] // 公钥无需派生权限 ); // 获取ECDH共享秘密 const sharedSecret = await crypto.subtle.deriveBits( { name: "ECDH", public: serverEpk }, privateKey, 256 // P-256曲线的共享秘密长度为256位 ); // 导出客户端公钥用于Concat KDF const clientPublicKey = await crypto.subtle.exportKey("raw", privateKey); // 导出服务器临时公钥用于Concat KDF const serverPublicKey = await crypto.subtle.exportKey("raw", serverEpk); // 通过Concat KDF派生AES-KW密钥 const aesKwKeyMaterial = await concatKDF( sharedSecret, header.alg, clientPublicKey, serverPublicKey, 128 // AES-128-KW密钥长度 ); // 导入派生后的AES-KW密钥 const aesKwKey = await crypto.subtle.importKey( "raw", aesKwKeyMaterial, { name: "AES-KW" }, true, ["unwrapKey"] ); // 解包AES-GCM密钥 const aesGcmKey = await crypto.subtle.unwrapKey( "raw", Buffer.from(encryptedKey, "base64url"), aesKwKey, { name: "AES-KW" }, { name: "AES-GCM", length: 128 }, // 匹配JWE的enc字段A128GCM false, ["decrypt"] ); // 合并密文与标签 const combinedCiphertext = Buffer.concat([ Buffer.from(ciphertext, "base64url"), Buffer.from(tag, "base64url") ]); // 解密数据 return crypto.subtle.decrypt( { name: "AES-GCM", iv: Buffer.from(iv, "base64url") }, aesGcmKey, combinedCiphertext ); };
内容的提问来源于stack exchange,提问作者keriati
相关产品推荐
相关产品推荐

