概述
为什么想起要研究这个问题?是因为笔者在工作学习中,遇到了这样一种情况。就是在程序中,使用的非对称加密的密钥和签名验证操作时,可能需要不同的密钥,非常不方便。搞清楚了这个问题,就可以基于相同的密钥类型,在不同的使用方式之间进行转换和传输,这样可以大大简化系统开发和应用的配置管理。
一个完整的公钥加密的密码学操作,通常包括了密钥对的生成、密钥协商、内容加密、签名和验证等环节。Nodejs的Crypto库中,有很多相关的实现和操作,但感觉都比较杂乱,这可能是缺乏统一规划和功能模块逐步完善所造成的(当然相比其他的语言和平台,已经非常简单方便了)。在其中,笔者发现ECDH这个对象的实现和应用,是最简洁优雅的,可以非常方便的完成密钥对的生成,密钥协商等操作。而且对于一个可配置的环境,其密钥的保存和加载也是非常方便的。
但到了签名和验证操作环节,却发现了一些问题。就是ECDH虽然能够使用标准的ECC曲线和密钥对生成,但这个密钥,却不能直接用于签名和验证。首先ECDH主要是为密钥协商计算而设计,本身没有相关签名验证相关的操作实现。这并不是一个太大的问题,因为nodejs也提供了相关签名验证的相关对象和方法。主要的问题是,ECDH的密钥对,不能直接在签名验证操作中使用,这就给程序的配置、相关密钥的管理和维护带来了很多不便。
我们希望实现的效果是,既然都使用同一种类型的非对称加密算法,甚至选择了相同的ECC曲线类型,那相关操作,都应当可以围绕这同一套配置方式来展开。本文研究的目的,就是希望找到使用一种配置,可以支持加密解密和签名验证操作的方式。
初看起来,这个目的,和本文的标题是有一些出入的,但实际上,实现这种效果的核心,就是在对ASN1结构的理解和应用之上的。这也是笔者在相关的研究和实现之后,才突然理解到的,希望也能够将这个理解和认识,分享给感兴趣的读者。
ECDH的基本应用
我们先来看看,一般在Nodejs中,如何使用ECDH对象,来进行相关的非对称加密的操作。 为了方便讨论,本文中使用的ECC都是SECP384R1,无论是ECDH操作和签名加密操作。
在ECDH方面,简要的操作过程和代码示例如下:
js
const CURVE = "secp384r1";
const echdA = createECDH(CURVE);
echdA.generateKeys();
let publicKeyA = echdA.getPublicKey();
let privateKeyA = echdA.getPrivateKey();
const echdB = createECDH(CURVE);
echdB.generateKeys();
let publicKeyB = echdB.getPublicKey();
let keyAB= echdA.computeSecret(publicKeyB);
console.log("KeyAB:", keyAB);
let keyBA= echdB.computeSecret(publicKeyA);
console.log("KeyBA:", keyBA);
const echdC = createECDH(CURVE);
echdC.setPrivateKey(privateKeyA);
let keyCB= echdC.computeSecret(publicKeyB);
console.log("KeyCA:", keyCB);
我们在代码中,可以看到,ECDH的使用非常简单方便:
- 先使用createECDH函数和曲线名称作为参数,创建ecdh对象
- 使用这个对象的generateKeys()方法,可以创建密钥对
- 在实例对象创建和密钥对生成之后,就可以使用其getPublicKey和getPrivateKey方法,导出Buffer类型的公钥和私钥
- 当然也可以指定导出的格式,如base64或者hex字符串,方便存储和传输
- 可以使用ecdh实例的computeSecret()方法,配合另一个同类型的公钥作为参数,来计算协商的密钥,后续可以用于加密解密
- 如果不使用临时生成的密钥对,也可以在创建ecdh实例后,可以使用setPrivate来设置私钥,这个操作在基于配置的应用程序运行中,是非常有用的
但就如前面所提到的,ecdh本身,并没有进一步加密解密和签名验证的功能。虽然,它协商计算出来的密钥,可以直接用于对称加密和解密(如用于createCipherIV函数),因为nodejs的cipher和decipher对象,是可以直接接受纯二进制密钥的。但是,它导出的私钥和公钥,却不能直接用于签名和验证,因为nodejs crypto的相关操作,并不简单的直接使用纯密钥内容,而是使用密钥对象。而ecdh导出的密钥信息,是无法直接来构造相关的密钥对象的,其实是需要进行转换和编码的,这就是本文要研究的问题的核心。
所以,在继续之前,我们再来看看,nodejs中是如何进行签名和验证操作的。
签名和验证操作
在nodejs中,使用相同的ECC算法和曲线参数,进行签名和验证操作,大致的过程和代码示例如下:
js
import {
generateKeyPairSync, createPrivateKey, createPublicKey,
createSign, createVerify, createECDH
} from "crypto";
const CURVE = "secp384r1";
let { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: CURVE });
const otext = "China.中国";
const sign = createSign("SHA256")
.update(otext)
.end()
.sign( privateKey)
.toString("base64");
console.log("Sign:", sign);
let keyString = publicKey.export({ format: "der", type: "spki" }).toString("base64");
console.log("PublicKey:", keyString);
let publicKey2 = createPublicKey({
key: Buffer.from(keyString,"base64"),
format: "der",
type: "spki"
})
const veri = createVerify("SHA256")
.update(otext)
.end()
.verify(publicKey2, Buffer.from(sign,"base64"));
console.log("Verify:", veri); // should true
const veri2 = createVerify("SHA256")
.update(otext+"x")
.end()
.verify(publicKey2, Buffer.from(sign,"base64"));
console.log("Verify2:", veri2); // should false for content changed
这个过程大体如下:
- 签名方,使用密钥对生成函数,指定曲线类型,生成密钥对,包括了私钥和公钥对象实例
- 签名方调用createSign方法,创建一个签名器,可以加载签名内容,并使用私钥进行签名
- 签名结果默认是buffer,当然一般导出为base64进行传输
- 公钥对象,也可以按照设置,导出为一个DER的base64形式,来进行发布
- 验证方,可以获取发布的公钥信息,并还原成为公钥对象
- 验证方,可以调用创建一个createVerify方法,创建一个验证器实例
- 验证器加载验证内容,并且使用签名公钥对象和签名信息,来进行验证
- 验证的返回结果是true和false,表示验证是否成功
- SHA256表示,在签名前先进行摘要计算,从而可以有效控制签名信息的规模
这段代码的问题在于,签名使用的私钥,是临时生成的。但在真实的应用场景中,很可能是需要使用一个配置好的密钥,来加载使用。当然,也可以直接方便的导出密钥,然后在后面加载使用,就和代码中公钥的导出和传输类似。
很自然的,笔者就构想,既然都使用相同的曲线和配置,createPrivateKey方法,应该也可以加载ECDH导出的私钥,并在签名和验证中使用。但是现实情况是,虽然是相同的曲线和密钥信息,ECDH导出的是私钥的原始信息(buffer),是无法直接在createPrivateKey中使用的,它使用的是ASN1标准编码后的信息。
所以,本文问题的核心,就是将私钥的原始信息,转换成为ASN1标准编码格式,并且可以用于创建签名使用的私钥对象。我们会在下一章节中,着重讨论这个问题。
ASN.1编码和格式
在开始之前,笔者认为需要先了解一下ASN1编码和相关的基本知识和概念。由于这个内容比较复杂,笔者并不打算非常非常深入的探讨,只是初步了解,能够有助于问题的理解和简单应用即可。
ASN.1,即Abstract Syntax Notation One,抽象语法标记一,是一种用于描述数据结构的标准表示法,广泛应用于通信协议中的数据编码和传输。它基于二进制格式,提供了一种与平台、编程语言无关的方式,来定义复杂数据结构。它还支持多种编码规则(如BER、DER、PER等),能够将抽象描述转换为具体的二进制或文本格式。我们可以理解,它是一种信息编码的事实标准,在密码学应用中的应用也非常广泛,比如密钥和数字证书等的编码。
遗憾的是,这个编码标准,基本是为计算机系统和软件程序而设计的,虽然严谨高效,但对于人类的解读是非常不友好的,所以在程序调试过程中,是不太直观的,我们会在后面的分析中看到。
除了ASN.1之外,笔者认为还需要了解一些其他相关的概念。
- DER
Distinguished Encoding Rules,可辨识编码规则。它是一种ASN1编码规则,要求唯一确定的编码,常用于密钥和数字证书(如X.509),信息安全等场景。本文默认的密钥导入导出的编码都是DER。密钥的DER就是一种带编码的二进制格式。
- PEM
Privacy-Enhanced Mail,隐私增强邮件。密钥导出可选择PEM格式,这时导出的密钥可能类似于下面的形式:
-----BEGIN PUBLIC KEY----- MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEVNL13u4QD7QQmxNrgKfTF7xtjICDxMU/ xfYFcbO2/sSf3L3xPbvufYKBcSSZ54gYb2ZLJ+iGEuP1Vg7dH37ghJauYn9dE2lc h9zfdZ9jYDAhYZ7trAr/TMJX/MQdWAna -----END PUBLIC KEY-----
显然,它是一种文本化结构化的信息封装方式。其实封装的内容,就是密钥DER信息的base64编码。PEM通常用于使用文件存储和加载密钥信息。
- SEC1
在导入导出密钥时,可以选择一个"类型"的参数。参数可以选择SEC1。
SEC1是密码学中定义椭圆曲线私钥/公钥格式的一种标准,属于Standards for Efficient Cryptography (SEC) 系列规范之一,它主要用于标准化椭圆曲线密码学(ECC,Elliptic Curve Cryptography)密钥的存储和传输格式。
- PKCS#1
Public-Key Cryptography Standards #1,它是RSA实验室制定的标准,定义RSA密钥的格式和加密/签名算法。本文中都使用ECC密钥,所以不会使用PKCS1类型导出公钥。
- PKSI#8
Public-Key Cryptography Standards #8, 是 RSA 实验室制定的一种通用私钥封装标准,属于 PKCS 系列规范 的一部分。它定义了如何存储和传输私钥(包括 RSA、ECC、DSA 等多种算法),并支持对私钥进行加密保护,适用于更广泛的密钥管理场景。理论上可以使用PKSI8导出私钥,但笔者使用的不多,通常都使用SEC1。
- SPKI
SPKI(Subject Public Key Info,主题公钥信息)是密码学中用于存储和传输公钥的一种标准格式,通常与 X.509 证书 和 PKI(公钥基础设施) 相关。它定义了如何编码公钥及其关联的算法标识符(如 RSA、ECC),广泛应用于 TLS/SSL、数字证书、密钥交换等场景。导出和导入公钥的时候,我们使用SPKI格式。
简单总结一下。在导入导出密钥时,format(格式)指定了密钥编码的形式,如二进制(DER)和文本(PEM),type(类型)指定了ASN1编码方式,包括SPKI用于公钥导出,SEC1或者PKSI8用于私钥的导出。
在了解了相关的基本概念和知识之后,下一章节我们来分析一下ASN的结构,和如何应用在密钥转换和构造的操作当中。
ASN.1 结构解析
前面的内容中,我们已经了解, ASN1信息结构,才是本文主题讨论的核心,其实也是很多兼容性和错误产生的原因。因为如果没法搞清楚ASN编码的结构,就无法从ECDH的密钥结构转换成为标准的用于签名验证的密钥结构。为了解决这一问题,笔者使用了反推的结构分析方式,在结合了一些AI对于ASN编码和结构的解释,过程如下。
- 标准编码
我们先使用以下代码,导出标准的ECC私钥的DER数据。为了方便数据的分析和观察(比如相同的内容片段搜索和长度对齐),这里使用了hex格式。
js
const {
generateKeyPairSync
} = require("crypto");
// 生成密钥对
let { publicKey, privateKey} = generateKeyPairSync("ec", {
namedCurve: "secp384r1"
});
// 导出私钥
let vkey = privateKey.export({
format: "der",
type: "sec1" ,
});
console.log(vkey.byteLength,vkey.toString("hex"));
// 私钥
3081a40201010430
40c5e99e9d45563273ba581a8830b47fd80d34cb3529fc47
0df995378ab176afc0df0b72f8ad0a2262ea757ae340092c
a00706052b81040022a16403620004
d87b7a43f9ce72cfdb76c4464dbf30a42650dd8ed401c4c6
5db98ad9017e4e58eb1770571e8f9aac5f68679024648a19
475330826c1cbc18f192a9107eeacb5d536c6852cee56d5f
21b604a1a4647c0ebe457a67d99ebce7c1359d6140ca78d9
// 再运行一次,得到新的密钥
3081a40201010430
e76c6526c98c1c3d7de2337744e7304e464cde1a8b614911
ced92f336f7a2ee87ed2a9a70c688199e008a64b6ac7fab4
a00706052b81040022a16403620004
fb31331e0ddc9512b9953f8876aebfc6b99aa85da58879c5
1fd1a583db787a78b0c3dad5b0f10d37ac4c85cb7000f9c7
750a0e00a5e76111669ed8e94537d14fc5684b000f851f6a
3ebf4df73d8599a26d355da9bcf9fc1d537f550165fcbdbc
这就是一个标准的ECC密钥对生成和密钥导出的操作,这个操作我们可以得到一个167字节的二进制数据(后面我们会看到为什么是167),在nodejs中的类型是buffer。 为了方便观察和分析,我们将其使用hex字符串来表示。我们已经知道,这个信息大体上是一个ASN.1编码的结构。这个编码本身是一个比较复杂的,但如果只是在工程上的应用,我们只需要理解它的构成和结构,而无需更深刻的理解其基础原理。
- 结构解析
如上面的代码所示,我们可以多运行几次以上的代码,就应该可以发现其中的规律。即这个编码中是由某种固定结构组成的,其中有动态变化的部分(如第二和第四部分),也有固定的内容(如第一和第三部分)。结合一些资料查找和编码原理的分析,我们可以了解到,对于secp384r1这种曲线,导出的私钥(der格式)编码结构如下:
js
// 标准的secp384r1导出der内容,SEC版本,共167个字节
03字节-序列编码
3081a4
03字节-版本编码
020101
02字节-私钥形式长度
0430
48字节-私钥
8c03042cab6ffa7dc83403ed008cf45be9c2797af012769c
5b58534f27a5f91416c2ba4e88c04f12545b22e5f54cf55c
09字节-OID
a00706052b81040022
05字节-附加公钥和参数
a164036200
01字节-公钥不压缩
04
96字节-公钥内容,其实包括了X和Y
583c1207234d0f4775892782c71936a697345a3b65828793
6569609c09fc226c9d19b4c40906e111a717a041ae8f9d79
327d6892086aba02b37aaf5d691077c8443b9382b3235825
3c0bb3be8b783c607dc1be681d1c37e6783baa96e96d5504
基于以上解构和分析,可以总结一下,一个完整的DER格式的secp381r1私钥,是由以下部分构成:
js
序列和长度: 3081A4, 序列,长度162字节
├── 版本编码: 020101, 版本编码,1字节,版本1
├── 私钥长度: 0430, 私钥本体OctetString,48字节
├── 私钥: ....(48字节)
├── SECP384R1 OID: A0 07 0605 2b81040022, 曲线Tag,7字节,1.3.132.0.34的编码
├── 公钥参数: A164, 公钥Tag,100字节
└── 公钥: 036200, BitString类型, 98字节,无padding
└──公钥内容: ... 1+96字节,非压缩, EC点位 (04 || X || Y)
- 私钥重构
在了解了这个密钥的结构之后,我们就可以比较方便的从一个私钥,来重构这个编码。我们可以从一个ECDH导出的私钥内容开始,然后计算它对应的公钥,随后将私钥和公钥填入到这个框架之中,就可以还原这个密钥完整的DER结构了。
这部分的实例代码,笔者想要先省略一下,因为后面我们有更好的方法,但基本概念相同。
- 简化重构
看到这里,相信有的读者会有几个疑问。就是在重构的时候,为什么需要完整的私钥和公钥信息呢? 这样可能需要在外部计算一遍公钥。难道不是理论上而言,每个私钥都有对应的公钥,只需要加载私钥内容,这个密钥对象其实就是已经包含了公钥或者可以推导出来吗?
确实是这样的,理论上我们可以直接导出和导入只包括私钥的结构。公钥可以在使用的时候进行计算。但笔者查找了资料,没有发现对于私钥对象,可以只导出私钥内容的操作方法。它们的解释是,同时包含公钥的内容,可以简化计算并且提高兼容性。但很多材料都提到,可以使用只包含私钥内容的DER数据来创建私钥。
但是,笔者在构造DER结构时,直接使用没有包括公钥部分的内容(结构树中公钥参数和内容的部分),来创建私钥的时候,是失败的。失败的信息提到了某种编码结构长度错误的问题。看来是不能简单的去除公钥,就可以直接导入密钥的。
这个问题的解决,还得从ASN的编码着手。经过对GPT的请教,它指出了问题的可能,是在第一部分,即序列编码这一块。由于缺少了公钥这部分参数和内容,整个结构的长度缩短了,序列的编码(原来是 81A4, A4代表后面序列的长度为164字节,加上序列一共167)也应该修改。我们看到,调整后的序列长度只有 3+2+48+9=62个字节,即0x3E。由于这个数值小于128,可以直接表示(大于128需要用两个字节表示,在前面加0x81),所以序列这部分的内容就是"303E"。
最后经过整理和尝试,只包括私钥内容的DER结构构造和私钥对象创建的完整的示例代码内容如下:
js
// 不带公钥的私钥 DER构造
const keyBuf = Buffer.concat([
Buffer.from([0x30, 0x3E]), // sequnce - 带公钥: [0x30, 0x81, 0xA4]
Buffer.from([0x02, 0x01, 0x01]), // version
Buffer.from([0x04, 0x30]), // private key length
Buffer.from(keyHex, "hex"), // 私钥的实际内容,48字节
Buffer.from([0xA0, 0x07]), // oid tag 和 length
Buffer.from([0x06, 0x05, 0x2B, 0x81, 0x04, 0x00, 0x22]) // oid 1.3.132.0.34
// Buffer.from([0xA1, 0x64, 0x03, 0x62, 0x00, 0x04]), // 附加公钥参数
// pkey1 // 公钥内容, 96字节
]);
// 基于DER信息创建私钥和公钥
const vkey2 = createPrivateKey({ key: keyBuf, format: "der", type: "sec1" });
const pkey2 = createPublicKey({ key: vkey2});
// use to sign and verify....
// 简化形式, 使用模板hex字符串
const keyBuf = Buffer.from("303E0201010430"+keyHex+"A00706052B81040022","hex");
至此,我们已经基本上能够比较清晰明确的了解到,在使用相同的曲线和参数的情况下,如何将ECDH导出的二进制简单的私钥内容,使用ASN1的结构来进行编码,并且构建可以用于签名和验证操作的密钥对象了。
扩展内容
在研究这个问题的过程中,笔者发现了一些有趣的扩展内容,觉得比较有趣,可以和读者分享一下。这里大力感谢各种AI给的分析和帮助。
- 一些TAG标识
根据上面的示例,我们可以总结在ASN编码中,涉及到一些特定的标识如下:
30 - 序列
02 - 整数
03 - BitString
04 - OctetString
06 - OID标识,后面通常接编码长度
8n - 字节长度声明,如81是一个字节等
A0 - 曲线Tag和参数
A1 - 密钥Tag
- OID
在前面的内容中,我们提到了secp384r1的OID编码,是1.3.132.0.34。 但编码后却是 "A0 07 06 05 2B 81 04 00 22",这是怎么来的呢?分析如下:
A0 是构造标签
07 后续内容长度,7字节
06 OIDTag,表示后面的信息是OID
05 OID的长度,5字节
2B 前两个数值合并 1x40+3= 43 -> 0x2B
8104 132大于128,加前位0x81,余数是132-128 = 4,表示为0x04
00 表示为0x00 以及 34 表示为0x22
类似的:
SECP256R1的OID编码是:1.2.840.10045.3.1.7, ASN编码是: 06 08 2A 86 48 CE 3D 03 01 07
SECP256R1的OID编码是:1.3.132.0.10, ASN编码是: 06 05 2B 81 04 00 0A
- Base128编码
OID使用一种Base128编码的方式,我们以SECP256R1的OID编码 1.2.840.10045.3.1.7 来说明:
头两位 1.2,编码成为 1x40+2=42,即0x2A
840: 840/128= 6余72,即 0x86 0x48
10045: 10045/128= 78余61,由于后面还有字节,需要设置第一位为1,(0x4E | 0x80) = 0xCE, 0x3D
3.1.7,简单编码成 030107
- 公钥参数
虽然不影响私钥构造和导入,但在完整的结构中,包括了公钥和公钥参数,即A164036200,它是这样构成的:
A1 上下文标签(这里是一个公钥)
64 后续长度,100字节
03 标签,后续是一个bitstring位串
62 位串的长度是 98字节
00 未使用位数,为0,即无padding
04 公钥未压缩
- SEQUENCE头结构
ASN.1 DER 编码是一种结构化编码,在其开始的30,表示这是一个SEQUENCE结构,有序集合,可以包括多个子元素。然后会跟随一个序列长度的编码。此处为 81A4,即164个字节。
为什么是81,其实这是一个定义长度的方式,它使用0x8N,来表示长度所占用的字节数。如果长度小于127,则直接使用长度数值(文中示例为64个字节,直接表示为 3E),否则计算长度编码。 81的意思是,长度信息占了一个字节,后续A4表示164。比如如果长度为800字节(0x0320),长度占两个字节,应该编码成为 820320。
- ECC密钥曲线类型
从本文中,我们就可以很容易理解,使用createPrivateKey创建密钥对象时,为什么不需要指定曲线和算法了,因为所使用的DER信息,已经包括了OID的内容,当然也就包括了曲线类型了。
这些曲线类型都有不同的名称,如SECP256R1等等,这些命名的大致意思是:
SEC: Standards for Efficient Cryptography,高效密码学(标准组织)制定的标准曲线。
P: Prime Field,素数域
384: 密钥长度为384位,即48字节, 曲线的基域素数p和阶n均为384位,提供约192位安全强度
R1: 随机曲线,版本1
K1: Koblitz曲线,某一个人为选择的固定曲线,区块链常用
在很多情况下,相同的曲线可能有不同的名字,但技术上其实是等价的,比如Prime256v1其实就是SECP256R1,Prime384V1就是SECP384R1。
一般情况下,我们熟悉的加解密算法,都使用32字节的密钥和256位的算法。实际上nodejs crypto中,支持多种类型的曲线,常见的包括secp256k1,secp256r1,secp384r1, secp521r1等等。本来笔者倾向于使用secp245k1的,但后来发现这个算法,并不是标准的NIST推荐的设置,只是由于它是比特币系统选择的主要算法,它的理由是标准的secp256r1可能存在一些问题。所以笔者索性就选择了更安全和标准的secp384r1,来作为标准算法。
公钥结构
虽然不是本文要讨论的重点和核心,但使用类似的方式,我们也可以进行公钥的处理。SECP384R1公钥的DER格式为120字节,其中后96个字节,就是实际的公钥内容。它的整个结构如下:
js
3076 外层sequence, 118个字节
3010 内层sequence, 16字节
0607 2a8648ce3d0201 ecPublicKey的OID, 7字节,编码1.2.840.10045.2.1
0605 2b81040022 OID,5字节,编码1.3.132.0.34
03620004 公钥参数,0x03是bitstring,0x62表示后续98个字节,0x04表示非压缩
...公钥(96字节)...
SQ结构如下:
SubjectPublicKeyInfo: 3076
├── AlgorithmIdentifier: 3010
│ ├── ecPublicKey (OID): 0607 2a8648ce3d0201
│ └── secp384r1 (OID): 0605 2b81040022
└── subjectPublicKey (BIT STRING): 036300
└── Uncompressed EC point (04 || X || Y)
小结
本文从一个如何使用ECDH所产生的密钥,来进行签名验证的操作的需求入手,分析了如何将其产生的私钥,按照创建签名私钥对象所要求的ASN.1格式进行转换和编码的操作。并探讨了ASN1编码和相关格式和类型,以及这个过程中的一些问题和技术细节。