浏览器和 Crypto 之 DHKE

发布于 2024-06-26 11:36:43 字数 9927 浏览 26 评论 0

基于 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 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

丿*梦醉红颜

暂无简介

0 文章
0 评论
21 人气
更多

推荐作者

yili302

文章 0 评论 0

晚霞

文章 0 评论 0

LLFFCC

文章 0 评论 0

陌路黄昏

文章 0 评论 0

xiaohuihui

文章 0 评论 0

你与昨日

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文