概述
笔者最近在研究nodejs crypto非对称加密相关的操作和实现。遇到了这么一个问题:
crypto ecdh对象生成的密钥对(包括公钥和私钥),是无法直接作为标准形式提供给前端浏览器的,因为在浏览器中,使用的并不是nodejs原生的crypto这个密码学库,而是一个浏览器实现标准subtle。这个库可以导入的密钥,是标准的密码封装形式(如pksc8/spki/der等)。而crypto使用的是简单的raw格式。这样,如果需要在两者之间进行互操作的话,就要考虑和实现在两者之间进行转换。
基本设定和概念
为了简化问题讨论,我们先做一些基本的设定如下:
- 密钥信息操作,涉及到前端(浏览器)和后端(nodejs)两个典型的环境,先不考虑其他更复杂的情况和环境
- 在crypto(后端,nodejs环境)中,所使用的核心模块是ecdh
- 在浏览器环境中,使用的是内置的webcrypt(subtle),这个模块需要HTTPS
- 在后端和前端,都没有使用第三方库,当然前端浏览器可能有一些版本支持的问题,但最近的浏览器版本应该都没有什么问题
- 非对称加密参数,使用的是椭圆曲线(ECC),曲线使用secp384r1(ECDH),在subtle中就是P-384
- 本文主要探讨的密钥转换、传输和兼容的问题,需要密码学和非对称加密的基础认知和知识
在正式开始之前,先简单的了解和约定一下相关的基本概念和名词。
- crypto
nodejs中的crypto模块。
笔者认为,crypto的密码学实现设计是非常优秀的,很多操作都简单、清晰、直接。比如很多内部操作,就可以直接使用这个Buffer。而subtle可能是考虑到兼容性的问题,设计和实现就相当的繁琐,所有的操作,都是基于密钥对象的,经常设计密钥的导入导出,使用和调试也不是很方便。
- ecdh
这里主要指的是nodejs crypto中的ecdh类和对象。它的核心是基于ECC椭圆曲线算法的DH密钥协商算法,提供了一套简单易用的非对称密码学操作。
- subtle
这里主要指的是在浏览器中,webcrypto相关操作的模块,它提供和nodejs crypto类似的功能,当然也包括非对称加密密钥相关处理的功能。
- RAW
这是密钥(公钥或者私钥)的原始形式,就是Buffer或者字节数组的形式。不同的算法,这些密钥的长度可能不同。比如P-384这个算法,其标准的私钥长度是48字节,公钥是97字节。
- DER
虽然在crypto中,一般大量使用密钥的RAW格式,但如果涉及和外部或者其他系统的交换,就需要使用标准的外部信息交换规范。在密码学应用中,DER就是其中一种比较常见的形式。DER意为可区分编码规则(Distinguished Encoding Rules)。它是一种遵循ASN.1(Abstract Syntax Notation One)标准的编码方式。
DER通过严格规则来保证编码的唯一性。可以保证同一数据结构在DER下编码的唯一性。DER使用二进制格式来编码数据,适合计算机进行处理。这些特性让它被用于需要高安全性和精确性的场景,比如数字证书、签名、密钥文件等。
DER封装的大小,显著的大于原始的RAW格式。例如P-384这个算法,私钥的DER是185字节,公钥的是120字节(后面会解释为什么会有这个差异)。
- ASN.1
正如前面所述,ASN.1是一种信息编码标准。和DER相比,它是一个更大更广泛的概念。DER是其中专门针对密码学相关的具体技术实现和规则。
- PEM
PEM意为私钥增强邮件格式(Privacy Enhanced Mail)。它其实是一种带格式的字符串形式的信息封装形式。比如可以将DER信息,用Base64转码后,封装在PEM文件或者信息当中。用于存储和传输。
和DER相比,因为有一定的格式和相关的描述信息,可读性较好。下面是一个PEM文件的内容示例:
cat vk.pem
-----BEGIN PRIVATE KEY-----
MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDAtS/SX8AvwsSkmY6OS
kI8FtC6TLFEVO26NWpggAO1zh18BKZVdAIXN1tynBHMAKeahZANiAARJZZjkurlx
DKXbni7ITdYw2s2M7w7ag373IKdM7itkuvVKOXtzl97J4XiNr+h2lyFb/kxBnNtR
1RyBgYgZqNG9Ax+frCKYYrg3LsOY6gYtThEg9VMOo1WPzYDDNynrISE=
-----END PRIVATE KEY-----
非常清楚,就是表明这是一个私钥文件,它使用格式定义化的字符串头和尾,将实际内容包裹起来。这里的实际内容,就是私钥的DER内容的base64编码。
其他可能的内容标识,还有可能是 "-----BEGIN PUBLIC KEY-----","-----BEGIN EC PUBLIC KEY-----"等等。信息中的换行符好像并不重要,只是为了易于阅读,相关程序都可以自动处理。
- PKCS8
它是Public-Key Cryptography Standards(公钥密码标准)的第8号。是针对密码学中用于私钥存储和交换的一种标准格式,它主要定义了如何对私钥进行封装,以便于在不同的系统和应用之间传输和存储。
PKCS8是一种通用格式,它可以支持和存储不同算法和类型的私钥,如RSA、DSA和EC等等。PKCS8使用ASN.1定义数据的存储结构,并以DER或PEM格式进行编码。它还支持可选的加密存储,来提高安全性。
- SPKI
SPKI是 Subject Public Key Information 的缩写,通常用于表示与公钥相关的信息,如证书或公钥数据中。它定义了一个公钥及其相关算法的结构化数据。
SPKI同样使用ASN.1标准。也同样使用DER和PEM进行编码。
需要特别注意的是,对于非对称加密的算法,公钥和私钥的处理方式是不同的。
操作和测试流程
ECDH密钥生成转换
在使用crypto和ecdh,笔者已经发现,虽然使用ECDH模块进行各种操作是非常方便的。比如,如果双方都是crypto环境,可以将公钥直接输出为base64信息来进行交换和使用。 但相同的逻辑,在和浏览器环境进行交互的时候,却是行不通的,因为subtle无法导入一个简单的base64公钥信息(RAW),它只接受标准的DER格式的信息。
所以,在这一阶段,我们的目标,就是基于ECDH对象,能够输出DER格式的密钥信息。理想情况下,包括私钥和公钥的处理。经过简单的研究,笔者已经了解到,最后生成的交换信息,应当是DER格式的。而这个格式,其实应该是将原始的密钥信息,遵循ASN.1相关标准,来进行编码和封装。
由于ECDH模块的功能比较简单,它的公钥和私钥就是以buffer形式存储的,没有KeyObject的概念,也就没有内置的exportKey功能,就需要自己来进行构建和实现。
本来笔者想借助AI的帮助的,但把问题提供给ChatGTP之后,它生成的代码,却无法有效的工作。虽然在大的框架和概念方面,它好像是正确的,但涉及到具体的参数和细节,好像就出了问题,换了几个AI,好像都是相同的问题。
在初次尝试无果之后,笔者只能另辟蹊径来解决这个问题,这里还用到了一些反向工程的方法。
DER反向工程
反向工程的目的是为了搞清楚,在当前的设定之下,DER是如何封装公私钥信息的。前面已经提到,ECDH没有提供密钥导出的功能,但笔者发现,在crypto的另一个模块,却提供了这个功能。下面是相关参考代码:
const { generateKeyPair,createECDH } = require('crypto');
const REGX_64 = /.{64}/g;
generateKeyPair('ec', {
namedCurve: 'secp384r1', // Options
},(err, publicKey, privateKey) => { // Callback function
if(!err) {
// public key
const publicKeyDer = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
const formattedPublicKey = publicKeyDer.replace(REGX_64, '$&\n');
console.log(`-----BEGIN PUBLIC KEY-----\n${formattedPublicKey}\n-----END PUBLIC KEY-----\n`);
// private key
const privateKeyDer = privateKey.export({ type: 'pkcs8', format: 'der' }).toString('base64');
const formattedPrivateKey = privateKeyDer.replace(REGX_64, '$&\n');
console.log(`-----BEGIN PRIVATE KEY-----\n${formattedPrivateKey}\n-----END PRIVATE KEY-----\n`);
} else { // Prints error if any`
console.log("Error: ", err);
}
});
// 执行结果
-----BEGIN PUBLIC KEY-----
MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAESWWY5Lq5cQyl254uyE3WMNrNjO8O2oN+
9yCnTO4rZLr1Sjl7c5feyeF4ja/odpchW/5MQZzbUdUcgYGIGajRvQMfn6wimGK4
Ny7DmOoGLU4RIPVTDqNVj82Awzcp6yEh
-----END PUBLIC KEY-----
-----BEGIN PRIVATE KEY-----
MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDAtS/SX8AvwsSkmY6OS
kI8FtC6TLFEVO26NWpggAO1zh18BKZVdAIXN1tynBHMAKeahZANiAARJZZjkurlx
DKXbni7ITdYw2s2M7w7ag373IKdM7itkuvVKOXtzl97J4XiNr+h2lyFb/kxBnNtR
1RyBgYgZqNG9Ax+frCKYYrg3LsOY6gYtThEg9VMOo1WPzYDDNynrISE=
-----END PRIVATE KEY-----
代码逻辑很简单,就是利用generateKeyPair函数,生成一个secp384r1类型的密钥对。然后这两个密钥都是KeyObject, 可以导出为标准形式的DER格式。后面还可以看到,简单的将它们用格式化字符串封装一下,就可以得到PEM文件了。
当然这并不是本节的重点,当前的要点,是这个DER,是如何封装公私钥内容的?
笔者这里使用了openssl工具,来对DER进行了分析。方法是将private key的内容,保存为vk.pem文件,然后执行openssl命令,如下:
[yanjh@localhost ~]$ openssl ec -in vk.pem -text -noout
read EC key
Private-Key: (384 bit)
priv:
2d:4b:f4:97:f0:0b:f0:b1:29:26:63:a3:92:90:8f:
05:b4:2e:93:2c:51:15:3b:6e:8d:5a:98:20:00:ed:
73:87:5f:01:29:95:5d:00:85:cd:d6:dc:a7:04:73:
00:29:e6
pub:
04:49:65:98:e4:ba:b9:71:0c:a5:db:9e:2e:c8:4d:
d6:30:da:cd:8c:ef:0e:da:83:7e:f7:20:a7:4c:ee:
2b:64:ba:f5:4a:39:7b:73:97:de:c9:e1:78:8d:af:
e8:76:97:21:5b:fe:4c:41:9c:db:51:d5:1c:81:81:
88:19:a8:d1:bd:03:1f:9f:ac:22:98:62:b8:37:2e:
c3:98:ea:06:2d:4e:11:20:f5:53:0e:a3:55:8f:cd:
80:c3:37:29:eb:21:21
ASN1 OID: secp384r1
NIST CURVE: P-384
这里就非常清楚了。openssl可以正确的读取这个pem文件,并且提取出对应的类型、公钥、私钥等信息。为了进行对比和分析,笔者还对DER的原始信息进行base64解码(hex),并且将其整理了一下,得到下面的结构:
- 编码头 10
3081b602 01003010 0607
- OID 8
2a8648ce 3d020106
- 私钥序列 17
052b8104 00220481 9e30819b 02010104
30
-- 私钥 48
2d4bf497 f00bf0b1 292663a3 92908f05
b42e932c 51153b6e 8d5a9820 00ed7387
5f012995 5d0085cd d6dca704 730029e6
-- 公钥序列 5
a1640362 00
-- 公钥 97
04496598 e4bab971 0ca5db9e 2ec84dd6
30dacd8c ef0eda83 7ef720a7 4cee2b64
baf54a39 7b7397de c9e1788d afe87697
215bfe4c 419cdb51 d51c8181 8819a8d1
bd031f9f ac229862 b8372ec3 98ea062d
4e1120f5 530ea355 8fcd80c3 3729eb21
21
到这里这里我们就将会很容易理解,这个私钥的DER结构,是基于一个固定的信息结构,将私钥和公钥都封装在了一起。笔者已经测试过,对于一个固定的参数(比如P384这个曲线),其他部分的内容都是一样的,变化的就只有私钥和公钥的部分。其中,私钥48个字节,公钥97个字节。这个完全的DER是185个字节。
这个分析,也澄清了笔者在使用非对称加密过程中,一个长久的疑问,就是私钥PEM,为什么比公钥的PEM或者编码信息大很多呢?那就是因为虽然是私钥PEM,却其实包含了公钥的内容,还有其他的编码结构。另外就是为什么不能直接将公钥的Base64传给前端来做解密或者验证呢?那是因为前端的subtle,只接受DER格式,而不支持纯RAW的公钥格式。
公钥DER编码
有些读者可能会注意到,前面笔者分析的是私钥的内容,但大多数场景中,我们其实都不需要导出私钥信息,那为什么笔者需要从私钥转换入手呢。那是因为,导出的公钥PEM文件,无法使用OpenSSL命令查看和分析它的结构。但如果有了密钥的完整结构,分析公钥的DER结构,也不是很困难的事情。笔者对相同密钥生成的公钥PEM进行Base64解码,可以得到下面的结构:
-- 序列 23
30763010 06072a86 48ce3d02 0106052b
81040022 036200
-- 公钥 97
04496598 e4bab971 0ca5db9e 2ec84dd6
30dacd8c ef0eda83 7ef720a7 4cee2b64
baf54a39 7b7397de c9e1788d afe87697
215bfe4c 419cdb51 d51c8181 8819a8d1
bd031f9f ac229862 b8372ec3 98ea062d
4e1120f5 530ea355 8fcd80c3 3729eb21
21
其实也很简单和清晰,就是公钥DER是120,其中最后97个字节,就是公钥的内容。
最后需要说明,这里的公钥私钥的大小,和封装后DER的大小,都是针对P-384这个曲线类型的,其他曲线类型,有不同的OID和序列,可能的参数不同,但掌握这个原理和方法,修改成其他的设置,也不是很难。
ECDH编码
前面我们已经通过反向工程的方式,了解了DER信息结构和构成。根据这些信息,就可以为ECDH的密钥编写相关的转换程序,示例如下:
// 配置信息
const Config = {
CURVE: "secp384r1",
// header of private pem
PEM_H: Buffer.from("3081b6020100301006072a8648ce3d020106052b8104002204819e30819b0201010430","hex"),
// midder of private pme
PEM_S: Buffer.from("a164036200","hex"),
// header of public pem
PEM_P: Buffer.from("3076301006072a8648ce3d020106052b81040022036200","hex"),
...
};
// 创建echd,生成密钥对
let ecdh = createECDH(Config.CURVE);
ecdh.generateKeys();
let vk = ecdh.getPrivateKey();
let pk = ecdh.getPublicKey();
let vder = Buffer.concat([Config.PEM_H, vk, Config.PEM_S, pk]).toString("base64");
let pder = Buffer.concat([Config.PEM_P, pk ]).toString("base64");
这个示例程序中,我们就可以独立的使用ecdh生成密钥对,并将它们转换成为DER格式和base64编码,来传输到浏览器进行交互操作了。
浏览器中的处理
前面我们看到了,crypto ecdh是如何将私钥和公钥转换成为标准的DER信息了。然后,如果需要和浏览器环境进行密钥交换的时候,就需要将这个DER提交给浏览器中的应用程序来进行加载,并进行后续的处理。
在浏览器中的操作,可能会包括导入DER密钥来生成密钥对象;和从密钥对象中,导出密钥的DER内容。
导入DER密钥
在浏览器环境中,使用subtle(内置模块和方法),可以处理传来的DER,一般是一个base64字符串,或者PEM文件内容。下面是相关操作的示例代码:
...
static CURVE_384 = { name: 'ECDH', namedCurve: 'P-384', lenPublic: 120, lenPrivate: 185 };
static HASH = "SHA-256";
static PKDF = { name: 'PBKDF2' };
static AES_GCM = { name: "AES-GCM", length: 256 };
static randomBuf = (len) => new Uint8Array(Array(len).fill(0).map(v=>0|Math.random()*255));
static b64Decode = (str64)=>Uint8Array.from(atob(str64), c => c.charCodeAt(0));
static b64Encode = (ary)=>btoa(String.fromCharCode.apply(null,
ary instanceof ArrayBuffer ? new Uint8Array(ary): ary
)).replace(/=/g, "");
// PEM string of private -> key object
static importKey = async (keyString) => {
try {
let keyBuf = this.b64Decode(keyString).buffer;
if (keyBuf.byteLength > 120) { // private key should be longer 185
return await crypto.subtle.importKey( "pkcs8",keyBuf, this.CURVE_384, true, ['deriveKey']);
} else {
return await crypto.subtle.importKey( "spki", keyBuf, this.CURVE_384, true, []);
}
} catch(err) {
console.log(err);
return null;
}
}
简单说明一下:
- 导入密钥,主要使用 crypto.subtle.importKey方法,这是一个promise,一般使用await同步执行
- 简单起见,笔者使用了单一的import方法,可以处理公钥或者私钥导入,依据是密钥的长度,超过120就是私钥
- 导入的内容参数是DER的base64编码,如果是PEM,则需要先去除头部和尾部结构
- 对于私钥内容,导入的类型是pkcs8,而公钥是spki
- 导入时,需要指定ECC曲线类型,这里是"P-384"
- 导入前,需要将base64字符串,解码成为ArrayBuffer
- 导入操作的结果,是一个KeyObject,用于后续的操作,对于公钥,通常是协商密钥或者是验证签名
导出密钥
在浏览器中,导出当前密钥的信息,通常是需要导出公钥传输给对方,来进行密钥协商或签名验证,是比较简单的,因为它有内置的exportKey方法,示例如下:
static exportKey = async (key, encode="der")=>{
try {
let keyBuf = await crypto.subtle.exportKey('spki', key);
return this.b64Encode(keyBuf);
} catch(err) {
console.log(err);
return null;
}
}
简单说明一下:
- 导出公钥,需要使用subtle.exportKey方法
- 非对称密钥公钥的导出类型,一般是spki
- 导出操作的另一个参数是公钥的KeyObject
- 导入结果是ArrayBuffer,可以转换为base64方便传输
DER解构
浏览器导出公钥后,传输到crypto环境中,ecdh也是不能直接使用的,需要从中提取公钥来使用,当然在理解的DER的构成之后,这个操作是非常简单的:
let kstring = "MHYwE...";
let keyBuf = Buffer.from(kstring,"base64").subarray(-97);
console.log(keyBuf.byteLength,keyBuf);
---
97 <Buffer 04 70 6f a4 bd
最后得到的这个keyBuf,就是可以直接在ecdh方法中直接使用的公钥信息。如果是需要传输私钥,也是类似的操作,稍微复杂一点。
小结
本文探讨了在nodejs环境中,ecdh对象的密钥,和在浏览器环境中subtle所使用的密钥之间,进行兼容性转换操作的原理和过程。解决了这个兼容性的问题,可以为后续的跨系统密钥协商、加解密操作和签名验证操作,提供了一个良好的技术基础。