You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

使用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后,按以下步骤解密:

  1. 客户端向服务器发送公钥
  2. 服务器返回Compact JWE消息
  3. 客户端通过服务器公钥和自身私钥派生共享AES 128 KW密钥
  4. 客户端使用共享AES 128 KW密钥解包AES 128 GCM密钥
  5. 客户端使用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密钥,需手动实现该流程:

  1. 用deriveBits获取ECDH共享秘密
  2. 结合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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.03 22:45:38