HLS双重防盗链对接教程

提供网页端与APP端对接HLS双重防盗链的接入方式与解密示例。
提示:该功能仅对开启TS加密功能后转码的视频生效。

对接之前请先了解下HLS双重防盗链的原理及设置 ⇒ 传送门。

接入方式分为Web端与APP端两大类:Web端推荐直接使用内置解析播放器,一个地址即可播放,无需编写解密代码;APP端自定义播放器场景则需按示例自行实现两重解密。

双重加密原理

该功能对key地址和key内容分别进行了加密,如下:

第一重:key地址加密

后端将m3u8中原始的key地址替换成AES加密后的tk参数:

# 原始key地址
#EXT-X-KEY:METHOD=AES-128,URI="/videos/202603/21/xxx/ts.key"

# 替换后的key地址(新版参数为 tk=)
#EXT-X-KEY:METHOD=AES-128,URI="https://你的域名/pk?tk=xxxxxx加密字符串"

# 加密规则:AES-256-ECB,密钥 = SHA256(完整KEY) 的32字节摘要
aesKey = SHA256("efvtoken")

第二重:key内容加密

前端还原真实key地址后,请求该地址返回的不是原始key内容,而是经过AES加密后的内容,需要再次解密才能得到真实key,如下:

# key内容加密规则与地址加密完全相同
aesKey = SHA256("efvtoken")

完整解密流程:

解析m3u8拿到加密的tk URI
       ↓
用「SHA256(完整KEY)」解密tk → 还原真实key地址
       ↓
请求真实 key 地址 → 返回加密后的key内容
       ↓
用「SHA256(完整KEY)」解密key内容 → 得到真实key
       ↓
用真实key解密TS切片正常播放

Web端使用

由于国产手机浏览器会劫持hls,导致我们使用相关hls播放器对接的解密逻辑会失效,这里推荐使用EFV解析播放器,可防手机浏览器劫持,并适配了HLS双重加密防盗链,同时支持记忆播放、开屏广告、暂停广告、弹窗广告、文字水印、图片水印和跑马灯广告,且水印位置实时贴合视频画面,包括横竖屏、随意缩放播放器均自动适应,等同烧录。

一个地址即可播放:

https://你的域名/player?url=<m3u8地址>&key=<访问密钥>
  • 访问密钥在后台解析播放器设置中生成:设置后调用必须携带&key=参数,留空则无需该参数

  • 支持&sub=传入字幕地址(srt / ass / vtt)

  • 支持&thumb=传入进度条悬停缩略图(可直接使用转码生成的预览缩略图地址,无需额外参数)

  • iframe嵌入即可使用:

      <iframe src="https://你的域名/player?url=<m3u8地址>" allowfullscreen style="width:100%;aspect-ratio:16/9;border:0"></iframe>
    

App内嵌WebView场景同理,直接嵌入上面的地址即可,无需自行实现解密逻辑。


APP端使用

APP端就需要自己对接了,以下为相关对接代码参考。

播放器拦截key请求的工作清单(以ExoPlayer为例,在DefaultHttpDataSource中拦截):
1. 拦截到key请求URL,提取tk参数,解密还原真实key地址
2. 请求真实key地址,拿到加密后的key内容
3. 用decryptKeyContent() 解密内容,得到真实key
4. 将真实key返回给播放器

Android (Kotlin) 示例

import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
import android.util.Base64
import java.security.MessageDigest

object AntiKeyDecryptor {
    private const val ANTI_KEY = "efvtoken"  // 与后台防盗链KEY保持一致

    // AES-256-ECB 密钥 = SHA256(完整KEY) 的32字节摘要
    private fun aesKey(): SecretKeySpec {
        val digest = MessageDigest.getInstance("SHA-256").digest(ANTI_KEY.toByteArray())
        return SecretKeySpec(digest, "AES")
    }

    // 通用AES-256-ECB解密(地址与内容同一套规则)
    private fun aesDecrypt(ciphertext: String): String {
        val cipher = Cipher.getInstance("AES/ECB/PKCS5Padding")
        cipher.init(Cipher.DECRYPT_MODE, aesKey())
        val decoded = Base64.decode(ciphertext, Base64.DEFAULT)
        return String(cipher.doFinal(decoded))
    }

    // 第一重:解密key地址
    fun decryptKeyUrl(tk: String): String {
        return aesDecrypt(tk)
    }

    // 第二重:解密key内容
    fun decryptKeyContent(encryptedContent: String): String {
        return aesDecrypt(encryptedContent)
    }

    // 从key URI提取tk并还原真实地址
    fun resolveKeyUrl(keyUri: String): String {
        return try {
            val tk = Uri.parse(keyUri).getQueryParameter("tk") ?: return keyUri
            decryptKeyUrl(tk)
        } catch (e: Exception) {
            keyUri
        }
    }
}

iOS (Swift) 示例

import CommonCrypto
import Foundation

struct AntiKeyDecryptor {
    static let antiKey = "efvtoken"  // 与后台防盗链KEY保持一致

    // AES-256-ECB 密钥 = SHA256(完整KEY) 的32字节摘要
    static func sha256(_ string: String) -> [UInt8] {
        var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
        let bytes = Array(string.utf8)
        CC_SHA256(bytes, CC_LONG(bytes.count), &digest)
        return digest
    }

    // 通用AES-256-ECB解密(地址与内容同一套规则)
    static func aesDecrypt(ciphertext: String) -> String? {
        guard let data = Data(base64Encoded: ciphertext) else { return nil }
        let keyBytes = sha256(antiKey)
        var outBuf = [UInt8](repeating: 0, count: data.count + kCCBlockSizeAES128)
        var outLen = 0
        let status = CCCrypt(
            CCOperation(kCCDecrypt), CCAlgorithm(kCCAlgorithmAES),
            CCOptions(kCCOptionECBMode | kCCOptionPKCS7Padding),
            keyBytes, kCCKeySizeAES256,
            nil,
            Array(data), data.count,
            &outBuf, outBuf.count, &outLen
        )
        guard status == kCCSuccess else { return nil }
        return String(bytes: outBuf[..<outLen], encoding: .utf8)
    }

    // 第一重:解密key地址
    static func decryptKeyUrl(tk: String) -> String? {
        return aesDecrypt(ciphertext: tk)
    }

    // 第二重:解密key内容
    static func decryptKeyContent(encrypted: String) -> String? {
        return aesDecrypt(ciphertext: encrypted)
    }

    // 从key URI提取tk并还原真实地址
    static func resolveKeyUrl(_ keyUri: String) -> String {
        guard let url = URLComponents(string: keyUri),
              let tk = url.queryItems?.first(where: { $0.name == "tk" })?.value,
              let real  = decryptKeyUrl(tk: tk) else { return keyUri }
        return real
    }
}

Flutter (Dart) 示例

import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:encrypt/encrypt.dart';

class AntiKeyDecryptor {
  static const antiKey = 'efvtoken';  // 与后台防盗链KEY保持一致

  // AES-256-ECB 密钥 = SHA256(完整KEY) 的32字节摘要
  static Key _aesKey() {
    final digest = sha256.convert(utf8.encode(antiKey));
    return Key(Uint8List.fromList(digest.bytes));
  }

  // 通用AES-256-ECB解密(地址与内容同一套规则)
  static String aesDecrypt(String ciphertext) {
    final encrypter = Encrypter(AES(_aesKey(), mode: AESMode.ecb, padding: 'PKCS7'));
    return encrypter.decrypt(Encrypted.fromBase64(ciphertext));
  }

  // 第一重:解密key地址
  static String decryptKeyUrl(String tk) {
    return aesDecrypt(tk);
  }

  // 第二重:解密key内容
  static String decryptKeyContent(String encrypted) {
    return aesDecrypt(encrypted);
  }

  // 从key URI提取tk并还原真实地址
  static String resolveKeyUrl(String keyUri) {
    try {
      final uri = Uri.parse(keyUri);
      final tk = uri.queryParameters['tk'];
      if (tk == null) return keyUri;
      return decryptKeyUrl(tk);
    } catch (e) {
      return keyUri;
    }
  }
}

注意事项

  • ANTI_KEY值必须与后台防盗链/图片加密设置中的HLS双重防盗链KEY完全一致
  • 后台若开启防盗链KEY定时轮换(默认关闭),轮换后硬编码KEY的App需发版更新;CDN对.key文件约有2分钟缓存,轮换后旧缓存解密失败属正常现象,稍后自愈
  • App端建议将ANTI_KEY混淆或加密存储在二进制中,避免直接明文暴露
  • 网页端也可使用时间戳防盗链功能替代,兼容性更好 ⇒ 传送门
  • 可配合获取TS加密Key和IV接口使用 ⇒ 传送门,提前在本地获取解密后的Key内容,跳过第二重解密请求