第十一章:DRM 数字版权保护
DRM(Digital Rights Management,数字版权管理)是视频内容分发中最复杂的技术领域之一。它涉及密码学、浏览器底层 API、操作系统安全模块、商业授权服务器等多个层次的协同工作。本章从 DRM 的核心组件讲起,逐层深入到 EME API 的完整实现、三大主流 DRM 方案的差异化处理、Multi-DRM 跨平台策略、License 服务器的工程接入,以及离线播放和常见调试问题。
第一部分:DRM 技术体系概览
为什么需要 DRM
HTTP 协议本质上是公开的,任何通过浏览器播放的视频理论上都可以被录制或下载。对于低价值内容,HTTPS 传输已经足够;但对于付费电影、体育赛事直播、教育版权内容等高价值资产,内容方需要更强的保护机制——这就是 DRM 的价值所在。DRM 通过加密内容、在可信环境中管理密钥,使得即便用户截获了加密的视频数据,也无法在未经授权的设备上解密播放。
DRM 的五个核心组件
理解 DRM 系统,首先要厘清五个核心概念之间的关系:nxrte.com
AES 加密是 DRM 的基础。视频内容在服务端被 AES-128 加密后分发,客户端必须持有对应密钥才能解密。在视频领域,AES 有两种工作模式:CTR(计数器模式,对应 cenc)和 CBC(密文分组链接模式,对应 cbcs),两者在安全性上相当,主要区别在于不同 DRM 系统的支持情况。
CENC(Common Encryption)是 MPEG 制定的通用加密规范,其核心价值是"一次加密,多 DRM 解密"——内容提供商只需将视频加密一次,Widevine、PlayReady、FairPlay 等不同的 CDM 均可解密同一份加密内容,避免了为每种 DRM 维护多份视频副本的高昂存储成本。
CDM(Content Decryption Module,内容解密模块)是 DRM 系统的核心执行单元,由各 DRM 厂商提供,以闭源形式内置在浏览器中。CDM 负责生成 License 请求、与 License 服务器通信、在可信环境中存储密钥并完成解密。CDM 之所以必须闭源,是因为一旦其实现细节公开,攻击者就可以构造出能绕过保护的自定义 CDM。
EME(Encrypted Media Extensions)是 W3C 标准化的浏览器 API,它本身不做任何解密工作,而是在 Web 应用和 CDM 之间提供了一套标准化的通信接口。可以将 EME 理解为"快递员"——它负责在应用层和 CDM 之间传递消息,但不关心消息的具体内容。web.dev
License 服务器是内容提供商运营的后端服务,负责验证用户身份、检查业务规则(订阅有效期、地域限制、并发设备数等),并向通过验证的 CDM 下发包含解密密钥的 License。
内容流程:
视频原文 → AES 加密 → 加密视频流(DASH/HLS)→ CDN 分发
播放流程:
浏览器 → EME API → CDM → License 服务器(验证身份)
↓
CDM 持有密钥 → 解密视频流 → 渲染
第二部分:EME API 完整实现
七步工作流
EME 的工作流程可以分解为七个明确的步骤,每一步都对应具体的 API 调用:
// ① 检测 Key System 支持
async function checkDRMSupport() {
const systems = [
{ name: 'Widevine', keySystem: 'com.widevine.alpha' },
{ name: 'PlayReady', keySystem: 'com.microsoft.playready' },
{ name: 'FairPlay', keySystem: 'com.apple.fps.1_0' },
{ name: 'ClearKey', keySystem: 'org.w3.clearkey' }, // 测试用,无商业保护
];
const config = [{
initDataTypes: ['cenc', 'keyids', 'webm'],
videoCapabilities: [{
contentType: 'video/mp4;codecs="avc1.42E01E"',
robustness: 'SW_SECURE_CRYPTO', // Widevine L3
}],
audioCapabilities: [{
contentType: 'audio/mp4;codecs="mp4a.40.2"',
robustness: 'SW_SECURE_CRYPTO',
}],
}];
for (const { name, keySystem } of systems) {
try {
await navigator.requestMediaKeySystemAccess(keySystem, config);
console.log(` 支持`);
} catch {
console.log(` 不支持`);
}
}
}// 完整 EME 播放器实现
class EMEPlayer {
constructor(videoEl) {
this.video = videoEl;
this.keySystem = 'com.widevine.alpha';
this.licenseUrl = 'https://license.example.com/widevine';
}
async init() {
// ② 请求 Key System 访问权限
const keySystemAccess = await navigator.requestMediaKeySystemAccess(
this.keySystem,
[{
initDataTypes: ['cenc'],
videoCapabilities: [{
contentType: 'video/mp4;codecs="avc1.42E01E"',
robustness: 'SW_SECURE_CRYPTO',
}],
audioCapabilities: [{
contentType: 'audio/mp4;codecs="mp4a.40.2"',
robustness: 'SW_SECURE_CRYPTO',
}],
persistentState: 'optional',
distinctiveIdentifier: 'optional',
}]
);
// ③ 创建 MediaKeys 实例
const mediaKeys = await keySystemAccess.createMediaKeys();
// ④ 将 MediaKeys 关联到 video 元素
await this.video.setMediaKeys(mediaKeys);
// ⑤ 监听 encrypted 事件(视频流中遇到加密内容时触发)
this.video.addEventListener('encrypted', (e) => this._onEncrypted(e, mediaKeys));
}
async _onEncrypted(event, mediaKeys) {
const { initDataType, initData } = event;
// ⑥ 创建 MediaKeySession 并生成 License 请求
const session = mediaKeys.createSession('temporary');
// 监听 message 事件(CDM 生成 License 请求后触发)
session.addEventListener('message', async (e) => {
await this._onMessage(e, session);
});
// 监听密钥状态变化
session.addEventListener('keystatuseschange', () => {
session.keyStatuses.forEach((status, keyId) => {
const hex = Array.from(new Uint8Array(keyId))
.map(b => b.toString(16).padStart(2, '0')).join('');
console.log(`密钥 : `);
if (status === 'output-restricted') {
console.warn('HDCP 不满足,无法播放高清内容');
this._switchToLowerQuality();
}
if (status === 'expired') {
console.warn('License 已过期,需要续期');
}
});
});
// 传入 initData,触发 CDM 生成 License 请求
await session.generateRequest(initDataType, initData);
}
async _onMessage(event, session) {
// event.message 是 CDM 生成的 License 请求(二进制)
const licenseRequest = event.message;
// ⑦ 将 License 请求发送到服务器,获取 License
try {
const response = await fetch(this.licenseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
'Authorization': `Bearer `,
},
body: licenseRequest,
});
if (!response.ok) {
throw new Error(`License 请求失败: `);
}
const license = await response.arrayBuffer();
// 将 License 更新到 session,CDM 即可解密内容
await session.update(license);
console.log('License 获取成功,开始解密播放');
} catch (err) {
console.error('License 获取失败:', err);
}
}
_switchToLowerQuality() {
// 降级到不受 HDCP 限制的低分辨率流
}
}
// 使用
const player = new EMEPlayer(document.getElementById('video'));
await player.init();
document.getElementById('video').src = 'https://cdn.example.com/encrypted.mpd';第三部分:三大 DRM 方案深度对比
平台支持矩阵
| DRM | 支持平台 | Key System 字符串 | 支持协议 | 加密模式 |
|---|---|---|---|---|
| Widevine | Chrome、Firefox、Edge、Android | com.widevine.alpha | DASH、HLS | cenc(CTR)、cbcs(CBC) |
| FairPlay | Safari(macOS/iOS)、tvOS | com.apple.fps.1_0 | 仅 HLS | cbcs(CBC) |
| PlayReady | Edge(Windows)、IE、Xbox | com.microsoft.playready | DASH、HLS、MSS | cenc(CTR)、cbcs(CBC) |
| ClearKey | 所有支持 EME 的浏览器 | org.w3.clearkey | DASH | cenc |
这张矩阵揭示了一个关键问题:没有任何一种 DRM 能覆盖所有平台。这正是 Multi-DRM 策略存在的根本原因。web.dev
Widevine 安全等级详解
Widevine 将设备分为三个安全等级,直接决定了可播放的内容质量上限。developer.aliyun.com trustkernel.com
L1(最高安全级别):视频解码和内容保护全部在硬件 TEE(可信执行环境)中完成,密钥永远不暴露到普通内存区域。支持 L1 的设备可以播放 4K、HDR 内容。Netflix、Amazon Prime Video 要求 L1 才能播放 1080P 及以上内容。旗舰 Android 手机通常支持 L1。
L2(中间级别):密钥存储在软件层,部分解码由硬件完成。安全性介于 L1 和 L3 之间,实际使用中较少见。
L3(最低安全级别):完全软件实现,无硬件保护。所有桌面浏览器(Chrome/Firefox/Edge)均为 L3,因为桌面平台通常没有标准化的 TEE 实现。L3 设备上 Netflix 限制为 720P,YouTube 限制为 1080P。
// 检测当前设备的 Widevine 安全等级
async function detectWidevineLevel() {
const levels = [
{ level: 'L1', robustness: 'HW_SECURE_ALL' },
{ level: 'L1', robustness: 'HW_SECURE_DECODE' },
{ level: 'L2', robustness: 'HW_SECURE_CRYPTO' },
{ level: 'L3', robustness: 'SW_SECURE_DECODE' },
{ level: 'L3', robustness: 'SW_SECURE_CRYPTO' },
];
for (const { level, robustness } of levels) {
try {
await navigator.requestMediaKeySystemAccess('com.widevine.alpha', [{
videoCapabilities: [{
contentType: 'video/mp4;codecs="avc1.42E01E"',
robustness,
}],
}]);
console.log(`当前设备支持 Widevine (robustness: )`);
return level;
} catch {
// 不支持该等级,继续尝试
}
}
return '不支持 Widevine';
}FairPlay 的特殊处理
FairPlay 是 Apple 专属的 DRM,其实现与 Widevine/PlayReady 有几个根本性差异,需要专门处理:medium.com
差异一:仅支持 HLS 协议。FairPlay 不支持 DASH,必须使用 HLS 流,且 m3u8 中的密钥 URI 使用 skd:// 协议头(而非 https://)。
差异二:需要 App Certificate(应用证书)。播放前必须先从 Apple 的 KSM 服务器获取应用证书,这是 FairPlay 特有的步骤,Widevine 和 PlayReady 没有对应流程。
差异三:initData 格式不同。FairPlay 的 initData 是 UTF-16 编码的 skd:// URI,需要从中解析出 Content ID。
差异四:License 请求/响应格式不同。FairPlay 的 License 请求称为 SPC(Server Playback Context),响应称为 CKC(Content Key Context),均为二进制格式,不能用 JSON 包装。
// FairPlay DRM 完整实现(Safari)
class FairPlayPlayer {
constructor(videoEl, config) {
this.video = videoEl;
this.certUrl = config.certUrl; // Apple 应用证书 URL
this.licenseUrl = config.licenseUrl; // KSM License 服务器 URL
this.certificate = null;
}
async init() {
// 第一步:获取 FairPlay 应用证书
const certResponse = await fetch(this.certUrl);
this.certificate = await certResponse.arrayBuffer();
// 第二步:请求 Key System 访问(FairPlay 专用配置)
const keySystemAccess = await navigator.requestMediaKeySystemAccess(
'com.apple.fps.1_0',
[{
initDataTypes: ['skd'],
videoCapabilities: [{
contentType: 'video/mp4;codecs="avc1.42E01E"',
}],
audioCapabilities: [{
contentType: 'audio/mp4;codecs="mp4a.40.2"',
}],
}]
);
const mediaKeys = await keySystemAccess.createMediaKeys();
// 第三步:将证书设置到 MediaKeys(FairPlay 必须步骤)
await mediaKeys.setServerCertificate(this.certificate);
await this.video.setMediaKeys(mediaKeys);
// 第四步:监听 encrypted 事件
this.video.addEventListener('encrypted', (e) => this._onEncrypted(e, mediaKeys));
}
async _onEncrypted(event, mediaKeys) {
// FairPlay 的 initDataType 是 'skd'
// initData 是 UTF-16 编码的 skd://contentID 字符串
const contentId = this._extractContentId(event.initData);
const session = mediaKeys.createSession();
session.addEventListener('message', async (e) => {
// e.message 是 SPC(Server Playback Context)二进制数据
const spc = e.message;
try {
// 将 SPC 发送到 License 服务器,获取 CKC
const response = await fetch(this.licenseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
// 部分服务器需要传递 Content ID
'X-Content-Id': contentId,
},
body: spc,
});
if (!response.ok) throw new Error(`FairPlay License 请求失败: `);
// CKC 必须是纯二进制,不能是 JSON
const ckc = await response.arrayBuffer();
await session.update(ckc);
} catch (err) {
console.error('FairPlay License 错误:', err);
}
});
await session.generateRequest('skd', event.initData);
}
// 从 UTF-16 编码的 initData 中提取 Content ID
_extractContentId(initData) {
// initData 是 skd://contentID 的 UTF-16 LE 编码
const uint16 = new Uint16Array(initData);
const str = String.fromCharCode(...uint16);
// 去掉 skd:// 前缀,提取 Content ID
return str.replace(/^skd:\/\//, '');
}
}第四部分:Multi-DRM 跨平台策略
为什么需要 Multi-DRM
单一 DRM 方案无法覆盖所有用户设备:Chrome 用户需要 Widevine,Safari 用户需要 FairPlay,Edge/Windows 用户可能需要 PlayReady。Multi-DRM 是指同时支持多种 DRM 系统,通过统一的 CENC 加密内容,根据客户端环境动态选择合适的 DRM 方案。
// Multi-DRM 自动选择器
const DRM_PRIORITY = [
{ name: 'Widevine', keySystem: 'com.widevine.alpha' },
{ name: 'PlayReady', keySystem: 'com.microsoft.playready' },
{ name: 'FairPlay', keySystem: 'com.apple.fps.1_0' },
{ name: 'ClearKey', keySystem: 'org.w3.clearkey' },
];
async function detectBestDRM() {
const config = [{
initDataTypes: ['cenc', 'skd'],
videoCapabilities: [{
contentType: 'video/mp4;codecs="avc1.42E01E"',
}],
}];
for (const drm of DRM_PRIORITY) {
try {
await navigator.requestMediaKeySystemAccess(drm.keySystem, config);
console.log(`选择 DRM 方案: `);
return drm;
} catch {
// 当前环境不支持,尝试下一个
}
}
throw new Error('当前浏览器不支持任何 DRM 方案');
}
// Multi-DRM 播放器工厂
async function createDRMPlayer(videoEl, drmConfig) {
const drm = await detectBestDRM();
switch (drm.name) {
case 'Widevine':
case 'PlayReady': {
const player = new EMEPlayer(videoEl);
player.keySystem = drm.keySystem;
player.licenseUrl = drm.name === 'Widevine'
? drmConfig.widevineLicenseUrl
: drmConfig.playreadyLicenseUrl;
await player.init();
return player;
}
case 'FairPlay': {
const player = new FairPlayPlayer(videoEl, {
certUrl: drmConfig.fairplayCertUrl,
licenseUrl: drmConfig.fairplayLicenseUrl,
});
await player.init();
return player;
}
default:
throw new Error('没有可用的 DRM 方案');
}
}CENC 内容打包
使用 Shaka Packager 对视频进行 CENC 加密,一次打包同时支持 Widevine 和 PlayReady:
# 安装 Shaka Packager
# https://github.com/shaka-project/shaka-packager
# 生成随机密钥(生产环境应由 DRM 服务商提供)
KEY_ID="e5007e2478d34c9e9d3f4f6a8e6b4c5d"
KEY="6fc96fe628a265b13f6b4f5e5e7b4f7c"
# CENC 打包(同时支持 Widevine + PlayReady)
packager \
'in=input.mp4,stream=video,output=video.mp4' \
'in=input.mp4,stream=audio,output=audio.mp4' \
--enable_raw_key_encryption \
--keys label=:key_id=${KEY_ID}:key=${KEY} \
--protection_scheme cenc \
--generate_static_iv \
--mpd_output manifest.mpd \
--hls_master_playlist_output master.m3u8
# 为 FairPlay 单独打包(cbcs 模式,仅 HLS)
packager \
'in=input.mp4,stream=video,output=video_fp.mp4' \
'in=input.mp4,stream=audio,output=audio_fp.mp4' \
--enable_raw_key_encryption \
--keys label=:key_id=${KEY_ID}:key=${KEY} \
--protection_scheme cbcs \
--hls_master_playlist_output master_fp.m3u8 \
--hls_key_uri "skd://${KEY_ID}"第五部分:Shaka Player 工程接入
为什么选择 Shaka Player
原生 EME API 虽然功能完整,但在生产环境中存在大量需要手动处理的边缘情况:License 请求重试、密钥轮换、多 DRM 自动切换、FairPlay 证书管理等。Shaka Player 封装了完整的 Multi-DRM 支持,是目前最适合生产环境的 DRM 播放方案。
import shaka from 'shaka-player';
class DRMPlayer {
constructor(videoEl) {
this.video = videoEl;
this.player = null;
}
async init() {
// 安装 Shaka 的 polyfill(处理浏览器兼容性差异)
shaka.polyfill.installAll();
if (!shaka.Player.isBrowserSupported()) {
throw new Error('当前浏览器不支持 Shaka Player');
}
this.player = new shaka.Player();
await this.player.attach(this.video);
// 监听错误事件
this.player.addEventListener('error', (e) => this._onError(e.detail));
}
async load(manifestUrl, drmConfig = {}) {
// 配置 Multi-DRM
this.player.configure({
drm: {
servers: {
'com.widevine.alpha': drmConfig.widevineLicenseUrl || '',
'com.microsoft.playready': drmConfig.playreadyLicenseUrl || '',
'com.apple.fps.1_0': drmConfig.fairplayLicenseUrl || '',
},
// License 请求重试策略
retryParameters: {
maxAttempts: 4,
baseDelay: 1000,
backoffFactor: 2,
fuzzFactor: 0.5,
},
// 高级配置
advanced: {
'com.widevine.alpha': {
videoRobustness: 'SW_SECURE_CRYPTO',
audioRobustness: 'SW_SECURE_CRYPTO',
},
},
},
});
// 注册 FairPlay 证书(仅 Safari 需要)
if (drmConfig.fairplayCertUrl) {
await this._setupFairPlay(drmConfig.fairplayCertUrl);
}
// 注册请求/响应过滤器(处理非标准 License 接口)
if (drmConfig.wrapLicenseRequest || drmConfig.unwrapLicenseResponse) {
this._setupLicenseFilters(drmConfig);
}
await this.player.load(manifestUrl);
}
async _setupFairPlay(certUrl) {
const response = await fetch(certUrl);
const certificate = await response.arrayBuffer();
this.player.configure('drm.advanced.com\\.apple\\.fps\\.1_0.serverCertificate',
new Uint8Array(certificate));
}
_setupLicenseFilters(config) {
const netEngine = this.player.getNetworkingEngine();
const RequestType = shaka.net.NetworkingEngine.RequestType;
// 请求过滤器:注入认证信息 + 自定义封装
netEngine.registerRequestFilter((type, request) => {
if (type !== RequestType.LICENSE) return;
// 注入认证 Token
request.headers['Authorization'] = `Bearer `;
request.headers['X-Device-Id'] = getDeviceId();
// 将请求体 base64 编码后放入 JSON(某些服务器要求)
if (config.wrapLicenseRequest) {
const body = new Uint8Array(request.body);
const base64 = btoa(String.fromCharCode(...body));
request.body = new TextEncoder().encode(JSON.stringify({
licenseRequest: base64,
contentId: config.contentId,
userId: getCurrentUserId(),
}));
request.headers['Content-Type'] = 'application/json';
}
});
// 响应过滤器:解析非标准响应格式
netEngine.registerResponseFilter((type, response) => {
if (type !== RequestType.LICENSE) return;
if (config.unwrapLicenseResponse) {
const text = new TextDecoder().decode(response.data);
const json = JSON.parse(text);
// 从 JSON 中提取 base64 编码的 License 并解码为二进制
const binary = atob(json.license);
response.data = Uint8Array.from(binary, c => c.charCodeAt(0)).buffer;
}
});
}
_onError(error) {
console.error('Shaka Player 错误:', error.code, error.message);
// DRM 错误码分类处理
const DRM_ERRORS = {
6000: 'DRM 初始化失败',
6001: '无法获取 License',
6002: '密钥已过期',
6003: '输出受限(HDCP 不满足)',
6004: 'CDM 内部错误',
6005: '不支持的 Key System',
6007: 'License 服务器返回错误',
};
if (error.category === shaka.util.Error.Category.DRM) {
const msg = DRM_ERRORS[error.code] || '未知 DRM 错误';
console.error(`DRM 错误: (代码: )`);
// HDCP 不满足时降级
if (error.code === 6003) {
this._handleOutputRestricted();
}
}
}
_handleOutputRestricted() {
// 切换到不受 HDCP 限制的低分辨率流
const tracks = this.player.getVariantTracks();
const lowQuality = tracks
.filter(t => t.height <= 720)
.sort((a, b) => b.bandwidth - a.bandwidth)[0];
if (lowQuality) {
this.player.selectVariantTrack(lowQuality, true);
console.log(`已降级到 p 以绕过 HDCP 限制`);
}
}
destroy() {
this.player?.destroy();
}
}
// 使用示例
const player = new DRMPlayer(document.getElementById('video'));
await player.init();
await player.load('https://cdn.example.com/manifest.mpd', {
widevineLicenseUrl: 'https://license.example.com/widevine',
fairplayLicenseUrl: 'https://license.example.com/fairplay',
fairplayCertUrl: 'https://cdn.example.com/fairplay/cert',
playreadyLicenseUrl: 'https://license.example.com/playready',
wrapLicenseRequest: true, // 服务器要求 JSON 封装
unwrapLicenseResponse: true, // 服务器返回 JSON 格式
contentId: 'movie-12345',
});第六部分:HLS AES-128 加密 vs DRM
理解两者的本质区别,对于正确选择内容保护方案至关重要。很多开发者误以为 HLS AES-128 加密等同于 DRM,但两者在安全强度上有根本性差异。
HLS AES-128 的密钥 URL 直接明文写在 m3u8 播放列表中(#EXT-X-KEY:METHOD=AES-128,URI="https://..."),任何能获取到 m3u8 的人都可以直接下载密钥并解密所有视频分片。DRM 的密钥则存储在 CDM 内部的可信环境中,应用层代码永远无法读取,攻击者即便截获了 License 响应也无法提取出明文密钥。
# FFmpeg 生成 HLS AES-128 加密流
# 首先创建 keyinfo 文件
echo "https://example.com/key.key" > keyinfo.txt # 密钥获取 URL
echo "key.key" >> keyinfo.txt # 本地密钥文件路径
echo "" >> keyinfo.txt # IV(留空则自动生成)
# 生成密钥文件(16 字节随机数)
openssl rand 16 > key.key
# FFmpeg 打包
ffmpeg -i input.mp4 \
-hls_time 4 \
-hls_key_info_file keyinfo.txt \
-hls_playlist_type vod \
-hls_segment_filename 'segment_%03d.ts' \
output.m3u8| 对比维度 | HLS AES-128 | DRM(Widevine/FairPlay) |
|---|---|---|
| 密钥存储 | m3u8 中明文 URL,可直接获取 | CDM 内部,硬件/软件保护 |
| 密钥获取方式 | 直接 HTTP GET | License 服务器 + 身份验证 |
| 解密位置 | 浏览器软件层 | CDM 可信执行环境 |
| 防屏幕录制 | 无法防止 | 可通过 HDCP 阻止(黑屏) |
| 防下载 | 无法防止(可提取分片+密钥) | 可有效阻止 |
| 实现成本 | 低(FFmpeg 一行命令) | 高(需 License 服务器) |
| 适用场景 | 内部培训、低价值内容 | 付费点播、院线同步、体育赛事 |
第七部分:DRM 服务商与 License 服务器
自建 vs SaaS
自建 License 服务器需要处理密钥生成与存储、License 请求解析与验证、商业规则引擎(地域限制、有效期、设备并发数)、证书轮换、安全审计等,技术门槛和合规成本都很高。对于大多数团队,使用 SaaS 服务商是更务实的选择。
主流 Multi-DRM 服务商按 License 请求量计费,提供开箱即用的 Widevine + FairPlay + PlayReady 三合一方案:
| 服务商 | 支持 DRM | 特点 |
|---|---|---|
| castLabs | Widevine / FairPlay / PlayReady | 欧洲总部,GDPR 合规,文档完善 |
| Axinom | 全方案 | 微软合作伙伴,PlayReady 深度集成 |
| BuyDRM | 全方案 | 美国,支持离线 DRM |
| PallyCon | 全方案 + 水印 | 韩国,亚洲节点多,中文支持好 |
| 阿里云 DRM | Widevine / FairPlay | 国内合规,低延迟,中文文档 |
密钥轮换
密钥轮换(Key Rotation)是指对视频的不同片段使用不同的加密密钥,即便攻击者获取了某个片段的密钥,也只能解密该片段,无法解密整部内容。在直播场景中,密钥轮换尤为重要——可以定期更换密钥,使已泄露的密钥快速失效。
// Shaka Player 处理密钥轮换(自动支持,无需额外配置)
// 每当遇到新的加密初始化数据,Shaka 会自动创建新的 session 并请求新 License
// 手动监听密钥轮换事件(用于日志和监控)
player.addEventListener('adaptation', () => {
const activeSessions = player.getMediaElement()
?.mediaKeys?.getSessions?.() ?? [];
console.log(`当前活跃 DRM Session 数: `);
});第八部分:离线 DRM 与设备绑定
持久化 License
离线播放需要持久化 License(Persistent License),与临时 License 的区别在于密钥可以被 CDM 持久化存储,下次启动应用时无需重新联网获取 License。这需要 License 服务器明确支持 persistent-license session 类型,且 CDM 支持本地持久化存储(移动端支持较好,桌面浏览器大多不支持)。
// 创建持久化 Session
const session = mediaKeys.createSession('persistent-license');
session.addEventListener('message', async (e) => {
const license = await fetchLicense(e.message);
await session.update(license);
// 持久化成功后,保存 sessionId 供下次恢复使用
const sessionId = session.sessionId;
localStorage.setItem('drm-session-id', sessionId);
console.log(`License 已持久化,sessionId: `);
});
await session.generateRequest(initDataType, initData);
// ——————————————————————————————————————
// 下次启动应用时,恢复持久化 License
async function restoreOfflineLicense(mediaKeys) {
const sessionId = localStorage.getItem('drm-session-id');
if (!sessionId) {
console.log('无持久化 License,需要联网获取');
return false;
}
const session = mediaKeys.createSession('persistent-license');
const loaded = await session.load(sessionId);
if (loaded) {
console.log('持久化 License 恢复成功,可离线播放');
return true;
} else {
// License 已过期或被撤销,清除本地记录
localStorage.removeItem('drm-session-id');
return false;
}
}
// 主动删除持久化 License(用户注销或内容下架时)
async function removeOfflineLicense(mediaKeys) {
const sessionId = localStorage.getItem('drm-session-id');
if (!sessionId) return;
const session = mediaKeys.createSession('persistent-license');
await session.load(sessionId);
await session.remove(); // 从 CDM 中删除持久化密钥
localStorage.removeItem('drm-session-id');
console.log('持久化 License 已删除');
}设备绑定与并发限制
商业 DRM 服务通常要求限制单账号的并发设备数(如 Netflix 限制同时播放设备数)。实现方式是在 License 请求时附带设备指纹,服务器端校验并拒绝超出限额的请求:
// 生成设备指纹(多维度特征组合)
async function getDeviceFingerprint() {
const components = [
navigator.userAgent,
navigator.language,
screen.width + 'x' + screen.height + 'x' + screen.colorDepth,
new Date().getTimezoneOffset().toString(),
navigator.hardwareConcurrency?.toString() ?? '',
navigator.deviceMemory?.toString() ?? '',
];
// Canvas 指纹(利用不同设备 GPU 渲染差异)
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.textBaseline = 'top';
ctx.font = '14px Arial';
ctx.fillText('DeviceFingerprint🎬', 2, 2);
components.push(canvas.toDataURL());
// 用 SubtleCrypto 计算哈希
const raw = components.join('|');
const buffer = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(raw)
);
return Array.from(new Uint8Array(buffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
// 在 License 请求中附带设备指纹
netEngine.registerRequestFilter(async (type, request) => {
if (type !== shaka.net.NetworkingEngine.RequestType.LICENSE) return;
const fingerprint = await getDeviceFingerprint();
request.headers['X-Device-Fingerprint'] = fingerprint;
request.headers['X-User-Token'] = getUserToken();
request.headers['X-Concurrent-Check'] = 'true';
});第九部分:常见问题与调试
黑屏 / 无法播放
这是最常见的 DRM 问题,原因通常是 HDCP 输出限制。当内容要求 HDCP 2.2 但显示链路不满足时,CDM 会将密钥状态设为 output-restricted,导致视频黑屏。解决方案是检测密钥状态并降级到较低分辨率的流:
session.addEventListener('keystatuseschange', () => {
let hasRestriction = false;
session.keyStatuses.forEach((status, keyId) => {
if (status === 'output-restricted') {
hasRestriction = true;
}
if (status === 'expired') {
// License 过期,触发续期流程
refreshLicense(session);
}
});
if (hasRestriction) {
// 降级到 720P 以下(不受 HDCP 限制)
const tracks = player.getVariantTracks()
.filter(t => t.height <= 720)
.sort((a, b) => b.bandwidth - a.bandwidth);
if (tracks.length > 0) {
player.selectVariantTrack(tracks[0], true);
} else {
alert('当前显示设备不支持播放此内容,请检查 HDCP 设置');
}
}
});License 请求 401 / 403
按以下顺序排查:
- License URL 是否正确:Widevine、FairPlay、PlayReady 各有独立 endpoint,不能混用。
- 认证 Token 是否有效:检查 Token 是否过期,是否正确放在
Authorization头中。 - 请求体格式是否符合服务器要求:部分服务器要求 base64 编码后放入 JSON,部分要求直接发送二进制。
- CORS 配置:License 服务器必须返回正确的
Access-Control-Allow-Origin头,否则浏览器会拦截 License 响应。
// 调试:打印 License 请求详情
netEngine.registerRequestFilter((type, request) => {
if (type !== shaka.net.NetworkingEngine.RequestType.LICENSE) return;
console.group('License 请求详情');
console.log('URL:', request.uris[0]);
console.log('Headers:', JSON.stringify(request.headers, null, 2));
console.log('Body (hex):',
Array.from(new Uint8Array(request.body))
.slice(0, 32)
.map(b => b.toString(16).padStart(2, '0'))
.join(' ') + '...'
);
console.groupEnd();
});
netEngine.registerResponseFilter((type, response) => {
if (type !== shaka.net.NetworkingEngine.RequestType.LICENSE) return;
console.log('License 响应状态:', response.status);
console.log('License 响应大小:', response.data.byteLength, 'bytes');
});Safari FairPlay 特有问题
Safari 的 FairPlay EME 实现有几个已知的坑,必须注意:
setServerCertificate()必须在generateRequest()之前调用,否则会抛出InvalidStateError。initData是 UTF-16 LE 编码的skd://URI,不是 UTF-8,解析时需要用Uint16Array。- License 响应(CKC)必须是纯二进制
ArrayBuffer,如果服务器返回 JSON,必须在响应过滤器中解包,否则session.update()会失败。 - 部分版本 Safari 不支持
persistent-licensesession 类型,离线播放需要使用 AVFoundation(Native App)。
// Safari FairPlay 常见错误处理
session.addEventListener('message', async (e) => {
try {
const response = await fetch(licenseUrl, {
method: 'POST',
body: e.message, // SPC 二进制
});
let ckc = await response.arrayBuffer();
// 如果服务器返回 base64 字符串,需要解码
const contentType = response.headers.get('Content-Type') || '';
if (contentType.includes('text') || contentType.includes('json')) {
const text = new TextDecoder().decode(ckc);
const parsed = contentType.includes('json')
? JSON.parse(text).ckc
: text.trim();
const binary = atob(parsed);
ckc = Uint8Array.from(binary, c => c.charCodeAt(0)).buffer;
}
await session.update(ckc);
} catch (err) {
// 区分错误类型
if (err.name === 'TypeError') {
console.error('CKC 格式错误,请检查 License 服务器响应格式');
} else if (err.name === 'QuotaExceededError') {
console.error('CDM Session 数量超限,请先关闭其他 Session');
} else {
console.error('FairPlay License 错误:', err);
}
}
});第十部分:DRM 完整接入清单
在实际项目中接入 DRM 时,以下检查清单可以帮助避免遗漏关键步骤:
内容侧准备:视频已用 CENC 加密(Widevine/PlayReady 用 cenc 模式,FairPlay 用 cbcs 模式);DASH manifest 中包含 <ContentProtection> 元素;HLS m3u8 中包含 #EXT-X-KEY 且 URI 为 skd:// 格式;CDN 已配置正确的 CORS 头。
License 服务器侧准备:Widevine、FairPlay、PlayReady 各有独立 endpoint;服务器能正确验证用户身份和业务规则;已配置 Access-Control-Allow-Origin 允许跨域;FairPlay 已完成 Apple 的 FPS 部署包申请并上传证书。
前端侧准备:使用 Shaka Player 或等效库封装 Multi-DRM 逻辑;实现请求过滤器注入认证头;实现响应过滤器处理非标准格式;实现密钥状态监听处理 output-restricted 和 expired;实现错误分类处理和自动重试;在低端设备上实现降级策略。
测试验证:在 Chrome(Widevine L3)、Safari(FairPlay)、Edge(PlayReady)三个环境分别验证;模拟 License 服务器返回 401/403 验证错误处理;模拟网络断开验证重试逻辑;在移动端验证 Widevine L1 播放 1080P 内容。