项目中有一个需求,播放加密的视频和音频,想到的方案有服务端鉴权和客户端本地解密两种思路。服务端可通过短时效的Token
来鉴权,避免视频/音频被盗播的问题,考虑到后端的人力紧张,后来选择了m3u8
加密后本地解密的方案。
关于m3u8
的相关基础,可以参考#恋猫de小郭#郭老师探索移动端音视频与GSYVideoPlayer
之旅 | Agora Talk
,解密的思路分为两种,分别是从结果着手和从过程着手,与我们面向对象编程和面向过程编程类似。理论上都是没有问题的,接下来看看实际编码。
从结果上着手
参考EXOPLAYER
利用自定义DATASOURCE
实现直接播放AES
加密音频,思路很简单,媒体资源本身从网络上下载下来就是字节流,我们直接对流进行解密就可以得到可以播放的音频视频资源了。
@Override
public long open(DataSpec dataSpec) throws IOException {
Assertions.checkState(dataSource == null);
// Choose the correct source for the scheme.
//选择正确的数据源方案
String scheme = dataSpec.uri.getScheme();
//如果URI是一个本地文件路径或本地文件的引用。
Timber.e("解密:000000," + scheme + ",path:" + dataSpec.uri.getPath());
if (Util.isLocalFileUri(dataSpec.uri)) {
//如果路径尾包含aitrip的文件名,使用解密类
if (dataSpec.uri.getPath().endsWith(".aitrip")) {
Aes128DataSource aes128DataSource =
new Aes128DataSource(getFileDataSource(), Aikey.getBytes(), Aikey.getBytes());
dataSource = aes128DataSource;
} else {//否则,正常解析mp3
if (dataSpec.uri.getPath().startsWith("/android_asset/")) {
dataSource = getAssetDataSource();
} else {
dataSource = getFileDataSource();
}
}
} else if (SCHEME_ASSET.equals(scheme)) {
dataSource = getAssetDataSource();
} else if (SCHEME_CONTENT.equals(scheme)) {
dataSource = getContentDataSource();
} else if (SCHEME_RTMP.equals(scheme)) {
dataSource = getRtmpDataSource();
} else {
dataSource = baseDataSource;
}
// Open the source and return.
return dataSource.open(dataSpec);
}
自定义的数据工厂类
AitripDataSource#open
github.com/ChangWeiBa/…
Base64
解码风波
本来抄完就了事了,但是,出现了一个异常,视频播放失败,提示密钥不对。这个玩笑就开大了,这个需求的时间就半天,隔壁IOS
同学视频已经播放出来了!
因为后端同学给我一个16
个字节的文件,告诉我这个是解密的密钥。于是我直接将文件的内容copy
出来,转成byte[]
,发现长度不对。不管使用Java
包还是android
包的base64
类来解码或者编码都不对,都不能得到预期的16
个字节。
后来发现通过IO
流来读取文件,可以获得预期的16
个byte
的密钥,但是我还是不得其解。直到后端同学给我一个File to data URI converter
的网站,通过解析这个文件得到一个24个字节的密文,最后通过#叶楠#叶老师的解惑后找到了原因。
- Q1.为什么文件内容作为字符串不能通过
getBytes
得到预期的密钥? - A1:因为文件内容含有特殊字符,经过转义后直接使用
getBytes
不能得到预期的密钥。 - Q2.为什么文件内容通过
Java
包还是android
包的base64
类来解码或者编码都不对? - A2.因为文件内容是未加密的明文,所以不管是解码还是编码,都不能得到长度为
16
的byte
数组。 - Q3.为什么
IO
流可以得到预期的密钥,而其他方式不行? - A3.看完了上面两个问题后,这个问题不再是问题。
需要注意的是,encryptionIV
和encryptionKey
在使用中,一般是两个不同的值,具体原因可看后文分析。
m3u8
解密风波——Input does not start with the #EXTM3U header
在正确设置了上面两个值以后,在播放的时候又遇到了一个新问题:Input does not start with the #EXTM3U header.
首先查看源码,了解到是下载网络文件后,检查文件头的时候没有找到#EXTM3U
,因此判断文件不是目标文件,所以直接抛出了异常。而这个链接下载下来是标准的m3u8
文件。这个时候隔壁的IOS
同学提测了,急死个人了!
抱着不懂就问的心态,我问了百度/谷歌,翻看了ExoPlayer
的issuse
,很多同学都遇到了类似的问题,官方回复均是提示资源是否正常?是否有缓存?后来仔细查看EXOPLAYER
利用自定义DATASOURCE
实现直接播放AES
加密音频和Aes128DataSource
源码,发现了问题的根源:从结果上来解密的思路不符合标准的HTTP Live Streaming
的规范。先看看下面两种m3u8
文件:
- 可直接播放的
m3u8
文件
#EXTM3U
#EXT-X-VERSION:4
#EXT-X-TARGETDURATION:7
#EXT-X-MEDIA-SEQUENCE:1
#EXT-X-PLAYLIST-TYPE:VOD
#EXTINF:6.006,
0640_00001.ts
#EXTINF:6.006,
0640_00002.ts
#EXTINF:6.006,
0640_00003.ts
....
#EXT-X-ENDLIST
- 需重定向的
m3u8
文件
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=3500000,RESOLUTION=1920x1080
1.告白气球_1080.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=1280x720
1.告白气球_720.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=750000,RESOLUTION=854x480
1.告白气球_480.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=375000,RESOLUTION=640x360
1.告白气球_360.m3u8
上面两种m3u8
文件加密以后也是符合HTTP Live Streaming
规范的,加密的部分仅仅是ts
文件,所以这种上来直接对m3u8
链接的内容进行Aes
解密,虽然可行,但是并不符合HTTP Live Streaming
规范。因此放弃这个思路,继续从头开始!
从过程上着手
从过程上着手,就是从播放m3u8
的流程上观察,找到一个合适的地方插入密钥。
m3u8
是一个一个的小片(ts
)组成的(引用自上文提及的探索移动端音视频与GSYVideoPlayer
之旅 | Agora Talk
),因此解析这一个一个的小片有一个专门的类——com.google.android.exoplayer2.source.hls.playlist. HlsPlaylistParser
。而我们可以从这里下手,在解析到EXT-X-KEY
的时候,插入我们本地的密钥即可。
观察
通过HlsPlaylistParser
的私有静态方法parseMediaPlaylist
我们可以看到整个解析过程,这里我们只要看一个片段,了解到密钥的解析过程即可。
private static HlsMediaPlaylist parseMediaPlaylist(
HlsMultivariantPlaylist multivariantPlaylist,
@Nullable HlsMediaPlaylist previousMediaPlaylist,
LineIterator iterator,
String baseUri)
throws IOException {
......
else if (line.startsWith(TAG_SKIP)) {
int skippedSegmentCount = parseIntAttr(line, REGEX_SKIPPED_SEGMENTS);
checkState(previousMediaPlaylist != null && segments.isEmpty());
int startIndex = (int) (mediaSequence - castNonNull(previousMediaPlaylist).mediaSequence);
int endIndex = startIndex + skippedSegmentCount;
if (startIndex < 0 || endIndex > previousMediaPlaylist.segments.size()) {
// Throw to force a reload if not all segments are available in the previous playlist.
throw new DeltaUpdateException();
}
for (int i = startIndex; i < endIndex; i++) {
Segment segment = previousMediaPlaylist.segments.get(i);
if (mediaSequence != previousMediaPlaylist.mediaSequence) {
// If the media sequences of the playlists are not the same, we need to recreate the
// object with the updated relative start time and the relative discontinuity
// sequence. With identical playlist media sequences these values do not change.
int newRelativeDiscontinuitySequence =
previousMediaPlaylist.discontinuitySequence
- playlistDiscontinuitySequence
+ segment.relativeDiscontinuitySequence;
segment = segment.copyWith(segmentStartTimeUs, newRelativeDiscontinuitySequence);
}
segments.add(segment);
segmentStartTimeUs += segment.durationUs;
partStartTimeUs = segmentStartTimeUs;
if (segment.byteRangeLength != C.LENGTH_UNSET) {
segmentByteRangeOffset = segment.byteRangeOffset + segment.byteRangeLength;
}
relativeDiscontinuitySequence = segment.relativeDiscontinuitySequence;
initializationSegment = segment.initializationSegment;
cachedDrmInitData = segment.drmInitData;
// AES 密钥uri
fullSegmentEncryptionKeyUri = segment.fullSegmentEncryptionKeyUri;
if (segment.encryptionIV == null
|| !segment.encryptionIV.equals(Long.toHexString(segmentMediaSequence))) {
fullSegmentEncryptionIV = segment.encryptionIV;
}
segmentMediaSequence++;
}
}else if (line.startsWith(TAG_KEY)) {
String method = parseStringAttr(line, REGEX_METHOD, variableDefinitions);
String keyFormat =
parseOptionalStringAttr(line, REGEX_KEYFORMAT, KEYFORMAT_IDENTITY, variableDefinitions);
// fullSegmentEncryptionKeyUri = null;
fullSegmentEncryptionIV = null;
if (METHOD_NONE.equals(method)) {
currentSchemeDatas.clear();
cachedDrmInitData = null;
} else /* !METHOD_NONE.equals(method) */ {
// 加密初始化向量,可为null
fullSegmentEncryptionIV = parseOptionalStringAttr(line, REGEX_IV, variableDefinitions);
if (KEYFORMAT_IDENTITY.equals(keyFormat)) {
if (METHOD_AES_128.equals(method)) {
// The segment is fully encrypted using an identity key.
// AES 密钥uri
fullSegmentEncryptionKeyUri = parseStringAttr(line, REGEX_URI, variableDefinitions);
} else {
// Do nothing. Samples are encrypted using an identity key, but this is not supported.
// Hopefully, a traditional DRM alternative is also provided.
}
} else {
if (encryptionScheme == null) {
encryptionScheme = parseEncryptionScheme(method);
}
SchemeData schemeData = parseDrmSchemeData(line, keyFormat, variableDefinitions);
if (schemeData != null) {
cachedDrmInitData = null;
currentSchemeDatas.put(keyFormat, schemeData);
}
}
}
}
......
通过上面的内容可以看到fullSegmentEncryptionIV
和fullSegmentEncryptionKeyUri
的赋值位置,我们可以在声明的时候直接通过自定义来赋值,但是需要注意的地方是,虽然两者的声明类型都是String,但是赋值的时候还是有区别:前者必须是Uri
类型,后者一般是16个字节的普通字符串。最后不要忘了在置空和赋值的地方注释掉原有的代码和相关逻辑。在项目中,fullSegmentEncryptionKeyUri
我是通过自定义来实现的,而fullSegmentEncryptionIV
使用的是m3u8
文件中值。
fullSegmentEncryptionIV
加密初始化向量转byte
过程
private static byte[] getEncryptionIvArray(String ivString) {
String trimmedIv;
if (Ascii.toLowerCase(ivString).startsWith("0x")) {
trimmedIv = ivString.substring(2);
} else {
trimmedIv = ivString;
}
byte[] ivData = new BigInteger(trimmedIv, /* radix= */ 16).toByteArray();
byte[] ivDataWithPadding = new byte[16];
int offset = ivData.length > 16 ? ivData.length - 16 : 0;
System.arraycopy(
ivData,
offset,
ivDataWithPadding,
ivDataWithPadding.length - ivData.length + offset,
ivData.length - offset);
return ivDataWithPadding;
}
fullSegmentEncryptionKeyUri
转byte
过程
@Nullable
private static Uri getFullEncryptionKeyUri(
HlsMediaPlaylist playlist, @Nullable HlsMediaPlaylist.SegmentBase segmentBase) {
if (segmentBase == null || segmentBase.fullSegmentEncryptionKeyUri == null) {
return null;
}
return UriUtil.resolveToUri(playlist.baseUri, segmentBase.fullSegmentEncryptionKeyUri);
}
支持相对地址
public static Uri resolveToUri(@Nullable String baseUri, @Nullable String referenceUri) {
return Uri.parse(resolve(baseUri, referenceUri));
}
按照RFC-3986
的规定执行解析,得到一个uri
public static String resolve(@Nullable String baseUri, @Nullable String referenceUri) {
StringBuilder uri = new StringBuilder();
// Map null onto empty string, to make the following logic simpler.
baseUri = baseUri == null ? "" : baseUri;
referenceUri = referenceUri == null ? "" : referenceUri;
int[] refIndices = getUriIndices(referenceUri);
if (refIndices[SCHEME_COLON] != -1) {
// The reference is absolute. The target Uri is the reference.
uri.append(referenceUri);
removeDotSegments(uri, refIndices[PATH], refIndices[QUERY]);
return uri.toString();
}
int[] baseIndices = getUriIndices(baseUri);
if (refIndices[FRAGMENT] == 0) {
// The reference is empty or contains just the fragment part, then the target Uri is the
// concatenation of the base Uri without its fragment, and the reference.
return uri.append(baseUri, 0, baseIndices[FRAGMENT]).append(referenceUri).toString();
}
if (refIndices[QUERY] == 0) {
// The reference starts with the query part. The target is the base up to (but excluding) the
// query, plus the reference.
return uri.append(baseUri, 0, baseIndices[QUERY]).append(referenceUri).toString();
}
if (refIndices[PATH] != 0) {
// The reference has authority. The target is the base scheme plus the reference.
int baseLimit = baseIndices[SCHEME_COLON] + 1;
uri.append(baseUri, 0, baseLimit).append(referenceUri);
return removeDotSegments(uri, baseLimit + refIndices[PATH], baseLimit + refIndices[QUERY]);
}
if (referenceUri.charAt(refIndices[PATH]) == '/') {
// The reference path is rooted. The target is the base scheme and authority (if any), plus
// the reference.
uri.append(baseUri, 0, baseIndices[PATH]).append(referenceUri);
return removeDotSegments(uri, baseIndices[PATH], baseIndices[PATH] + refIndices[QUERY]);
}
// The target Uri is the concatenation of the base Uri up to (but excluding) the last segment,
// and the reference. This can be split into 2 cases:
if (baseIndices[SCHEME_COLON] + 2 < baseIndices[PATH]
&& baseIndices[PATH] == baseIndices[QUERY]) {
// Case 1: The base hier-part is just the authority, with an empty path. An additional '/' is
// needed after the authority, before appending the reference.
uri.append(baseUri, 0, baseIndices[PATH]).append('/').append(referenceUri);
return removeDotSegments(uri, baseIndices[PATH], baseIndices[PATH] + refIndices[QUERY] + 1);
} else {
// Case 2: Otherwise, find the last '/' in the base hier-part and append the reference after
// it. If base hier-part has no '/', it could only mean that it is completely empty or
// contains only one segment, in which case the whole hier-part is excluded and the reference
// is appended right after the base scheme colon without an added '/'.
int lastSlashIndex = baseUri.lastIndexOf('/', baseIndices[QUERY] - 1);
int baseLimit = lastSlashIndex == -1 ? baseIndices[PATH] : lastSlashIndex + 1;
uri.append(baseUri, 0, baseLimit).append(referenceUri);
return removeDotSegments(uri, baseIndices[PATH], baseLimit + refIndices[QUERY]);
}
}
这里提一句,虽然RFC-3986
规范有文件协议file://
和网络协议http://
等,但是并不包括assets://
。虽然MediaItem
里面支持了读取assets
文件,是因为DefaultDataSource#open()
函数单独提供了支持,而上面fullSegmentEncryptionKeyUri
转byte
过程却没有单独提供支持方法,所以,密钥文件是不支持直接asset:///media/webvtt/typical
这种赋值方式的。
动手
找到修改的地方了,我们就开始着手自定义HlsPlaylistParser
,很简单,拷贝HlsPlaylistParser
到项目中(最好是重命名,方便识别),然后设置静态变量keyPath
,供外部动态赋值,然后重写parseMediaPlaylist
函数,代码如下:
public final class CustomHlsPlaylistParser implements ParsingLoadable.Parser<HlsPlaylist> {
......
public static String keyPath = "";
......
private static HlsMediaPlaylist parseMediaPlaylist(
HlsMultivariantPlaylist multivariantPlaylist,
@Nullable HlsMediaPlaylist previousMediaPlaylist,
LineIterator iterator,
String baseUri)
throws IOException {
...
String fullSegmentEncryptionKeyUri = keyPath;
...
else if (line.startsWith(TAG_SKIP)) {
int skippedSegmentCount = parseIntAttr(line, REGEX_SKIPPED_SEGMENTS);
checkState(previousMediaPlaylist != null && segments.isEmpty());
int startIndex = (int) (mediaSequence - castNonNull(previousMediaPlaylist).mediaSequence);
int endIndex = startIndex + skippedSegmentCount;
if (startIndex < 0 || endIndex > previousMediaPlaylist.segments.size()) {
// Throw to force a reload if not all segments are available in the previous playlist.
throw new DeltaUpdateException();
}
for (int i = startIndex; i < endIndex; i++) {
Segment segment = previousMediaPlaylist.segments.get(i);
if (mediaSequence != previousMediaPlaylist.mediaSequence) {
// If the media sequences of the playlists are not the same, we need to recreate the
// object with the updated relative start time and the relative discontinuity
// sequence. With identical playlist media sequences these values do not change.
int newRelativeDiscontinuitySequence =
previousMediaPlaylist.discontinuitySequence
- playlistDiscontinuitySequence
+ segment.relativeDiscontinuitySequence;
segment = segment.copyWith(segmentStartTimeUs, newRelativeDiscontinuitySequence);
}
segments.add(segment);
segmentStartTimeUs += segment.durationUs;
partStartTimeUs = segmentStartTimeUs;
if (segment.byteRangeLength != C.LENGTH_UNSET) {
segmentByteRangeOffset = segment.byteRangeOffset + segment.byteRangeLength;
}
relativeDiscontinuitySequence = segment.relativeDiscontinuitySequence;
initializationSegment = segment.initializationSegment;
cachedDrmInitData = segment.drmInitData;
// 上面已经赋值 keyPath
// fullSegmentEncryptionKeyUri = segment.fullSegmentEncryptionKeyUri;
if (segment.encryptionIV == null
|| !segment.encryptionIV.equals(Long.toHexString(segmentMediaSequence))) {
fullSegmentEncryptionIV = segment.encryptionIV;
}
segmentMediaSequence++;
}
} else if (line.startsWith(TAG_KEY)) {
String method = parseStringAttr(line, REGEX_METHOD, variableDefinitions);
String keyFormat =
parseOptionalStringAttr(line, REGEX_KEYFORMAT, KEYFORMAT_IDENTITY, variableDefinitions);
// 上面已经赋值 keyPath
// fullSegmentEncryptionKeyUri = null;
fullSegmentEncryptionIV = null;
if (METHOD_NONE.equals(method)) {
currentSchemeDatas.clear();
cachedDrmInitData = null;
} else /* !METHOD_NONE.equals(method) */ {
fullSegmentEncryptionIV = parseOptionalStringAttr(line, REGEX_IV, variableDefinitions);
if (KEYFORMAT_IDENTITY.equals(keyFormat)) {
if (METHOD_AES_128.equals(method)) {
// 上面已经赋值 keyPath
// fullSegmentEncryptionKeyUri = parseStringAttr(line, REGEX_URI, variableDefinitions);
} else {
// Do nothing. Samples are encrypted using an identity key, but this is not supported.
// Hopefully, a traditional DRM alternative is also provided.
}
} else {
if (encryptionScheme == null) {
encryptionScheme = parseEncryptionScheme(method);
}
SchemeData schemeData = parseDrmSchemeData(line, keyFormat, variableDefinitions);
if (schemeData != null) {
cachedDrmInitData = null;
currentSchemeDatas.put(keyFormat, schemeData);
}
}
}
}
}
......
}
由于HlsPlaylistParser
是通过工厂类构造的,因此我们还得自定义一个工厂类,代码如下:
/** Default implementation for {@link HlsPlaylistParserFactory}. */
public final class CustomHlsPlaylistParserFactory implements HlsPlaylistParserFactory {
@Override
public ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser() {
return new CustomHlsPlaylistParser();
}
@Override
public ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser(
HlsMultivariantPlaylist multivariantPlaylist,
@Nullable HlsMediaPlaylist previousMediaPlaylist) {
return new CustomHlsPlaylistParser(multivariantPlaylist, previousMediaPlaylist);
}
}
调用自定义的CustomHlsPlaylistParserFactory
的方式很简单,ExoPlayer
已经考虑到自定义的使用场景了。
// Create a data source factory.
DataSource.Factory dataSourceFactory = new DefaultHttpDataSource.Factory();
// Create a HLS media source pointing to a playlist uri.
HlsMediaSource hlsMediaSource =
new HlsMediaSource.Factory(dataSourceFactory)
// create custom HlsPlaylistParserFactory
.setPlaylistParserFactory(CustomHlsPlaylistParserFactory())
.createMediaSource(MediaItem.fromUri(hlsUri));
// Create a player instance.
ExoPlayer player = new ExoPlayer.Builder(context).build();
// Set the media source to be played.
player.setMediaSource(hlsMediaSource);
// Prepare the player.
player.prepare();
最后,不要忘了初始化这个CustomHlsPlaylistParser#keyPath
。上文提到过,这个地方最好是动态设置本地文件file://
或者动态配置后端密钥http://
,适合自己业务就好。
File(this.filesDir,"enc.key").writeBytes(byteArrayOf(-108,1,121,41,-36,-54,-110,-107,67,-61,70,-88,64,101,-72,92))
CustomHlsPlaylistParser.key = "file://${File(this.filesDir,"enc.key").absolutePath}"
至此,我们就解决了ExoPlayer
播放m3u8
时,本地解密Aes
加密音频/视频的问题。虽然走了不少弯路甚至死胡同,但是只要一步一步往前走,就没有走不累的脚?!
由于加密的m3u8
视频涉及项目业务,这里代码就不开源了,需要的同学可以联系我!