JMicro是基于Java实现的微服务平台,最近花了两个周未实现服务间安全调用支持。
JMicro服务调用分两个部份,分别为内部服务间相互调用和外部客户端通过API网关调用JMicro集群内部服务,前者支持双向加密加签,并且支持全RSA加密(效率底,安全性高)及RSA+AES混合加密解密,后者只支持RSA+AES混合加密解密,类似于HTTPS的功能。
内部RPC实现安全通信配置
内部服务间安全通信配置比较简单,如下为JMicro系统登陆接口:
upSsl=true表示客户端请求登陆时,上行数据需要做安全加密处理,客户端使用自己的私钥签名,用服务端公钥对数据或AES密钥做加密处理;
downSsl=true表示服务端返回登陆成功数据时(任何情况下RPC调用失败都不做加密处理),下行数据需要做安全加密处理,服务端使用己方私钥加签,用客户端公钥或AES动态密钥对下行数据做加密处理。
EncType=0表示RSA+AES混合加密解密,数据发送方生成动态密钥S并用对方公钥对S进行RSA加密,S的密文和数据一起发送给对方。用密钥S对数据做AES加密。此方式加密解密效率非常高,但要保证密钥足够复杂,不易被暴力破解。
EncType=1表示全RSA加密,直接用对方公钥对参数做RSA非对称加密,用自己的私钥加签,对方收到数据时,用对方自己私钥解密数据,用发送方公钥验签,此方式安全性高,但效率较底,使用于对安全性要求非常高,数据量少的场景。
JMicro密码生成算法
完整代码:
https://github.com/mynewworldyyl/jmicro/blob/master/apics/src/main/java/cn/jmicro/api/rsa/EncryptUtils.java
JMicro实现一套动态密码生成算法,可以根据需要生成指定长度的密码或IV值。
首先定义3个常量
//一级随机种子 private static final Random RSEED = new Random(System.currentTimeMillis()); //密码表长度 public static final int CHAR_TABLE_LEN = 512; //密码表 public static final char[] USABLE_CHAR = new char[CHAR_TABLE_LEN];
系统启动时,生成密码表,代码如下
static { createPwdTable(); } private static void createPwdTable() { int len = CHAR_TABLE_LEN; Random r = new Random(RSEED.nextInt()); //StringBuffer sb = new StringBuffer(); for(int i = 0; i < len; i++) { int rv = r.nextInt(); if(rv < 0) { rv = -rv; } int c = (rv % 85) + 33; USABLE_CHAR[i] = (char)c; //sb.append((char)c); } }
以一级随机数生成随机种子再实例化一个随机数,这样保证每调用一次createPwdTable方法,生成的随机密码表都不一样,系统按照一定时间间隔调用createPwdTable方法刷新密码表。
基于以上的密码表,生成指定长度密码,代码如下:
public static String generatorStrPwd(int len) { StringBuffer data = new StringBuffer(); Random r = new Random(System.currentTimeMillis()); for(int i = 0; i < len; i++) { int idx = r.nextInt(1024)%CHAR_TABLE_LEN; data.append(USABLE_CHAR[idx]); } return data.toString(); }
公钥管理
JMicro支持N个动态服务系统,每个系统都可以有其独立的私钥和公钥,且每个系统的每个服务在被调用之前都不知道当前系统有多少个系统多少个服务在运行,所以不可能预先在每个系统中配置好对方的公钥,因此公钥的安全分发成为JMicro安全系统的一个核心问题。正如HTTPS证书一样,需要权威的证书中心来维护,JMicro目前不支持从公开的证书中心取证书,一方面因为成本原因,另一方面,JMicro是一个由N个JVM的M个服务组成的微服务系统,需要N个证书,并且支持证书动态更新,因此JMicro实现一个独特的证书管理系统,并且抽象出标准的获取证书的RPC接口。接口定义如下:
package cn.jmicro.api.security; import java.util.List; import cn.jmicro.api.Resp; import cn.jmicro.codegenerator.AsyncClientProxy; @AsyncClientProxy public interface ISecretService { /** * 根据实例前缀拿取公钥 * @param instancePrefix 服务前缀,不同的前缀有不同的公钥,相同前缀只能一个公钥启用 * @return */ Resp<String> getPublicKeyByInstance(String instancePrefix); /** * 我的公钥列表,只能拿取当前账号下的公钥列表 * @return */ Resp<List<JmicroPublicKey>> publicKeysList(); /** * 创建一个公私钥,同时可选给私钥加密保护 * 如果创建成功,返回公钥和私给调用者,服务端只保留公钥,不存储私钥,所以创建者一定要保留好私钥及私钥密码,私钥一旦丢失, * 将无法找回,只能重新生成。 * @param instancePrefix 服务前缀 * @param password 私钥密码 * @return */ Resp<JmicroPublicKey> createSecret(String instancePrefix,String password); /** * 删除未启用的公钥,删除后将无法恢复 * 启用中的公钥不能删除 * @param id * @return */ Resp<Boolean> deletePublicKey(Long id); /** * 更新公钥前缀 * 启用中的公钥不能更新 * @param id * @param instancePrefix * @return */ Resp<Boolean> updateInstancePrefix(Long id,String instancePrefix); /** * 增加一个线下生成的公钥到系统中 * @param instancePrefix * @param publicKey * @return */ Resp<JmicroPublicKey> addPublicKeyForInstancePrefix(String instancePrefix, String publicKey); /** * 启用公钥,公钥启用后,其他系统就可以根据前缀取得公钥,从而可以与前缀所对应的系统做安全通信 * 同一个前缀同一时刻只能有一个公钥被启用 * @param id * @param enStatus * @return */ Resp<Boolean> enablePublicKey(Long id,boolean enStatus); }
除了getPublicKeyByInstance RPC方法之外,其他方法为可选实现,用于对公钥做维护。getPublicKeyByInstance用于服务请求方与服务提供方做交互之前获取对方的公钥,以便能与对方做安全通信。如果是双向安全通信,服务方收到客户端请求后,也要根据客户端实例前缀获取客户端的公钥做返回数据加密及上行数据验签。
下图为JMicro系统实现代码
可以看出,此方法本身也是双向安全通信的RSA+AES混合加密模式,从而保证任何调用此方法取得的公钥都是可信任的,不可能存在“中间人”修改作假的问题。
调用此方法的前提条件是安全中心的公钥需要预配置到客户端系统中,因为安全中心只需要一对公私钥,任何系统都可以提前获得此公钥。以下为JMicro默认两个预先配
置好的公钥,APICS模块为JMicro的最基础模块,所以JMicro微服务应用都可以安全地获得公钥接口权限。我们使用的电脑的操作系统是不是预先保存有权威证书管理中心的公钥?
注意:因为考虑到JMicro系统安全原因,JMicro公钥管理系统没有做开源,但是只需要根据以上接口的定义实现相关逻辑,就可以做同样的功能。
Java客户端通过Api网关调用JMicro服务
前面说过,API网关与外部系统通信,只支持RSA+AES混合模式,严格来讲,是API网关与基于Web浏览器为基础的网页端通信,只支持RSA+AES混合模式,而Java客户端与API网关通信,可以支持全RSA加密模式。因为在WEB应用中,如果需要支持全RSA模式,则需要为WEB端分配一对公私钥,而WEB端的全部资源,需要从服务端加载,我们不可能把私钥加载到用户的浏览器吧,如果私钥这样加载,那还有什么安全性可言呢?所以在WEB端不支持全RSA加密模式或双向RSA加密模式,并非技术上实现不了,而是RSA加密算法本身的特性所决定其没有意义,甚至存在涉密风险!
还有一个很有意思的问题,有很多没搞明白RSA加密算法原理的同学经常会有用私钥加密公钥解密这种想法,这也是没有意义的?为什么呢?因为公钥是公开的,大家都可以获取到,用私钥加密和没加密就没什么区别了!
对于Java客户端与服务网关通信,通过以下配置即可实现双向安全配置,encType可以是0或1:
@BeforeClass public static void setUp() { ApiGatewayConfig config = new ApiGatewayConfig(Constants.TYPE_SOCKET,"192.168.56.1",9092); config.setUpSsl(true);//上行加密 config.setDownSsl(true);//下行加密 config.setEncType(0);//AES加密数据 ApiGatewayClient.initClient(config); socketClient = ApiGatewayClient.getClient(); }
浏览器WEB客户端通过Api网关调用JMicro服务
在网页初始化时,通过以下代码启用安全通信支持,客户端请求Api网关的全部请求参数将被RSA+AES加密,Api网关返回数据也将被加密并签名,客户端做解密并验签。
window.jm.config.sslEnable = true; window.jm.rpc.init(window.jm.config.ip,window.jm.config.port);
下面说下JMicro使用JSEncrypt和CryptoJS方式,有需要的可以直接复制代码使用,否则很多细节调起来相当麻烦。
首先是客户端请求加密,通过JSEncrypt做RSA非对称加密AES密钥,然后用AES密钥加密数据
encrypt: function (msg){ if(!this.pwdTable) { this.init(); } msg.setUpSsl(true); msg.setDownSsl(true); msg.setEncType(false); let opts = { mode : CryptoJS.mode.CBC , padding : CryptoJS.pad.Pkcs7, keySize : this.keySize, iv :null , salt : null }; let iv = jm.eu.genStrPwd(16); //通过密码表生成16个字节的动态偏移量 opts.iv = CryptoJS.enc.Utf8.parse(iv); //将偏移量转为UTF8编码,服务端使用时也要相应地使用utf8字节数组 msg.salt = jm.utils.toUTF8Array(iv); //将偏移量和数据一起发送给服务端,注意是utf8字节编码 if(!this.pwd || new Date().getTime() - this.lastUpdatePwdTime > 1000*60*5 ) {
//首次进来或超过5分钟更新一次密码 this.pwd = jm.eu.genStrPwd(16);//生成密码,方式和IV相同,但是功能不一样,参考前面关于密码表的说明 msg.setSec(true);//告诉服务端,有AES密码更新 msg.sec = this.encryptRas(this.pwd);//对AES密码做RSA加密 } let b64Str = jm.utils.byteArr2Base64(msg.payload);//将要发送的字节数据转为base64字符串格式,因为AES只接受字符串加密,同时方便服务器更好地处理解码 var encrypted = CryptoJS.AES.encrypt(b64Str, CryptoJS.enc.Utf8.parse(this.pwd),opts);//开始加密,密钥转为UTF8格式,保证Java服务端相同编码 msg.payload = this.wordToByteBuffer(encrypted.ciphertext);//encrypted.ciphertext是一个以WordArray,也就是一个整数数组,要将此整数数据转为字节数组
},
RAS加密密钥encryptRas
encryptRas:function(strContent) { if(!this.pwdTable) {
//初始化密码表 this.init(); } let rst = this.rsae.encrypt(strContent); return jm.utils.toUTF8Array(rst); //rst是base64编码后的十六进制字符串,此处对这个base64字符串做utf8编码转为字节数组
},
对应Java端的解密密钥
//对密钥做utf8解码为字符串,结果是密码密文的base64编码
String b64Str = new String(msg.getSec(),Constants.CHARSET);
//对密文做base64解码,得到密文的字节数组, byte[] sec = Base64.getDecoder().decode(b64Str);
//解密密文,得到字节码形式的AES密码明文,字节码是密码的UTF8编码后的字节数组 secrect = EncryptUtils.decryptRsa(myPriKey,sec, 0, sec.length);
解密出密码明文后,开始用这个密码解密数据
SecretKey originalKey = new SecretKeySpec(secrect, 0, secrect.length, EncryptUtils.KEY_AES); ByteBuffer bb = (ByteBuffer) msg.getPayload(); //msg.getSalt() 是客户端传过来的IV值的utf8编码后的数组, byte[] d = EncryptUtils.decryptAes(bb.array(), 0, bb.limit(), msg.getSalt(), k.key);
if(msg.isFromWeb()) { //因为WEB端是将数据做Base64编码为字符串后做的加密,所以Java端同样要将结果做Base64解码 d = Base64.getDecoder().decode(d); }
Java端对返回给WEB端的数据做加密
//因为WEB端验签时只认字符串,所以加签前把数据转为Base64字符串
byte[] b64Data = Base64.getEncoder().encode(bb.array()); sign = EncryptUtils.sign(b64Data, 0, b64Data.length, this.myPriKey);
数据AES加密
byte[] edata = EncryptUtils.encryptAes(bb.array(), 0, bb.limit(), salt, sec.key);
JS端做解密
let b64str = jm.utils.byteArr2Base64(msg.payload); var decrypted = CryptoJS.AES.decrypt(b64str,utf8pwd,opts); let dedata = this.byteBuffer2ByteArray(this.wordToByteBuffer(decrypted));
JS验签
if(!this.rsae.verify(jm.utils.byteArr2Base64(dedata), msg.sign, CryptoJS.MD5)) { throw "Invalid sign"; }
完整代码可以参考
https://github.com/mynewworldyyl/jmicro/blob/master/apics/src/main/java/cn/jmicro/api/rsa/EncryptUtils.java
https://github.com/mynewworldyyl/jmicro/blob/master/api/src/main/java/cn/jmicro/api/security/SecretManager.java
https://github.com/mynewworldyyl/jmicro/blob/master/mng.web/public/js/rpc.js
JMicro 微服务管理系统: http://jmicro.cn/