From a5ee125fb25df1aa26a21b644ee5bd9ec57d3fc8 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Sun, 27 Sep 2026 22:30:11 +0200 Subject: [PATCH] Android TV: F1TV cbcs-samples zelf ontsleutelen (ClearKey kan alleen AES-CTR). --- .../vonas/mediacluster/tv/data/ApiClient.kt | 4 + .../tv/playback/CbcsDecryption.kt | 286 ++++++++++++++++++ .../mediacluster/tv/playback/PlayerFactory.kt | 63 +++- 3 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/CbcsDecryption.kt diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt index 7a47e6e..b7a5024 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/data/ApiClient.kt @@ -559,6 +559,7 @@ class ApiClient( DrmInfo( type = drmObj.optString("type").ifBlank { "clearkey" }, keys = keys, + scheme = drmObj.optString("scheme").ifBlank { null }, ).takeIf { it.keys.isNotEmpty() } } else null IptvPlayInfo( @@ -860,6 +861,7 @@ class ApiClient( DrmInfo( type = drmObj.optString("type").ifBlank { "clearkey" }, keys = keys, + scheme = drmObj.optString("scheme").ifBlank { null }, ).takeIf { it.keys.isNotEmpty() } } else null return IptvPlayInfo( @@ -1619,6 +1621,8 @@ data class ClearKeyEntry( data class DrmInfo( val type: String, val keys: List, + /** "cbcs" = app ontsleutelt zelf (Android ClearKey kan alleen AES-CTR). */ + val scheme: String? = null, ) data class IptvPlayInfo( diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/CbcsDecryption.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/CbcsDecryption.kt new file mode 100644 index 0000000..9b0fcff --- /dev/null +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/CbcsDecryption.kt @@ -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, + private val delegate: HlsExtractorFactory = DefaultHlsExtractorFactory(), +) : HlsExtractorFactory { + override fun createExtractor( + uri: Uri, + format: Format, + muxedCaptionFormats: List?, + timestampAdjuster: TimestampAdjuster, + responseHeaders: Map>, + 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, +) : 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, +) : ExtractorOutput { + private val tracks = HashMap() + + 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, +) : 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) + } +} diff --git a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt index aa9d16f..703d996 100644 --- a/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt +++ b/apps/android-tv/app/src/main/java/nl/vonas/mediacluster/tv/playback/PlayerFactory.kt @@ -31,7 +31,12 @@ import androidx.media3.extractor.mp4.PsshAtomUtil import android.os.Looper import java.nio.ByteBuffer 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.MediaSource +import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.video.VideoRendererEventListener import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory @@ -234,10 +239,11 @@ fun buildLiveTvPlayer(context: Context): ExoPlayer { .build() val fallbackDrmProvider = DefaultDrmSessionManagerProvider() - val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory) + val defaultMediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory) .setDrmSessionManagerProvider { mediaItem -> clearKeyDrmSessionManagerFor(mediaItem) ?: fallbackDrmProvider.get(mediaItem) } + val mediaSourceFactory = CbcsAwareMediaSourceFactory(defaultMediaSourceFactory, dataSourceFactory) return ExoPlayer.Builder(appContext, renderersFactory) .setTrackSelector(trackSelector) @@ -259,6 +265,42 @@ private data class ClearKeyMaterial( private val clearKeyLicenses = ConcurrentHashMap() private const val TAG = "VmcLiveDrm" +/** cbcs-sleutels (kid hex → key) per MediaItem.mediaId; zie [CbcsDecryptingHlsExtractorFactory]. */ +private val cbcsKeys = ConcurrentHashMap>() + +/** 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) private fun clearKeyDrmSessionManagerFor(mediaItem: MediaItem): DrmSessionManager? { 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() + 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() + 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()) { val mediaId = "ck-${UUID.randomUUID()}" 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. */ private fun swapUuidEndianness(b: ByteArray): ByteArray { require(b.size == 16)