Webcrypto 和 Nodejs-Crypto 互操作
本文中的所有应用和示例,可参见项目代码文件:webcrypto.js
Webcrypto 已经是 Web 标准和 API,现代浏览器应有和支持的密码学工具箱。
在 WebCrypto 之前,我们在客户端通常需要引入一个外部的密码学工具库,如 CryptoJS 或者 SJCL 等,来实现密码学相关的数据处理。WebCrypto 给我们一个新的选项,在不引入第三方程序的情况下,实现和满足上述需求。
在一个具体的应用实现和我们的技术体系中,主要问题在于,需要建立一套体系和架构,使 Webcrypto 作为前端,可以和 Nodejs 作为后端(通过 crypto 实现) 进行交互,来实现业务和数据的安全需求。需要遵循以下原则:
- 无第三方依赖和库,但可以在应用内做适当封装,简化开发和应用
- 支持主流算法实现、选择和参数,并在浏览器和服务端可以相互验证
- 支持能够在网络上使用 HTTP 传输的序列化和反序列化对象,建议使用 Base64URL 和 UTF-8 编码方式
- 支持客户端/服务器端的交叉运算/逆运算和再运算
在浏览器中,webcrypto 通过 window.crypto 对象(crypto.subtle) 实现,当然,这些实现只能在 https 环境中有效。通常情况下,webcrypto 是 Promise 化的,当然也可以链接调用,实现相关处理的组合。详见项目示例。
可能涉及到的处理过程包括:
编码和转换
首先需要明确,所有编码转换的核心是最基础的数据结构,在客户端和服务器端分别为 Uint8Array 和 Buffer,两者基本上是对等的,都使用一个无符号的 int8 数组来表示信息。
然后以此为基础,进行各种形式的转换,来满足不同的应用需求和场景。
服务器端
在服务器端 Nodejs 提高了非常强大的编码转换,一般通过 Buffer.from 和 toString 方法就可以实现转换,如
Buffer.from(hexstring, "hex").toString("base64")
浏览器端
浏览器端没有类似于 Node 的 buffer 对象,处理和转换不够直观。可能要借助如 TextEncoder,atob,btoa,Uint8Array 等方式和对象进行组合处理。
// convert a Uint8array to hex string let hstring = new Uint8Array(ary).reduce((v,x)=> v + ("0"+x.toString(16)).slice(-2),"") // hex string to Uint8Array let harray = new Uint8Array(hexstring.match(/.{1,2}/g).map(b => parseInt(b, 16))); // array to base64 let bstr = Array.from(new Uint8Array(ary)).map(byte => String.fromCharCode(byte)).join(''); // Utf8 to array to base64 let ary = new TextEncoder().encode(signData) let bstr = btoa(Array.from(ary).reduce((v,b)=> v+ String.fromCharCode(b),"")) console.log("bstr :",bstr) // base64 to array and decode to utf8 string let ary = Uint8Array.from(atob(b64str), c => c.charCodeAt(0)) let ostr = new TextDecoder().decode(ary) // Base64URL from base64 string or reverse let base64url = (text.indexOf('+')>-1 || text.indexOf('/')>-1 ) ? text.replace(/\//g,'_').replace(/\+/g,'-').replace(/\=/g,'') : (text.indexOf('_') > -1 || text.indexOf('-') > -1) ? text.replace(/_/g,'/').replace(/-/g,'+') : text
随机数发生器
随机生成指定位数的字节数组,可以转换为 Hex 作为随机字符串
let salt = crypto.randomBytes(8); let iv =[...salt.map(v=>v ^ 0x36), ...salt].map(v=> v^ 0x5c);
也可以随机生成 hex 字符串,然后转换为数组,这是通用方法
let rstr = Math.random().toString(36).slice(2,10)
使用 WebCrypto 处理随机字符和数字
let salt = crypto.getRandomValues(new Uint8Array(8)) // random array let iv = [...salt.map(v=>v^0x36),...salt].map(v=>v^0x5c) // two salt as iv let iter = 800 + 0|Math.random()*400 // random number
摘要算法和消息验证码
webcrypto 和 crypto 都原生支持标准的 sha256 和 hmac-sha256,摘要算法和消息验证码,可以直接使用。传输建议使用 base64,存储使用 hex。
const encUtf = new TextEncoder() const aryB64 = (ary) => btoa(Array.from(new Uint8Array(ary)).reduce((v,b)=> v + String.fromCharCode(b),"")) const aryHex = (ary) => new Uint8Array(ary).reduce((v,b)=> v+ ("0"+b.toString(16)).slice(-2),"") const hmac = async (text,passwd) =>{ const algo = { name: "HMAC", hash: { name: "SHA-256" }} let salt = crypto.getRandomValues(new Uint8Array(8)) let skey = encUtf.encode(passwd) let sign = await crypto.subtle .importKey("raw", skey, algo, false, ["sign"]) .then( k1 => crypto.subtle.sign( "HMAC", k1, salt)) .then( s1 => crypto.subtle.importKey("raw", s1, algo, false, ["sign"])) .then( k2 => crypto.subtle.sign( "HMAC", k2, encUtf.encode(text))) .then( s2 => aryB64(s2)+"."+aryB64(salt)) .catch(err=>{ console.log(err.message) return null }) console.log("hmac:",sign) } const sha256 = async (text)=> { let sresult = await crypto.subtle.digest("SHA-256", encUtf.encode(text)) .then(aryHex) .catch(err=>{ console.log(err.message) return null }) console.log("sha:", sresult) } sha256("颜建华 123") hmac("颜建华 123","password")
可以看到,核心分别是 digest 和 sign 方法,注意本例中,使用了共享密码加密的随机密钥作为 hmac 的密钥。
在 nodejs 端,这个处理就更加简单了:
let sha256 = crypto.createHash("sha256").update("hellodata").digest("hex"); let hmac256 = crypto.createHmac("sha256","password").update("hellodata").digest("hex");
HashCode
可以在摘要或消息验证码的基础上,进一步使用将其简化成为一个 HashCode 数值,作为一种简单的签名和验证信息。
其实现的基本思路和 Java HashCode 类似,参考代码如下
hashCode (data, option = { input : "string", output: "i36"}) { let ichar, rcode = 0 if (!data || data.length ==0 ) return 0 switch(option.input) { case "array": for (let i = 0, l= data.length; i < l; i++) { rcode = rcode * 31 + data[i] rcode &= rcode // Convert to 32bit integer } break case "hex": for(let i=0,l=data.length; i<l; i+=2) { rcode = rcode * 31 + parseInt(data.slice(i,i+2)) rcode &= rcode // Convert to 32bit integer } break default: // string or utf8 for (let i = 0, l= data.length; i < l; i++) { rcode = rcode*31 + data.charCodeAt(i) rcode &= rcode // Convert to 32bit integer } } if (option.output != "int") { // uint, hex, i36 rcode &= 0x7FFFFFFF } switch(option.output) { case "i36": return rcode.toString(36) case "hex": return rcode.toString(16) default : return rcode } }
此方案中,输出结果本质上为一个 32 位整数,默认的输入为 UTF8 字符串(和 UTF8 解码后再计算结果的结果不同),可选 Hex 字符串、Uint8Array;
默认输出为无符号 i36 格式,但可选整数、无符号整数和 16 进制整数。
密码衍生算法
基于数据加密安全特别是前向安全性的考虑,一般在密钥协商成功之后,不建议直接使用共享密码作为加密使用,而使用一些密钥衍生算法,生成随机动态密钥来进行信息的加密。生成过程中的辅助参数,如 salt、iv、iter 等,本身不需要再加密,也需要在传递信息时一并传递。这类算法通常被称为 KDF(Key Derivation Function,密钥派生函数),在加密过程中使用 KDF,可以通过大量消耗算力和资源,有效提高暴力破解的难度,并提高更好的前向安全性。
webcrypto 和 crypto 都支持 pbkdf2 密码衍生算法。建议作为标准使用。详见对称加密章节。
密钥对象
webcrypto 和 crypto 均提供了一系列方法和参数,来对密钥进行处理。主要是密钥(对) 生成、导入导出等方法。一般而言,在进行加密、解密、签名、验证、密钥协商等操作是,都需要进行密钥的生成或者导入。
在 webcrypto 中,常见的密钥操作包括
- generateKey - 生成密钥或密钥对
- importKey - 导入密钥
- exportKey - 导出密钥, 用于存储或者交换,格式建议为 der,通常公钥使用 spki 类型,私钥使用 pkcs8 类型。不建议直接使用 raw 或者 jwk 格式(只限定在浏览器中操作)
webcrypto 中的相关操作,详见后续的签名和验证章节。
crypto 中的操作方式类似,示例代码如下:
// generate ecc key pair const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'secp384r1' }); // export public key let spkey = base64URL(publicKey.export({ type: "spki", format: "der"}).toString("base64")); console.log(`spkey: ${spkey}`); // export to private key let svkey = base64URL(privateKey.export({ type: "pkcs8", format: "der"}).toString("base64")); console.log(`svkey: ${svkey}`); // load public key from string let publicKey = crypto.createPublicKey({ key: base64Buf(cpKey), format: "der", type: "spki", }); // load private key from string let privateKey = crypto.createPrivateKey({ key: base64Buf(cvKey), format: "der", type: "pkcs8", });
crypto 的密码衍生算法示例如下:
// key generator,参数分别为 passwd,salt,iv, iter, hashmethod key = crypto.pbkdf2Sync(Buffer.from(passwd), salt, iter, 32, ALGO_GCM.HASH);
密钥协商算法
密钥协商,使用两端支持的 ECDH 相关方法,基本参数为 secp384r1-sha256。
在 crypto 的代码示例如下:
const cpkey = "BP_CZN3_2RXzHLxx16rH0YPKNDigg_ojbJrPJC1ADOBp-66WbnPUy4IOmwb-QVvPhczmk8pdA4P64xRxmh3LcB_CYHQXulezVSbU7Qtd3BU9Ml6yip9tVHFiwNbPWL8pfQ"; const svkey = "hyJ45QUei2duPLmlq+KIgdEs+R6N/rkFoUXnXscSMYTSR8eajGgkLfFbD7Gjpegl"; const spkey = "BOprhzRyNBgh0Fno83GLC39E6Qj0V2PujPM9CONm0BrPkDf3SaY5aoNpZKDJwYjJzsmhjzUuZwjF82rdDySYdHhEhVGJwX5SE75m02xR2jlCugfvsMKspCQ94fTDO5uoAw=="; const alice = crypto.createECDH('secp384r1'); // alice.generateKeys(); // generate if needed alice.setPrivateKey(Buffer.from(svkey,"base64")); const alicePublicKey = alice.getPublicKey('base64'); // 97bit const alicePrivateKey = alice.getPrivateKey('base64'); // 48bit console.log(`publicKey: ${alicePublicKey}`); console.log(`privateKey: ${alicePrivateKey}`); const sharedSecret = alice.computeSecret(cpkey, 'base64', 'hex').slice(0,64); console.log(`sharedSecret: ${sharedSecret}`);
crypto 可以生成密钥对,也可以从存储的密钥字符串中恢复密钥,只需要保存私钥,可以计算出公钥。需要注意,此曲线会协商出 384 位的密钥,如果需要生成 256 位的密钥,需要进行截断。
在 webcrypto 端的示例代码如下:
const cpkey2 = "BP_CZN3_2RXzHLxx16rH0YPKNDigg_ojbJrPJC1ADOBp-66WbnPUy4IOmwb-QVvPhczmk8pdA4P64xRxmh3LcB_CYHQXulezVSbU7Qtd3BU9Ml6yip9tVHFiwNbPWL8pfQ" const cvkey2 = "MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDD5PS0O5M_dYVxoPmpuBQONVfY8u_tVZTF-huLl26ALVttwDvB5YTH62fRR6H_e3vKhZANiAAT_wmTd_9kV8xy8cdeqx9GDyjQ4oIP6I2yazyQtQAzgafuulm5z1MuCDpsG_kFbz4XM5pPKXQOD-uMUcZody3AfwmB0F7pXs1Um1O0LXdwVPTJesoqfbVRxYsDWz1i_KX0" const spkey2 = "BOprhzRyNBgh0Fno83GLC39E6Qj0V2PujPM9CONm0BrPkDf3SaY5aoNpZKDJwYjJzsmhjzUuZwjF82rdDySYdHhEhVGJwX5SE75m02xR2jlCugfvsMKspCQ94fTDO5uoAw==" // server public and client private key let cc = await ecdh(spkey2,cvkey2) console.log("ecdh :", cc ) // ecdh method if not set client key, will generate key pair async function ecdh(spkey,ckey) { let sKey, cKeys, aspKey = b64Ary(spkey) return crypto.subtle .importKey("raw", aspKey , ALGO_ECDH, false, [] ) .then(k1 => { // get server key object sKey = k1 if (ckey) { // import private key return crypto.subtle.importKey("pkcs8", b64Ary(ckey),ALGO_ECDH, false, ["deriveKey", "deriveBits"] ) } else { // genarate client keypair return crypto.subtle .generateKey(ALGO_ECDH, true, ["deriveKey", "deriveBits"]) //whether the key is extractable (i.e. can be used in exportKey) .then( key=> { cKeys = key return cKeys.privateKey }) } }) .then( vkey => crypto.subtle.deriveKey({ ...ALGO_ECDH, public: sKey, }, vkey, { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"])) .then (eKey => crypto.subtle.exportKey( "raw", eKey)) .then ( shareKey=>{ let rkey = { shareKey : ary2Hex(shareKey) } // only return result if (ckey) return rkey // export share key and key pair return crypto.subtle .exportKey( "raw", cKeys.publicKey) .then( pk=>{ rkey.pkey = ary2B64(pk) return crypto.subtle.exportKey( "pkcs8", cKeys.privateKey) }) .then( vk=>{ rkey.vkey = ary2B64(vk) return rkey }) }) .catch(err=>{ console.log(err) return null }) }
两端使用同一种公钥的编码方式,即默认的 crypto.getPublicKey("base64") 导出的 base64 字符串,在 webcrypto 使用 raw 方式导入。
私钥方面,均保留各自的处理方式。webcrypto 使用 pkcs8, crypto 为 base64。
对称加密算法
对称加密算法,选择 aes-256-gcm,是基于双方支持和兼容的考虑。临时加密过程,共享密钥使用 pbkdf2 算法;会话加密使用 DH 生成的 key。
webcrypt 的实现如下:
// encrpt use aes Gcm use webcrypt async function aesGcmEncrypt(plaintext, password) { // salt iv and iter generate let salt = crypto.getRandomValues(new Uint8Array(8)) // get 96-bit random iv let iv = Uint8Array.from([...salt,...salt]) // two salt as iv let iter = 800 + 0|Math.random()*400 // password to key const cbuffer = await crypto.subtle.importKey( "raw", encUtf.encode(password), "PBKDF2", false, ["deriveBits", "deriveKey"] ).then( km => { return crypto.subtle.deriveKey( { salt, name: "PBKDF2", hash: "SHA-256", iterations: iter }, km, { name: "AES-GCM", length: 256}, true, [ "encrypt", "decrypt" ] ) }).then( key => { return crypto.subtle.encrypt( { name: 'AES-GCM', iv }, key, encUtf.encode(plaintext) ) // encrypt plaintext using key }) // encrypt return [ary2B64(cbuffer),ary2B64(salt),iter].join(".") // return iv+ciphertext }
加密后输出的结果,除加密内容外,还可能包括 salt、iter 等其他参数。在 nodejs 端的实现如下:
const ALGO_GCM = { TEXT : "aes-256-gcm", HASH : "sha256", TGSIZE : 16 } // user passwd encrypt exports.aes_gcm_encrypt = (ptext, passwd) => { if (typeof ptext == "object") ptext = JSON.stringify(ptext); let key, iv, r, tag, salt = crypto.randomBytes(8), iter = 800 + 0 | Math.random()*400; try { // key generator key = crypto.pbkdf2Sync(Buffer.from(passwd), salt, iter, 32, ALGO_GCM.HASH); iv = Buffer.concat([salt,salt]); const cipher = crypto.createCipheriv(ALGO_GCM.TEXT, key, iv); r = Buffer.concat([cipher.update(ptext), cipher.final(), cipher.getAuthTag()]).toString("base64"); r = [r,salt.toString("base64"),iter].join(".").replace(/\//g,'_').replace(/\+/g,'-').replace(/\=/g,'');; } catch (error) { console.log(error.message); r = null; } return r; }
解密的操作类似,比较关键的是 key 的导入和处理,这里不再累述。
签名和验证
经过测试和评估,签名验证建议采用以下算法和主要参数
- 基础签名验证算法为 ECDSA
- 选择的曲线为 P-384(对应 secp384r1)
- 摘要方式为 SHA256
在 webcrypto 中,实现的示例代码如下:
// algo param const ECDH_CURVE = "P-384" //can be "P-256", "P-384", or "P-521" ALGO_HASH = "SHA-256", ALGO_DSA = { name: "ECDSA", namedCurve: ECDH_CURVE }, ALGO_ECDH = { name: "ECDH", namedCurve: ECDH_CURVE }, ALGO_SIGN = { name: "ECDSA", hash: { name: ALGO_HASH }}, ALGO_AES = "AES-GCM" async function sign(data, cvkey) { let cKeys, adata = new TextEncoder().encode((typeof data === "object") ? JSON.stringify(data) : data) return crypto.subtle .generateKey(ALGO_DSA, true, ["sign"]) .then( key=> { if (cvkey) { return crypto.subtle.importKey("pkcs8", b64Ary(cvkey), ALGO_DSA, false, ["sign"] ) } else { cKeys = key return cKeys.privateKey } }) .then(vkey => crypto.subtle.sign( ALGO_SIGN , vkey, adata )) .then(sign =>{ sign = ary2B64(sign) if (cvkey) return { sign } return crypto.subtle .exportKey( "spki", cKeys.publicKey) .then( pk=>{ cpKey = ary2B64(pk) return crypto.subtle.exportKey( "pkcs8", cKeys.privateKey) }) .then( vk=>{ cvKey = ary2B64(vk) return { sign, cpKey, cvKey } }) }) .catch(err => { console.log(err) return null }) }
注意信息!!
默认的情况下,WebCrypto 的签名和验证无法和 NodeCrypto 直接兼容,原因是默认情况下 NodeCrypto 使用 ASN1 编码来处理信息,而 WebCrypto 使用 IEEE1363 进行编码。
当然,可以使用如 ASN1 库来进行编码的转换,但最简单的处理的方式是在 Nodejs 签名是,直接使用 1363 编码,这样可以直接在客户端进行验证;同样,在客户端签名后不做处理,传输到服务器端验证时,使用 1363 编码方式引入处理。这一操作的核心是在签名和验证方法中设置 dsaEncoding 选项为 ieee-p1363。
在 nodejs 端,具体实现示例如下:
// Server side sign with private key object let sign1363 = crypto.sign("SHA256", Buffer.from(signData), {key: privateKey, dsaEncoding: "ieee-p1363"}).toString("base64"); console.log("sign1363:",sign1363, sign1363.length); // Client side verify no special convert return crypto.subtle.verify({ name: "ECDSA", hash: {name: "SHA-256"}, }, pkey, asign, adata ) // server side verify use 1363 let vresult = crypto.verify("SHA256",Buffer.from(data), { key:publicKey, dsaEncoding:"ieee-p1363" }, base64Buf(sign)); console.log("verify2:",vresult);
附加说明
ASN sign 转换
针对 webcrypto 和 nodecrypto 签名和验证信息不相容的问题,在网络上有人提出了将签名内容转换后,再验证的解决方案。
在 webcrypto 中的,示例代码如下,转换后提交给 nodejs crypto 验证通过:
// Auxs const hlength = (hex)=> { return ('00' + (hex.length / 2).toString(16)).slice(-2).toString(); } const asnSign = (sign)=> { let signHex = Array.prototype.map.call(sign, x => ('0' + x.toString(16)).slice(-2)).join(''), r = signHex.substring(0,96), s = signHex.substring(96), rPre = true, sPre = true; while(r.indexOf('00') === 0) { r = r.substring(2) rPre = false } if (rPre && parseInt(r.substring(0, 2), 16) > 127) { r = '00' + r; } while(s.indexOf('00') === 0) { s = s.substring(2) sPre = false } if(sPre && parseInt(s.substring(0, 2), 16) > 127) { s = '00' + s } let payload = '02' + hlength(r) + r + '02' + hlength(s) + s return '30' + hlength(payload) + payload; }
此方案应该应用到了 ASN1 的编码方式和原理,但遗憾的是,作者没有提供反向的转换代码,这一就不能实现在 node 中默认签名,在 web 中验证的方式。
选择 GCM 模式的原因和 authtag 的处理
之所以建议选择 AES-GCM 模式,因为它是一种 AEAD(Authenticated Encryption with Associated Data,带有相关数据的认证加密) 加密形式的实现,和一般的 AES 加密方式相比,它可以同时具备保密性,完整性和可认证性。AEAD 产生的原因很简单,单纯的对称加密算法,其解密步骤是无法确认密钥是否正确的。也就是说,加密后的数据可以用任何密钥执行解密运算,得到一组疑似原始数据,而不知道密钥是否是正确的,也不知道解密出来的原始数据是否正确。
虽然结合 Hash 和 AES 加密算法,也可以对加密过程和数据进行验证,但无论是 ETM(Encryption Then Mac,先加密后摘要)、E&M(Encryption and MAC,加密和摘要)、MTE(MAC Then Encryption 摘要后加密),都有一定的安全问题,所以需要从更底层设计如 GCM 这种加密结合验证的模式。
GCM ( Galois/Counter Mode) 是一种 AEAD 实现,它在 AES 计数器模式(Counter) 的基础上,增加了 GMAC 消息验证码,它利用伽罗华域(Galois Field,GF,有限域) 乘法运算来计算消息的 MAC 值。其他的 AEAD 实现还包括 AES-CCM 等,选择 GCM 的原因是在 WebCrypto API 中和 Nodejs 中都有原生支持。
在实现细节上,和普通的 AES 模式如 CBC 相比,这一类的模式会增加一个 authtag 数据,这一数据在 webcrypto 中,会直接合并到加密内容当中,所以在 crypto 端解密时,需要先分离此内容,并设置到解密实例中。如下述代码所示(此 tagsize 默认值为 16):
content = Buffer.from(content,"base64"); tag = content.slice(-ALGO_GCM.TGSIZE); content = content.slice(0,-ALGO_GCM.TGSIZE); ... // deciphter const decipher = crypto.createDecipheriv(ALGO_GCM.TEXT, key, iv); decipher.setAuthTag(tag); // default utf8 r = decipher.update(content) + decipher.final();
同样,在 nodejs 端加密的时候,需要将 tagdata 合并到加密结果中:
const cipher = crypto.createCipheriv(ALGO_GCM.TEXT, key, iv); r = Buffer.concat([cipher.update(ptext), cipher.final(), cipher.getAuthTag()]).toString("base64");
其他的具有验证功能的 AES 模式,都可以使用类似的处理方式。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论