一、项目背景
公司各业务线以及相关产品,会收集用户敏感数据,这些敏感数据在通讯链路上以明文形式传输。伴随着个人信息保护法的出台,对于个人隐私保护的力度持续加大。个人信息安全规范明确要求,个人敏感信息在传输时必须进行加密。根据规范中提出的敏感信息加密的标准,我们将实现一套符合标准的敏感数据加密传输方案。为满足敏感数据生命周期传输环节的监管合规要求和数据安全要求,需要对用户敏感数据在传输环节进行协议级别和字段级别的加密处理,避免用户敏感数据发生泄露。
在推进业务方完成敏感数据的卸载和脱敏处理过程中,业务自行加密面临的核心问题有:
- 业务客户端需配合改造,成本高效率低
- 加密安全标准无法对齐和验收
为此,需要提供一套通用的端到端下行数据加解密能力。
二、项目目标
在客户端和服务端交互的过程中,原应只有图中的红色模块才能获取数据明文,但是现在所有的绿色模块也均能获取数据的明文,因此存在被攻击的可能。
本项目为解决上述问题,实现了完整的端到端加密传输链路,技术方案覆盖安卓/IOS/Web/RN等多平台,本文将介绍Web端数据传输加密SDK的设计思路与实现原理。
三、整体方案
技术架构
抽象通用零信任加解密能力(Zero-Trust Data Protection),封装对应SDK以支持上层多种应用场景
抽象通用零信任加解密能力(Zero-Trust Data Protection),封装对应SDK以支持上层多种应用场景
Encryption Web SDK
数据加密Web端SDK,劫持业务网络请求,对请求进行上层封装,携带加密信息和相关证书,对响应进行解密并返回给业务调用
ZDP SDK
Zero-Trust Data Protection
零信任数据保护方案SDK,集成可信SDK Delta,支持请求数据时带上加密相关证书,提供数据解密能力
ZDP Lib
零信任数据保护方案服务端,存储加密密钥,提供数据加密能力
Encryption Config Server
传输加密配置服务端,下发加密配置(如:哪些域名、path需要加密传输)到web端
网关Loader
网关层loader,加密传输与ZDP Lib交互,修改加密字段内容并返回响应
Delta
收敛敏感数据加解密标准算法的SDK,提供加解密算法支持
核心流程
主要分为两个阶段:初始化阶段和下行加密阶段
初始化阶段
- web浏览器在启动时,Encryption Web SDK初始化本地私钥并发向证书server发起CSR请求
- 证书server签发客户端证书, 返回返回服务端证书
- Encryption Web SDK向Encryption Config Server发起请求,获取包含加密的域名 +path信息
下行加密阶段
- web浏览器发起请求, Encryption Config Server判读当前请求的resp是否包含需要加密的字段, 如果包含, 则在请求头上附带客户端证书信息
- 请求响应到达网关, 网关loader判断当前path是否包含需要加密的字段, 如果包含, 则从请求头中获取客户端证书, 进行字段抽取, 请求ZDP-lib使用客户端证书和服务端私钥进行加密.
- loader修改字段, 返回给客户端, 客户端请求ZDP-SDK进行字段解密, 获取明文数据
四、详细方案
SDK设计思路
ZDP初始化
- 核心流程:创建ZDP对象 -> 获取证书信息 -> 获取加密配置 -> 开启请求拦截器
- 初始化ZDP-SDK:由ZDP-SDK生成客户端公私钥,并获取服务端证书。需要ZDP-SDK提供的功能如下:
功能 | 说明 |
---|---|
初始化SDK | 初始化ZDP-SDK,检查本地环境,必要情况下生成客户端公私钥,请求服务端证书与颁发客户端证书 |
更新证书 | 重新请求颁发新的证书(客户端证书、服务端证书) |
重新生成公私钥 | 重新生成客户端公私钥、请求服务端证书与颁发客户端证书。用于本地公私钥异常的情况(未正常生成) |
获取证书信息 | 获取客户端证书信息,用于在Request Header中带给server |
// 初始化ZDP SDK
async initZDP() {
this.secureSDK = new (window as any).UCSecuritySDK({
proxy: false, // 由于SDK目前支持劫持能力,所有初始化的时候必须关闭劫持能力
aid: 1128, // 当前aid
cryptType: 'delta',
certType: 'request',
});
// 初始化完成之后获取当前的端上证书数据
const res = await this.secureSDK.cryptoSDK.getClientAndServerData();
const { sn, clientCert } = res || {};
// 服务端证书序列号
this.sn = sn;
// 客户端证书
this.clientCert = clientCert;
// 获取加密配置(待拦截的域名+path集合)
const response = await fetch(
'/api/transport_control/downstream/conf/client/get_enc_api',
{
method: 'GET',
mode: 'cors',
headers: {
'Content-Type': 'application/json',
},
}
);
const body = await response.json();
this.interceptUrls = body.data?.encrypt_api_list ?? [];
// 拦截请求
this.requestInterceptor();
}
请求拦截器
- 请求拦截: 分为XHR与Fetch拦截器,XHR实现依赖ajax-hook,Fetch实现依赖fetch-intercept
XHR 实现
import {
proxy,
XhrRequestConfig,
XhrRequestHandler,
XhrError,
XhrErrorHandler,
XhrResponse,
XhrResponseHandler
} from 'ajax-hook';
// 拦截xhr请求
xhrInterceptor() {
const that = this;
proxy({
// 请求发起前进入
onRequest: (config: XhrRequestConfig, handler: XhrRequestHandler) => {
// 判断config.host、config.url是否在待加密请求interceptUrls中
const request = that.isRequestEncyptionNeeded(config);
// 域名+path匹配命中,请求头携带加密信息
if (request) {
const newConfig = that.modifyRequestHeaders(IRequestType.XHR, config);
handler.next(newConfig);
} else { // 匹配未命中,请求不做任何处理
handler.next(config);
}
},
// 请求发生错误时进入,比如超时;注意,不包括http状态码错误,如404仍然会认为请求成功
onError: (err: XhrError, handler: XhrErrorHandler) => {
// 错误上报
handler.next(err);
},
// 请求成功后进入
onResponse: async (response: XhrResponse, handler: XhrResponseHandler) => {
// 获取response header
const responseHeaders = response.headers;
const { code, mode, field } = that.resolveResponseHeader(responseHeaders);
switch (code) {
// 直接明文返回
case IReqResponseCode.DecodeError:
case IReqResponseCode.HeaderParamsMiss:
case IReqResponseCode.NoEncryption:
handler.next(response);
break;
// 正常加密,分字段解密和body解密进行处理
case IReqResponseCode.Encryption:
that.handleEncryptionResponse(mode, field, response, handler);
break;
// 客户端证书错误,刷新数据返回错误
case IReqResponseCode.ClientError:
await that.refreshData();
await that.refreshServerData();
await that.initZDP();
handler.next(response);
break;
// 服务端错误,返回错误
case IReqResponseCode.ServerError:
// 错误上报
handler.next(response);
default:
break;
}
},
});
}
Fetch实现
// 拦截fetch请求
fetchInterceptor() {
const that = this;
fetchIntercept.register({
request(url, config) {
// 判断config.host、config.url是否在待加密请求interceptUrls中
const request = that.isRequestEncyptionNeeded(url);
// 域名+path匹配命中,请求头携带加密信息
if (request) {
const newConfig = that.modifyRequestHeaders(IRequestType.Fetch, config);
return [url, newConfig];
}
// 匹配未命中,请求不做任何处理
return [url, config];
},
requestError(error) {
// 错误上报
return Promise.reject(error);
},
response(response: FetchInterceptorResponse) {
// Modify the reponse object
const clonedResponse = response.clone();
// 获取headers
const headers = {};
for (const pair of clonedResponse.headers.entries()) {
headers[pair[0]] = pair[1];
}
const { code, mode, field } = that.resolveResponseHeader(headers);
switch (code) {
// 直接明文返回
case IReqResponseCode.DecodeError:
case IReqResponseCode.HeaderParamsMiss:
case IReqResponseCode.NoEncryption:
return response;
// 正常加密,分字段解密和body解密进行处理
case IReqResponseCode.Encryption:
try {
const json = () =>
clonedResponse.json().then(async data => {
const res = JSON.stringify(data);
if (mode === IReqResponseMode.BodyEncrypt) {
return await that.secureSDK.cryptoSDK.decrypt(res);
}
return await that.decryptResponseField(field, res);
});
response.json = json;
return response;
} catch (err) {
// 错误上报
return response;
}
// 客户端证书错误,刷新数据返回错误
case IReqResponseCode.ClientError:
that.refreshData();
that.refreshServerData();
that.initZDP();
return response;
// 服务端错误,返回错误
case IReqResponseCode.ServerError:
// 错误上报
return response;
default:
break;
}
return response;
},
responseError(error) {
// 错误上报
return Promise.reject(error);
},
});
}
- 拦截处理: 分为上行Request header标记与下行Response Header解析
上行Request header标记
对命中配置的请求,需在Request header中增加标记,服务端识别标记后,对body进行加密;
具体标记字段如下:
key | 说明 |
---|---|
bd-timon-client-crt | 客户端证书信息(等同于公钥) |
bd-timon-version | 客户端SDK版本 |
bd-timon-ts | 时间戳 |
bd-timon-req-sign | 签名,使用客户端私钥对上述三个key的摘要进行加签 |
bd-timon-local-server-crt-sn | 客户端本地保存的服务端证书序列号 |
// 修改请求头
modifyRequestHeaders(requestType: IRequestType, config: IRequestConfig): IRequestConfig {
const cert = this.clientCert;
const timestamp = new Date().getTime().toString();
const sn = this.sn;
if (requestType === IRequestType.XHR) {
const headers = config.headers || {};
config.headers = {
...headers,
// 客户端证书信息(等同于公钥)
'bd-timon-client-crt': cert,
// 时间戳
'bd-timon-ts': timestamp,
// 服务端证书序列号
'bd-timon-local-server-crt-sn': sn,
};
} else {
config = config || {};
const modifiedHeaders = new Headers(config?.headers || {});
modifiedHeaders.append('bd-timon-client-crt', cert);
modifiedHeaders.append('bd-timon-ts', timestamp);
modifiedHeaders.append('bd-timon-local-server-crt-sn', sn);
config.headers = modifiedHeaders;
}
return config;
}
下行Response Header解析
服务端对回包进行加密后,会在Response header增加特定字段,用于标识该body已加密,并给定body加密字段路径。
key | 说明 |
---|---|
bd-timon-code | 响应码,标识加解密状态: |
bd-timon-encrypt-mode | 0 明文, 1 字段加密, 2 body加密 |
bd-timon-encrypt-field | 服务端正常加密后, 说明哪些字段是加密的字段协议: body描述 |
bd-timon-remote-server-crt-sn | 服务端本地的证书版本 |
对下行ResponseHeader进行解析:
// 解析响应头,获取解密信息
resolveResponseHeader(headers: XhrResponse['headers']): IResponseHeaders {
try {
const code = headers['bd-timon-code'];
const mode = headers['bd-timon-encrypt-mode'];
const field = headers['bd-timon-encrypt-field'];
return {
code, // 以1、2、3、4、5开头
mode, // 0 明文, 1 字段加密, 2 body加密
field: field ? JSON.parse(field) : [], // 哪些字段是加密的
};
} catch (err) {
// 错误上报
return {
code: -2, // 解析错误码
};
}
}
- 响应解密:分为响应body与字段类型的解密
响应解密实现
- handleEncryptionResponse:解析响应,对body解密类型的响应直接使用ZDP SDK提供解密方法secureSDK.cryptoSDK.decrypt()进行解密,对字段解密类型的响应,调用decryptResponseField()方法进行处理:
// 处理响应解密
async handleEncryptionResponse(
mode: IReqResponseMode,
field: string,
response: XhrResponse,
handler: XhrResponseHandler
) {
// 解析body
const responseBody = response.response;
// body解密
if (mode === IReqResponseMode.BodyEncrypt) {
const res = this.secureSDK.cryptoSDK.decrypt(responseBody);
res.then(data => {
// 返回解密后的res
handler.next(
Object.assign({}, response, {
response: data,
})
);
});
} else {
// 字段解密
const res = await this.decryptResponseField(field, responseBody);
handler.next(
Object.assign({}, response, {
response: res,
})
);
}
}
- decryptResponseField:遍历待解密字段数组field,解密每一个具体字段,使用JsonParser类的modifyNodeByPath()处理field每一个加密字段,解密修改字段为原始内容:
// 解密响应字段
async decryptResponseField(field: string, responseBody: XhrResponse['response']): Promise<XhrResponse['response']> {
try {
// 初始化JsonParser对象,解析responseBody
const parser = new JsonParser(JSON.parse(responseBody));
// 循环处理field每一个加密字段,解密修改字段为原始内容
for (const path of field) {
await parser.modifyNodeByPath(path, (val: string) => {
return new Promise(async resolve => {
const res = await this.secureSDK.cryptoSDK.decrypt(val);
resolve(res);
});
});
}
return JSON.stringify(parser.nodeVal);
} catch (err) {
return null;
}
}
解密算法实现
实现原理
举个