一、关键词
HTTP,HTTPS,AES,SHA-1,MD5,消息摘要,数字签名,数字加密,Java,Servlet,Bouncy Castle
二、名词解释
数字摘要:是将任意长度的消息变成固定长度的短消息,它类似于一个自变量是消息的函数,也就是Hash函数。数字摘要就是采用单项Hash函数将需要加密的明文“摘要”成一串固定长度(128位)的密文这一串密文又称为数字指纹,它有固定的长度,而且不同的明文摘要成密文,其结果总是不同的,而同样的明文其摘要必定一致。
AES:密码学中的高级加密标准(Advanced Encryption Standard,AES),又称Rijndael加密法,是美国联邦政府采用的一种区块加密标准。这个标准用来替代原先的DES,已经被多方分析且广为全世界所使用。是一种对称加密算法。
SHA-1:安全哈希算法(Secure Hash Algorithm)主要适用于数字签名标准 (Digital Signature Standard DSS)里面定义的数字签名算法(Digital Signature Algorithm DSA)。 SHA1有如下特性:不可以从消息摘要中复原信息;两个不同的消息不会产生同样的消息摘要。
MD5:Message Digest Algorithm MD5(中文名为消息摘要算法第五版)为计算机安全领域广泛使用的一种散列函数,用以提供消息的完整性保护。
三、项目背景
某合作公司需要通过互联网向我司传递一些用户数据,但是我所在项目组的外网服务器上并无部署https,只能基于http进行数据传输。为了保护双方共同的用户数据,必须对在互联网上传输的信息进行加密处理。
四、方案设计
这里涉及到两个问题,一是采用什么样的远程消息传递框架,二是如何对传输的数据进行加密。
本人平时开发所用的语言主要是Java,对于Jsp/Servlet还比较熟悉,结合去年参加过所在公司的微信公众号开发的经验,设计出了如下方案:
1.在客户端采用构造http post请求,把用户数据加密后放入request body中,并在http参数中放入调用方的签名;
2.服务端接收到请求,提取参数进行签名校验,通过后从request body中提取密文进行解密,然后进行后续处理,最终生成响应返回给客户端。
以下是具体处理的流程图:
在数据加密阶段,基于性能以及效率考虑,采用了Bouncy Castle提供的AES算法,而生成签名则采用了jdk提供的SHA-1,值得注意的是,基于安全考虑,消息密文的消息摘要也被列入到参与数字签名的参数之一。
五、代码实现
1.AES加密工具类:
import org.apache.commons.lang.StringUtils; import org.apache.log4j.Logger; import org.bouncycastle.crypto.CipherParameters; import org.bouncycastle.crypto.engines.AESFastEngine; import org.bouncycastle.crypto.modes.CBCBlockCipher; import org.bouncycastle.crypto.paddings.PaddedBufferedBlockCipher; import org.bouncycastle.crypto.params.KeyParameter; import org.bouncycastle.crypto.params.ParametersWithIV; import org.bouncycastle.util.encoders.Hex; /** * AES encryption and decryption tool. * * @author ben * @creation 2014年3月20日 */ public class AESTool { protected static final Logger log = Logger.getLogger(AESTool.class); private byte[] initVector = { 0x32, 0x37, 0x36, 0x35, 0x34, 0x33, 0x32, 0x31, 0x38, 0x27, 0x36, 0x35, 0x33, 0x23, 0x32, 0x31 }; /** * FIXME For demo only, should rewrite this method in your product environment! * * @param appid * @return */ public String findKeyById(String appid) { // Fake key. String key = "123456789012345678901234567890~!"; return key; } /** * Encrypt the content with a given key using aes algorithm. * * @param content * @param key * must contain exactly 32 characters * @return * @throws Exception */ public String encrypt(String content, String key) throws Exception { if (key == null) { throw new IllegalArgumentException("Key cannot be null!"); } String encrypted = null; byte[] keyBytes = key.getBytes(); if (keyBytes.length != 32 && keyBytes.length != 24 && keyBytes.length != 16) { throw new IllegalArgumentException( "Key length must be 128/192/256 bits!"); } byte[] encryptedBytes = null; encryptedBytes = encrypt(content.getBytes(), keyBytes, initVector); encrypted = new String(Hex.encode(encryptedBytes)); return encrypted; } /** * Decrypt the content with a given key using aes algorithm. * * @param content * @param key * must contain exactly 32 characters * @return * @throws Exception */ public String decrypt(String content, String key) throws Exception { if (key == null) { throw new IllegalArgumentException("Key cannot be null!"); } String decrypted = null; byte[] encryptedContent = Hex.decode(content); byte[] keyBytes = key.getBytes(); byte[] decryptedBytes = null; if (keyBytes.length != 32 && keyBytes.length != 24 && keyBytes.length != 16) { throw new IllegalArgumentException( "Key length must be 128/192/256 bits!"); } decryptedBytes = decrypt(encryptedContent, keyBytes, initVector); decrypted = new String(decryptedBytes); return decrypted; } /** * Encrypt data. * * @param plain * @param key * @param iv * @return * @throws Exception */ public byte[] encrypt(byte[] plain, byte[] key, byte[] iv) throws Exception { PaddedBufferedBlockCipher aes = new PaddedBufferedBlockCipher( new CBCBlockCipher(new AESFastEngine())); CipherParameters ivAndKey = new ParametersWithIV(new KeyParameter(key), iv); aes.init(true, ivAndKey); return cipherData(aes, plain); } /** * Decrypt data. * * @param cipher * @param key * @param iv * @return * @throws Exception */ public byte[] decrypt(byte[] cipher, byte[] key, byte[] iv) throws Exception { PaddedBufferedBlockCipher aes = new PaddedBufferedBlockCipher( new CBCBlockCipher(new AESFastEngine())); CipherParameters ivAndKey = new ParametersWithIV(new KeyParameter(key), iv); aes.init(false, ivAndKey); return cipherData(aes, cipher); } /** * Encrypt or decrypt data. * * @param cipher * @param data * @return * @throws Exception */ private byte[] cipherData(PaddedBufferedBlockCipher cipher, byte[] data) throws Exception { int minSize = cipher.getOutputSize(data.length); byte[] outBuf = new byte[minSize]; int length1 = cipher.processBytes(data, 0, data.length, outBuf, 0); int length2 = cipher.doFinal(outBuf, length1); int actualLength = length1 + length2; byte[] result = new byte[actualLength]; System.arraycopy(outBuf, 0, result, 0, result.length); return result; } public static void main(String[] args) throws Exception { AESTool aesTool = new AESTool(); String appid = "canairport001"; String key = aesTool.findKeyById(appid); String xml = "<root><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name></root>"; String encrypted = aesTool.encrypt(xml, key); System.out.println("encrypted: \n" + encrypted); System.out.println("encrypted length: \n" + encrypted.length()); String decrypted = aesTool.decrypt(encrypted, key); System.out.println("decrypted: \n" + decrypted); System.out.println("decrypted length: \n" + decrypted.length()); boolean isSuccessful = StringUtils.equals(decrypted, xml); System.out.println(isSuccessful); } }
2.数字签名工具类:
import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Collections; import java.util.List; import org.apache.commons.lang.StringUtils; import org.apache.log4j.Logger; /** * @author lixuanbin * @creation 2013-1-30 */ public class SignatureUtil { protected static Logger log = Logger.getLogger(SignatureUtil.class); private static final char[] hexArray = "0123456789ABCDEF".toCharArray(); private String encryptionAlgorithm = "SHA-1"; public String bytesToHexString(byte[] bytes) { char[] hexChars = new char[bytes.length * 2]; for (int j = 0; j < bytes.length; j++) { int v = bytes[j] & 0xFF; hexChars[j * 2] = hexArray[v >>> 4]; hexChars[j * 2 + 1] = hexArray[v & 0x0F]; } return new String(hexChars); } public byte[] hexStringToBytes(String s) { int len = s.length(); byte[] data = new byte[len / 2]; for (int i = 0; i < len; i += 2) { data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4) + Character .digit(s.charAt(i + 1), 16)); } return data; } /** * 使用指定算法生成消息摘要,默认是md5 * * @param strSrc * , a string will be encrypted; <br/> * @param encName * , the algorithm name will be used, dafault to "MD5"; <br/> * @return */ public String digest(String strSrc, String encName) { MessageDigest md = null; String strDes = null; byte[] bt = strSrc.getBytes(); try { if (encName == null || encName.equals("")) { encName = "MD5"; } md = MessageDigest.getInstance(encName); md.update(bt); strDes = bytesToHexString(md.digest()); // to HexString } catch (NoSuchAlgorithmException e) { log.error("Invalid algorithm: " + encName); return null; } return strDes; } /** * 根据appid、token、lol以及时间戳来生成签名 * * @param appid * @param token * @param lol * @param millis * @return */ public String generateSignature(String appid, String token, String lol, long millis) { String timestamp = String.valueOf(millis); String signature = null; if (StringUtils.isNotBlank(token) && StringUtils.isNotBlank(timestamp) && StringUtils.isNotBlank(appid)) { List<String> srcList = new ArrayList<String>(); srcList.add(timestamp); srcList.add(appid); srcList.add(token); srcList.add(lol); // 按照字典序逆序拼接参数 Collections.sort(srcList); Collections.reverse(srcList); StringBuilder sb = new StringBuilder(); for (int i = 0; i < srcList.size(); i++) { sb.append(srcList.get(i)); } signature = digest(sb.toString(), encryptionAlgorithm); srcList.clear(); srcList = null; } return signature; } /** * 验证签名: <br/> * 1.根据appid获取该渠道的token;<br/> * 2.根据appid、token、lol以及时间戳计算一次签名;<br/> * 3.比较传过来的签名以及计算出的签名是否一致; * @param signature * @param appid * @param lol * @param millis * @return */ public boolean isValid(String signature, String appid, String lol, long millis) { String token = findTokenById(appid); String calculatedSignature = generateSignature(appid, token, lol, millis); log.info("calculated signature: \n" + calculatedSignature); if (StringUtils.equals(calculatedSignature, signature)) { return true; } else { return false; } } /** * FIXME For demo only, should be a different string in production. * @param appid * @return */ public String findTokenById(String appid) { String token = "#@!1234567890!@#"; return token; } public static void main(String[] args) { SignatureUtil generator = new SignatureUtil(); String xmlString = "<root><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name><name>test</name></root>"; System.out.println(xmlString.getBytes().length); String digest = generator.digest(xmlString, "MD5"); System.out.println(digest); System.out.println(digest.getBytes().length); String appid = "canairport001"; String token = generator.findTokenById(appid); long millis = System.currentTimeMillis(); String signature = generator.generateSignature(appid, token, digest, millis); System.out.println(signature); boolean isValid = generator.isValid(signature, appid, digest, millis); System.out.println(isValid); } }
3.发送方代码:
import java.io.IOException; import java.util.HashMap; import java.util.Iterator; import java.util.Map; import java.util.Map.Entry; import org.apache.commons.lang.StringUtils; import org.apache.http.HttpEntity; import org.apache.http.HttpHost; import org.apache.http.HttpResponse; import org.apache.http.HttpStatus; import org.apache.http.auth.AuthScope; import org.apache.http.auth.UsernamePasswordCredentials; import org.apache.http.client.ClientProtocolException; import org.apache.http.client.methods.HttpPost; import org.apache.http.conn.params.ConnRoutePNames; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.DefaultHttpClient; import org.apache.http.message.BasicHeader; import org.apache.http.protocol.HTTP; import org.apache.http.util.EntityUtils; import org.apache.log4j.Logger; /** * @author ben * @creation 2014年6月9日 */ public class HttpclientUtil { protected static final Logger log = Logger.getLogger(HttpclientUtil.class); /** * 根据传入的uri和参数map拼接成实际uri * * @param uri * @param paraMap * @return */ public String buildUri(String uri, Map<String, String> paraMap) { StringBuilder sb = new StringBuilder(); uri = StringUtils.trim(uri); uri = StringUtils.removeEnd(uri, "/"); uri = StringUtils.removeEnd(uri, "?"); sb.append(uri); if (paraMap != null && !paraMap.isEmpty()) { sb.append("?"); Iterator<Entry<String, String>> iterator = paraMap.entrySet() .iterator(); while (iterator.hasNext()) { Map.Entry<String, String> pair = iterator.next(); try { String keyString = pair.getKey(); String valueString = pair.getValue(); sb.append(keyString); sb.append("="); sb.append(valueString); sb.append("&"); } catch (Exception e) { log.error(e, e); } } } return StringUtils.removeEnd(sb.toString(), "&"); } /** * Post an xml string to a specific host. * * @param targetHost * @param targetPort * @param protocol * @param proxyHost * @param proxyPort * @param proxyUser * @param proxyPassword * @param uri * @param paraMap * @param xml * @param charset * @return * @throws ClientProtocolException * @throws IOException */ public String postXmlString(String targetHost, int targetPort, String protocol, String proxyHost, int proxyPort, String proxyUser, String proxyPassword, String uri, Map<String, String> paraMap, String xml, String charset) throws ClientProtocolException, IOException { String result = null; DefaultHttpClient httpclient = new DefaultHttpClient(); if (StringUtils.isNotBlank(proxyHost) && proxyPort > 0) { // 设置上网代理 AuthScope authScope = new AuthScope(proxyHost, proxyPort); if (StringUtils.isNotBlank(proxyUser) && StringUtils.isNotBlank(proxyPassword)) { // 设置上网代理的用户名和密码 UsernamePasswordCredentials upc = new UsernamePasswordCredentials( proxyUser, proxyPassword); httpclient.getCredentialsProvider().setCredentials(authScope, upc); } HttpHost proxy = new HttpHost(proxyHost, proxyPort); httpclient.getParams().setParameter(ConnRoutePNames.DEFAULT_PROXY, proxy); } HttpHost host = new HttpHost(targetHost, targetPort, protocol); uri = buildUri(uri, paraMap); log.info("post uri: " + uri); log.info("post content: " + xml); HttpPost post = new HttpPost(uri); StringEntity se = new StringEntity(xml, StringUtils.isNotBlank(charset) ? charset : "utf-8"); se.setContentEncoding(new BasicHeader(HTTP.CONTENT_TYPE, "application/xml")); post.setEntity(se); HttpResponse response = httpclient.execute(host, post); if (HttpStatus.SC_OK == response.getStatusLine().getStatusCode()) { HttpEntity entity = response.getEntity(); if (entity != null) { result = EntityUtils.toString(entity); log.info("post result: " + result); } } else { log.error("post failed, status code: " + response.getStatusLine().getStatusCode()); } return result; } public static void main(String[] args) throws Exception { AESTool aes = new AESTool(); SignatureUtil signatureUtil = new SignatureUtil(); String appid = "canairport001"; String token = signatureUtil.findTokenById(appid); String key = aes.findKeyById(appid); long millis = System.currentTimeMillis(); String xml = "<dependency><groupId>commons-lang</groupId><artifactId>commons-lang</artifactId><version>2.5</version></dependency>"; xml = aes.encrypt(xml, key); String lol = signatureUtil.digest(xml, "MD5"); String signature = signatureUtil.generateSignature(appid, token, lol, millis); log.info("lol: \n" + lol); log.info("signature: \n" + signature); String uri = "http://127.0.0.1:8080/demo/psginfo.do"; Map<String, String> paraMap = new HashMap<String, String>(); paraMap.put("s", signature); paraMap.put("a", appid); paraMap.put("t", String.valueOf(millis)); paraMap.put("l", lol); paraMap.put("o", "test"); HttpclientUtil util = new HttpclientUtil(); try { String result = util.postXmlString("127.0.0.1", 8080, "http", null, 0, null, null, uri, paraMap, xml, "utf-8"); result = aes.decrypt(result, key); System.out.println(result); } catch (ClientProtocolException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } } }
4.服务端代码:
import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.PrintWriter; import java.io.UnsupportedEncodingException; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.apache.commons.lang.StringUtils; import org.apache.log4j.Logger; import co.speedar.wechat.util.AESTool; import co.speedar.wechat.util.SignatureUtil; /** * Servlet implementation class PsginfoServlet */ @WebServlet(urlPatterns = { "/psginfo.do" }, loadOnStartup = 1) public class PsginfoServlet extends HttpServlet { protected static final Logger log = Logger.getLogger(PsginfoServlet.class); private static final long serialVersionUID = 6536688299231165548L; private SignatureUtil signatureUtil = new SignatureUtil(); private AESTool aes = new AESTool(); /** * @see HttpServlet#HttpServlet() */ public PsginfoServlet() { super(); } /** * @see HttpServlet#doGet(HttpServletRequest request, HttpServletResponse * response) */ protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String echostr = request.getParameter("e"); log.info("echostr before echo: " + echostr); String signature = request.getParameter("s"); String appid = request.getParameter("a"); String timestamp = request.getParameter("t"); String lol = request.getParameter("l"); long millis = Long.valueOf(timestamp); // Need to check signature in product mode. if (signatureUtil.isValid(signature, appid, lol, millis)) { PrintWriter writer = response.getWriter(); log.info("echostr after echo: " + echostr); writer.print(echostr); writer.flush(); writer.close(); } } /** * @see HttpServlet#doPost(HttpServletRequest request, HttpServletResponse * response) */ protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // Get request parameters. String signature = request.getParameter("s"); String appid = request.getParameter("a"); String timestamp = request.getParameter("t"); String lol = request.getParameter("l"); String operation = request.getParameter("o"); long millis = Long.valueOf(timestamp); // Get xml data. String encoding = StringUtils .isNotBlank(request.getCharacterEncoding()) ? request .getCharacterEncoding() : "utf-8"; String requestXmlString = getXmlStringFromHttpRequest(request); String digest = signatureUtil.digest(requestXmlString, "MD5"); // Check signature and digest. if (StringUtils.equals(digest, lol)) { if (signatureUtil.isValid(signature, appid, lol, millis)) { try { String key = aes.findKeyById(appid); requestXmlString = aes.decrypt(requestXmlString, key); log.info("received xml data:\n" + requestXmlString); // 校验xml合法性并执行相应动作 String responseXmlString = doSomeThing(requestXmlString, operation); responseXmlString = aes.encrypt(responseXmlString, key); log.info("responsed xml data:\n" + responseXmlString); response.setCharacterEncoding(encoding); PrintWriter writer = response.getWriter(); writer.print(responseXmlString); writer.flush(); writer.close(); } catch (Exception e) { log.error(e, e); } } else { log.error("invalid signature"); } } else { log.error("invalid digest."); } } /** * TODO Write your own business here. * * @param xml * @param operation * @return */ private String doSomeThing(String xml, String operation) { return "done"; } /** * Extract xml string form http request. * * @param request * @return * @throws IOException */ private String getXmlStringFromHttpRequest(HttpServletRequest request) { String requestXmlString = ""; try { InputStream inputStream = request.getInputStream(); String encoding = StringUtils.isNotBlank(request .getCharacterEncoding()) ? request.getCharacterEncoding() : "utf-8"; requestXmlString = getXmlStringFromInputStream(inputStream, encoding); encoding = null; inputStream.close(); inputStream = null; } catch (IOException e) { log.error(e, e); } return requestXmlString; } /** * Extract xml string from the inputStream. * * @param inputStream * @param charsetName * @return */ private String getXmlStringFromInputStream(InputStream inputStream, String charsetName) { String resultXmlString = ""; String tempString = null; BufferedReader bufferedReader; try { bufferedReader = new BufferedReader(new InputStreamReader( inputStream, charsetName)); tempString = bufferedReader.readLine(); while (tempString != null) { resultXmlString += tempString; tempString = bufferedReader.readLine(); } tempString = null; bufferedReader.close(); bufferedReader = null; } catch (UnsupportedEncodingException e) { log.error(e, e); } catch (IOException e) { log.error(e, e); } return StringUtils.trim(resultXmlString); } }
5.maven配置:
<dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk16</artifactId> <version>1.46</version> </dependency> <dependency> <groupId>commons-lang</groupId> <artifactId>commons-lang</artifactId> <version>2.5</version> </dependency> <dependency> <groupId>org.apache.httpcomponents</groupId> <artifactId>httpclient</artifactId> <version>4.2.5</version> </dependency> <dependency> <groupId>org.apache.httpcomponents</groupId> <artifactId>httpmime</artifactId> <version>4.2.5</version> </dependency>
六、结语
在本方案设计实现过程中,消息传递的框架采用的是Java开发者所熟悉的Servlet技术,摘要、签名、加密所采用的算法,以及所依赖的第三方jar也是比较有口碑又大众化的货,对于有类似需要的开发者来说,本方案具有一定的参考意义。远程传递消息框架以及生成签名的环节,主要是模仿了微信公众平台的消息交互方式以及生成签名的思路,而有所创新的一小点是,把消息密文的MD5值也参与到了签名运算中,增加了被仿冒的难度,同时也便于服务方校验消息在传递过程中是否有被第三方所篡改。
基于简化工程配置的考虑,本示例项目中没有使用spring,您可以在您的生产项目中把本示例中的代码改造成春哥的单例业务bean。密钥、token建议别直接写到春哥的context配置文件中,而是写在您的生产容器的环境变量中,防止被窃取。
另外,在本方案中生成签名的参数您可以酌情增减并调换顺序,替换签名所采用的算法,或者根据您的实际需要“个性化”一下您的加密算法,以期达到更好的安全效果。
Last but not the least,在密钥以及token交换的阶段,请采取您所认可的安全有效的方式进行,譬如面对面,微信,qq,微薄私信,电话,短信,邮件(可以参考本人之前写过的一篇文章:http://lixuanbin.iteye.com/blog/1544344)
七、参考资料
【Java加密与解密的艺术】——作者:梁栋,出版日期:2010年12月,ISBN:978-7-111-29762-8
http://stackoverflow.com/questions/6729834/need-solution-for-wrong-iv-length-in-aes
http://baike.baidu.com/view/941329.htm?fr=aladdin
http://baike.baidu.com/subview/133041/5358738.htm?fr=aladdin
http://baike.baidu.com/view/1228622.htm?fr=aladdin
http://baike.baidu.com/view/7636.htm?fr=aladdin