记一次HLS视频加解密方案的过程(2)

写在前面

上个月花了点时间做了下视频流媒体的方案。
传送门:记一次HLS视频加解密方案的过程(1)
最后实现的效果是用自己的播放器播放了hls视频流,且视频都加过密了,形成一整套基础的方案。
这次呢,出了个方案的升级版,因为使用场景的限制,导致公司必须要对hls视频流进行二次加密。


过程

我在网上查了很久,google关于key加密的文章已经读到20页+了。
最后只查到了jwplayer这个国外的公司有一种方案,就是对m3u8这个索引文件的AES-128这个字段的获取上面加token,让key文件获取的不是那么容易,但是具体实现还是没有给出来,给出来的android demo也是各种错误,跑不起来。
我想各个大厂对自己的hls视频流一定有自己的二次加密的方式,因为我在试用保利威视的sdk的时候,下载了人家的完整的加密视频,但是我没有办法用自己的播放器播出来,所以,肯定做了二次加密的文章。


方案

跟上回一样先说一下方案,简单的说,就是对key文件的获取上面进行操作。
首先要明白几个道理:

  1. 就是播放器读的是m3u8文件,所以操作m3u8文件,就是操作了播放器。
  2. 播放器读m3u8内部信息,最后所发出的请求,全部是GET请求,不争的事实。

明白这两点,思路就出来了:

  1. 在服务器中保存的m3u8文件要进行错误混淆。
  2. 在移动端开启http服务器。
  3. 下载服务器m3u8文件,并进行改写。
  4. 播放本地服务器中m3u8文件,至播放完毕。
  5. 删除改写过后的m3u8文件,并关闭http服务器。

步骤大致就是上述那样了,具体说明一下。


m3u8修改混淆

看一个普通的m3u8文件的构造:

1
2
3
4
5
6
7
8
9
10
11
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:20
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-KEY:METHOD=AES-128,URI="http://test.com/enc.key",IV=0xc25a7ccb63553b6be3f72a89b50d4dd0
#EXTINF:15.840000,
Y29_050102_VD_04_0_ts
#EXTINF:16.720000,
Y29_050102_VD_04_1_ts
#EXT-X-ENDLIST

加入错误混淆后,去掉URI节点的key,并且保存在服务器中:

1
2
3
4
5
6
7
8
9
10
11
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:20
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-KEY:METHOD=AES-128,URI="",IV=0xc25a7ccb63553b6be3f72a89b50d4dd0
#EXTINF:15.840000,
Y29_050102_VD_04_0_ts
#EXTINF:16.720000,
Y29_050102_VD_04_1_ts
#EXT-X-ENDLIST

这样,在m3u8中就不存在被别人获取到key的问题了。


Android开启Http服务器

我用的是nanoHttp,比较好用。
首先写个类集成NanoHTTPD,作为服务调度类。

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package sohero.com.testhlsvideo.video;

import ...;

/**
* 项目名称:sohero.com.testhlsvideo.video
* 类创建者:QHY.
* 时间:2019/4/29
* 类说明:开启httpServer,获取文件类
*/
public class HlsHttpServer extends NanoHTTPD {

public HlsHttpServer(String hostname, int port) {
super(hostname, port);
}

public HlsHttpServer(int port) {
super(port);
}

@Override
public Response serve(IHTTPSession session) {

try {
session.parseBody(new HashMap<String, String>());
} catch (IOException e) {
e.printStackTrace();
} catch (ResponseException e) {
e.printStackTrace();
}

MyApplication app = new MyApplication();
String path = Environment.getExternalStorageDirectory().getPath() + "/HlsTemp/";//获取文件夹地址
Log.i(TAG, "uri: " + path);
FileInputStream fis = null;
try {
fis = new FileInputStream(path + session.getUri());
} catch (FileNotFoundException e) {
e.printStackTrace();
}

try {
return Response.newFixedLengthResponse(Status.OK, "application/octet-stream", fis, fis.available());
} catch (IOException e) {
e.printStackTrace();
}
return newFixedLengthResponse("404 error,no such file");
}
}

创建一个Service,开启HttpServer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package sohero.com.testhlsvideo;

import ...;

public class MyServer extends Service {

@Override
public IBinder onBind(Intent intent) {
throw new UnsupportedOperationException("Not yet implemented");
}

@Override
public int onStartCommand(Intent intent, int flags, int startId) {
HlsHttpServer myServer = new HlsHttpServer(8080);
try {
// 开启HTTP服务
myServer.start();
} catch (IOException e) {
e.printStackTrace();
}
return super.onStartCommand(intent, flags, startId);
}
}


下载m3u8文件

下载m3u8文件,并进行改写,代码:

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
package sohero.com.testhlsvideo;

import ...;

/**
* 项目名称:sohero.com.testhlsvideo
* 类创建者:QHY.
* 时间:2019/4/29
* 类说明:下载m3u8文件
*/
public class HttpFileUtils {
public static boolean downLoadFile(String httpUrl, String name) {
String HLS_PATH = Environment.getExternalStorageDirectory().getPath() + "/HlsTemp/";
try {
URL url = new URL(httpUrl);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setReadTimeout(30000);
conn.setConnectTimeout(3000);
conn.connect();

//读到流中
InputStream is = conn.getInputStream();
byte buf[] = new byte[1024];
int len = 0;
ByteArrayOutputStream bos = new ByteArrayOutputStream();
while ((len = is.read(buf)) != -1) {
bos.write(buf, 0, len);
}

//新建文件
File file = new File(HLS_PATH);
if (!file.exists()) {
file.mkdir();
}
File m3u8file = new File(HLS_PATH, name);

//写到流中
FileOutputStream fos = new FileOutputStream(m3u8file);
fos.write(bos.toByteArray());

//关闭流
fos.close();
bos.close();
is.close();
conn.disconnect();
} catch (IOException e) {
e.printStackTrace();
return false;
}
return writeFile(HLS_PATH + name);

}

private static boolean writeFile(String path) {
File file = new File(path);
FileReader reader = null;
try {
reader = new FileReader(file);

BufferedReader bReader = new BufferedReader(reader);
StringBuilder sb = new StringBuilder();
String s = "";
int line = 0;
while ((s = bReader.readLine()) != null) {
if (line >= 7 && line % 2 == 1) {
sb.append(ContentValues.NET_PATH + s + "\n");
} else {
sb.append(s + "\n");
}
line += 1;
}
String str = sb.toString();
Log.v("Tag", str);
String[] strs = str.split("\"");
str = strs[0] + "\"" + ContentValues.KEY_PATH + "\"" + strs[2];
FileWriter writer = new FileWriter(file);
writer.write(str);
writer.flush();
writer.close();
bReader.close();
reader.close();
//handler.sendEmptyMessage(0x002);
Log.v("Tag", str);
} catch (IOException e) {
e.printStackTrace();
return false;
}
return true;
}
}


播放视频

本来想随便写写activity,后来想想,还是乖乖的用mvp吧。

接口

  1. IMainContract:
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
package sohero.com.testhlsvideo;

import io.reactivex.Observable;

/**
* 项目名称:sohero.com.testhlsvideo
* 类创建者:QHY.
* 时间:2019/5/7
* 类说明:Main页面接口
*/
public interface IMainContract {
interface IMainPersenter {

/**
* 获取权限
*/
void getPermission();

/**
* 下载文件,自己封装的RxJava
*/
void getHlsFile();

/**
* 刷新sb,用了RxLifeCycle,防止内存泄露,感觉快用了Rx全家桶了
*/
void refreshProgress();

/**
* 删除文件
*/
void deleteHlsFile();
}

interface IMainView {
/**
* 获取权限成功
*/
void getPermissionSuccess();

/**
* 获取权限失败
*/
void getPermissionFailed(String msg);

/**
* 下载文件成功
*/
void getHlsFileSuccess();

/**
* 下载文件失败
*
* @param msg 错误信息
*/
void getHlsFileFailed(String msg);

/**
* 刷新seekBar的progress,一秒一次
*/
void refreshProgress();

/**
* 播放完成,删除文件成功
*/
void deleteHlsFileSuccess();

/**
* 播放完成,删除文件失败
*/
void deleteHlsFileFailed(String msg);
}

interface IMainModel{

/**
* 下载文件,耗时操作
*/
Observable<Boolean> getHlsFile();

/**
* 删除文件,耗时操作
*/
Observable<Boolean> deleteHlsFile();
}
}

实现类

  1. Activity:
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
package sohero.com.testhlsvideo;

import...;

import sohero.com.testhlsvideo.video.media.UsIjkVideoView;
import tv.danmaku.ijk.media.player.IMediaPlayer;


public class MainActivity extends AppCompatActivity implements SeekBar.OnSeekBarChangeListener, IMainContract.IMainView, IMediaPlayer.OnPreparedListener, IMediaPlayer.OnCompletionListener {

UsIjkVideoView ijkVideoView;//IjkPlayer,我封装了一层,用法一样
SeekBar sb;//seekBar

private IMainContract.IMainPersenter persenter; //persenter

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
initView();
persenter = new MainPersenter(this, this);
persenter.getPermission();
}

private void initView() {
setContentView(R.layout.activity_main);
ijkVideoView = findViewById(R.id.ijk_vv);
sb = findViewById(R.id.main_sb);
sb.setOnSeekBarChangeListener(this);
}

@Override
public void getPermissionSuccess() {
startService(new Intent(MainActivity.this, MyServer.class));
//开始下载m3u8文件
persenter.getHlsFile();
}

@Override
public void getPermissionFailed(String msg) {
Toast.makeText(MainActivity.this, msg, Toast.LENGTH_SHORT).show();
}

@Override
public void getHlsFileSuccess() {
Toast.makeText(this, "下载完成,播放视频", Toast.LENGTH_SHORT).show();
startPlayVideo();
}

@Override
public void getHlsFileFailed(String msg) {
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show();
}

@Override
public void refreshProgress() {
sb.setProgress(ijkVideoView.getCurrentPosition());
}

@Override
public void deleteHlsFileSuccess() {
Toast.makeText(this, "播放完成,缓存文件删除成功", Toast.LENGTH_SHORT).show();
sb.setMax(0);
sb.setProgress(0);
ijkVideoView.release(true);
}

@Override
public void deleteHlsFileFailed(String msg) {
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show();
}


/**
* 开始播放视频,开始刷新进度
*/
private void startPlayVideo() {
ijkVideoView.setVideoPath(ContentValues.LOCAL_PATH + ContentValues.FILE_NAME);
ijkVideoView.setOnPreparedListener(this);
ijkVideoView.setOnCompletionListener(this);
}

@Override
public void onPrepared(IMediaPlayer iMediaPlayer) {
ijkVideoView.start();
sb.setMax((int) iMediaPlayer.getDuration());
persenter.refreshProgress();
}

@Override
public void onCompletion(IMediaPlayer iMediaPlayer) {
persenter.deleteHlsFile();
}

/**
* seekBar相关
*
* @param seekBar
* @param progress
* @param fromUser
*/
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {

}

@Override
public void onStartTrackingTouch(SeekBar seekBar) {

}

@Override
public void onStopTrackingTouch(SeekBar seekBar) {
ijkVideoView.seekTo(seekBar.getProgress());
}


}
  1. Persenter:
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
package sohero.com.testhlsvideo;

import java.util.concurrent.TimeUnit;

import ...;


/**
* 项目名称:sohero.com.testhlsvideo
* 类创建者:QHY.
* 时间:2019/5/7
* 类说明:Persenter
*/
public class MainPersenter implements IMainContract.IMainPersenter {

private IMainContract.IMainView view;
private IMainContract.IMainModel model;
private MainActivity activity;


public MainPersenter(IMainContract.IMainView view, MainActivity context) {
this.view = view;
this.activity = context;
model = new MainModel(activity);
}

@Override
public void getPermission() {
USUtils.getInstance().usePermission()
.setContext(activity)
.setPermission(UsConst.Permission.WRITE_EXTERNAL_STORAGE)
.getPermission(new UsPermission.PermissionCallBack() {
@Override
public void success() {
view.getPermissionSuccess();
}

@Override
public void failed() {
view.getPermissionFailed("please get permission for app");
}
});
}


@Override
public void getHlsFile() {
model.getHlsFile()
.subscribe(new UsRxJava<Boolean>() {
@Override
public void _onNext(Boolean s) {
view.getHlsFileSuccess();
}

@Override
public void _onError(String msg) {
view.getHlsFileFailed(msg);
}

@Override
public void _onFinish(Disposable d) {

}
});
}


@Override
public void refreshProgress() {
Observable.interval(1, TimeUnit.SECONDS).compose(UsRxJava.<Long>thread_main()).subscribe(new Consumer<Long>() {
@Override
public void accept(Long aLong) throws Exception {
view.refreshProgress();
}
});
}


@Override
public void deleteHlsFile() {
model.deleteHlsFile()
.subscribe(new UsRxJava<Boolean>() {
@Override
public void _onNext(Boolean aBoolean) {
view.deleteHlsFileSuccess();
}

@Override
public void _onError(String msg) {
view.deleteHlsFileFailed(msg);
}

@Override
public void _onFinish(Disposable d) {

}
});

}
}
  1. model:
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package sohero.com.testhlsvideo;

import android.os.Environment;

import java.io.File;

import io.reactivex.Observable;
import io.reactivex.ObservableEmitter;
import qhy.com.unlimitedswordutils.USUtils;
import qhy.com.unlimitedswordutils.UsObservable;
import qhy.com.unlimitedswordutils.UsRxJava;

/**
* 项目名称:sohero.com.testhlsvideo
* 类创建者:QHY.
* 时间:2019/5/7
* 类说明:model
*/
public class MainModel implements IMainContract.IMainModel{

private MainActivity activity;

public MainModel(MainActivity activity) {
this.activity = activity;
}

@Override
public Observable<Boolean> getHlsFile() {
return USUtils.getInstance().useRx().create(new UsObservable.UsSubcribe<Boolean>() {
@Override
public void doSomeThings(ObservableEmitter<Boolean> e) {
boolean result = HttpFileUtils.downLoadFile(activity, ContentValues.NET_PATH, ContentValues.FILE_NAME);
if (result) {
e.onNext(true);
} else {
e.onError(new Throwable("download failed"));
}
}
}).compose(UsRxJava.<Boolean>io_main());
}

@Override
public Observable<Boolean> deleteHlsFile() {
return USUtils.getInstance().useRx().create(new UsObservable.UsSubcribe<Boolean>() {
@Override
public void doSomeThings(ObservableEmitter<Boolean> e) {
File file = new File(Environment.getExternalStorageDirectory() + "/HlsTemp/" + ContentValues.FILE_NAME);
if (file.exists() && file.isFile()) {
file.delete();
e.onNext(true);
} else {
e.onError(new Exception("no such file"));
}
}
}).compose(UsRxJava.<Boolean>thread_main());
}
}
  1. 地址
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package sohero.com.testhlsvideo;

/**
* 项目名称:sohero.com.testhlsvideo
* 类创建者:QHY.
* 时间:2019/5/7
* 类说明:地址
*/
public class ContentValues {
//本地视频地址
public static final String LOCAL_PATH = "http://127.0.0.1:8080/";
//网络视频地址
public static final String NET_PATH = "http://www.deep-blue.cloud:8000/program/testHlsVideo/";
//key地址
public static final String KEY_PATH = "http://www.deep-blue.cloud:8000/program/testHlsVideo/key/enc.qhy";
//保存的文件名称
public static final String FILE_NAME = "butter_fly_list.m3u8";
}

总结

视图我就不上了,这个项目回头我给上传一下github,节省篇幅,我把import的包全给删掉了。
总体思路就是上述那样,在服务器上的m3u8文件是个假文件,就算是抓包也是抓到的假文件,到移动端,改写m3u8,填入正确信息,在URI上面填入正确的key路径信息,做到无法盗取全链。
因为我不会写后台,所以对接口也比较蒙,其实完全可以先获取下token,然后改写URI,服务端要验证token后才返回文件,这样会更好一些。


写在最后

应该是以后不会更新这两篇文章了,毕竟要换工作了,不知道会不会还是继续深入研究,如果有什么不懂的话,可以下面问我,也可以戳我邮箱 775495797@qq.com ,匆匆忙忙的写的这篇文章,很多地方还没校对,先这样。

—————–更新—————–
花了点时间校对,改了下demo,顺带把项目上传到github中,在给几个传送门:

demo资源(有教程,可也以看我以前的那篇文章):点这里
项目github(欢迎给star):点这里
项目zip下载(as:3.4,gradle:4.8.1,gradle-tools:3.2.1):点这里

以上

请我喝杯咖啡吧~

支付宝
微信