前言
我们的诸多创新产品在客户端与服务端通信时对请求体数据使用通讯加密库进行了加密,通讯加密库的核心的由C和Rust编写,以动态链接库/静态库的方式提供加解密入口。在客户端,通信加密在iOS和Android进行了集成,在服务端,通过语言绑定的方式调用动态库(如Java使用JNI的方式)。美中不足的是,诸多创新产品服务端使用的语言不一,需要为多种语言编写绑定。同时服务端的接入也要主动调用函数完成加解密,这无疑增加了服务端的开发成本和调试难度。在众多创新产品需要小步快跑的今天,让业务去完成密钥交换、数字签名、对称加密等相关接口开发测试工作,会拖慢产品快速孵化上线的步伐。因此这篇文章主要回顾梳理通讯加密库的流程,找出当前版本的加密库存在的问题,思考如何对现有流程优化,并探索优化落地的可行性方案。
大纲
- 通讯加密的过程
- 通讯加密库的开发与现存问题
- 通讯解密库的接入与流程优化
- 代理层自动加解密的尝试
- 总结
一、通讯加密的过程
通讯加密的历史
我们都知道,加密是将明文信息转变为不可读的密文内容的过程。加密可以达到信息即便在传输过程中被截取,截取者也无法理解其内容的目的。如通过改变字母表顺序,通过一些偏移和逆转同样可以完成加密,但这种方式只要密文够长,就可以通过频率分析的方式破解。二战以后随着计算机和电子学的发展,加密通常使用公钥或对称密钥,并且依赖现代计算机架构在破解密钥上低效的事实来保证安全性。
我们通讯加密的现状
常用的对称加密指的是在加密和解密时使用相同的密钥,非对称加密指的是加密和解密使用的是不同的密钥。出于性能考虑,我们在通讯加密库中绝大部分使用的都是对称加密。对称加密需要服务端和客户端持有相同的私钥进行加密。
通讯加密库
- 使用了椭圆曲线迪菲-赫尔曼密钥交换 (Elliptic Curve Diffie–Hellman key exchange)即ECDH作为密钥交换算法,
- 使用了椭圆曲线数字签名算法(Elliptic Curve Digital Signature Algorithm) 即ECDSA作为数字签名算法。
- 使用了高级加密标准(Advanced Encryption Standard,缩写:AES) 作为数据的加密算法。
这些算法再此不进行展开,EC系列有多重椭圆曲线可选,如secp256k1(k256)、secp384r1(p384)等。比特币和以太坊的数字签名算法也是EDCSA,其采用的椭圆曲线为secp256k1(k256),因为加密货币的采用,使得k256椭圆曲线是目前使用最为普遍的椭圆曲线。但就加密强度或签名有效性而言,这几种椭圆曲线没有本质区别。
密钥交换的过程
密钥交换的过程较为简单,如下图所示。
-
客户端发起密钥交换,在本地生成一个临时我们的随机数作为私钥,并基于椭圆曲线算出公钥,将公钥发送给服务端。
-
服务端收到公钥后,同样在本地生成一个临时的随机数作为私钥,并与客户端公钥计算出共享密钥
Shared Secret
, 同时生成服务端公钥发送给客户端。 -
客户端收到服务端公钥后,同样计算出共享密钥,此共享密钥与服务端的计算结果相同。
-
客户端和服务端将临时的公私钥丢弃,并将计算出的共享密钥保存,用在接下来对称加密中。
数字签名的过程
使用ECDH进行密钥交换是不安全的,因为并没有校验公钥发送者的身份,所以可以被中间人攻击。基于同样的原因,所有的DH密钥交换都是不安全的。比特币等数字加密货币同样面临身份验证的问题,在一笔比特币交易转账中,如何证明自己拥有某一笔比特币是通过数字签名来完成的。简单来说数字签名与在付款合同中的笔迹签名的作用一样,都可以用来核实对方身份。
为了生成数字签名,要有一对公私钥,将某段消息使用私钥进行签名,并将消息和签名同时发送出去,其他人就可以通过公钥验证该签名的正确性,来确定消息的发送方,也就达到了核实身份的目的。
数字签名的算法有很多中,在通讯加密库中,我们采用了ECDSA算法。具体做法是服务端生成公私钥,并把公钥预埋在客户端中。在ECDH密钥交换环节,服务端向客户端下发一次性公钥时,对公钥进行签名,这样客户端就可以验证该公钥确实为我们的服务端发出,从而在密钥交换环节阻断了中间人攻击的可能性。具体的流程如下图所示:
完成了密钥交换之后, 客户端和服务端可以使用密钥进行AES对称加密通讯,这部分较为简单,此处不做展开。
二、通讯加密库的开发
现有的通讯加密库使用C代码开发,封装ECDH、ECDSA、AES等算法到动态链接库.dylib .so中,然后基于此动态链接库,编写各个语言的绑定。包括如下语言。
- iOS加解密SDK,通过Objective C编写iOS Native调用
- Android加解密SDK,通过Java编写Android Native调用
- 各语言加解密SDK,大多通过ffi的方式调用
目前开发所面临的问题是:
- 版本管理混乱。在各个业务中出现的Crash、内存泄露等问题不确定是哪版代码,排查问题第一步就受阻。业务在迁移、重构等过程中稍有不慎就可能造成API无法解密的大故障。
- 内存安全无法稳定保证。多个版本的C代码维护起来较为困难,内存安全严重依赖C语言编程经验丰富的老手不踩坑,遗憾的是当前能持续维护加密库的开发同学C内存管理实战经验并不丰富。
三、接入通讯加密库业务要做哪些工作
首先客户端iOS、Android都要接入SDK,这是必须的不能省掉的工作。
然后是服务端需要按语言接入SDK,服务端需要完成密钥交换接口的开发、密钥存储以及在中间件层对请求体完成加解密。
其实服务端接入过程还可以简化,加解密的过程可以做在Nginx反向代理上,这样做有几个优势:
- 服务端可以不再开发加解密相关接口,加解密对服务端透明,可以按照正常API进行开发
- 通讯加密库开发不再为多语言编写绑定,减少了工作量
- 版本统一且与业务部署解耦,简化问题排查,同时为加解密服务降级带来更加通用可行的方案。
同时,这样做也丢掉了部分灵活性,特别是密钥与SESSION绑定的部分,业务方如有需求,仍需开发接口。
四、反向代理层完成自动加解密的可行性
我们尝试使用openresty作为反向代理,这样就可以通过lua调用通讯加密库的动态链接库,编写密钥交换与自动加解密的API。这个想法的原型验证工作可以分为三个部分
- 编写加密库动态链接库的lua语言绑定(luajit绑定)
- 使用luajit调用通讯加密库的动态链接库,生成密钥交换接口
- 使用luajit完成对请求body与响应body的改写
通过Spring boot启动http服务
@SpringBootApplication
@RestController
public class DemoSpringBootApplication {
@PostMapping("/api")
public String plaintextApiDemo(@CookieValue(value = "user_id", defaultValue = "111") String userId,
@RequestBody String body) throws JsonProcessingException {
System.out.println("Body: " + body);
return plus_one(body);
}
public String plus_one(String plaintext) throws JsonProcessingException {
ObjectMapper objectMapper = new ObjectMapper();
Map<String, Object> map = objectMapper.readValue(plaintext, new TypeReference<Map<String,Object>>(){});
int origin = (int) map.get("origin");
origin += 1;
HashMap<String, String> output = new HashMap<>();
output.put("answer", String.valueOf(origin));
output.put("status", "ok");
System.out.println("output: " + output);
return objectMapper.writeValueAsString(output);
}
}
Nginx配置示意 nginx.conf
http {
lua_package_path "lib/?.lua;;";
init_by_lua_block {
require("cjson");
require("resty.cookie");
require("crypto_utils");
}
server {
listen 8010;
server_name localhost;
location / {
root html;
index index.html index.htm;
}
location /exchange-key {
default_type application/json;
content_by_lua_file lua/exchange.lua;
}
location /api {
default_type text/html;
proxy_pass http://127.0.0.1:8080;
rewrite_by_lua_file lua/body_decrypt.lua;
header_filter_by_lua_block {
ngx.header.content_length = nil;
}
body_filter_by_lua_file lua/body_encrypt.lua;
}
}
}
密钥交换 exchange.lua
local crypto = require("message_crypto")
local cjson = require("cjson")
local crypto_utils = require("crypto_utils")
local NGX = ngx;
-- 密钥交换
local client_public_key = NGX.unescape_uri(NGX.var.arg_key)
local server_crypto = crypto.exchange(client_public_key)
-- 密钥存入Redis, 基于user_id
local user_id = crypto_utils.get_cookie("user_id")
local key = string.format("ss:%s", user_id)
crypto_utils.redis_set_key(key, server_crypto["shared_secret"])
-- 返回JSON数据
local json_resp = cjson.encode {
public = server_crypto["public_key"],
signature = server_crypto["signature"],
}
NGX.say(json_resp)
请求体解密 body_decrypt.lua
local crypto = require("message_crypto")
local crypto_utils = require("crypto_utils")
local NGX = ngx;
-- 获取共享密钥, 基于user_id
local user_id = crypto_utils.get_cookie("user_id")
local key = string.format("ss:%s", user_id)
local shared_secret = crypto_utils.redis_get_key(key)
NGX.ctx.shared_secret = shared_secret
-- 获取Http body string
NGX.req.read_body()
local body = NGX.req.get_body_data()
if not body then
NGX.say("No http body!")
end
-- 将请求Body改写为明文数据
local body_decrypted = crypto.decrypt(body, shared_secret)
NGX.req.set_body_data(body_decrypted)
响应体加密 body_encrypt.lua
local crypto = require("message_crypto")
local NGX = ngx;
NGX.arg[1] = crypto.encrypt(NGX.arg[1], NGX.ctx.shared_secret)
NGX.arg[2] = true
以上代码完成了在Nginx层完成密钥交换、通讯加密的过程,对Java代码完全是透明的。并且代码量少,维护简单。
五、总结
现有的通讯加密库安全性良好,但存在诸多开发管理上的问题。在代理层的统一加解密将减轻业务开发与加密库维护负担, 让创新业务更好更快的开展。