本文深入探讨了JSON Web Key (JWK) 中椭圆曲线公钥坐标的正确编码方法。针对从私钥派生公钥时常见的坐标未规范化和字节长度填充不足问题,提供了详细的解决方案和代码示例。通过遵循规范化的坐标提取和正确的字节填充策略,确保生成的JWK公钥与标准保持一致,实现互操作性。
1. JWK椭圆曲线公钥坐标编码规范
JSON Web Key (JWK) 是一种用于表示加密密钥的JSON数据结构。对于椭圆曲线 (EC) 公钥,其 x 和 y 坐标是关键组成部分。根据 JWK 规范,x 和 y 成员应包含椭圆曲线点的 x 和 y 坐标。它们必须以大端字节序 (big-endian) 表示,然后进行 Base64url 编码。
例如,对于P-521曲线,其坐标表示需要固定长度,即521位需要向上取整到下一个8的倍数,即528位,也就是66字节。这意味着无论实际数值大小,x 和 y 的大端字节表示都必须填充到66字节。
2. 从私钥派生公钥的常见问题
在实际开发中,尤其是在使用第三方密码学库(如 elliptic.js)从私钥派生公钥并尝试手动构造JWK时,开发者常常会遇到生成的 x 和 y 坐标与使用 crypto.subtle.exportKey 等标准API导出的结果不匹配的问题。这通常源于两个核心原因:坐标未规范化和字节长度填充不足。
让我们通过一个示例代码来演示这个问题:
const elliptic = require('elliptic'); const EC = elliptic.ec; const {base16, base64url} = require('rfc4648'); const BN = require("bn.js"); // 辅助函数:将BN对象转换为Base64url字符串,但未进行固定长度填充 const padBase16ToWholeOctets = s => s.length % 2 === 0 ? s : '0' + s; const bnToB64 = n => base64url.stringify(base16.parse(padBase16ToWholeOctets(n.toString(16)))); (async () => { console.log('--- 初始尝试 ---'); let keyPair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-521" }, true, ['sign']); let jwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey); console.log('导出的JWK私钥部分:', jwk); const dHex = base16.stringify(base64url.parse(jwk.d, { loose: true })); const ec = new EC('p521'); // 错误:toJSON() 可能不会提供规范化的坐标 const [x_bn, y_bn] = ec.curve.g.mul(new BN(dHex, 16, 'be')).toJSON(); console.log(`预期 x: ` + jwk.x); console.log(`实际 x (toJSON): ` + bnToB64(x_bn)); // 可能会不匹配 console.log(`预期 y: ` + jwk.y); console.log(`实际 y (toJSON): ` + bnToB64(y_bn)); // 可能会不匹配 console.log('----------------'); })();
运行上述代码,你会发现 实际 x 和 实际 y 的输出与 预期 x 和 预期 y 并不一致。
3. 解决方案:规范化坐标与字节填充
要正确地从椭圆曲线点对象中提取并编码 x 和 y 坐标,需要解决上述两个问题。
3.1 坐标规范化
elliptic.js 库中的 Point 对象,其 toJSON() 方法可能不会直接返回用于JWK的规范化 x 和 y 坐标。正确的做法是使用 getX() 和 getY() 方法,它们返回的是 BN (BigNumber) 对象,代表了曲线点的规范化坐标值。
将上述代码中的坐标提取部分修改为:
// ... const ec = new EC('p521'); const point = ec.curve.g.mul(new BN(dHex, 16, 'be')); // 计算公钥点 // 正确:使用 getX() 和 getY() 获取规范化坐标 const x_bn_normalized = point.getX(); const y_bn_normalized = point.getY(); console.log(`实际 x (getX): ` + bnToB64(x_bn_normalized)); console.log(`实际 y (getY): ` + bnToB64(y_bn_normalized)); // ...
3.2 字节长度填充
JWK规范要求 x 和 y 坐标的大端字节表示必须填充到固定长度。这个长度取决于所使用的椭圆曲线。
- P-256 曲线: 256位 / 8 = 32字节
- P-384 曲线: 384位 / 8 = 48字节
- P-521 曲线: 521位,向上取整到下一个8的倍数是528位 / 8 = 66字节
因此,在将 BN 对象转换为十六进制字符串后,需要将其填充到对应的字节长度。对于P-521曲线,66字节对应132个十六进制字符。
修改 bnToB64 辅助函数,加入固定长度的零填充:
// 辅助函数:将BN对象转换为Base64url字符串,并进行固定长度填充 const bnToB64Padded = (n, byteLength) => { const hexString = padBase16ToWholeOctets(n.toString(16)); // 填充到指定字节长度的十六进制字符串(每个字节2个十六进制字符) const paddedHexString = hexString.padStart(byteLength * 2, '0'); return base64url.stringify(base16.parse(paddedHexString)); };
4. 完整示例与正确实践
结合上述两点修正,以下是正确从私钥派生P-521曲线公钥并生成JWK x 和 y 坐标的完整代码:
const elliptic = require('elliptic'); const EC = elliptic.ec; const {base16, base64url} = require('rfc4648'); const BN = require("bn.js"); // 辅助函数:将BN对象转换为Base64url字符串,并进行固定长度填充 const padBase16ToWholeOctets = s => s.length % 2 === 0 ? s : '0' + s; const bnToB64Padded = (n, byteLength) => { const hexString = padBase16ToWholeOctets(n.toString(16)); // 填充到指定字节长度的十六进制字符串(每个字节2个十六进制字符) const paddedHexString = hexString.padStart(byteLength * 2, '0'); return base64url.stringify(base16.parse(paddedHexString)); }; (async () => { console.log('--- 正确实践 ---'); // 1. 生成P-521 ECDSA密钥对 let keyPair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-521" }, true, ['sign']); // 2. 导出JWK格式的私钥,包含公钥的x, y坐标 let jwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey); console.log('导出的完整JWK:', jwk); // 3. 从JWK私钥中提取私钥参数 'd' const dHex = base16.stringify(base64url.parse(jwk.d, { loose: true })); // 4. 初始化elliptic曲线对象 (P-521) const ec = new EC('p521'); // 5. 使用私钥参数 'd' 和基点 'g' 计算公钥点 const point = ec.curve.g.mul(new BN(dHex, 16, 'be')); // 6. 提取规范化的 x 和 y 坐标 (BN对象) const x_bn_normalized = point.getX(); const y_bn_normalized = point.getY(); // 7. 定义P-521曲线的坐标字节长度 (521位 -> 66字节) const P521_BYTE_LENGTH = 66; // 8. 将规范化坐标转换为Base64url编码字符串,并进行固定长度填充 const actual_x_b64 = bnToB64Padded(x_bn_normalized, P521_BYTE_LENGTH); const actual_y_b64 = bnToB64Padded(y_bn_normalized, P521_BYTE_LENGTH); console.log(`预期 x (来自crypto.subtle): ` + jwk.x); console.log(`实际 x (手动计算): ` + actual_x_b64); console.log(`预期 y (来自crypto.subtle): ` + jwk.y); console.log(`实际 y (手动计算): ` + actual_y_b64); // 验证是否匹配 console.log(`x 匹配结果: ${jwk.x === actual_x_b64}`); console.log(`y 匹配结果: ${jwk.y === actual_y_b64}`); console.log('----------------'); })();
运行这段代码,你会发现 实际 x 和 实际 y 的输出与 预期 x 和 预期 y 完全一致。
5. 注意事项与总结
- 曲线类型与字节长度: 务必根据所使用的椭圆曲线类型(如P-256, P-384, P-521)确定正确的字节长度进行填充。这是确保JWK互操作性的关键。
- 库函数选择: 在使用第三方密码学库时,要仔细查阅其API文档,了解如何获取规范化的坐标值。避免使用可能返回非规范化或非固定长度表示的方法(如某些库的 toJSON())。
- Base64url编码: 确保使用正确的Base64url编码,而不是标准的Base64编码。Base64url不包含 +, /, = 字符,而是使用 -, _,且不带填充。
- 安全性: 在生产环境中,推荐优先使用浏览器内置的 WebCrypto API (crypto.subtle) 或经过严格审计的密码学库来处理密钥生成和导出,以确保安全性和合规性。
通过理解JWK规范中椭圆曲线公钥坐标的编码要求,并正确处理坐标的规范化和字节长度填充,可以确保手动生成的JWK公钥与标准保持一致,从而实现不同系统间的无缝互操作。
以上就是JWK椭圆曲线公钥坐标js json 编码 浏览器 字节 ai 常见问题 crypto json 字符串 数据结构 JS 对象