Android TV: F1TV cbcs-samples zelf ontsleutelen (ClearKey kan alleen AES-CTR).

This commit is contained in:
Jos Vooges | STH 2026-09-27 22:30:11 +02:00
parent e89ba46eb8
commit a5ee125fb2
3 changed files with 352 additions and 1 deletions

View file

@ -559,6 +559,7 @@ class ApiClient(
DrmInfo( DrmInfo(
type = drmObj.optString("type").ifBlank { "clearkey" }, type = drmObj.optString("type").ifBlank { "clearkey" },
keys = keys, keys = keys,
scheme = drmObj.optString("scheme").ifBlank { null },
).takeIf { it.keys.isNotEmpty() } ).takeIf { it.keys.isNotEmpty() }
} else null } else null
IptvPlayInfo( IptvPlayInfo(
@ -860,6 +861,7 @@ class ApiClient(
DrmInfo( DrmInfo(
type = drmObj.optString("type").ifBlank { "clearkey" }, type = drmObj.optString("type").ifBlank { "clearkey" },
keys = keys, keys = keys,
scheme = drmObj.optString("scheme").ifBlank { null },
).takeIf { it.keys.isNotEmpty() } ).takeIf { it.keys.isNotEmpty() }
} else null } else null
return IptvPlayInfo( return IptvPlayInfo(
@ -1619,6 +1621,8 @@ data class ClearKeyEntry(
data class DrmInfo( data class DrmInfo(
val type: String, val type: String,
val keys: List<ClearKeyEntry>, val keys: List<ClearKeyEntry>,
/** "cbcs" = app ontsleutelt zelf (Android ClearKey kan alleen AES-CTR). */
val scheme: String? = null,
) )
data class IptvPlayInfo( data class IptvPlayInfo(

View file

@ -0,0 +1,286 @@
package nl.vonas.mediacluster.tv.playback
import android.net.Uri
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.DataReader
import androidx.media3.common.Format
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.common.util.TimestampAdjuster
import androidx.media3.common.util.UnstableApi
import android.os.Looper
import androidx.media3.exoplayer.analytics.PlayerId
import androidx.media3.exoplayer.drm.DrmSession
import androidx.media3.exoplayer.drm.DrmSessionEventListener
import androidx.media3.exoplayer.drm.DrmSessionManager
import androidx.media3.exoplayer.hls.DefaultHlsExtractorFactory
import androidx.media3.exoplayer.hls.HlsExtractorFactory
import androidx.media3.exoplayer.hls.HlsMediaChunkExtractor
import androidx.media3.extractor.ExtractorInput
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
import androidx.media3.extractor.text.SubtitleParser
import java.io.EOFException
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* Software-ontsleuteling van cbcs (AES-CBC patroon) samples tussen de fMP4-extractor en de decoder.
* Android's ClearKey-plugin ondersteunt alleen AES-CTR; F1TV levert HLS cbcs (SAMPLE-AES).
* De stream komt rechtstreeks van de CDN; alleen de sleutels komen van de server.
*/
@OptIn(UnstableApi::class)
class CbcsDecryptingHlsExtractorFactory(
private val keysByKid: Map<String, ByteArray>,
private val delegate: HlsExtractorFactory = DefaultHlsExtractorFactory(),
) : HlsExtractorFactory {
override fun createExtractor(
uri: Uri,
format: Format,
muxedCaptionFormats: List<Format>?,
timestampAdjuster: TimestampAdjuster,
responseHeaders: Map<String, List<String>>,
sniffingExtractorInput: ExtractorInput,
playerId: PlayerId,
): HlsMediaChunkExtractor = CbcsDecryptingChunkExtractor(
delegate.createExtractor(
uri,
format,
muxedCaptionFormats,
timestampAdjuster,
responseHeaders,
sniffingExtractorInput,
playerId,
),
keysByKid,
)
override fun setSubtitleParserFactory(subtitleParserFactory: SubtitleParser.Factory): HlsExtractorFactory {
delegate.setSubtitleParserFactory(subtitleParserFactory)
return this
}
override fun experimentalParseSubtitlesDuringExtraction(parseSubtitlesDuringExtraction: Boolean): HlsExtractorFactory {
delegate.experimentalParseSubtitlesDuringExtraction(parseSubtitlesDuringExtraction)
return this
}
override fun experimentalSetCodecsToParseWithinGopSampleDependencies(codecsToParseWithinGopSampleDependencies: Int): HlsExtractorFactory {
delegate.experimentalSetCodecsToParseWithinGopSampleDependencies(codecsToParseWithinGopSampleDependencies)
return this
}
override fun getOutputTextFormat(sourceFormat: Format): Format = delegate.getOutputTextFormat(sourceFormat)
}
/**
* Playlist-`EXT-X-KEY` (Widevine KEYFORMAT) zet DrmInitData op de HLS-tracks; de samples zijn
* na [CbcsDecryptingTrackOutput] al clear, dus geen DRM-sessie en crypto-type NONE.
*/
@OptIn(UnstableApi::class)
object CbcsClearDrmSessionManager : DrmSessionManager {
override fun setPlayer(playbackLooper: Looper, playerId: PlayerId) = Unit
override fun acquireSession(eventDispatcher: DrmSessionEventListener.EventDispatcher?, format: Format): DrmSession? = null
override fun getCryptoType(format: Format): Int = C.CRYPTO_TYPE_NONE
}
@OptIn(UnstableApi::class)
private class CbcsDecryptingChunkExtractor(
private val delegate: HlsMediaChunkExtractor,
private val keysByKid: Map<String, ByteArray>,
) : HlsMediaChunkExtractor {
override fun init(extractorOutput: ExtractorOutput) {
delegate.init(CbcsDecryptingExtractorOutput(extractorOutput, keysByKid))
}
override fun read(extractorInput: ExtractorInput): Boolean = delegate.read(extractorInput)
override fun isPackedAudioExtractor(): Boolean = delegate.isPackedAudioExtractor
override fun isReusable(): Boolean = delegate.isReusable
override fun recreate(): HlsMediaChunkExtractor = CbcsDecryptingChunkExtractor(delegate.recreate(), keysByKid)
override fun onTruncatedSegmentParsed() = delegate.onTruncatedSegmentParsed()
}
@OptIn(UnstableApi::class)
private class CbcsDecryptingExtractorOutput(
private val delegate: ExtractorOutput,
private val keysByKid: Map<String, ByteArray>,
) : ExtractorOutput {
private val tracks = HashMap<Int, CbcsDecryptingTrackOutput>()
override fun track(id: Int, type: Int): TrackOutput =
tracks.getOrPut(id) { CbcsDecryptingTrackOutput(delegate.track(id, type), keysByKid) }
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
}
/**
* Buffert de bytes van één sample (encryptie-header + data zoals de extractor ze schrijft),
* ontsleutelt bij [sampleMetadata] en geeft een clear sample door zonder CryptoData.
*/
@OptIn(UnstableApi::class)
private class CbcsDecryptingTrackOutput(
private val delegate: TrackOutput,
private val keysByKid: Map<String, ByteArray>,
) : TrackOutput {
private var buffer = ByteArray(256 * 1024)
private var pending = 0
private val out = ParsableByteArray()
private val cipher: Cipher = Cipher.getInstance("AES/CBC/NoPadding")
private val block = ByteArray(16)
private val iv = ByteArray(16)
private var cachedKid: ByteArray? = null
private var cachedKey: SecretKeySpec? = null
override fun format(format: Format) {
delegate.format(
if (format.drmInitData != null) format.buildUpon().setDrmInitData(null).build() else format,
)
}
override fun durationUs(durationUs: Long) = delegate.durationUs(durationUs)
override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int {
ensureCapacity(pending + length)
val read = input.read(buffer, pending, length)
if (read == C.RESULT_END_OF_INPUT) {
if (allowEndOfInput) return C.RESULT_END_OF_INPUT
throw EOFException()
}
pending += read
return read
}
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
ensureCapacity(pending + length)
data.readBytes(buffer, pending, length)
pending += length
}
override fun sampleMetadata(
timeUs: Long,
flags: Int,
size: Int,
offset: Int,
cryptoData: TrackOutput.CryptoData?,
) {
val start = pending - offset - size
check(start >= 0) { "cbcs: sample-buffer inconsistent (pending=$pending size=$size offset=$offset)" }
val end = start + size
var dataStart = start
var outFlags = flags
if (flags and C.BUFFER_FLAG_ENCRYPTED != 0 && cryptoData != null) {
dataStart = decryptSample(start, end, flags, cryptoData)
outFlags = flags and C.BUFFER_FLAG_ENCRYPTED.inv()
}
val outSize = end - dataStart
out.reset(buffer, end)
out.setPosition(dataStart)
delegate.sampleData(out, outSize, TrackOutput.SAMPLE_DATA_PART_MAIN)
delegate.sampleMetadata(timeUs, outFlags, outSize, 0, null)
if (offset > 0) System.arraycopy(buffer, end, buffer, 0, offset)
pending = offset
}
/** Ontsleutelt in-place; geeft de positie terug waar de (clear) sample-data begint. */
private fun decryptSample(start: Int, end: Int, flags: Int, crypto: TrackOutput.CryptoData): Int {
check(crypto.cryptoMode == C.CRYPTO_MODE_AES_CBC) { "cbcs: niet-ondersteunde crypto-mode ${crypto.cryptoMode}" }
var p = start
val signal = buffer[p++].toInt() and 0xFF
val ivSize = signal and 0x7F
val subsampleEncryption = signal and 0x80 != 0
iv.fill(0)
System.arraycopy(buffer, p, iv, 0, minOf(ivSize, 16))
p += ivSize
var subsampleCount = 0
var subsampleTable = 0
if (subsampleEncryption) {
subsampleCount = readU16(p)
p += 2
subsampleTable = p
p += subsampleCount * 6
}
if (flags and C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA != 0) {
// Encryptie-header → [4 byte lengte][supplemental] → hoofddata; alleen hoofddata is versleuteld.
val supplementalSize = readI32(p)
val dataStart = p
decryptRanges(p + 4 + supplementalSize, end, subsampleEncryption, subsampleCount, subsampleTable, crypto)
return dataStart
}
decryptRanges(p, end, subsampleEncryption, subsampleCount, subsampleTable, crypto)
return p
}
private fun decryptRanges(
dataStart: Int,
end: Int,
subsampleEncryption: Boolean,
subsampleCount: Int,
subsampleTable: Int,
crypto: TrackOutput.CryptoData,
) {
val key = keyFor(crypto.encryptionKey)
if (!subsampleEncryption) {
decryptPattern(dataStart, end - dataStart, key, crypto)
return
}
var pos = dataStart
for (i in 0 until subsampleCount) {
val entry = subsampleTable + i * 6
pos += readU16(entry)
val encrypted = readI32(entry + 2)
check(pos + encrypted <= end) { "cbcs: subsample buiten sample" }
if (encrypted > 0) decryptPattern(pos, encrypted, key, crypto)
pos += encrypted
}
}
/** Eén beschermd bereik: IV reset, patroon crypt:skip in 16-byte blokken (0:0 = alle blokken). */
private fun decryptPattern(offset: Int, length: Int, key: SecretKeySpec, crypto: TrackOutput.CryptoData) {
val blocks = length / 16
if (blocks == 0) return
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
var crypt = crypto.encryptedBlocks
var skip = crypto.clearBlocks
if (crypt == 0 && skip == 0) {
crypt = blocks
}
var b = 0
while (b < blocks) {
val n = minOf(crypt, blocks - b)
for (k in 0 until n) {
val at = offset + (b + k) * 16
cipher.update(buffer, at, 16, block, 0)
System.arraycopy(block, 0, buffer, at, 16)
}
b += n + skip
}
}
private fun keyFor(kid: ByteArray): SecretKeySpec {
cachedKey?.let { if (cachedKid.contentEquals(kid)) return it }
val hex = kid.joinToString("") { "%02x".format(it.toInt() and 0xFF) }
val raw = keysByKid[hex] ?: error("cbcs: geen sleutel voor KID $hex")
return SecretKeySpec(raw, "AES").also {
cachedKid = kid.copyOf()
cachedKey = it
}
}
private fun readU16(p: Int): Int =
((buffer[p].toInt() and 0xFF) shl 8) or (buffer[p + 1].toInt() and 0xFF)
private fun readI32(p: Int): Int =
((buffer[p].toInt() and 0xFF) shl 24) or ((buffer[p + 1].toInt() and 0xFF) shl 16) or
((buffer[p + 2].toInt() and 0xFF) shl 8) or (buffer[p + 3].toInt() and 0xFF)
private fun ensureCapacity(needed: Int) {
if (needed <= buffer.size) return
var n = buffer.size
while (n < needed) n *= 2
buffer = buffer.copyOf(n)
}
}

View file

@ -31,7 +31,12 @@ import androidx.media3.extractor.mp4.PsshAtomUtil
import android.os.Looper import android.os.Looper
import java.nio.ByteBuffer import java.nio.ByteBuffer
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
import androidx.media3.datasource.DataSource
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.video.VideoRendererEventListener import androidx.media3.exoplayer.video.VideoRendererEventListener
import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory
@ -234,10 +239,11 @@ fun buildLiveTvPlayer(context: Context): ExoPlayer {
.build() .build()
val fallbackDrmProvider = DefaultDrmSessionManagerProvider() val fallbackDrmProvider = DefaultDrmSessionManagerProvider()
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory) val defaultMediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory)
.setDrmSessionManagerProvider { mediaItem -> .setDrmSessionManagerProvider { mediaItem ->
clearKeyDrmSessionManagerFor(mediaItem) ?: fallbackDrmProvider.get(mediaItem) clearKeyDrmSessionManagerFor(mediaItem) ?: fallbackDrmProvider.get(mediaItem)
} }
val mediaSourceFactory = CbcsAwareMediaSourceFactory(defaultMediaSourceFactory, dataSourceFactory)
return ExoPlayer.Builder(appContext, renderersFactory) return ExoPlayer.Builder(appContext, renderersFactory)
.setTrackSelector(trackSelector) .setTrackSelector(trackSelector)
@ -259,6 +265,42 @@ private data class ClearKeyMaterial(
private val clearKeyLicenses = ConcurrentHashMap<String, ClearKeyMaterial>() private val clearKeyLicenses = ConcurrentHashMap<String, ClearKeyMaterial>()
private const val TAG = "VmcLiveDrm" private const val TAG = "VmcLiveDrm"
/** cbcs-sleutels (kid hex → key) per MediaItem.mediaId; zie [CbcsDecryptingHlsExtractorFactory]. */
private val cbcsKeys = ConcurrentHashMap<String, Map<String, ByteArray>>()
/** HLS cbcs-items → eigen HlsMediaSource met software-ontsleuteling; al het andere ongewijzigd. */
@OptIn(UnstableApi::class)
private class CbcsAwareMediaSourceFactory(
private val default: MediaSource.Factory,
private val dataSourceFactory: DataSource.Factory,
) : MediaSource.Factory {
private var loadErrorHandlingPolicy: LoadErrorHandlingPolicy? = null
override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
default.setDrmSessionManagerProvider(drmSessionManagerProvider)
return this
}
override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory {
this.loadErrorHandlingPolicy = loadErrorHandlingPolicy
default.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
return this
}
override fun getSupportedTypes(): IntArray = default.supportedTypes
override fun createMediaSource(mediaItem: MediaItem): MediaSource {
val keys = mediaItem.mediaId.takeIf { it.isNotBlank() }?.let { cbcsKeys[it] }
?: return default.createMediaSource(mediaItem)
Log.i(TAG, "cbcs software-decrypt mediaId=${mediaItem.mediaId} kids=${keys.size}")
return HlsMediaSource.Factory(dataSourceFactory)
.setExtractorFactory(CbcsDecryptingHlsExtractorFactory(keys))
.setDrmSessionManagerProvider { CbcsClearDrmSessionManager }
.also { f -> loadErrorHandlingPolicy?.let { f.setLoadErrorHandlingPolicy(it) } }
.createMediaSource(mediaItem)
}
}
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
private fun clearKeyDrmSessionManagerFor(mediaItem: MediaItem): DrmSessionManager? { private fun clearKeyDrmSessionManagerFor(mediaItem: MediaItem): DrmSessionManager? {
val mediaId = mediaItem.mediaId?.takeIf { it.isNotBlank() } ?: return null val mediaId = mediaItem.mediaId?.takeIf { it.isNotBlank() } ?: return null
@ -382,6 +424,23 @@ fun liveMediaItem(
) )
} }
val clearKeys = drm?.takeIf { it.type.equals("clearkey", ignoreCase = true) }?.keys.orEmpty() val clearKeys = drm?.takeIf { it.type.equals("clearkey", ignoreCase = true) }?.keys.orEmpty()
if (clearKeys.isNotEmpty() && isHls && drm?.scheme.equals("cbcs", ignoreCase = true)) {
// Android ClearKey kan geen AES-CBC (cbcs) → zelf ontsleutelen, zonder DrmConfiguration.
val mediaId = "cbcs-${UUID.randomUUID()}"
val keyMap = HashMap<String, ByteArray>()
for (k in clearKeys) {
val kid = hexToBytes(k.kid)
val key = hexToBytes(k.key)
keyMap[kid.toHex()] = key
keyMap[swapUuidEndianness(kid).toHex()] = key
}
cbcsKeys[mediaId] = keyMap
if (cbcsKeys.size > 32) {
cbcsKeys.keys.take(cbcsKeys.size - 24).forEach { cbcsKeys.remove(it) }
}
Log.i(TAG, "cbcs mediaItem keys=${clearKeys.size} url=${url.take(96)}")
return builder.setMediaId(mediaId).build()
}
if (clearKeys.isNotEmpty()) { if (clearKeys.isNotEmpty()) {
val mediaId = "ck-${UUID.randomUUID()}" val mediaId = "ck-${UUID.randomUUID()}"
val keyIds = buildList { val keyIds = buildList {
@ -447,6 +506,8 @@ private fun hexToBytes(hex: String): ByteArray {
} }
} }
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it.toInt() and 0xFF) }
/** Microsoft GUID / sommige ClearKey packs: DWORD/WORD little-endian in eerste 8 bytes. */ /** Microsoft GUID / sommige ClearKey packs: DWORD/WORD little-endian in eerste 8 bytes. */
private fun swapUuidEndianness(b: ByteArray): ByteArray { private fun swapUuidEndianness(b: ByteArray): ByteArray {
require(b.size == 16) require(b.size == 16)