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;
}
}
}