js与java对接AES-128-GCM加密、解密算法
一、什么是AES加密
常见的加密主要分为两类:对称加密和非对称加密,AES加密就是对称加密的一种,即加密和解密使用相同的一把密钥。它的全称是Advanced Encryption Standard(高级加密标准),主要是用来取代DES加密算法,目前已经被全世界广泛采用。
二、AES的基本构成
概念:
- 明文P(plainText):未经加密的数据
- 密钥K(key):用来加密明文的密码。在对称加密算法中,加密与解密的密钥是相同的,由双方协商产生,绝不可以泄漏
- 密文C(cipherText): 经过加密的数据
- 加密函数E(encrypt):C = E(K, P),即将明文和密钥作为参数,传入加密函数中,就可以获得密文
- 解密函数D(decrypt):P = D(K, C),即将密文和密钥作为参数,传入解密函数中,就可以获得明文
AES的构成:
- 分组(或者叫块):ES是一种分组加密技术,分组加密就是把明文分成一组一组的,每组长度相等,每次加密一组数据,直到加密完整个明文。那你可能要问:为何要进行分组呢?比如一个应用程序总共就只能获得3M的内存空间来执行,而需要加密的文件是100M,这个时候就不得不进行文件拆解加密。在AES标准规范中,分组长度只能是128 bits,也就是每个分组为16个bytes
- 密钥长度:AES支持的密钥长度可以是128 bits、192 bits或256 bits。密钥的长度不同,推荐加密轮数也不同
加密轮数越多,当然安全性越好,但也更耗费时间
- 加密模式:因为分组加密只能加密固定长度的分组,而实际需要加密的明文可能超过分组长度,此时就要对分组密码算法进行迭代,以完成整个明文加密,迭代的方法就是加密模式。它有很多种,常见的工作模式如下图:
- 初始向量(IV, Initialization Vector):它的作用和MD5的“加盐”有些类似,目的是防止同样的明文块,始终加密成同样的密文块,以CBC模式为例:
在每一个明文块加密前,会让明文块和一个值先做异或操作。IV作为初始化变量,参与第一个明文块的异或,后续的每一个明文块和它前一个明文块所加密出的密文块相异或,从而保证加密出的密文块都不同。
- 填充方式(padding):由于密钥只能对确定长度的数据块进行处理,而数据的长度通常是可变的,因此需要对最后一块做额外处理,在加密前进行数据填充。常用的模式有PKCS5, PKCS7, NOPADDING
- 附加消息(AAD,Additional Authenticated Data):附加消息不是重要数据,它只是可以包含在协议中的纯数据,需要对其进行完整性保护,但不需要加密。一个很好的例子是加密IP数据包的标头。如果对它进行加密,则不能将其用于路由;如果不保护它的完整性,则攻击者可能会更改消息的长度或源地址,而收件人却不知道
以上这些构成元素,通常会以参数的形式出现在前后端的加密插件中,在使用前,我们要协商统一,才能完成加解密。
三、AES GCM模式
上文提到了AES工作模式的概念,在介绍GCM之前,我们需要先了解下CTR模式
CTR(Counter Mode,计数器模式):
图中可以看出,加密过程使用了密钥、Nonce(类似IV)、Counter(一个从0到n的编号),与上文提及的CBC模式相比,CTR最大的优势是可以并行执行,因为所有的块只依赖于Nonce与Counter,并不会依赖于前一个密文块,适合高速传输需求。但CTR不能提供密文消息完整性校验的功能(未被篡改),所以我们需要引入另一个概念:MAC(消息认证码)。
MAC(Message Authentication Code, 消息认证码):
是一种用来确认消息完整性并进行认证的技术。通过输入消息与共享密钥,可以生成一段固定长度的数据(MAC值)
收发双方需要提前共享一个密钥,发送者使用密钥生成消息的MAC值,并随消息一起发送,接收者通过共享密钥计算收到消息的MAC值,与随附的MAC值做比较,从而判断消息是否被改过(完整性),对于篡改者,由于没有密钥(认证),也就无法对篡改后的消息计算MAC值
GMAC ( Galois message authentication code mode, 伽罗瓦消息验证码 ):
对应到上图中的消息认证码,GMAC就是利用伽罗华域(Galois Field,GF,有限域)乘法运算来计算消息的MAC值。假设秘钥长度为128bits, 当密文大于128bits时,需要将密文按128bits进行分组。应用流程如下图
GCM( Galois/Counter Mode ) :
GCM中的G就是指GMAC,C就是指CTR。
GCM可以提供对消息的加密和完整性校验,另外,它还可以提供附加消息的完整性校验。在实际应用场景中,有些信息是我们不需要保密,但信息的接收者需要确认它的真实性的,例如源IP,源端口,目的IP,IV,等等。因此,我们可以将这一部分作为附加消息加入到MAC值的计算当中。下图的Ek表示用对称秘钥k对输入做AES运算。最后,密文接收者会收到密文、IV(计数器CTR的初始值)、MAC值。
四、前端加密、解密
前端能使用gcm加密的库比较少,先后尝试了 node-forge 和 crypto-js 两个库。其中node-forge提供了AES-GCM加密模式,但是很遗憾,加密之后的密文后端无法解开。crypto-js没有此加密模式。
最终采用了node端使用的库 crypto
npm install --save crypto // 注意不是crypto-js npm install --save buffer // 如果是 node 端自带buffer,但是浏览器没有,需要引入buffer库支持
import { Buffer } from 'buffer' import crypto from 'crypto' /** * aes-128-gcm 加密 * @param {String} msg 加密字符串 * @param {String} key 密钥 * @returns 加密后的字符串,16进制 */ function Encrypt(msg, key) { try { var pwd = Buffer.from(key, 'hex') var iv = crypto.randomBytes(12) var cipher = crypto.createCipheriv('aes-128-gcm', pwd, iv) var enc = cipher.update(msg, 'utf8', 'hex') enc += cipher.final('hex') //cipher.getAuthTag() 方法返回一个 Buffer,它包含已从给定数据计算后的认证标签。 //cipher.getAuthTag() 方法只能在使用 cipher.final() 之后调用 这里返回的是一个十六进制后的数组 var tags = cipher.getAuthTag() enc = Buffer.from(enc, 'hex') // 由于和java对应的AES/GCM/PKCS5Padding模式对应 所以采用这个拼接 var totalLength = iv.length + enc.length + tags.length var bufferMsg = Buffer.concat([iv, enc, tags], totalLength) return bufferMsg.toString('hex') } catch (e) { console.log("Encrypt is error", e) return null } } /** * aes-128-gcm 解密 * @param {String} serect 密文 16进制 * @param {String} key 密钥 16进制 * @returns */ function Decrypt(serect, key) { try { var tmpSerect = Buffer.from(serect, 'hex') var pwd = Buffer.from(key, 'hex') // 读取数组 var iv = tmpSerect.slice(0, 12) var cipher = crypto.createDecipheriv('aes-128-gcm', pwd, iv) // 这边的数据为 去除头的iv12位和尾部的tags的16位 var msg = cipher.update(tmpSerect.slice(12, tmpSerect.length - 16)) return msg.toString('utf8') } catch (e) { console.log("Decrypt is error", e) return null } }
如果后端采用 base64形式,只需要将 hex 替换成 base64即可
五、后端Java加、解密
import javax.crypto.Cipher; import javax.crypto.SecretKey; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.util.Base64; public class AESUtil { public static String parseByte2HexStr(byte buf[]) { StringBuffer sb = new StringBuffer(); for (int i = 0; i < buf.length; i++) { String hex = Integer.toHexString(buf[i] & 0xFF); if (hex.length() == 1) { hex = '0' + hex; } sb.append(hex.toUpperCase()); } return sb.toString(); } public static byte[] parseHexStr2Byte(String hexStr) { if (hexStr.length() < 1) return null; byte[] result = new byte[hexStr.length() / 2]; for (int i = 0; i < hexStr.length() / 2; i++) { int high = Integer.parseInt(hexStr.substring(i * 2, i * 2 + 1), 16); int low = Integer.parseInt(hexStr.substring(i * 2 + 1, i * 2 + 2), 16); result[i] = (byte) (high * 16 + low); } return result; } /*** * aes-128-gcm 加密 * @params msg 为加密信息 password为32位的16进制key * @return 返回base64编码,也可以返回16进制编码 **/ public static String Encrypt(String msg, String password) { try { byte[] sSrc = msg.getBytes("UTF-8"); //修改添加字符集 byte[] sKey = AESUtil.parseHexStr2Byte(password); SecretKeySpec skeySpec = new SecretKeySpec(sKey, "AES"); Cipher cipher = Cipher.getInstance("AES/GCM/PKCS5Padding"); cipher.init(Cipher.ENCRYPT_MODE, skeySpec); //这边是获取一个随机的iv 默认为12位的 byte[] iv = cipher.getIV(); //执行加密 byte[] encryptData = cipher.doFinal(sSrc); //这边进行拼凑 为 iv + 加密后的内容 byte[] message = new byte[12 + sSrc.length + 16]; System.arraycopy(iv, 0, message, 0, 12); System.arraycopy(encryptData, 0, message, 12, encryptData.length); return Base64.getEncoder().encodeToString(message); } catch (Exception ex) { return null; } } /*** * aes-128-gcm 解密 * @return msg 返回字符串 */ public static String Decrypt(String serect, String password) { try { byte[] sSrc = Base64.getDecoder().decode(serect); byte[] sKey = AESUtil.parseHexStr2Byte(password); GCMParameterSpec iv = new GCMParameterSpec(128, sSrc, 0, 12); Cipher cipher = Cipher.getInstance("AES/GCM/PKCS5Padding"); SecretKey key2 = new SecretKeySpec(sKey, "AES"); cipher.init(Cipher.DECRYPT_MODE, key2, iv); //这边和nodejs不同的一点是 不需要移除后面的16位 byte[] decryptData = cipher.doFinal(sSrc, 12, sSrc.length - 12); return new String(decryptData); } catch (Exception ex) { return null; } } public static void main(String[] args) { String testMsg = "{'ai':'test-accountId','name':'username','idNum':'371321199012310912'}"; String testPwd = "10210b07c5cf31b30f722f9b5896de5c"; String enc = AESUtil.Encrypt(testMsg, testPwd); System.out.println("加密结果 " + testPwd); String dec = AESUtil.Decrypt(enc, testPwd); System.out.println("解密结果 " + dec); } }
参考文章: