媒体流加密
更新时间: 2024/09/18 16:26:13
在金融行业等对用户隐私数据要求较高的行业场景中,往往需要额外采用媒体流加密方式保障用户数据在网络传输过程中的安全性、保障用户的信息安全和数据安全。网易云信在默认加密算法的基础上,提供了内置国密加密和自定义加密方案,进一步保障数据安全。
功能介绍
媒体流加密指在音视频流传输过程中对音视频数据进行加密,网易云信提供以下两种加密方式。
- 内置国密加密:使用 NERTC SDK 预置的国密加密模式给媒体流加密。
- 自定义加密:通过 NERTC SDK 提供的可绑定的订阅事件来自定义媒体流的加密模式。网易云信服务器可以对自定义加密后的媒体流进行传输,加解密均在客户端完成。
注意事项
- 您只可选择 SDK 内置的国密加密算法或自定义加密算法,两种加密算法不可同时使用。
- 同一房间内,所有开启媒体流加密的用户必须使用相同的加密模式、密钥等,否则加入房间时会报错并触发
Client.on("crypt-error")
回调。 - 密钥等参数均由您自己的服务器进行分发。
内置加密注意事项:
- 安全起见,若您选择 SDK 内置加密算法,建议每次启用媒体流加密时都更换新的密钥。
自定义加密注意事项:
-
若您选择自定义加密,请在创建房间前启用自定义加密,该房间的属性为“自定义加密房间”。后续用户必须也启用自定义加密才可成功加入。
-
加入“自定义加密房间”的双方若使用了不同的自定义加密算法,会强行进行加解密,会出现视频画面绿屏,音频机械音。
-
自定义加密功能依赖
encodedInsertableStreams
接口,目前仅 Chrome94 及以上版本的浏览器支持此功能。 -
若要在某个 RTC 音视频通话房间中启用自定义加密,则必须保证加入房间的用户使用的客户端为 iOS、Android、Windows、macOS 或 Web 端,不支持小程序、Sip、Linux等其他平台客户端加入“自定义加密房间”。
-
使用了自定义加密的 RTC 音视频通话房间,不支持使用云端录制、云端播放、旁路推流等媒体服务,不支持安全通服务,不支持服务端本地录制服务。
示例项目
网易云信在 GitHub 上提供自定义加密的开源示例项目 CustomEncryption,您可以前往下载体验,也可以在线体验此功能。
开通媒体流加密
-
登录网易云信控制台。
-
在首页单击指定应用名称。
-
在产品总览区域,单击音视频通话 2.0 产品选项卡中的功能配置。
-
单击高级功能页签,单击 音视频媒体流加密 的开关按钮,开启媒体流加密。
-
单击确定。
内置加密
功能原理
启用内置国密加密的实现步骤:
- 您的服务端生成密钥和盐并加密传输给客户端。
- 用户 A 采集音视频流之后,通过
setEncryptionMode
和setEncryptionSecret
启用加密并传入加密模式、密钥,SDK 会用您选择的加密算法对音视频流进行加密,并将加密后的音视频流传输到网易云信音视频服务。 - 网易云信音视频服务中转加密的音视频流。
- 用户 B 收到音视频流之后,通过
setEncryptionMode
和setEncryptionSecret
传入相同的加密模式、密钥,SDK 会使用同样的加密算法和密钥对音视频流解密,并解码与渲染。
配置步骤
-
在您的服务端生成密钥。参考以下命令通过 OpenSSL 随机生成 String 型、16 字节的密钥。
参考以下命令通过 OpenSSL 随机生成 String 型、16 字节的密钥。
// 随机生成一个 string 型、16 字节的密钥,并将该密钥传入 `enableEncryption` 的 config 参数。 openssl rand -hex 16 dba643c8ba6b6dc738df43d9fd624293b4b12d87a60f518253bd10ba98c48453
-
客户端在加入房间前调用
setEncryptionMode
开启媒体流加密功能,并设置加密模式。 -
客户端从服务端获取 String 型密钥,并在加入房间前调用
setEncryptionSecret
时传入 SDK。
- 加密模式目前仅支持国密 SM4 对称加密算法。
- 密钥由客户端生成,格式为 String 类型的字符串。
示例代码
// 例如,使用 sm4-128-ecb
client.setEncryptionMode('sm4-128-ecb');
client.setEncryptionSecret('abcdefghijklmnop');
// 然后通过client.join()加入房间
自定义加密
功能原理
启用自定义加密的实现步骤:
- 您的服务端生成密钥并加密传输给客户端。
- 用户 A 采集音视频流之后,在返回的
sender-transform
回调事件中启用自定义加密,SDK 会将加密后的音视频流传输到网易云信音视频服务。 - 网易云信音视频服务中转加密的音视频流。
- 用户 B 收到音视频流之后,在返回的
receiver-transform
回调事件中启用自定义解密,选择和用户 A 相同的加密算法对媒体流解密,SDK 会将解密后的媒体流进行解码与渲染。
配置步骤
- 在初始化后加入房间前,绑定相关订阅事件和自定义加解密的回调,并调用
client.enableCustomTransform()
方法启用自定义加密。 - 加入房间后在
sender-transform
回调中处理自定义加密,或在receiver-transform
回调中处理自定义解密。
- H264 数据应只加密 I 帧和 P 帧,且需在 0x00 0x00 0x00 0x01 后保留三位不做加密,详细信息请参考示例代码。
- 由于有丢包情况存在,请勿在帧与帧间使用类似 cbc 的加密模式。
- 通过
sender-transform
和receiver-transform
返回的对象内可能包含多个 I 帧和 P 帧,请分别按需做加密。
示例代码
<!DOCTYPE html>
<html>
<body>
<div id="localDiv" style="height: 500px;"></div>
<div id="remoteDiv" style="height: 500px;"></div>
<script src="<SDK地址>"></script>
<script src="<加密库地址>"></script>
<script>
const rc4_secret = "I_AM_A_KEY"
function encodeFunctionRC4({mediaType, encodedFrame, controller}){
// 加密算法,以RC4为例
// 本示例中使用的SM4加密库地址: https://www.npmjs.com/package/sm4-128-ecb
if (encodedFrame.data.byteLength){
const u8Arr1 = new Uint8Array(encodedFrame.data);
const info = findCryptIndexH264(u8Arr1)
const h264Index = info.pos;
if (mediaType === "audio" || h264Index <= 0){
SM4.rc4_encrypt(u8Arr1, rc4_secret, {shiftStart: 0});
}else{
info.frames.forEach((frameInfo)=>{
if (frameInfo.frameType === "IFrame" || frameInfo.frameType === "PFrame"){
SM4.rc4_encrypt(u8Arr1, rc4_secret, {
shiftStart: frameInfo.pos + customEncryptionOffset,
end: frameInfo.posEnd
});
}
})
}
}
controller.enqueue(encodedFrame);
}
function decodeFunctionRC4({mediaType, encodedFrame, controller}){
// 解密算法,以RC4为例
if (encodedFrame.data.byteLength){
const u8Arr1 = new Uint8Array(encodedFrame.data);
const info = findCryptIndexH264(u8Arr1)
const h264Index = info.pos;
if (mediaType === "audio" || h264Index <= 0){
SM4.rc4_decrypt(u8Arr1, rc4_secret, {shiftStart: 0});
}else{
info.frames.forEach((frameInfo)=>{
if (frameInfo.frameType === "IFrame" || frameInfo.frameType === "PFrame")
SM4.rc4_decrypt(u8Arr1, rc4_secret, {
shiftStart: frameInfo.pos + customEncryptionOffset,
end: frameInfo.posEnd
});
})
}
}
controller.enqueue(encodedFrame);
}
function printInfoBeforeDecrypt(evt){
// 工具函数,帮助判断是否有解密前数据
if ((evt.mediaType === "video" || evt.mediaType === "screen") && printRecvVideoFrame){
const u8Arr1 = new Uint8Array(evt.encodedFrame.data);
const info = findCryptIndexH264(u8Arr1);
console.log(`(解密前)uid ${evt.uid},媒体类型 ${evt.mediaType},帧类型 ${evt.encodedFrame.type},帧长度 ${evt.encodedFrame.data.byteLength},H264帧类型`, info.frames.map((frame)=>{return frame.frameType}).join(), ",前100字节帧内容", u8Arr1.slice(0, 100));
}
}
function printInfoBeforeEncrypt(evt){
// 工具函数,帮助判断是否有加密前数据
if ((evt.mediaType === "video" || evt.mediaType === "screen") && printEncodedVideoFrame){
const u8Arr1 = new Uint8Array(evt.encodedFrame.data);
const info = findCryptIndexH264(u8Arr1);
console.log(`(加密前)媒体类型 ${evt.mediaType},大小流 ${evt.streamType},帧类型 ${evt.encodedFrame.type},帧长度 ${evt.encodedFrame.data.byteLength},H264帧类型`, info.frames.map((frame)=>{return frame.frameType}).join(), ",前100字节帧内容", u8Arr1.slice(0, 100));
}
}
// H264在 0x00 0x00 0x00 0x01 后需保留三位不做加密
const customEncryptionOffset = 3
const naluTypes = {
7: "SPS",
8: "PPS",
6: "SEI",
5: "IFrame",
1: "PFrame",
}
function findCryptIndexH264(data){
// 输入一个 UInt8Array,在其中寻找I帧和P帧
// 输入中可能会出现多个I帧和P帧时,需要分别编码/解码
const result = {
frames: [],
// pos表示第一个I帧或P帧的nalu type的位置+offset
pos: -1
};
for (let i = 3; i < data.length; i++){
if (data[i - 1] === 0x01 && data[i - 2] === 0x00 && data[i - 3] === 0x00){
// 低四位为1为p帧,低四位为5为i帧。算法待改进
// 加密后的rtp分包环节会依赖nalu位置。建议只对关键帧和参考帧进行加密,保留其他帧类型,并且加密时沿naluType向后再保留至少三个字节。
// 不同设备编码后的定位符既可能出现0,0,0,1,0,0,1,也可能两种定位符交替出现。
// 加密算法可能会破坏rbsp的语法,容易在内容里引入和rbsp冲突的字段,造成nalu划分失败,表现为低概率性的绿屏。建议先转成sodb,再加密,再转回rbsp。
// https://zhuanlan.zhihu.com/p/281176576
// https://stackoverflow.com/questions/24884827/possible-locations-for-sequence-picture-parameter-sets-for-h-264-stream/24890903#24890903
let frameTypeInt = data[i] & 0x1f;
let frameType = naluTypes[frameTypeInt] || "nalu_" + frameTypeInt
if (result.frames.length){
//不包含这位
result.frames[result.frames.length - 1].posEnd = i - 3
if (data[i - 4] === 0x00) {
result.frames[result.frames.length - 1].posEnd -= 1
}
}
result.frames.push({
pos: i,
frameType
});
if (result.pos === -1 && (frameType === "IFrame" || frameType === "PFrame")){
result.pos = i + customEncryptionOffset
}
}
}
return result;
}
/**
* SDK加密接口。一个典型的加密过程是这样的:
*
* const u8Arr1 = new Uint8Array(evt.encodedFrame.data);
* // 对u8Arr1进行加密,获得u8Arr2后:
* evt.encodedFrame.data = u8Arr2.buffer
* evt.controller.enqueue(evt.encodedFrame);
*
*/
const processSenderTransform = function(evt){
printInfoBeforeEncrypt(evt)
encodeFunctionRC4(evt)
}
const processReceiverTransform = function (evt){
printInfoBeforeDecrypt(evt)
decodeFunctionRC4(evt)
}
const main = async ()=>{
let rtc = {};
// 1. 创建client
rtc.client = NERTC.createClient({appkey: "<您的appkey>", debug: true});
// 2. 绑定订阅事件
rtc.client.on('stream-added', (evt)=>{
rtc.client.subscribe(evt.stream);
})
rtc.client.on('stream-subscribed', (evt)=>{
evt.stream.play(document.getElementById('remoteDiv'));
});
// 自定义加密回调
rtc.client.on('sender-transform', processSenderTransform)
// 自定义解密回调
rtc.client.on('receiver-transform', processReceiverTransform)
// 3. 启用自定义加密
rtc.client.enableCustomTransform()
// 4. 加入频道
await rtc.client.join({
channelName: 'channel163',
uid: 123,
token: '<您的token>', // 如关闭了安全模式,则不需要该参数。
});
// 5. 创建localStream
rtc.localStream = NERTC.createStream({
video: true,
audio: true,
client: rtc.client,
uid: 123
});
await rtc.localStream.init();
// 6. 设置本地播放方式
rtc.localStream.setLocalRenderMode({
width: 640,
height: 480
})
rtc.localStream.play(document.getElementById('localDiv'))
// 7. 发布localStream
rtc.client.publish(rtc.localStream);
}
main()
</script>
</body>
</html>