ExoPlayer客户端解密m3u8音频/视频

2,971 阅读7分钟

项目中有一个需求,播放加密的视频和音频,想到的方案有服务端鉴权和客户端本地解密两种思路。服务端可通过短时效的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流来读取文件,可以获得预期的16byte的密钥,但是我还是不得其解。直到后端同学给我一个File to data URI converter的网站,通过解析这个文件得到一个24个字节的密文,最后通过#叶楠#叶老师的解惑后找到了原因。

  • Q1.为什么文件内容作为字符串不能通过getBytes得到预期的密钥?
  • A1:因为文件内容含有特殊字符,经过转义后直接使用getBytes不能得到预期的密钥。
  • Q2.为什么文件内容通过Java包还是android包的base64类来解码或者编码都不对?
  • A2.因为文件内容是未加密的明文,所以不管是解码还是编码,都不能得到长度为16byte数组。
  • Q3.为什么IO流可以得到预期的密钥,而其他方式不行?
  • A3.看完了上面两个问题后,这个问题不再是问题。

需要注意的是,encryptionIVencryptionKey在使用中,一般是两个不同的值,具体原因可看后文分析。

m3u8解密风波——Input does not start with the #EXTM3U header

在正确设置了上面两个值以后,在播放的时候又遇到了一个新问题:Input does not start with the #EXTM3U header.首先查看源码,了解到是下载网络文件后,检查文件头的时候没有找到#EXTM3U,因此判断文件不是目标文件,所以直接抛出了异常。而这个链接下载下来是标准的m3u8文件。这个时候隔壁的IOS同学提测了,急死个人了!

抱着不懂就问的心态,我问了百度/谷歌,翻看了ExoPlayerissuse,很多同学都遇到了类似的问题,官方回复均是提示资源是否正常?是否有缓存?后来仔细查看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);
      }
    }
  }
} 
......

通过上面的内容可以看到fullSegmentEncryptionIVfullSegmentEncryptionKeyUri的赋值位置,我们可以在声明的时候直接通过自定义来赋值,但是需要注意的地方是,虽然两者的声明类型都是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;
}

fullSegmentEncryptionKeyUribyte过程

@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()函数单独提供了支持,而上面fullSegmentEncryptionKeyUribyte过程却没有单独提供支持方法,所以,密钥文件是不支持直接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视频涉及项目业务,这里代码就不开源了,需要的同学可以联系我!