ECC-ECDH

背景

加密是一个很有深度的话题,本文只是谈一下项目中的一点 POC 过程和思考。这里主要介绍一下 ECDHNode.Js 实现,后续会再探讨一下 Golang,虽说语言是一部分,但是实际应用的过程中还是有非常多的坑要踩.

ECDH

了解 ECDH,首先要了解 ECC,也就是椭圆曲线加密。当然,本文主要讲实现,具体数学逻辑过程有兴趣的可以另外找相关文档,就当我是搬一下砖。
ECDH 一个秘钥磋商的过程,秘钥磋商,就是 Server & Client 双方可以在不通过任何请求传输秘钥的情况下,只根据对方的公钥,结合自身的私钥,磋商出共同的一个秘钥(AES key), 以此保证用于加密的秘钥的安全性。

结合公私钥加解密的思路最简而言之就是:

1
2
3
Server PrivateKey + Client PublicKey ===
Client PrivateKey + Server PublicKey ===
Secret Key

实践

首先用到的模块有:crypto, jsrsasign, fs . 因为没有研究透 jsrsasign.js 这个模块,所以偷懒结合了一下 Node 原生模块 crypto.js 来处理。

值得注意的是:

Node 相关的加密模块,cryptojsrsasign 所操作的 key,基本都是需要传 PEM 格式的;ECDH API 是例外,用的是 ECPoint ,下文会提到。

  1. 我这里服务端只存了一对固定的 Private/Public Key,存的是 PEM 格式的。生成的方式可以使用在线工具,或者直接代码实现;
    这里用的是 ‘secp256k1’ 曲线算法,算法列表可以通过 crypto.getCurves() 获取:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
generateKeyPair(namedCurve) {
const {publicKey, privateKey} = generateKeyPairSync('ec', {
publicExponent: 0x10001,
namedCurve: namedCurve || 'secp256k1',
publicKeyEncoding: {
type: 'spki',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
}
});
writeFileSync(`${dirPath}/ecdh_pub.pem`, publicKey, err => {
if (err) {
console.log('--[CREATE_PUB_KEY]--', err);
throw new Error(err.message);
}
});
writeFileSync(`${dirPath}/ecdh_priv.pem`, privateKey, err => {
if (err) {
console.log('--[CREATE_PRIV_KEY]--', err);
throw new Error(err.message);
}
});
this.privateKeyPEM = publicKey;
this.publicKeyPEM = privateKey;
};
  1. 因为这次是跟 Android 做集成,其他客户端也一样,通常传递的 PublicKey 不会是完整的 PEM 格式,或是没有 —BEGIN PUBLIC KEY— / —END PUBLIC KEY— 的头尾结构,所以我们在接收到客户端的 KEY 时需要重新拼接首位结构,这里用两个很蠢的实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* Generate base key to PEM, add 'BEGIN/END'
* @param {base64} baseKey
* @returns {string} PEM string
*/
const generateBaseKeyToPEM = baseKey => {
return `-----BEGIN PUBLIC KEY-----\n${baseKey}\n-----END PUBLIC KEY-----`;
};
/**
* Generate PEM to base key, remove 'BEGIN/END'
* @param {base64} keyPEM
* @returns {string} base string
*/
const generatePEMToBaseKey = keyPEM => {
const keys = keyPEM.split('\n');
return keys[1] + keys[2];
};
  1. 这一步是 ECDH 最重要的一步,根据 Node Crypto 的 API,官方提供了三个关键的API:
1
2
3
4
5
6
7
8
crypto.createECDH(curveName)
<!-- 创建 ECDH 公私钥-->
ecdh.generateKeypair();
ecdh.setPrivateKey(privateKey[, encoding])
ecdh.computeSecret(otherPublicKey[, inputEncoding][, outputEncoding])

这里用到的 privateKey 是我们服务端的私钥,otherPublicKey 是客户端的公钥,而且 crypto 模块提供的 API 能接受的 privateKey 的格式,实际上是 ECPoint,而不是完整的 PEM 或者 X.509 标准之类的 KEY。

该结论可以从第二个 API - ecdh.generateKeypair(); 的输出得出,默认 generate 出来的公私钥都是核心 ECPoint 那一段,而不是标准的 X.509 或者 ASN.1

所以,我们需要从服务端的私钥 PEM 文件,提取出该 privateKey 的 ECPoint,和从客户端的公钥提取对应格式的 ECPoint。

1
2
3
4
5
6
7
8
9
constructor() {
const privateKey = readFileSync(`${dirPath}/ecdh_priv.pem`).toString();
const publicKey = readFileSync(`${dirPath}/ecdh_pub.pem`).toString();
const {pubKeyHex, prvKeyHex} = KEYUTIL.getKeyFromPlainPrivatePKCS8PEM(privateKey);
this.privateKeyPEM = privateKey;
this.publicKeyPEM = publicKey;
this.privateKeyPoint = hextob64(prvKeyHex);
this.publicKeyPoint = hextob64(pubKeyHex);
};
  1. 最后是磋商,计算秘钥:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    /**
    * Compute secret by public key PEM
    * @param {base64} otherPublicKeyPoint ecdh public key from other client/server
    * @returns {string | base64} secret key
    */
    computeSecretByPEM(otherPublicKeyPEM) {
    const {pubKeyHex} = KEYUTIL.getKey(otherPublicKeyPEM);
    const ecdh = createECDH('secp256k1');
    ecdh.setPrivateKey(this.privateKeyPoint, 'base64');
    const secret = ecdh.computeSecret(hextob64(pubKeyHex), 'base64', 'base64');
    console.log('[secret]:', secret);
    return secret;
    };
  2. 至于上面提到的,为什么我们要自己 setPrivateKey,是因为我们项目实际应用中还有签名和验签。所以需要使用我们自己的 privatekey、publicKey 来操作,而不用默认 Generate 出来的 Key.

下面是签名和验签:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
* Signature by private, from Jsrsasign.js
* @param {string | object} data encrypt plain text
* @returns {base64} signature
*/
signatureByKJUR(data) {
try {
const sig = new KJUR.crypto.Signature({
'alg': 'SHA1withECDSA'
});
console.log(this.privateKeyPEM);
sig.init(this.privateKeyPEM);
sig.updateString(data);
return hextob64(sig.sign());
}
catch (err) {
throw new Error(err);
}
};
/**
* Verify signature by public key, from Jsrsasign.js
* @param {string} data
* @param {base64} signature
* @param {base64} publicKey
* @returns {Boolean} verify result
*/
verifySignatureByKJUR({data, signature, publicKey}) {
const sig = new KJUR.crypto.Signature({
'alg': 'SHA1withECDSA'
});
sig.init(generateBaseKeyToPEM(publicKey));
sig.updateString(data);
return sig.verify(b64tohex(signature));
};

总结

上述是 node 中的简单应用,我把我遇到的坑都一起总结在完整的代码里了,有些做法很粗糙,但是也是一个 POC 的结果,模块的 API 也没有很深入的研究,没有做到物尽其用,但是相信结论是相似的,如果有说错的地方,烦请纠正我,我也是刚学了一点点,不吝赐教。

完整代码