DRM 완벽 가이드 | Widevine·FairPlay·PlayReady·AES-128 총정리
이 글의 핵심
DRM(Digital Rights Management) 기술의 모든 것. Widevine, FairPlay, PlayReady, AES-128 암호화, EME, CENC 표준부터 Netflix, YouTube, Spotify의 DRM 구현까지 실전 예제로 완벽 이해.
들어가며: DRM이 필요한 이유
Netflix, Disney+, Spotify 같은 스트리밍 서비스는 어떻게 콘텐츠를 보호할까요? 답은 DRM(Digital Rights Management)입니다. DRM은 디지털 콘텐츠의 불법 복제를 방지하고, 승인된 사용자만 재생할 수 있게 하는 기술입니다.
이 글에서 다룰 내용:
- 주요 DRM 시스템 (Widevine, FairPlay, PlayReady)
- DRM 동작 원리와 아키텍처
- EME (Encrypted Media Extensions)
- CENC (Common Encryption) 표준
- 실전 구현 예제
목차
- DRM 기본 개념
- 주요 DRM 시스템
- Widevine (Google)
- FairPlay (Apple)
- PlayReady (Microsoft)
- EME와 CENC
- HLS AES-128 암호화
- 실전 구현
- DRM 우회 방지
- 비교 및 선택 가이드
1. DRM 기본 개념
DRM이란?
DRM(Digital Rights Management)은 디지털 콘텐츠의 암호화, 라이선스 관리, 접근 제어를 통해 저작권을 보호하는 기술입니다.
DRM 구성 요소
flowchart TB
subgraph Content[콘텐츠 제공자]
Video[원본 비디오]
Encoder[인코더/패키저]
KeyServer[키 서버]
end
subgraph CDN[CDN]
Encrypted[암호화된<br/>콘텐츠]
end
subgraph Client[클라이언트]
Player[비디오 플레이어]
CDM[CDM<br/>Content Decryption Module]
TEE[TEE/Hardware<br/>보안 영역]
end
subgraph License[라이선스 서버]
Auth[인증]
LicenseDB[라이선스 DB]
end
Video --> Encoder
Encoder -->|암호화| Encrypted
Encoder -->|키 저장| KeyServer
Encrypted --> Player
Player -->|라이선스 요청| Auth
Auth -->|검증| LicenseDB
LicenseDB -->|키 전달| CDM
CDM -->|복호화| TEE
TEE -->|재생| Player
DRM 동작 흐름
sequenceDiagram
participant User as 사용자
participant Player as 플레이어
participant CDN
participant License as 라이선스 서버
participant CDM as CDM (보안 모듈)
User->>Player: 비디오 재생 요청
Player->>CDN: 암호화된 콘텐츠 요청
CDN->>Player: 암호화된 세그먼트 전달
Player->>License: 라이선스 요청<br/>(사용자 인증 토큰)
License->>License: 권한 확인<br/>(구독, 지역, 기기)
License->>Player: 라이선스 + 복호화 키
Player->>CDM: 복호화 요청
CDM->>CDM: 하드웨어 보안 영역에서<br/>복호화
CDM->>Player: 복호화된 프레임
Player->>User: 비디오 재생
2. 주요 DRM 시스템
DRM 생태계
flowchart TB
subgraph Google
Widevine[Widevine]
Chrome[Chrome]
Android[Android]
end
subgraph Apple
FairPlay[FairPlay]
Safari[Safari]
iOS[iOS/tvOS]
end
subgraph Microsoft
PlayReady[PlayReady]
Edge[Edge]
Xbox[Xbox]
end
subgraph Open
ClearKey[ClearKey]
AES128[HLS AES-128]
end
Widevine --> Chrome
Widevine --> Android
FairPlay --> Safari
FairPlay --> iOS
PlayReady --> Edge
PlayReady --> Xbox
플랫폼별 DRM 지원
| 플랫폼 | Widevine | FairPlay | PlayReady | ClearKey |
|---|---|---|---|---|
| Chrome | ✅ L1/L3 | ❌ | ❌ | ✅ |
| Safari | ❌ | ✅ | ❌ | ✅ |
| Edge | ✅ | ❌ | ✅ | ✅ |
| Firefox | ✅ L3 | ❌ | ❌ | ✅ |
| Android | ✅ L1/L3 | ❌ | ❌ | ✅ |
| iOS | ❌ | ✅ | ❌ | ✅ |
| Xbox | ❌ | ❌ | ✅ | ❌ |
3. Widevine (Google)
Widevine이란?
Widevine은 Google이 개발한 DRM으로, Android와 Chrome에서 널리 사용됩니다. Netflix, YouTube Premium, Disney+가 사용합니다.
Widevine 보안 레벨
flowchart TB
subgraph L1[Widevine L1<br/>Hardware-backed]
L1_Desc[✅ TEE/Secure Processor<br/>✅ 4K/HDR 지원<br/>✅ 최고 보안]
end
subgraph L2[Widevine L2<br/>Software-backed]
L2_Desc[⚠️ 소프트웨어 보안<br/>⚠️ 1080p 제한<br/>⚠️ 중간 보안]
end
subgraph L3[Widevine L3<br/>Software only]
L3_Desc[❌ 기본 보안<br/>❌ 480p/SD 제한<br/>❌ 낮은 보안]
end
L1 --> Best[최고 화질]
L2 --> Medium[중간 화질]
L3 --> Low[낮은 화질]
Widevine 아키�ecture
flowchart TB
subgraph App[애플리케이션]
Player[Video Player]
EME[EME API]
end
subgraph Browser[브라우저]
CDM[Widevine CDM]
end
subgraph Hardware[하드웨어 (L1)]
TEE[Trusted Execution<br/>Environment]
SecureVideo[Secure Video Path]
end
subgraph Backend[백엔드]
License[Widevine<br/>License Server]
Content[암호화된<br/>콘텐츠]
end
Player --> EME
EME --> CDM
CDM --> TEE
Player --> Content
CDM --> License
TEE --> SecureVideo
SecureVideo --> Display[디스플레이]
Widevine 구현 예시
// HTML5 Video + EME (Encrypted Media Extensions)
const video = document.querySelector('video');
const config = [{
initDataTypes: ['cenc'],
videoCapabilities: [{
contentType: 'video/mp4; codecs="avc1.42E01E"',
robustness: 'SW_SECURE_CRYPTO' // L3
// robustness: 'HW_SECURE_ALL' // L1
}],
audioCapabilities: [{
contentType: 'audio/mp4; codecs="mp4a.40.2"',
robustness: 'SW_SECURE_CRYPTO'
}]
}];
// MediaKeys 생성
navigator.requestMediaKeySystemAccess('com.widevine.alpha', config)
.then(keySystemAccess => {
return keySystemAccess.createMediaKeys();
})
.then(mediaKeys => {
return video.setMediaKeys(mediaKeys);
})
.then(() => {
console.log('✅ Widevine initialized');
// 비디오 로드
video.src = 'https://cdn.example.com/encrypted-video.mp4';
})
.catch(err => {
console.error('❌ DRM error:', err);
});
// 라이선스 요청 처리
video.addEventListener('encrypted', (event) => {
const session = video.mediaKeys.createSession();
session.addEventListener('message', (event) => {
// 라이선스 서버에 요청
fetch('https://license.example.com/widevine', {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
'Authorization': 'Bearer ' + userToken
},
body: event.message
})
.then(response => response.arrayBuffer())
.then(license => {
// 라이선스 적용
return session.update(license);
})
.then(() => {
console.log('✅ License acquired');
})
.catch(err => {
console.error('❌ License error:', err);
});
});
// 세션 생성
session.generateRequest(event.initDataType, event.initData);
});
Widevine 레벨 확인
// 브라우저에서 Widevine 레벨 확인
async function checkWidevineLevel() {
const configs = [
{
label: 'L1 (Hardware)',
config: [{
initDataTypes: ['cenc'],
videoCapabilities: [{
contentType: 'video/mp4; codecs="avc1.42E01E"',
robustness: 'HW_SECURE_ALL'
}]
}]
},
{
label: 'L3 (Software)',
config: [{
initDataTypes: ['cenc'],
videoCapabilities: [{
contentType: 'video/mp4; codecs="avc1.42E01E"',
robustness: 'SW_SECURE_CRYPTO'
}]
}]
}
];
for (const {label, config} of configs) {
try {
await navigator.requestMediaKeySystemAccess('com.widevine.alpha', config);
console.log(`✅ ${label} supported`);
return label;
} catch (e) {
console.log(`❌ ${label} not supported`);
}
}
return 'Not supported';
}
checkWidevineLevel();
4. FairPlay (Apple)
FairPlay란?
FairPlay는 Apple이 개발한 DRM으로, iOS, macOS, tvOS, Safari에서 사용됩니다. HLS (HTTP Live Streaming)와 통합되어 있습니다.
FairPlay 동작 흐름
sequenceDiagram
participant Player as AVPlayer
participant App as 앱
participant KSM as Key Server Module
participant License as FairPlay License Server
Player->>App: 암호화된 콘텐츠 감지
App->>KSM: SPC 요청<br/>(Server Playback Context)
KSM->>App: SPC 생성
App->>License: SPC + 인증 토큰
License->>License: 권한 검증
License->>App: CKC<br/>(Content Key Context)
App->>KSM: CKC 전달
KSM->>Player: 복호화 키
Player->>Player: 콘텐츠 재생
FairPlay 구현 (iOS/Swift)
import AVFoundation
class FairPlayManager: NSObject {
var asset: AVURLAsset?
var resourceLoaderDelegate: FairPlayResourceLoaderDelegate?
func playVideo(url: URL, certificateURL: URL, licenseURL: URL) {
// AVURLAsset 생성
asset = AVURLAsset(url: url)
// Resource Loader Delegate 설정
resourceLoaderDelegate = FairPlayResourceLoaderDelegate(
certificateURL: certificateURL,
licenseURL: licenseURL
)
asset?.resourceLoader.setDelegate(
resourceLoaderDelegate,
queue: DispatchQueue.main
)
// AVPlayer로 재생
let playerItem = AVPlayerItem(asset: asset!)
let player = AVPlayer(playerItem: playerItem)
player.play()
}
}
class FairPlayResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate {
let certificateURL: URL
let licenseURL: URL
var certificate: Data?
init(certificateURL: URL, licenseURL: URL) {
self.certificateURL = certificateURL
self.licenseURL = licenseURL
super.init()
// 인증서 다운로드
loadCertificate()
}
func loadCertificate() {
URLSession.shared.dataTask(with: certificateURL) { data, _, error in
if let data = data {
self.certificate = data
print("✅ FairPlay certificate loaded")
}
}.resume()
}
func resourceLoader(
_ resourceLoader: AVAssetResourceLoader,
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
) -> Bool {
guard let url = loadingRequest.request.url,
let certificate = certificate else {
return false
}
// skd:// URL 처리
guard url.scheme == "skd" else {
return false
}
// SPC (Server Playback Context) 생성
let contentId = url.host ?? ""
let contentIdData = contentId.data(using: .utf8)!
do {
let spcData = try loadingRequest.streamingContentKeyRequestData(
forApp: certificate,
contentIdentifier: contentIdData,
options: nil
)
// 라이선스 서버에 요청
requestLicense(spc: spcData, contentId: contentId) { ckcData in
if let ckc = ckcData {
// CKC (Content Key Context) 적용
loadingRequest.dataRequest?.respond(with: ckc)
loadingRequest.finishLoading()
print("✅ FairPlay license acquired")
} else {
loadingRequest.finishLoading(with: NSError(
domain: "FairPlay",
code: -1,
userInfo: nil
))
}
}
return true
} catch {
print("❌ SPC generation failed: \(error)")
return false
}
}
func requestLicense(spc: Data, contentId: String, completion: @escaping (Data?) -> Void) {
var request = URLRequest(url: licenseURL)
request.httpMethod = "POST"
request.httpBody = spc
request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data {
completion(data)
} else {
completion(nil)
}
}.resume()
}
}
FairPlay HLS 매니페스트
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
# FairPlay 키 정보
#EXT-X-KEY:METHOD=SAMPLE-AES,URI="skd://content-id-12345",KEYFORMAT="com.apple.streamingkeydelivery",KEYFORMATVERSIONS="1"
# 세그먼트
#EXTINF:10.0,
segment-0.m4s
#EXTINF:10.0,
segment-1.m4s
#EXTINF:10.0,
segment-2.m4s
5. PlayReady (Microsoft)
PlayReady란?
PlayReady는 Microsoft가 개발한 DRM으로, Windows, Xbox, Edge에서 사용됩니다.
PlayReady 아키텍처
flowchart TB
subgraph Client[클라이언트]
App[애플리케이션]
PRT[PlayReady Runtime]
TEE[Trusted Execution<br/>Environment]
end
subgraph Server[서버]
Content[암호화된 콘텐츠]
License[PlayReady<br/>License Server]
end
App --> PRT
PRT --> TEE
App --> Content
PRT --> License
License -->|라이선스| PRT
Content -->|암호화된 데이터| PRT
PRT -->|복호화| TEE
PlayReady 구현 (C#)
using Windows.Media.Protection;
using Windows.Media.Protection.PlayReady;
public class PlayReadyManager
{
private MediaProtectionManager protectionManager;
public void Initialize()
{
// MediaProtectionManager 생성
protectionManager = new MediaProtectionManager();
// PlayReady 서비스 요청 처리
protectionManager.ServiceRequested += OnServiceRequested;
// 컴포넌트 로드 실패 처리
protectionManager.ComponentLoadFailed += OnComponentLoadFailed;
// PlayReady 속성 설정
var props = new Windows.Foundation.Collections.PropertySet();
props.Add(
"{F4637010-03C3-42CD-B932-B48ADF3A6A54}",
"Windows.Media.Protection.PlayReady.PlayReadyWinRTTrustedInput"
);
protectionManager.Properties.Add("Windows.Media.Protection.MediaProtectionSystemId",
"{F4637010-03C3-42CD-B932-B48ADF3A6A54}");
protectionManager.Properties.Add("Windows.Media.Protection.MediaProtectionSystemIdMapping",
props);
}
private async void OnServiceRequested(
MediaProtectionManager sender,
ServiceRequestedEventArgs args)
{
if (args.Request is PlayReadyIndividualizationServiceRequest)
{
// 개별화 요청
var request = args.Request as PlayReadyIndividualizationServiceRequest;
await request.BeginServiceRequest();
}
else if (args.Request is PlayReadyLicenseAcquisitionServiceRequest)
{
// 라이선스 요청
var request = args.Request as PlayReadyLicenseAcquisitionServiceRequest;
// 커스텀 헤더 추가 (인증 토큰 등)
request.ChallengeCustomData = GetAuthToken();
await request.BeginServiceRequest();
Console.WriteLine("✅ PlayReady license acquired");
}
}
private void OnComponentLoadFailed(
MediaProtectionManager sender,
ComponentLoadFailedEventArgs args)
{
Console.WriteLine($"❌ Component load failed: {args.Information.Items}");
}
private string GetAuthToken()
{
// 사용자 인증 토큰 생성
return "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...";
}
}
6. EME와 CENC
EME (Encrypted Media Extensions)
EME는 W3C 표준으로, 브라우저에서 DRM 콘텐츠를 재생하기 위한 JavaScript API입니다.
// EME 지원 확인
if ('mediaKeys' in HTMLMediaElement.prototype) {
console.log('✅ EME supported');
} else {
console.log('❌ EME not supported');
}
// 지원되는 DRM 시스템 확인
const drmSystems = [
'com.widevine.alpha', // Widevine
'com.apple.fps.1_0', // FairPlay
'com.microsoft.playready', // PlayReady
'org.w3.clearkey' // ClearKey
];
for (const system of drmSystems) {
try {
await navigator.requestMediaKeySystemAccess(system, [{}]);
console.log(`✅ ${system} supported`);
} catch (e) {
console.log(`❌ ${system} not supported`);
}
}
CENC (Common Encryption)
CENC는 하나의 암호화된 파일을 여러 DRM 시스템에서 사용할 수 있게 하는 표준입니다.
flowchart TB
Original[원본 비디오] --> Encoder[인코더]
Encoder --> CENC[CENC 암호화<br/>단일 파일]
CENC --> Chrome[Chrome<br/>Widevine]
CENC --> Safari[Safari<br/>FairPlay]
CENC --> Edge[Edge<br/>PlayReady]
Chrome --> Play1[재생]
Safari --> Play2[재생]
Edge --> Play3[재생]
CENC 암호화 (Shaka Packager)
# Shaka Packager로 CENC 암호화
packager \
in=input.mp4,stream=video,output=video_encrypted.mp4 \
in=input.mp4,stream=audio,output=audio_encrypted.mp4 \
--enable_raw_key_encryption \
--keys label=:key_id=<KEY_ID>:key=<KEY> \
--protection_scheme cenc \
--clear_lead 3 \
--mpd_output manifest.mpd
# 여러 DRM 시스템 지원
packager \
in=input.mp4,stream=video,output=video.mp4 \
--enable_widevine_encryption \
--key_server_url "https://license.widevine.com/cenc/getcontentkey" \
--content_id "content-123" \
--signer "widevine_test" \
--enable_playready_encryption \
--playready_server_url "https://playready.example.com" \
--enable_fairplay_encryption \
--fairplay_key_uri "skd://fairplay-key-123"
7. HLS AES-128 암호화
HLS AES-128이란?
HLS AES-128은 Apple의 HTTP Live Streaming에서 사용하는 간단한 암호화 방식입니다. 완전한 DRM은 아니지만, 기본적인 콘텐츠 보호를 제공합니다.
HLS 암호화 매니페스트
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
# AES-128 암호화 키 정보
#EXT-X-KEY:METHOD=AES-128,URI="https://example.com/key.bin",IV=0x12345678901234567890123456789012
# 세그먼트
#EXTINF:10.0,
segment-0.ts
#EXTINF:10.0,
segment-1.ts
#EXTINF:10.0,
segment-2.ts
HLS 암호화 생성 (FFmpeg)
# 1. 암호화 키 생성 (16바이트)
openssl rand 16 > enc.key
# 2. IV 생성 (16바이트)
openssl rand -hex 16 > enc.iv
# 3. 키 정보 파일 생성
echo "https://example.com/key.bin" > enc.keyinfo
cat enc.key >> enc.keyinfo
cat enc.iv >> enc.keyinfo
# 4. FFmpeg로 암호화
ffmpeg -i input.mp4 \
-c:v libx264 -c:a aac \
-hls_time 10 \
-hls_key_info_file enc.keyinfo \
-hls_playlist_type vod \
-hls_segment_filename "segment_%03d.ts" \
playlist.m3u8
# 생성된 파일:
# - playlist.m3u8 (매니페스트)
# - segment_000.ts, segment_001.ts, ... (암호화된 세그먼트)
HLS 복호화 (Python)
from Crypto.Cipher import AES
import requests
def decrypt_hls_segment(segment_url, key_url, iv):
"""HLS 세그먼트 복호화"""
# 암호화 키 다운로드
key_response = requests.get(key_url)
key = key_response.content
# 암호화된 세그먼트 다운로드
segment_response = requests.get(segment_url)
encrypted_data = segment_response.content
# AES-128 CBC 복호화
cipher = AES.new(key, AES.MODE_CBC, iv)
decrypted_data = cipher.decrypt(encrypted_data)
# PKCS7 패딩 제거
padding_length = decrypted_data[-1]
decrypted_data = decrypted_data[:-padding_length]
return decrypted_data
# 사용
iv = bytes.fromhex('12345678901234567890123456789012')
decrypted = decrypt_hls_segment(
'https://cdn.example.com/segment_000.ts',
'https://example.com/key.bin',
iv
)
with open('decrypted_segment.ts', 'wb') as f:
f.write(decrypted)
8. 실전 구현
멀티 DRM 플레이어
class MultiDRMPlayer {
constructor(videoElement) {
this.video = videoElement;
this.drmSystem = null;
}
async initialize(manifestUrl, licenseUrl, authToken) {
// DRM 시스템 감지
this.drmSystem = await this.detectDRM();
console.log(`🔒 DRM System: ${this.drmSystem}`);
// DRM 설정
await this.setupDRM(licenseUrl, authToken);
// 비디오 로드
this.video.src = manifestUrl;
await this.video.play();
}
async detectDRM() {
// 플랫폼별 DRM 감지
const ua = navigator.userAgent;
if (/iPhone|iPad|iPod/.test(ua)) {
return 'fairplay';
} else if (/Android/.test(ua)) {
return 'widevine';
} else if (/Windows/.test(ua)) {
// Edge는 PlayReady와 Widevine 모두 지원
return 'widevine'; // 또는 'playready'
} else if (/Mac/.test(ua)) {
return 'fairplay';
}
return 'widevine'; // 기본값
}
async setupDRM(licenseUrl, authToken) {
if (this.drmSystem === 'fairplay') {
await this.setupFairPlay(licenseUrl, authToken);
} else if (this.drmSystem === 'widevine') {
await this.setupWidevine(licenseUrl, authToken);
} else if (this.drmSystem === 'playready') {
await this.setupPlayReady(licenseUrl, authToken);
}
}
async setupWidevine(licenseUrl, authToken) {
const config = [{
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'
}]
}];
const keySystemAccess = await navigator.requestMediaKeySystemAccess(
'com.widevine.alpha',
config
);
const mediaKeys = await keySystemAccess.createMediaKeys();
await this.video.setMediaKeys(mediaKeys);
// 라이선스 요청 처리
this.video.addEventListener('encrypted', (event) => {
const session = mediaKeys.createSession();
session.addEventListener('message', async (event) => {
const response = await fetch(licenseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
'Authorization': `Bearer ${authToken}`
},
body: event.message
});
const license = await response.arrayBuffer();
await session.update(license);
});
session.generateRequest(event.initDataType, event.initData);
});
console.log('✅ Widevine initialized');
}
async setupFairPlay(licenseUrl, authToken) {
// FairPlay는 네이티브 구현 필요
console.log('ℹ️ FairPlay requires native implementation');
}
async setupPlayReady(licenseUrl, authToken) {
const config = [{
initDataTypes: ['cenc'],
videoCapabilities: [{
contentType: 'video/mp4; codecs="avc1.42E01E"'
}]
}];
const keySystemAccess = await navigator.requestMediaKeySystemAccess(
'com.microsoft.playready',
config
);
const mediaKeys = await keySystemAccess.createMediaKeys();
await this.video.setMediaKeys(mediaKeys);
console.log('✅ PlayReady initialized');
}
}
// 사용
const player = new MultiDRMPlayer(document.querySelector('video'));
player.initialize(
'https://cdn.example.com/manifest.mpd',
'https://license.example.com/widevine',
'user-auth-token-123'
);
9. DRM 우회 방지
보안 레이어
flowchart TB
subgraph Layer1[레이어 1: 암호화]
Encrypt[AES-128/256<br/>콘텐츠 암호화]
end
subgraph Layer2[레이어 2: 키 관리]
KeyRotation[키 로테이션]
KeyDerivation[키 파생]
end
subgraph Layer3[레이어 3: 하드웨어 보안]
TEE[Trusted Execution<br/>Environment]
SecurePath[Secure Video Path]
end
subgraph Layer4[레이어 4: 애플리케이션]
Obfuscation[코드 난독화]
AntiDebug[디버깅 방지]
Watermark[워터마크]
end
subgraph Layer5[레이어 5: 네트워크]
TokenAuth[토큰 인증]
GeoBlock[지역 차단]
RateLimit[재생 횟수 제한]
end
Encrypt --> KeyRotation
KeyRotation --> TEE
TEE --> Obfuscation
Obfuscation --> TokenAuth
워터마킹
from PIL import Image, ImageDraw, ImageFont
import hashlib
def add_forensic_watermark(video_frame, user_id, timestamp):
"""포렌식 워터마크 추가"""
img = Image.fromarray(video_frame)
draw = ImageDraw.Draw(img)
# 사용자 식별 정보 해시
watermark_data = f"{user_id}:{timestamp}"
watermark_hash = hashlib.sha256(watermark_data.encode()).hexdigest()[:16]
# 보이지 않는 워터마크 (LSB 스테가노그래피)
pixels = img.load()
width, height = img.size
for i, bit in enumerate(watermark_hash):
if i >= width * height:
break
x = i % width
y = i // width
r, g, b = pixels[x, y]
# LSB에 워터마크 비트 삽입
r = (r & 0xFE) | (ord(bit) & 0x01)
pixels[x, y] = (r, g, b)
return img
# 워터마크 추출
def extract_watermark(video_frame):
"""워터마크 추출"""
img = Image.fromarray(video_frame)
pixels = img.load()
width, height = img.size
watermark_bits = []
for i in range(16): # 16자 해시
x = i % width
y = i // width
r, g, b = pixels[x, y]
watermark_bits.append(chr(r & 0x01))
return ''.join(watermark_bits)
화면 캡처 방지
// 화면 캡처 감지 (완벽하지 않음)
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
// 화면 캡처 의심
console.warn('⚠️ Screen capture suspected');
video.pause();
}
});
// 개발자 도구 감지
setInterval(() => {
const before = new Date();
debugger; // 개발자 도구 열려있으면 멈춤
const after = new Date();
if (after - before > 100) {
console.warn('⚠️ DevTools detected');
video.pause();
}
}, 1000);
// 우클릭 방지
video.addEventListener('contextmenu', (e) => {
e.preventDefault();
});
// 드래그 방지
video.addEventListener('dragstart', (e) => {
e.preventDefault();
});
실전 시나리오
시나리오 1: Netflix 스타일 DRM
from flask import Flask, request, jsonify
import jwt
import time
import hashlib
app = Flask(__name__)
class DRMLicenseServer:
def __init__(self):
self.content_keys = {} # content_id -> key
self.user_licenses = {} # user_id -> [content_ids]
def generate_content_key(self, content_id):
"""콘텐츠 암호화 키 생성"""
key = hashlib.sha256(f"{content_id}:{time.time()}".encode()).digest()[:16]
self.content_keys[content_id] = key
return key
def verify_user_access(self, user_id, content_id):
"""사용자 접근 권한 확인"""
# 구독 상태 확인
subscription = get_user_subscription(user_id)
if not subscription or not subscription.is_active():
return False
# 지역 제한 확인
user_country = get_user_country(user_id)
content_regions = get_content_regions(content_id)
if user_country not in content_regions:
return False
# 동시 재생 제한 확인
active_sessions = get_active_sessions(user_id)
if len(active_sessions) >= subscription.max_streams:
return False
return True
def issue_license(self, user_id, content_id, device_id):
"""라이선스 발급"""
if not self.verify_user_access(user_id, content_id):
return None
# 라이선스 생성
license_data = {
'user_id': user_id,
'content_id': content_id,
'device_id': device_id,
'issued_at': int(time.time()),
'expires_at': int(time.time()) + 86400, # 24시간
'max_resolution': '1080p',
'offline_allowed': False
}
# JWT로 서명
license_token = jwt.encode(
license_data,
'secret-key',
algorithm='HS256'
)
# 콘텐츠 키 (암호화되어 전달)
content_key = self.content_keys.get(content_id)
return {
'license': license_token,
'key': content_key.hex(),
'expires_in': 86400
}
@app.route('/license/widevine', methods=['POST'])
def widevine_license():
# Widevine 라이선스 요청 처리
challenge = request.data # Widevine challenge
auth_token = request.headers.get('Authorization')
# 토큰 검증
try:
payload = jwt.decode(auth_token.replace('Bearer ', ''), 'secret-key', algorithms=['HS256'])
user_id = payload['user_id']
content_id = payload['content_id']
except:
return jsonify({'error': 'Invalid token'}), 401
# 라이선스 발급
drm = DRMLicenseServer()
license_data = drm.issue_license(user_id, content_id, request.remote_addr)
if not license_data:
return jsonify({'error': 'Access denied'}), 403
# Widevine 라이선스 응답 (실제로는 Widevine SDK 사용)
return license_data['key'], 200, {'Content-Type': 'application/octet-stream'}
시나리오 2: 오프라인 재생 (다운로드)
// 오프라인 DRM 콘텐츠 저장
class OfflineDRMPlayer {
async downloadContent(manifestUrl, licenseUrl, authToken) {
// Persistent License 요청
const config = [{
initDataTypes: ['cenc'],
videoCapabilities: [{
contentType: 'video/mp4; codecs="avc1.42E01E"'
}],
persistentState: 'required', // 오프라인 지원
sessionTypes: ['persistent-license']
}];
const keySystemAccess = await navigator.requestMediaKeySystemAccess(
'com.widevine.alpha',
config
);
const mediaKeys = await keySystemAccess.createMediaKeys();
// Persistent Session 생성
const session = mediaKeys.createSession('persistent-license');
// 라이선스 요청
session.addEventListener('message', async (event) => {
const response = await fetch(licenseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
'Authorization': `Bearer ${authToken}`
},
body: event.message
});
const license = await response.arrayBuffer();
await session.update(license);
// Session ID 저장 (나중에 재사용)
const sessionId = session.sessionId;
localStorage.setItem('drm-session-' + manifestUrl, sessionId);
console.log('✅ Persistent license stored');
});
// 콘텐츠 다운로드 (Service Worker 사용)
await this.downloadSegments(manifestUrl);
}
async playOffline(manifestUrl) {
// 저장된 Session ID 로드
const sessionId = localStorage.getItem('drm-session-' + manifestUrl);
if (!sessionId) {
throw new Error('No offline license found');
}
// MediaKeys 복원
const keySystemAccess = await navigator.requestMediaKeySystemAccess(
'com.widevine.alpha',
[{ persistentState: 'required' }]
);
const mediaKeys = await keySystemAccess.createMediaKeys();
await this.video.setMediaKeys(mediaKeys);
// Session 로드
const session = mediaKeys.createSession('persistent-license');
await session.load(sessionId);
// 오프라인 콘텐츠 재생
this.video.src = 'offline://' + manifestUrl;
await this.video.play();
console.log('✅ Playing offline content');
}
async downloadSegments(manifestUrl) {
// Service Worker에서 세그먼트 다운로드 및 캐싱
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('download-' + manifestUrl);
}
}
DRM 콘텐츠 패키징
Shaka Packager로 멀티 DRM
#!/bin/bash
INPUT="input.mp4"
OUTPUT_DIR="output"
# Widevine + PlayReady + FairPlay 동시 지원
packager \
in=$INPUT,stream=video,output=$OUTPUT_DIR/video.mp4 \
in=$INPUT,stream=audio,output=$OUTPUT_DIR/audio.mp4 \
--mpd_output $OUTPUT_DIR/manifest.mpd \
--hls_master_playlist_output $OUTPUT_DIR/master.m3u8 \
\
--enable_widevine_encryption \
--key_server_url "https://license.widevine.com/cenc/getcontentkey/widevine_test" \
--content_id "content-123" \
--signer "widevine_test" \
--aes_signing_key "1ae8ccd0e7985cc0b6203a55855a1034afc252980e970ca90e5202689f947ab9" \
--aes_signing_iv "d58ce954203b7c9a9a9d467f59839249" \
\
--enable_playready_encryption \
--playready_server_url "https://playready.example.com/rightsmanager.asmx" \
--playready_key_id "10000000100010001000100000000001" \
--playready_key "3a2a1b68dd2b9d8b8d8d8d8d8d8d8d8d" \
\
--enable_fairplay_encryption \
--fairplay_key_uri "skd://fairplay-key-123" \
\
--protection_scheme cenc \
--clear_lead 3
echo "✅ Multi-DRM packaging complete"
DASH 매니페스트 (CENC)
<?xml version="1.0"?>
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011">
<Period>
<AdaptationSet mimeType="video/mp4" codecs="avc1.42E01E">
<!-- DRM 정보 -->
<ContentProtection schemeIdUri="urn:mpeg:dash:mp4protection:2011" value="cenc"/>
<!-- Widevine -->
<ContentProtection schemeIdUri="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed">
<cenc:pssh>AAAAA...</cenc:pssh>
</ContentProtection>
<!-- PlayReady -->
<ContentProtection schemeIdUri="urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95">
<cenc:pssh>BBBBB...</cenc:pssh>
</ContentProtection>
<Representation bandwidth="1000000" width="1280" height="720">
<SegmentTemplate media="video_$Number$.m4s" initialization="video_init.mp4"/>
</Representation>
</AdaptationSet>
</Period>
</MPD>
라이선스 서버 구현
Node.js 라이선스 서버
const express = require('express');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const app = express();
app.use(express.raw({ type: 'application/octet-stream', limit: '10mb' }));
// 콘텐츠 키 저장소
const contentKeys = new Map();
// 라이선스 발급
app.post('/license/:drm', async (req, res) => {
const drmType = req.params.drm; // widevine, fairplay, playready
const challenge = req.body;
const authToken = req.headers.authorization?.replace('Bearer ', '');
try {
// 토큰 검증
const payload = jwt.verify(authToken, process.env.JWT_SECRET);
const { userId, contentId, deviceId } = payload;
// 권한 확인
const hasAccess = await verifyUserAccess(userId, contentId);
if (!hasAccess) {
return res.status(403).json({ error: 'Access denied' });
}
// 콘텐츠 키 조회
let contentKey = contentKeys.get(contentId);
if (!contentKey) {
// 새 키 생성
contentKey = crypto.randomBytes(16);
contentKeys.set(contentId, contentKey);
}
// DRM별 라이선스 생성
let license;
if (drmType === 'widevine') {
license = await generateWidevineLicense(challenge, contentKey, userId);
} else if (drmType === 'fairplay') {
license = await generateFairPlayLicense(challenge, contentKey, userId);
} else if (drmType === 'playready') {
license = await generatePlayReadyLicense(challenge, contentKey, userId);
}
// 재생 로그
await logPlayback(userId, contentId, deviceId, drmType);
res.set('Content-Type', 'application/octet-stream');
res.send(license);
console.log(`✅ License issued: ${drmType} for user ${userId}`);
} catch (err) {
console.error('❌ License error:', err);
res.status(401).json({ error: 'Invalid token' });
}
});
async function verifyUserAccess(userId, contentId) {
// 구독 확인
const subscription = await getSubscription(userId);
if (!subscription || !subscription.active) {
return false;
}
// 지역 제한 확인
const userCountry = await getUserCountry(userId);
const contentRegions = await getContentRegions(contentId);
if (!contentRegions.includes(userCountry)) {
return false;
}
// 동시 재생 제한
const activeSessions = await getActiveSessions(userId);
if (activeSessions.length >= subscription.maxStreams) {
return false;
}
return true;
}
async function generateWidevineLicense(challenge, contentKey, userId) {
// 실제로는 Widevine SDK 사용
// 여기서는 간소화된 예시
// 1. Challenge 파싱
// 2. 콘텐츠 키로 라이선스 생성
// 3. 사용자별 제한 사항 추가 (해상도, 기간 등)
const license = Buffer.from('widevine-license-data');
return license;
}
app.listen(3000, () => {
console.log('🔒 DRM License Server running on port 3000');
});
DRM 비교
기술적 비교
| DRM | 개발사 | 플랫폼 | 보안 레벨 | 최대 화질 | 오프라인 |
|---|---|---|---|---|---|
| Widevine L1 | Android, Chrome | 하드웨어 | 4K | ✅ | |
| Widevine L3 | Chrome, Firefox | 소프트웨어 | 480p | ❌ | |
| FairPlay | Apple | iOS, Safari | 하드웨어 | 4K | ✅ |
| PlayReady | Microsoft | Windows, Xbox | 하드웨어 | 4K | ✅ |
| HLS AES-128 | Apple | 모든 플랫폼 | 기본 | 제한 없음 | ✅ |
| ClearKey | W3C | 모든 플랫폼 | 없음 | 제한 없음 | ✅ |
서비스별 DRM 사용
flowchart TB
subgraph Netflix
N_W[Widevine L1/L3]
N_F[FairPlay]
N_P[PlayReady]
end
subgraph YouTube_Premium
Y_W[Widevine L3]
Y_F[FairPlay]
end
subgraph Disney_Plus
D_W[Widevine L1]
D_F[FairPlay]
D_P[PlayReady]
end
subgraph Spotify
S_W[Widevine L3]
S_F[FairPlay]
end
DRM 우회 기법과 대응
일반적인 우회 시도
flowchart TB
Attack[DRM 우회 시도]
A1[화면 캡처]
A2[메모리 덤프]
A3[디버거 사용]
A4[CDM 추출]
A5[HDMI 캡처]
Attack --> A1
Attack --> A2
Attack --> A3
Attack --> A4
Attack --> A5
A1 --> D1[대응: 워터마크]
A2 --> D2[대응: 메모리 암호화]
A3 --> D3[대응: 안티 디버깅]
A4 --> D4[대응: TEE/하드웨어]
A5 --> D5[대응: HDCP]
HDCP (High-bandwidth Digital Content Protection)
HDCP: 디지털 비디오 인터페이스 보호
HDMI/DisplayPort 연결에서 콘텐츠를 암호화하여
캡처 장비로 녹화를 방지
DRM L1 + HDCP → 완전한 보호 경로
안티 디버깅
// 디버거 감지
(function() {
let devtoolsOpen = false;
const threshold = 160;
setInterval(() => {
if (window.outerWidth - window.innerWidth > threshold ||
window.outerHeight - window.innerHeight > threshold) {
if (!devtoolsOpen) {
devtoolsOpen = true;
console.log('⚠️ DevTools detected, pausing playback');
document.querySelector('video')?.pause();
}
} else {
devtoolsOpen = false;
}
}, 500);
})();
// 코드 난독화 (webpack)
module.exports = {
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
terserOptions: {
mangle: true,
compress: {
drop_console: true,
drop_debugger: true
}
}
})
]
}
};
실전 플레이어 라이브러리
Shaka Player (Google)
// Shaka Player로 멀티 DRM 지원
import shaka from 'shaka-player';
const video = document.querySelector('video');
const player = new shaka.Player(video);
// DRM 설정
player.configure({
drm: {
servers: {
'com.widevine.alpha': 'https://license.example.com/widevine',
'com.microsoft.playready': 'https://license.example.com/playready'
},
advanced: {
'com.widevine.alpha': {
'videoRobustness': 'SW_SECURE_CRYPTO',
'audioRobustness': 'SW_SECURE_CRYPTO'
}
}
}
});
// 라이선스 요청 인터셉터
player.getNetworkingEngine().registerRequestFilter((type, request) => {
if (type === shaka.net.NetworkingEngine.RequestType.LICENSE) {
// 인증 헤더 추가
request.headers['Authorization'] = 'Bearer ' + getAuthToken();
}
});
// 비디오 로드
player.load('https://cdn.example.com/manifest.mpd')
.then(() => {
console.log('✅ Video loaded');
video.play();
})
.catch(err => {
console.error('❌ Load error:', err);
});
Video.js + videojs-contrib-eme
import videojs from 'video.js';
import 'videojs-contrib-eme';
const player = videojs('my-video', {
plugins: {
eme: {
keySystems: {
'com.widevine.alpha': {
url: 'https://license.example.com/widevine',
licenseHeaders: {
'Authorization': 'Bearer ' + authToken
},
videoRobustness: 'SW_SECURE_CRYPTO',
audioRobustness: 'SW_SECURE_CRYPTO'
},
'com.apple.fps.1_0': {
certificateUri: 'https://example.com/fairplay.cer',
licenseUri: 'https://license.example.com/fairplay',
licenseHeaders: {
'Authorization': 'Bearer ' + authToken
}
}
}
}
}
});
player.src({
src: 'https://cdn.example.com/manifest.mpd',
type: 'application/dash+xml'
});
player.play();
DRM 테스트
Widevine 테스트 콘텐츠
// Google의 공개 테스트 스트림
const testStreams = {
widevine: {
manifest: 'https://storage.googleapis.com/shaka-demo-assets/angel-one-widevine/dash.mpd',
license: 'https://cwip-shaka-proxy.appspot.com/no_auth'
},
clearKey: {
manifest: 'https://storage.googleapis.com/shaka-demo-assets/angel-one/dash.mpd',
license: 'https://cwip-shaka-proxy.appspot.com/clearkey'
}
};
// Shaka Player로 테스트
async function testDRM() {
const player = new shaka.Player(video);
player.configure({
drm: {
servers: {
'com.widevine.alpha': testStreams.widevine.license
}
}
});
try {
await player.load(testStreams.widevine.manifest);
console.log('✅ Widevine test successful');
} catch (err) {
console.error('❌ Widevine test failed:', err);
}
}
브라우저 DRM 지원 확인
async function checkDRMSupport() {
const drmSystems = [
{ name: 'Widevine', keySystem: 'com.widevine.alpha' },
{ name: 'FairPlay', keySystem: 'com.apple.fps.1_0' },
{ name: 'PlayReady', keySystem: 'com.microsoft.playready' },
{ name: 'ClearKey', keySystem: 'org.w3.clearkey' }
];
const results = {};
for (const drm of drmSystems) {
try {
const config = [{
initDataTypes: ['cenc'],
videoCapabilities: [{
contentType: 'video/mp4; codecs="avc1.42E01E"'
}]
}];
await navigator.requestMediaKeySystemAccess(drm.keySystem, config);
results[drm.name] = '✅ Supported';
} catch (e) {
results[drm.name] = '❌ Not supported';
}
}
console.table(results);
return results;
}
// 실행
checkDRMSupport();
성능 최적화
라이선스 캐싱
class LicenseCache {
constructor() {
this.cache = new Map();
this.maxAge = 3600 * 1000; // 1시간
}
async getLicense(contentId, licenseUrl, authToken) {
const cacheKey = `${contentId}:${authToken}`;
const cached = this.cache.get(cacheKey);
// 캐시 확인
if (cached && Date.now() - cached.timestamp < this.maxAge) {
console.log('✅ License from cache');
return cached.license;
}
// 라이선스 요청
const response = await fetch(licenseUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${authToken}`
}
});
const license = await response.arrayBuffer();
// 캐시 저장
this.cache.set(cacheKey, {
license,
timestamp: Date.now()
});
console.log('✅ License from server');
return license;
}
clear() {
this.cache.clear();
}
}
const licenseCache = new LicenseCache();
프리로딩
// 라이선스 미리 요청
async function preloadLicense(contentId, licenseUrl, authToken) {
const config = [{
initDataTypes: ['cenc'],
videoCapabilities: [{
contentType: 'video/mp4; codecs="avc1.42E01E"'
}]
}];
const keySystemAccess = await navigator.requestMediaKeySystemAccess(
'com.widevine.alpha',
config
);
const mediaKeys = await keySystemAccess.createMediaKeys();
const session = mediaKeys.createSession();
// 미리 라이선스 요청
session.addEventListener('message', async (event) => {
const response = await fetch(licenseUrl, {
method: 'POST',
headers: { 'Authorization': `Bearer ${authToken}` },
body: event.message
});
const license = await response.arrayBuffer();
await session.update(license);
console.log('✅ License preloaded');
});
// 더미 init data로 세션 생성
const initData = new Uint8Array([/* PSSH box */]);
await session.generateRequest('cenc', initData);
}
정리
DRM 선택 가이드
flowchart TD
Start[DRM 선택] --> Q1{플랫폼은?}
Q1 -->|iOS/Safari| FairPlay[FairPlay]
Q1 -->|Android/Chrome| Widevine[Widevine]
Q1 -->|Windows/Xbox| PlayReady[PlayReady]
Q1 -->|멀티 플랫폼| Multi[멀티 DRM<br/>CENC]
Multi --> Q2{보안 수준은?}
Q2 -->|최고| L1[Widevine L1<br/>+ FairPlay<br/>+ PlayReady]
Q2 -->|중간| L3[Widevine L3<br/>+ FairPlay]
Q2 -->|기본| AES[HLS AES-128]
구현 복잡도
복잡도: FairPlay > PlayReady > Widevine > HLS AES-128
FairPlay:
- 인증서 발급 필요
- 네이티브 구현 필수
- Apple 승인 필요
Widevine:
- 웹 표준 EME 사용
- JavaScript로 구현 가능
- Google 계약 필요
PlayReady:
- Windows 플랫폼 통합
- C# 구현 권장
- Microsoft 계약 필요
HLS AES-128:
- 간단한 구현
- FFmpeg로 생성 가능
- 계약 불필요
비용 비교
라이선스 비용 (대략적):
Widevine:
- 무료 (테스트)
- 상용: 기기당 과금
FairPlay:
- Apple Developer Program: $99/년
- 추가 비용 없음
PlayReady:
- 기기당 라이선스 비용
- 볼륨 할인 가능
HLS AES-128:
- 완전 무료
- 오픈소스
베스트 프랙티스
1. 멀티 DRM 전략
// 플랫폼 감지 후 적절한 DRM 선택
function selectDRM() {
const ua = navigator.userAgent;
if (/iPhone|iPad|iPod/.test(ua)) {
return {
system: 'com.apple.fps.1_0',
licenseUrl: 'https://license.example.com/fairplay'
};
} else if (/Android/.test(ua)) {
return {
system: 'com.widevine.alpha',
licenseUrl: 'https://license.example.com/widevine'
};
} else if (/Windows/.test(ua)) {
// PlayReady 또는 Widevine
return {
system: 'com.widevine.alpha',
licenseUrl: 'https://license.example.com/widevine'
};
}
return {
system: 'com.widevine.alpha',
licenseUrl: 'https://license.example.com/widevine'
};
}
2. 에러 처리
video.addEventListener('error', (event) => {
const error = video.error;
if (error.code === MediaError.MEDIA_ERR_ENCRYPTED) {
console.error('❌ DRM error:', error.message);
// 사용자에게 친화적인 메시지
showError('이 콘텐츠를 재생할 수 없습니다. 브라우저를 업데이트하거나 다른 기기를 사용해주세요.');
}
});
// MediaKeySession 에러
session.addEventListener('keystatuseschange', () => {
session.keyStatuses.forEach((status, keyId) => {
console.log(`Key ${keyId}: ${status}`);
if (status === 'expired') {
console.warn('⚠️ License expired, requesting new license');
requestNewLicense();
} else if (status === 'internal-error') {
console.error('❌ DRM internal error');
}
});
});
3. 보안 체크리스트
# DRM 구현 체크리스트
# ✅ 콘텐츠 암호화
# - AES-128 이상 사용
# - 키 로테이션 구현
# ✅ 라이선스 서버
# - HTTPS 필수
# - 토큰 기반 인증
# - 권한 검증 (구독, 지역, 기기)
# ✅ 클라이언트
# - EME API 사용
# - 에러 처리
# - 폴백 전략
# ✅ 모니터링
# - 재생 로그
# - 이상 패턴 감지
# - 라이선스 요청 빈도
# ✅ 추가 보호
# - 워터마크
# - 화면 캡처 방지
# - 안티 디버깅
참고 자료
- Widevine DRM
- Apple FairPlay Streaming
- Microsoft PlayReady
- W3C Encrypted Media Extensions
- MPEG CENC Specification
- Shaka Player
- Shaka Packager
한 줄 요약: 멀티 플랫폼 지원을 위해 Widevine, FairPlay, PlayReady를 모두 지원하는 CENC 방식을 사용하고, 라이선스 서버에서 철저한 권한 검증을 수행하여 콘텐츠를 보호하세요.