在基于B/S 的业务系统中,如果要设计开发加密解密机制。有几种设计选型:
- 可以使用现成的HTTPS 架构,后端部署用知名签名机构生成的证书。
- 可以使用现成的HTTPS 架构,后端部署自签名的证书,但是用户需要在浏览器中导入自签名证书。
- 基于HTTP 传输加密的数据。也就是说,虽然使用非加密的传输协议,但是数据本身是加密的。
本文说明的加密系统是基于第三个技术选型进行设计,即在使用HTTP 协议进行传输,并对传输数据加密。
系统架构
在该架构中存在一个加密服务,对外提供RESTful的HTTP GET API: /crypto/key , 该API的响应体中包含AES 对称加密算法的配置信息,同时包含RSA 公钥。AES算法的配置信息在加密服务中使用RSA的私钥进行了加密。加密服务的使用者需要从加密服务/crypto/key 获取算法配置信息。该加密系统支持的场景为:
- 在同一业务系统的前端和后端之间进行加密。
- 在不同业务系统之间进行加密。
其架构图如下所示:
其主要步骤有: 1)业务系统A 的前端通过HTTP 请求,从加密服务获取加密算法相关信息,并通过封装的驱动文件 解析加密算法信息。 2)业务系统A 的后端通过HTTP 请求,从加密服务获取加密算法相关信息,并通过封装的驱动文件 解析加密算法信息。 3)业务系统A 前端使用AES 加密业务数据,通过HTTP 请求发送到后端。后端接收到前端的加密数据后,使用AES 解密数据,并进行后续的处理。相反的方向是类似的,后端使用AES 加密业务数据,通过HTTP 传输到前端,前端使用AES 解密接收到的后端加密数据,并进行后续的处理。 4)业务系统B 的前端和后端与加密服务进行的交互以及业务系统B 的前后端间的加密传输,和步骤1,2,3 的说明一样,不再重复阐述。 5)业务系统A 和业务系统B 进行交互时,业务系统A 的后端使用AES 加密数据,通过HTTP 传输到业务系统B 后端,业务系统B 后端使用AES 解密数据,并进行后续处理。反方向的流程是类似的处理。
加密服务
加密服务是个web服务,其设计不限于某种程序语言。既可以使用基于Spring boot的java程序设计语言开发也可以使用Node.js 平台或其他任何程序设计语言开发。本文使用Spring boot 进行设计开发。 加密服务Spring boot的入口程序如下:
@SpringBootApplication
public class CryptoApplication {
public static void main(String[] args) {
SpringApplication.run(CryptoApplication .class, args);
}
@Bean
public CommandLineRunner init(final CryptoController cryptoController) {
CommandLineRunner commandLineRunner = (String ...strings) -> {
cryptoController.init();
};
return commandLineRunner;
}
}
加密服务controller:
@RestController
public class CryptoController {
@Value("${crypto.pubFile}")
private String publicKeyFile;
@Value("${crypto.prvFile}")
private String privateKeyFile;
@Value("${crypto.aes.mode}")
private String aesMode;
@Value("${crypto.aes.key}")
private String aesKey;
@Value("${crypto.aes.iv}")
private String aesIv;
private String publicKey;
private RSAPrivateKey privateKey;
private byte[] aesKeyBytes;
private byte[] aesIvBytes;
@GetMapping(value = "/crypto/key", produces = "application/json")
public ResponseEntity<CryptoKey> getCryptoKey(@RequestHeader String referer) throws Exception {
Assert.isTrue (StringUtils.hasText(aesMode));
Assert.isTrue (StringUtils.hasText(aesKey));
Assert.isTrue (StringUtils.hasText(aesIv));
Assert.isTrue (StringUtils.hasText(publicKey));
Assert.isTrue (privateKey != null);
boolean refererOk = checkReferer(referer)
if (!refererOk) {
return ResponseEntity.badRequest().build();
}
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.ENCRYPT_MODE, privateKey);
cipher.update("aes".getBytes(StandardCharsets.UTF_8));
byte[] algBytes = cipher.doFinal();
cipher.init(Cipher.ENCRYPT_MODE, privateKey);
cipher.update(aesMode.getBytes(StandardCharsets.UTF_8));
byte[] modeBytes = cipher.doFinal();
cipher.init(Cipher.ENCRYPT_MODE, privateKey);
cipher.update(aesKeyBytes);
byte[] keyBytes = cipher.doFinal();
cipher.init(Cipher.ENCRYPT_MODE, privateKey);
cipher.update(aesIvBytes);
byte[] ivBytes = cipher.doFinal();
cipher.init(Cipher.ENCRYPT_MODE, privateKey);
ByteBuffer byteBuffer = ByteBuffer.allocate(Long.BYTES);
byteBuffer.putLong(System.currentTimeMillis());
cipher.update(byteBuffer.array());
byte[] versionBytes = cipher.doFinal();
CryptoKey cryptoKey = new CryptoKey();
cryptoKey.setK(publicKey);
cryptoKey.setA(Codec.bytesToBase64String(algBytes));
cryptoKey.setM(Codec.bytesToBase64String(modeBytes));
cryptoKey.setP(Codec.bytesToBase64String(keyBytes));
cryptoKey.setV(Codec.bytesToBase64String(ivBytes));
cryptoKey.setT(Codec.bytesToBase64String(versionBytes));
return ResponseEntity.ok(cryptoKey);
}
public void init() {
Assert.isTrue (StringUtils.hasText(publicKeyFile));
Assert.isTrue (StringUtils.hasText(privateKeyFile));
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(2048);
try (InputStream inputStream = new ClassPathResource(publicKeyFile).getInputStream()) {
FileCopyUtils.copy(inputStream, byteArrayOutputStream);
byte[] bytes = byteArrayOutputStream.toByteArray();
publicKey = new String(bytes, StandardCharsets.UTF_8);
} catch (Exception e) {
logger.error(e.getMessage());
return;
}
try (InputStream inputStream = new ClassPathResource(privateKeyFile).getInputStream();
InputStreamReader inputStreamReader = new InputStreamReader(inputStream, StandardCharsets.UTF_8);
PemReader pemReader = new PemReader(inputStreamReader)) {
PemObject pemObject = pemReader.readPemObject();
PKCS8EncodedKeySpec pkcs8EncodedKeySpec = new PKCS8EncodedKeySpec(pemObject.getContent());
KeyFactory factory = KeyFactory.getInstance("RSA");
privateKey = (RSAPrivateKey) factory.generatePrivate(pkcs8EncodedKeySpec);
} catch (Exception e) {
logger.error(e.getMessage());
return;
}
aesKeyBytes = Codec.base64StringToBytes(aesKey);
aesIvBytes = Codec.base64StringToBytes(aesIv);
}
private boolean checkReferer(String referer) {
URI uri = URI.create(referer);
String host = uri.getHost();
if ("localhost".equals(host)) {
return false;
}
if ("127.0.0.1".equals(host)) {
return false;
}
return true;
}
}
需要注意的是:
- 该API要求校验请求头
referer ,从而保证请求是来自部署于web服务器中的客户端程序。
CryptoKey 是pojo 类,加密服务API的响应体中是该类的JSON格式数据。该类的定义为:
public class CryptoKey {
private String k;
private String a;
private String m;
private String p;
private String v;
private String t;
public String getK() {
return k;
}
public void setK(String k) {
this.k = k;
}
public String getA() {
return a;
}
public void setA(String a) {
this.a = a;
}
public String getM() {
return m;
}
public void setM(String m) {
this.m = m;
}
public String getP() {
return p;
}
public void setP(String p) {
this.p = p;
}
public String getV() {
return v;
}
public void setV(String v) {
this.v = v;
}
public String getT() {
return t;
}
public void setT(String t) {
this.t = t;
}
}
需要说明的是:
- 加密服务可以随时更新算法相关的信息,如RSA的公钥和私钥,AES的密钥和初始向量。在
CryptoKey 类中存在一个版本字段,加密服务每次更新其算法相关信息,该版本字段也同步更新。该机制可以进一步提高加密服务的数据安全。加密服务的使用者必须确认双方加密算法的版本信息一致,否则无法进行正确的加解密。 - 加密服务需要部署
PEM 格式的RSA公钥和私钥文件。
客户端驱动文件
客户端是运行在浏览器环境中的JavaScript程序。客户端驱动文件封装了加解密函数,便于客户端直接使用。该驱动文件的实现为:
export const getAesConfig = function(json) {
let pubKeyPem = json["k"];
let secretKey = json["p"];
let algorithm = json["a"];
let aesMode = json["m"];
let aesIv = json["v"];
let version = json["t"];
let secretKeyPlain = crypto.publicDecrypt(pubKeyPem, Buffer.from(base64.toByteArray(secretKey)));
let aesIvPlain = crypto.publicDecrypt(pubKeyPem, Buffer.from(base64.toByteArray(aesIv)));
let algorithmPlain = crypto.publicDecrypt(pubKeyPem, Buffer.from(base64.toByteArray(algorithm)));
let aesModePlain = crypto.publicDecrypt(pubKeyPem, Buffer.from(base64.toByteArray(aesMode)));
let versionPlain = crypto.publicDecrypt(pubKeyPem, Buffer.from(base64.toByteArray(version)));
return {algorithmPlain, aesModePlain, secretKeyPlain, aesIvPlain, versionPlain};
}
export const encrypt = function(plainText, aesModePlain, secretKeyPlain, aesIvPlain) {
let plainTextBytes = aesJs.utils.utf8.toBytes(plainText);
let aesOfb = new aesJs.ModeOfOperation.ofb(secretKeyPlain, aesIvPlain);
let cipherTextBytes = aesOfb.encrypt(plainTextBytes);
return base64.fromByteArray(cipherTextBytes);
}
export const decrypt = function(cipherText, aesModePlain, secretKeyPlain, aesIvPlain) {
let cipherTextBytes = base64.toByteArray(cipherText);
let aesOfb = new aesJs.ModeOfOperation.ofb(secretKeyPlain, aesIvPlain);
let plainTextBytes = aesOfb.decrypt(cipherTextBytes);
return aesJs.utils.utf8.fromBytes(plainTextBytes);
}
export const encryptPassword = function(password, aesModePlain, secretKeyPlain, aesIvPlain) {
let plainData = password + aesModePlain + base64.fromByteArray(secretKeyPlain) + base64.fromByteArray(aesIvPlain);
let cipherData = encrypt(plainData, aesModePlain, secretKeyPlain, aesIvPlain);
password = new MD5().update(cipherData).digest('hex');
let salt = encrypt(Date.now(), aesModePlain, secretKeyPlain, aesIvPlain);
return {password, salt};
}
需要说明的是:
- 单独封装了密码处理函数
encryptPassword ,因为密码通常需要某种哈希进行处理,如MD5 , SHA1 等,从而保证其不可逆。
下面是一个简单的html 页面,用于说明如何在浏览器客户端环境使用驱动文件封装的相关加解密函数。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Crypto driver test.</title>
</head>
<body>
<script src="crypto-driver-bundle.js"></script>
<script>
async function test(){
let serverUrl = "http://ip:port/crypto/key";
let response = await fetch(serverUrl);
if (!response.ok) {
console.error("Fetch failed.");
return;
}
let body = await response.json();
let {algorithmPlain, aesModePlain, secretKeyPlain, aesIvPlain, versionPlain} = cryptoDriver.getAesConfig(body);
let {password, salt} = cryptoDriver.encryptPassword("123456", aesModePlain, secretKeyPlain, aesIvPlain);
let plainText = "Hello world.";
let cipherText = cryptoDriver.encrypt(plainText, aesModePlain, secretKeyPlain, aesIvPlain);
let original = cryptoDriver.decrypt(cipherText, aesModePlain, secretKeyPlain, aesIvPlain);
if (plainText === original) {
console.log("Test OK.");
} else {
console.log("Test failed.");
}
}
test();
</script>
<p> Crypto driver </p>
</body>
</html>
需要说明的是:
- 文件
crypto-driver-bundle.js 是使用webpack 打包客户端驱动文件源代码后的生成文件,对外导出了cryptoDriver 对象,其有四个属性,对应加解密相关的四个函数:getAesConfig ,encryptPassword ,encrypt 和decrypt 。
服务端的驱动文件
因为JavaScript是浏览器客户端的事实标准,因此需要为客户端提供基于JavaScript封装的驱动文件,但是对于服务端,其程序实现可以是Java,Node.js,Python等其他程序设计语言,为便于服务端使用加密服务,也需要为服务端提供封装的驱动文件,其实现逻辑和客户端驱动文件一致,封装getAesConfig ,encryptPassword ,encrypt 和decrypt 四个加解密函数。
|