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)
    }