Reproducing encrypted video using ExoPlayer
check your proxy, given the following configuration.
ALLOWED_TRACK_TYPES = "SD_HD"
content_key_specs = [{ "track_type": "HD",
"security_level": 1,
"required_output_protection": {"hdcp": "HDCP_NONE" }
},
{ "track_type": "SD",
"security_level": 1,
"required_output_protection": {"cgms_flags": "COPY_FREE" }
},
{ "track_type": "AUDIO"}]
request = json.dumps({"payload": payload,
"content_id": content_id,
"provider": self.provider,
"allowed_track_types": ALLOWED_TRACK_TYPES,
"use_policy_overrides_exclusively": True,
"policy_overrides": policy_overrides,
"content_key_specs": content_key_specs
?
In the ExoPlayer demo app - DashRenderBuilder.java has a method 'filterHdContent' this always returns true if device is not level 1 (Assuming here it's L3). This causes the player to disregard the HD AdaptionSet in the mpd whilst parsing it.
You can set the filterHdContent to always return false if you want to play HD, however it is typical of content owners to require a L1 Widevine implementation for HD content.
check this link for more https://github.com/google/ExoPlayer/issues/1116 https://github.com/google/ExoPlayer/issues/1523
Eventually I found the solution.
I used a no-padding for the encryption algorithm, in this way:
cipher = Cipher.getInstance("AES/CTR/NoPadding", "BC");
so that the size of the encrypted file and the clear file size remain the same. So now I created the stream:
cipherInputStream = new CipherInputStream(inputStream, cipher) {
@Override
public int available() throws IOException {
return in.available();
}
};
This is because the Java documentation says about ChiperInputStream.available()
that
This method should be overriden
and actually I think is it more like a MUST, because the values retrieved from that method are often really strange.
And that is it! Now it works perfectly.
This problem had me tearing my hair out, so I finally caved and implemented a streaming cipher for AES/CBC that lets you skip ahead. CBC theoretically allows for random reads, you need to initialize the cipher with the previous block's ciphertext as the initialization vector and then read ahead until the spot you needed. Sample project with full implementation here. Here's the key classes:
import android.net.Uri
import android.util.Log
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.upstream.DataSource
import com.google.android.exoplayer2.upstream.DataSpec
import com.google.android.exoplayer2.upstream.TransferListener
import ar.cryptotest.exoplayer2.MainActivity.Companion.AES_TRANSFORMATION
import java.io.EOFException
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.lang.RuntimeException
import javax.crypto.Cipher
import javax.crypto.CipherInputStream
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
const val TAG = "ENCRYPTING PROCESS"
class BlockCipherEncryptedDataSource(
private val secretKeySpec: SecretKeySpec,
private val uri: Uri,
cipherTransformation: String = "AES/CBC/PKCS7Padding"
) : DataSource {
private val cipher: Cipher = Cipher.getInstance(cipherTransformation)
private lateinit var streamingCipherInputStream: StreamingCipherInputStream
private var bytesRemaining: Long = 0
private var isOpen = false
private val transferListeners = mutableListOf<TransferListener>()
private var dataSpec: DataSpec? = null
@Throws(EncryptedFileDataSourceException::class)
override fun open(dataSpec: DataSpec): Long {
this.dataSpec = dataSpec
if (isOpen) return bytesRemaining
try {
setupInputStream()
streamingCipherInputStream.forceSkip(dataSpec.position)
computeBytesRemaining(dataSpec)
} catch (e: IOException) {
throw EncryptedFileDataSourceException(e)
}
isOpen = true
transferListeners.forEach { it.onTransferStart(this, dataSpec, false) }
return C.LENGTH_UNSET.toLong()
}
private fun setupInputStream() {
val path = uri.path ?: throw RuntimeException("Tried decrypting uri with no path: $uri")
val encryptedFileStream = File(path).inputStream()
val initializationVector = ByteArray(cipher.blockSize)
encryptedFileStream.read(initializationVector)
streamingCipherInputStream =
StreamingCipherInputStream(
encryptedFileStream,
cipher,
IvParameterSpec(initializationVector),
secretKeySpec
)
}
@Throws(IOException::class)
private fun computeBytesRemaining(dataSpec: DataSpec) {
if (dataSpec.length != C.LENGTH_UNSET.toLong()) {
bytesRemaining = dataSpec.length
return
}
if (bytesRemaining == Int.MAX_VALUE.toLong()) {
bytesRemaining = C.LENGTH_UNSET.toLong()
return
}
bytesRemaining = streamingCipherInputStream.available().toLong()
}
@Throws(EncryptedFileDataSourceException::class)
override fun read(buffer: ByteArray, offset: Int, readLength: Int): Int {
if (bytesRemaining == 0L) {
Log.e(TAG, "End - No bytes remaining")
return C.RESULT_END_OF_INPUT
}
val bytesRead = try {
streamingCipherInputStream.read(buffer, offset, readLength)
} catch (e: IOException) {
throw EncryptedFileDataSourceException(e)
}
// Reading -1 means an error occurred
if (bytesRead < 0) {
if (bytesRemaining != C.LENGTH_UNSET.toLong())
throw EncryptedFileDataSourceException(EOFException())
return C.RESULT_END_OF_INPUT
}
// Bytes remaining will be unset if file is too large for an int
if (bytesRemaining != C.LENGTH_UNSET.toLong())
bytesRemaining -= bytesRead.toLong()
dataSpec?.let { nonNullDataSpec ->
transferListeners.forEach {
it.onBytesTransferred(this, nonNullDataSpec, false, bytesRead)
}
}
return bytesRead
}
override fun addTransferListener(transferListener: TransferListener) {
transferListeners.add(transferListener)
}
override fun getUri(): Uri = uri
@Throws(EncryptedFileDataSourceException::class)
override fun close() {
Log.e(TAG, "Closing stream")
try {
streamingCipherInputStream.close()
} catch (e: IOException) {
throw EncryptedFileDataSourceException(e)
} finally {
if (isOpen) {
isOpen = false
dataSpec?.let { nonNullDataSpec ->
transferListeners.forEach { it.onTransferEnd(this, nonNullDataSpec, false) }
}
}
}
}
class EncryptedFileDataSourceException(cause: IOException?) : IOException(cause)
class StreamingCipherInputStream(
private val sourceStream: InputStream,
private var cipher: Cipher,
private val initialIvParameterSpec: IvParameterSpec,
private val secretKeySpec: SecretKeySpec
) : CipherInputStream(
sourceStream, cipher
) {
private val cipherBlockSize: Int = cipher.blockSize
@Throws(IOException::class)
override fun read(b: ByteArray, off: Int, len: Int): Int = super.read(b, off, len)
fun forceSkip(bytesToSkip: Long) {
val bytesSinceStartOfCurrentBlock = bytesToSkip % cipherBlockSize
val bytesUntilPreviousBlockStart =
bytesToSkip - bytesSinceStartOfCurrentBlock - cipherBlockSize
try {
if (bytesUntilPreviousBlockStart <= 0) {
cipher.init(
Cipher.DECRYPT_MODE,
secretKeySpec,
initialIvParameterSpec
)
return
}
var skipped = sourceStream.skip(bytesUntilPreviousBlockStart)
while (skipped < bytesUntilPreviousBlockStart) {
sourceStream.read()
skipped++
}
val previousEncryptedBlock = ByteArray(cipherBlockSize)
sourceStream.read(previousEncryptedBlock)
cipher.init(
Cipher.DECRYPT_MODE,
secretKeySpec,
IvParameterSpec(previousEncryptedBlock)
)
skip(bytesUntilPreviousBlockStart + cipherBlockSize)
val discardableByteArray = ByteArray(bytesSinceStartOfCurrentBlock.toInt())
read(discardableByteArray)
} catch (e: Exception) {
Log.e(TAG, "Encrypted video skipping error", e)
throw e
}
}
// We need to return the available bytes from the upstream.
// In this implementation we're front loading it, but it's possible the value might change during the lifetime
// of this instance, and reference to the stream should be retained and queried for available bytes instead
@Throws(IOException::class)
override fun available(): Int {
return sourceStream.available()
}
}
}
class BlockCipherEncryptedDataSourceFactory(
private val secretKeySpec: SecretKeySpec,
private val uri: Uri,
private val cipherTransformation: String = "AES/CBC/PKCS7Padding"
) : DataSource.Factory {
override fun createDataSource(): BlockCipherEncryptedDataSource {
return BlockCipherEncryptedDataSource(secretKeySpec, uri, cipherTransformation)
}
}
Example how to play encrypted audio file, hope this will help to someone. I'm using Kotlin here
import android.net.Uri
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.upstream.DataSource
import com.google.android.exoplayer2.upstream.DataSourceInputStream
import com.google.android.exoplayer2.upstream.DataSpec
import com.google.android.exoplayer2.util.Assertions
import java.io.IOException
import javax.crypto.CipherInputStream
class EncryptedDataSource(upstream: DataSource) : DataSource {
private var upstream: DataSource? = upstream
private var cipherInputStream: CipherInputStream? = null
override fun open(dataSpec: DataSpec?): Long {
val cipher = getCipherInitDecrypt()
val inputStream = DataSourceInputStream(upstream, dataSpec)
cipherInputStream = CipherInputStream(inputStream, cipher)
inputStream.open()
return C.LENGTH_UNSET.toLong()
}
override fun read(buffer: ByteArray?, offset: Int, readLength: Int): Int {
Assertions.checkNotNull<Any>(cipherInputStream)
val bytesRead = cipherInputStream!!.read(buffer, offset, readLength)
return if (bytesRead < 0) {
C.RESULT_END_OF_INPUT
} else bytesRead
}
override fun getUri(): Uri {
return upstream!!.uri
}
@Throws(IOException::class)
override fun close() {
if (cipherInputStream != null) {
cipherInputStream = null
upstream!!.close()
}
}
}
In function above you need to get Cipher which was used for encryption and init it: smth like this
fun getCipherInitDecrypt(): Cipher {
val cipher = Cipher.getInstance("AES/CTR/NoPadding", "BC");
val iv = IvParameterSpec(initVector.toByteArray(charset("UTF-8")))
val skeySpec = SecretKeySpec(key, TYPE_RSA)
cipher.init(Cipher.DECRYPT_MODE, skeySpec, iv)
return cipher
}
Next step is creating DataSource.Factory
for DataSource
we've implemented earlier
import com.google.android.exoplayer2.upstream.DataSource
class EncryptedFileDataSourceFactory(var dataSource: DataSource) : DataSource.Factory {
override fun createDataSource(): DataSource {
return EncryptedDataSource(dataSource)
}
}
And last step is players initialization
private fun prepareExoPlayerFromFileUri(uri: Uri) {
val player = ExoPlayerFactory.newSimpleInstance(
DefaultRenderersFactory(this),
DefaultTrackSelector(),
DefaultLoadControl())
val playerView = findViewById<PlayerView>(R.id.player_view)
playerView.player = player
val dsf = DefaultDataSourceFactory(this, Util.getUserAgent(this, "ExoPlayerInfo"))
//This line do the thing
val mediaSource = ExtractorMediaSource.Factory(EncryptedFileDataSourceFactory(dsf.createDataSource())).createMediaSource(uri)
player.prepare(mediaSource)
}