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/