浏览器和 Crypto 之 DHKE
基于 ECC 的密钥协商体系相对比较复杂,本例讨论了一种基于简单标准化的 DHKE (Diffie Hellman 密钥协商) 机制。纯标准 DH 的实现基于离散对数和求模公式的数学原理。可以简单方便的实现。其保证安全的基本原理和 RSA 类似,即计算两个质数的乘积非常简单,但由结果来分解成原来的两个质数,在质数非常大的时候却是极为困难的。
其数学证明在这里不在详细阐述,仅讨论简单的实现过程和示例如下:
实现原理
- 协商的双方分别为 Alice 和 Bob
- 首先约定双方共同使用两个素数,分别为 P (素数) 和 G (生成数 Generator,素质,并且为 P 的原根),如 P=23, G=5
- Alice 选择一个不大于 P 的秘密整数 Av=6,基于 Av 计算一个公开数 Ap=GAv%P=56%23=8
- Bob 选择一个不大于 P 的秘密整数 Bv=15,并基于 Bv 计算一个公开数 Bp=GBv%P=515%23=19
- 双方分别将公开数发送给对方,Alice 得到 Bp,Bob 得到 Ap
- Alice 基于 Bp、Av 和 P 计算,Ak=BpAv%P=196%23=2
- Bob 基于 Ap、Bv 和 P 计算,Bk=ApBv%P=815%23=2
- 即 Ak=BK,完成密钥协商
- 整个过程中 P、G、Ap 和 Bp 是公开的,Av 和 Bv 是秘密的
实现中的工程问题
DH 算法操作本身比较简单,实际应用中的主要问题是需要考虑大整数的计算和处理,特别是在浏览环境中;其他还包括各种数据的编码、转换和传输过程中的兼容性。
- Nodejs Crypto 模块,提供了内置的 DH 对象和相关的支持
- 浏览器端,似乎没有好用的 DH 实现库(使用复杂、文档缺乏)
- 基于安全性的考虑,需要使用很大的整数,可能会超过浏览器默认的支持(2^53)
使用大整数处理,需要考虑的问题包括:
- 大整数和字符串(hex) 之间的转换
- 大整数的数学计算加减乘除
- 大整数的求幂、求余
解决方案和考虑
- 服务端还是直接使用了 Nodejs Crypto 模块的 DH 相关实现
- 浏览器端使用了自己的实现
- 浏览器端,引用了第三方 Bigint 库来支持大整数的操作
- 默认使用 96 位的质数作为 P,Hex 字符串长度为 24 位,G=2 (后期改为 128 位/32 位)
使用场景
- 客户端和服务端 AES 密钥协商,多客户端(动态密钥)-单服务端(静态密钥)
- 多个服务端密钥协商(使用 DH Group)
详细实现和代码如下:
客户端实现
由于使用场景比较单纯,客户端实现基于自行编写的 DH 方法,但由于兼容性和功能性的需求,需要一个外部依赖 BigInteger.js
。
可以使用 CDN 来引用。
这里使用了预制的 P 和 G(少一次信息交换),默认的 G=2,简化问题,2 是任何素数的原根。
<script src="https://cdn.bootcdn.net/ajax/libs/big-integer/1.6.48/BigInteger.min.js"></script> // 预定义 Generator 和 Prime 常数 const SG = "02", SP = "dc8c00f2d4b354c6eabd00b3", SP1 = bigInt(SP,16).divide(10); // 基于预定义参数,生成对应的 bigint 对象,所有计算都基于大整数对象的方法进行 const base = bigInt(SG,16); // generator const modulo = bigInt(SP,16); // prime // 全局变量声明,私钥、公钥和计算出的共享密钥 let vk,pk,sk; // 私钥生成方法,同时计算对应的公钥 function genKeys(){ // 在 0.1P 和 0.9P 中随机选择一个数作为私钥 vk = bigInt.randBetween(SP1,SP1.multiply(9)); // 基于私钥生成公钥 pk = base.modPow(vk, modulo); // 返回密钥的 Hex 值,如 6dd1d6e0faf1f7dbe282f0a9,92dc8e1307f007d763267003 return [vk.toString(16),pk.toString(16)]; } // 共享密钥计算,data 为外部公钥,hex 格式 function do_dh(data) { document.write("Server Key:<br/>" + data + "<br/><br/>"); // 计算共享密钥 let spk = bigInt(data,16); let sk = spk.modPow(vk,modulo); sk = sk.toString(16); // 可能需要补 0,如 a2a2972ade96a15e793c89a8 if (sk.length < 24) sk = (Array(24).fill(0).join('')+sk).slice(-24); document.write("Shared Key:<br/>" + sk + "<br/><br/>"); }
涉及的大整数计算(大整数对象)方法包括:
- bigInt(): 从字符串生成大整数对象,一般为 hex
- modPow(): 大整数先幂后余
这个方法是 DH 计算的核心,没错,bigint 库直接提供了这个方法 - divide(): 大整数除
- multiply(): 大整数乘
- randBetween(): 大整数范围随机数据
我们使用这个方法在 0.1 和 0.9 之间随机选择整数作为私钥 - toString(16): 大整数转换为字符串
客户端实现(SJCL)
在实现的过程中,突然发现平时经常使用的 SJCL 也有 bigint 的支持,经过研究发现,SJCL 的实现更简单,而且不需要额外的 bigint 库。所以本章节讨论在其支持下的 DH 实现。
实现的代码和分析如下:
const MLEN= 96, // length SP = "dc8c00f2d4b354c6eabd00b3", // 预置质数,同服务端 MG = new sjcl.bn("2"), MP = new sjcl.bn(SP), // 创建 bn 对象,默认输入为 hex 字符串 MP1 = new sjcl.bn(SP).halveM().halveM().halveM(), // random range min MP7 = MP1.mul(7); // random range max // 全局变量 KEY let VK,PK,SK; // 补位方法 function pad(skey,klen = 24) { if (skey.startsWith("0x")) skey = skey.slice(2); return (skey.length < klen) ? (Array(klen).fill(0).join('')+skey).slice(-klen) : skey; } // 生成密钥对 function genKeys() { while(1) { // 生成随机大整数,检查符合要求 VK = new sjcl.bn(sjcl.codec.hex.fromBits(sjcl.random.randomWords(MLEN/32))); if (VK.greaterEquals(MP1) && MP7.greaterEquals(VK)) break; } // 计算公钥 PK = MG.powermod(VK,MP); // 返回结果 return [VK.toString(),PK.toString()]; } // 基于外部公钥 data, 计算共享密钥, function doDH(data) { document.write("Server Key:<br/>" + data + "<br/><br/>"); let spk = new sjcl.bn(data); SK = spk.powermod(VK,MP).toString(); SK = pad(SK); document.write("Shared Key:<br/>" + SK + "<br/><br/>"); }
说明和要点如下:
- sjcl.bn 是 SJCL 的大整数对象,提供了常用大整数处理方法
- new sjcl.bn (hex): 创建 bn 对象,输入为 Hex 字符串
- b.powermode(): sjcl 版本的先幂后余,参数和结果都为 bn
- bn.halveM(): bn 没有内置整数除法,使用 halveM(除 2) 来代替
- bn.mul(): bn 有整数乘法,这里用于确定取随机数的范围为 1/8 ~ 7/8
- bn.greaterEquals(): bn 没有提供范围随机数的生成方法,解决方案是随机生成一个给定位数的数值,并检查是否在范围之内
- bn.toString(): 默认输出为 0x 开头的 hex 字符串,和其他系统交互时,需要裁剪和补位
改进的随机私钥生成
利用 bn.random() 方法,具体如下
// 设置最大值对象 MP-2000 MP1 = MP.sub(new sjcl.bn("2000")); // random max // 生成随机数,+1000 VK = sjcl.bn.random(MP1).add(new sjcl.bn("1000"));
服务端实现
服务端实现基于 crypto 的 DiffieHellman 对象。使用约定参数和预先计算好的服务端密钥对。
// 常量、预选素数和预设公钥、私钥 const DH = { G : "02", // generator P : "dc8c00f2d4b354c6eabd00b3", // prime K : "6aa89298740bf8ed53502242", // public key V : "6e4822e41c370f8f957fc11e" // private key } // 输出方法, param 包括客户端提供的公钥,用于计算共享密钥 exports.dh = (param)=>{ // 基于预制值创建 DH 对象 const alice = crypto.createDiffieHellman(DH.P,"hex",DH.G,"hex"); // 或者动态生成方式为 const alice = crypto.createDiffieHellman(96); // 生成和设置密钥对 alice.generateKeys(); // 使用预制密钥 alice.setPrivateKey(DH.V,"hex"); alice.setPublicKey(DH.K,"hex"); // 计算共享密钥,输出响应 let skey = alice.computeSecret(param.ck,"hex","hex"); console.log("Shared Key:", skey); return { R:200, C: DH.K } }
所涉及的 DH 对象操作和方法包括:
- createDiffieHellman(): 创建 DH 对象
参数可以是质数长度,或者预制的 P 和 G(默认为 2) - getPrime(): 获取质数
如果是根据长度创建的 DH 对象,测方法获取其自动选择的大质数。 - getGenerator(): 获取生成数
如果不使用预置数据,生成数和质数应该以某种方式,传送给另一方 - generateKeys(): 生成密钥对
自动生成私钥和公钥数据 - setPrivateKey(): 手动设置(预选) 私钥,
需要注意,设置私钥,似乎系统不会自动重新计算公钥,即现在没有方法可以从私钥得到对应的公钥 - setPublicKey(): 手动设置(预选) 公钥
本身意义不大,因为共享密钥计算只使用私钥 - computeSecret(): 计算共享密钥
所有方法都可以指定参数和编码方式,一般为 hex。
改进和增强
在前面的工作基础上,可以改进并增强使用共享密钥对数据交换进行加密的过程。
uldh 封装
为了方便使用,我对 dh 进行了封装如下,所有的 key 都 pad 到 32 位:
// need sjcl bn function Uldh(prime) { this.bn = sjcl.bn ; this.MG = new this.bn("02"); this.MP = new this.bn(prime); this._pad = (skey,klen = 32) => { if (skey.startsWith("0x")) skey = skey.slice(2); return (skey.length < klen) ? (Array(klen).fill(0).join('')+skey).slice(-klen) : skey; }, this.getKeys = (vkey)=>{ if (this.VK) { // return [this.VK.toString(), this.PK.toString()]; } else if (vkey) { // generate public key by private key this.VK = new this.bn(vkey); this.PK = this.MG.powermod(this.VK,this.MP); } else { this.VK = this.bn.random(this.MP.sub(new this.bn("2000"))).add(new this.bn("1000")); this.PK = this.MG.powermod(this.VK,this.MP); } return [ this._pad(this.VK.toString()), this._pad(this.PK.toString())]; } this.compute = (spkey)=>{ let spk = new this.bn(spkey); let sk = spk.powermod(this.VK,this.MP).toString(); return this._pad(sk); } }
使用方式如下:
// client key const uldh = new Uldh(DH_PRIME); let cks = uldh.getKeys();
实际加密和解密 key
DH 计算出来的 key,对于 AES-256 而言长度不够,也不建议直接使用; 本案中的使用是结合加密过程中随机生成的 IV 来使用的;即使用 IV 对 key 进行 Hmac-SHA256 计算,得到的结果作为实际加密解密 key;iv 伴随加密内容进行传输,并在对方分离。
参考核心代码如下:
// 客户端加密 let iv = sjcl.random.randomWords(2), // random iv cp = { mode: "ccm", ks : KLEN , ts : TLEN * 8, iv : iv }; crp = {}; // sha256 share key for encrypt use iv as key of key console.log("CIV:", sjcl.codec.hex.fromBits(iv) ); let ckey = new sjcl.misc.hmac(iv).encrypt(cskey); let encrypted = sjcl.encrypt(ckey, ptext, cp, crp); // ... let cresult = [cobject.ct,cobject.iv].join(","); // 服务端解密 let [ctext, iv2] = cresult.split(","); iv2 = Buffer.from(iv2,"base64"); let etext = Buffer.from(ctext,"base64").toString("hex"); let etag = etext.slice(-COPTION.authTagLength*2); etext = etext.slice(0,-COPTION.authTagLength*2); console.log("Cryped Sjcl:",etext,etag); // sha256 server share key let skey = crypto.createHmac("sha256",iv2).update(sskey).digest(); const decipher = crypto.createDecipheriv(ALGO, skey, iv2 , COPTION); // 服务端加密 let iv3 = crypto.randomBytes(8); skey = crypto.createHmac("sha256",iv3).update(sskey).digest(); const cipher = crypto.createCipheriv(ALGO, skey, iv3, COPTION); let crypted = cipher.update(ptext, 'utf8', 'hex'); crypted += cipher.final('hex'); let tag3 = cipher.getAuthTag().toString("hex"); console.log("Crypted:", crypted, tag3); crypted = crypted+tag3; crypted = Buffer.from(crypted,"hex").toString("base64"); let sresult3 = [crypted,iv3.toString("base64")].join(","); // 客户端解密 cskey 为协商密钥 let [etext4,iv4] = sresult3.split(","); ckey = new sjcl.misc.hmac(sjcl.codec.base64.toBits(iv4)).encrypt(cskey); cobject = JSON.stringify({ ...CTEMPLE, ...{ iv: iv4, ct: etext4 }}); let dresult = sjcl.decrypt(ckey, cobject);
改进要点
- DH 质数增大到 128 位,协商密钥长度为 32 位
- 使用随机 IV 来混淆 key
- 封装 DH,方便使用和集成
- 默认使用 aes-256-ccm,提供最高的安全性
需要注意的问题
- 如果使用预置参数,如大质数,服务端预制公私钥,一定要注意他们之间的关联关系。
- 在使用 iv 对 key 进行加密时,应直接使用 buffer 或 bitArray,避免转码过程中的错误。(sjcl 所有的字符串默认都是 utf8 编码)
- tag 的分隔和拼接
- uldh 的初始化
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
上一篇: 异构数据库业务数据同步机制设计
下一篇: 彻底找到 Tomcat 启动速度慢的元凶
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论