webRTC-端到端加密

概述

Insertable Stream 可插入流是新的 WebRTC API,,可用来操作通过 RTCPeerConnection 传送的 MediaStreamTracks 中的每一个字节。它让上层应用能对 WebRTC 底层媒体进行访问,让以往 WebRTC 应用中许多不可能做的情况都成为可能了, 比如替换视频聊天时的背景,实时进行音视频处理(降噪,美颜,打水印,加特效等)。

最新的规范在这里 https://w3c.github.io/webrtc-encoded-transform/


webRTC 音视频处理流程

发送流程
  1. 从媒体设备/其他采集源中获得一帧一帧的数据
  2. 对原始数据进行编码
  3. <- 在这里插入自定义逻辑
  4. SRTP 加密
  5. 发送
接收流程
  1. 接收网络 RTP 包
  2. SRTP 解密
  3. RTP 组包
  4. <- 在这里插入自定义逻辑
  5. 解码数据
  6. 渲染数据

WebRTC Insertable Streams 可以让我们在发送流程中的 3 ,接收流程的 4 加入处理编码后的数据的能力, 起初是为了端到端加密而设计, 但他的使用场景确可以进一步的拓展。


Stream API

Streams 标准提供了一组通用的 API,用于创建此类流数据并与之交互,这些数据体现在可读流、可写流和转换流中。

  • readable streams
  • writable streams
  • transform streams

这些 API 旨在更有效地映射到低级的 I/O 原始操作,包括在适当的情况下对字节流进行专门的处理。

它们允许将多个流轻松组合到管道链中,或者可以通过读取器和写入器直接使用。最后,它们被设计为自动提供背压和排队。

https://streams.spec.whatwg.org/ 有关于 Streams 的各种属性和方法的介绍。

ReadableStream

readable stream 是在 JavaScript 中由来自底层的 ReadableStream 对象表示的数据源,这是网络上或者本地某个地方的资源,可以从中获取数据。

WritableStream

可写流是您可以写入数据的目的地,在 JavaScript 中由 WritableStream 对象表示。 它用作对于底层接收器之上的抽象,一个可写入原始数据的底层的 I/O sink。

TransformStream

转换流,它允许我们对媒体流的原始数据进行操作,这也是端到端加密的核心部分。它的定义如下:

1
2
3
4
5
6
7
8
9
10
new TransformStream({
transform: transformFuction
});

function transformFuction (chunk, controller) {
// 对 chunk.data 进行操作
...

controller.enqueue(chunk);
}

transform 接受一个方法,该方法有两个参数:

  • chunk:数据块。chunk.data 是原始数据,它是一个 ArrayBuffer 类型的数据
  • controller:控制器,用于将修改后的 chunk 压入队列
管道链

Stream API 可以用一个称为 pipe chain 的结构将这些流一个一个串起来,具体方法有 pipeThrough 和 pipeTo。

1
ReadableStream.pipeThrough(TransformStream).pipeTo(WritableStream)

这样就实现了对原始数据的修改。


Insertable Streams API

可插入流其实指的是一种转换流,它可以在媒体流的处理过程中插入一些处理逻辑。它通过转换器 RTCRtpTransform 来实现,而 RTCRtpSender 和 RTCRtpReceiver 可操作 RTCRtpTransform。也就是说,可插入流可通过 RTCRtpSender 和 RTCRtpReceiver 上附加的 API 来将处理。

RTCRtpSender 对象的获取方法:

1
2
3
4
5
6
7
8
9
10
localStream.getTracks().forEach(track => {
const sender = pc.addTrack(track, localStream);
});

// 或者
localStream.getTracks().forEach(track => {
pc.addTrack(track, localStream);
});
const senders = pc.getSenders();
senders.forEach(sender => {});

其中 sender 就是 RTCRtpSender 对象。

RTCRtpReceiver 对象的获取方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
pc.ontrack(event => {
const receiver = event.receiver;
event.streams.forEach(stream => {
video.srcObject = stream;
});
});

// 或者
pc.ontrack(event => {
event.streams.forEach(stream => {
video.srcObject = stream;
});
});
const receivers = pc.getReceivers();
receivers.forEach(receiver => {})

其中 receiver 就是 RTCRtpReceiver 对象。


接下来我们来看一下使用可插入流的具体代码(信令的交互这里省略):

定义全局变量:

1
2
3
4
5
var localStream;
var pc

const localVideo = document.getElementById('local_video');
const remoteVideo = document.getElementById('remote_video');

调用媒体:

1
2
3
4
5
6
navigator.mediaDevices.getUserMedia({ video: true, audio: true }).then(function (mediastream) {
localStream = mediastream; // 本地视频流
localVideo.srcObject = mediastream; // 播放本地视频流
}).catch(function (e) {
alert(e);
});

初始化 PeerConnection 的时候需要加上特殊参数:

1
2
3
pc = new RTCPeerConnection({
encodedInsertableStreams: true,
});

发送方插入逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
localMediaStream.getTracks().forEach(track => {
pc.addTrack(track, this.localMediaStream);
});
pc.getSenders().forEach(sender => {setupSenderTransform(sender)});

function setupSenderTransform (sender) {
const senderStreams = sender.createEncodedStreams();

const { readable, writable } = senderStreams;

const transformStream = new TransformStream({
transform: encodeFunction
});

readable.pipeThrough(transformStream).pipeTo(writable);
}

function encodeFunction(chunk, controller) {
chunk.data = Encrypt(chunk.data);

controller.enqueue(chunk);
}

接收方插入逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
pc.addEventListener('track', function (event) {
setupReceiverTransform(event.receiver);
event.streams.forEach(stream => {
remoteVideo.srcObject = stream;
});
});

function setupReceiverTransform (receiver) {
const receiverStreams = receiver.createEncodedStreams();

const { readable, writable } = receiverStreams;

const transformStream = new TransformStream({
transform: decodeFuction
});

readable.pipeThrough(transformStream).pipeTo(writable);
}

function decodeFuction (chunk, controller) {
chunk.data = Decrypt(chunk.data);

controller.enqueue(chunk);
}

这里的 Encrypt 和 Decrypt 是加解密的方法。我们用 AES 来进行加解密:

先安装依赖

1
npm install crypto-js

使用方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import CryptoJS from "crypto-js";

const key = CryptoJS.enc.Utf8.parse("1234123412ABCDEF"); //十六位十六进制数作为密钥
const iv = CryptoJS.enc.Utf8.parse('ABCDEF1234123412'); //十六位十六进制数作为密钥偏移量

// 解密
function Decrypt(data) {
const hex_str = Buffer2String(data);
let decrypt = CryptoJS.AES.decrypt(hex_str, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 });
return String2Buffer(decrypt.toString(CryptoJS.enc.Utf8));
}

// 加密
function Encrypt(data) {
let encrypted = CryptoJS.AES.encrypt(Buffer2String(data), key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 });
return String2Buffer(encrypted.toString());
}

function String2Buffer (str) {
var buffer = new ArrayBuffer(str.length);
var uint8arr = new Uint8Array(buffer);
for(var i=0; i<str.length; i++) {
uint8arr[i] = str.charCodeAt(i);
}
return buffer;
}

function Buffer2String (buffer) {
var uint8arr = new Uint8Array(buffer);
var str = '';
for(var i=0; i<uint8arr.length; i++) {
str += String.fromCharCode(uint8arr[i]);
}
return str;
}

由于 chunk.data 是 ArrayBuffer 类型,所以需要经过一系列的类型转换。