mbmc
mbmc

Reputation: 5105

Android + exoplayer: play AES encrypted videos, locally

On a linux box, I have an MP4 video that is encrypted with openssl:

openssl enc -aes-128-ecb -a -in video.mp4 -out video.enc -K `cat aes.key`

Please note, this is an exercise, the strength of the algo doesn't matter.

That file is sent to an Android app, and I'm trying to play it using ExoPlayer.

I've done some tests beforehand with text files to make sure the decryption was working properly

fun decrypt(key: ByteArray, data: ByteArray): ByteArray {
    val spec = SecretKeySpec(key, "AES")
    val cipher = Cipher.getInstance("AES/ECB/PKCS5Padding")
    cipher.init(Cipher.DECRYPT_MODE, spec)
    globalCipher.init(Cipher.DECRYPT_MODE, spec)
    return cipher.doFinal(data)
}

Regarding ExoPlayer, it's a bit overwhelming between AesCipherDataSource, AesCipherDataSink, SimpleCache etc. I was not able to put together a simple way to play the video.

fun playVideo() {
    val player = SimpleExoPlayer.Builder(this).build()
    playerView.player = player

    val dataSourceFactory = DefaultDataSourceFactory? // <-- what's the factory?
    val dataSource = AesCipherDataSource(globalCipher, ?) // <-- what's the data source?
    val extractorsFactory: ExtractorsFactory = DefaultExtractorsFactory()
    try {
        val uri = Uri.fromFile(File(path, "video.enc"))
        val videoSource =
                ExtractorMediaSource(uri, dataSourceFactory, extractorsFactory, null, null)
        player.prepare(videoSource)
        player.playWhenReady = true
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

So questions:

  1. how to achieve playing this encrypted video locally?
  2. what would need to change once that video is served over HTTP? (need to add a manifest? headers?)

Upvotes: 2

Views: 5835

Answers (2)

Mohsen Mousavi
Mohsen Mousavi

Reputation: 173

I know it might be too late to answer this question, but for the sake of others who might be struggling with seek issues, here is the full implementation of ExoPlayer DataSource which plays an encrypted local file(either in ECB or other modes) and fully supports backward and forward seek as well.

I also wrote an Article in Medium in which I explained the nitty-gritty details of it as well as elaborated on how to support playing an encrypted file from a remote server on the fly.

To have your encrypted local file played by ExoPlayer supporing backward and foreward seek, please take the following steps:

First: Implement a custom DataSource which takes your key and iv (if you are using CTR or other modes with IvSpecParameter) in the constructor as follows:


class FileCipherEncryptedDataSource(private val key: ByteArray, private val iv: ByteArray?) :
    DataSource {

    private var encryptedFileStream: FileInputStream? = null
    private var cipherInputStream: FileCipherInputStream? = null
    private var bytesToRead: Long = 0
    private var bytesRead: Int = 0
    private var isOpen = false
    private var dataSpec: DataSpec? = null

    @Throws(IOException::class)
    override fun open(dataSpec: DataSpec): Long {
        this.dataSpec = dataSpec
        if (isOpen) return bytesToRead
        try {
            setupCipherInputStream()
            cipherInputStream?.forceSkip(dataSpec.position)

            if (dataSpec.length != C.LENGTH_UNSET.toLong()) {
                bytesToRead = dataSpec.length
                return bytesToRead
            }
            if (bytesToRead == Int.MAX_VALUE.toLong()) {
                bytesToRead = C.LENGTH_UNSET.toLong()
                return bytesToRead
            }
            bytesToRead = cipherInputStream!!.available().toLong()
        } catch (e: IOException) {
            throw IOException(e)
        }
        isOpen = true
        return bytesToRead
    }

    private fun setupCipherInputStream() {
        val path = uri?.path ?: throw RuntimeException("Path can NOT be empty!")
        encryptedFileStream = File(path).inputStream()
        val keySpec = SecretKeySpec(
            key, // use you won byte array key
            "AES", //use your own alg.
        )
        // if you are using ECB mode, it is not required.
        val ivParameterSpec = IvParameterSpec(
            iv, // ues you own iv key
        )
        val cipher = Cipher.getInstance(
            "AES/ECB/PKCS5Padding" // use your own mode.
        )
        cipherInputStream = FileCipherInputStream(
            encryptedFileStream!!,
            cipher,
            keySpec,
            ivParameterSpec,// if you are using ECB, remove this line.
        )
    }

    @Throws(IOException::class)
    override fun read(buffer: ByteArray, offset: Int, readLength: Int): Int {
        if (bytesToRead == 0L) {
            return C.RESULT_END_OF_INPUT
        }

        bytesRead = try {
            cipherInputStream!!.read(buffer, offset, readLength)
        } catch (e: IOException) {
            throw IOException(e)
        }

        if (bytesRead < 0) {
            if (bytesToRead != C.LENGTH_UNSET.toLong())
                throw IOException(EOFException())
            return C.RESULT_END_OF_INPUT
        }

        if (bytesToRead != C.LENGTH_UNSET.toLong())
            bytesToRead -= bytesRead
        return bytesRead
    }

    override fun addTransferListener(transferListener: TransferListener) {}

    override fun getUri(): Uri? = dataSpec?.uri

    @Throws(IOException::class)
    override fun close() {
        try {
            encryptedFileStream?.close()
            cipherInputStream?.close()
        } catch (e: IOException) {
            throw IOException(e)
        } finally {
            if (isOpen) {
                isOpen = false
            }
        }
    }
}

Second: Impelement a custom CipherInputStream which has forceSkip function replacing the original skip function sice it is not going to work in our case:

class FileCipherInputStream(
    private val upstream: FileInputStream,
    private var cipher: Cipher,
    private val secretKeySpec: SecretKeySpec,
    private val ivParameterSpec: IvParameterSpec? = null,
) : CipherInputStream(upstream, cipher) {

    // in case of ECB mode, use this method
    fun forceSkip(bytesToSkip: Long): Long {
        val skipOverFlow = bytesToSkip % cipher.blockSize
        val skipBlockPosition = bytesToSkip - skipOverFlow
        try {
            if (skipBlockPosition <= 0) {
                initCipher()
                return 0L
            }
            var upstreamSkipped = upstream.skip(skipBlockPosition)
            while (upstreamSkipped < skipBlockPosition) {
                upstream.read()
                upstreamSkipped++
            }
            val cipherBlockArray = ByteArray(cipher.blockSize)
            upstream.read(cipherBlockArray)
            initCipher()
            val cipherSkipped = skip(skipBlockPosition)
            val negligibleBytes = ByteArray(skipOverFlow.toInt())
            read(negligibleBytes)
            return cipherSkipped
        } catch (e: Exception) {
            e.printStackTrace()
            return 0
        }
    }

    // in case of other modes with IV, use this method
    fun forceIvSkip(bytesToSkip: Long): Long {
        val skipped: Long = upstream.skip(bytesToSkip)
        try {
            val skipOverFlow = (bytesToSkip % cipher.blockSize).toInt()
            val skipBlockPosition = bytesToSkip - skipOverFlow
            val blocksNumber = skipBlockPosition / cipher.blockSize
            val ivOffset = BigInteger(1, ivParameterSpec!!.iv).add(
                BigInteger.valueOf(blocksNumber)
            )
            val ivOffsetBytes = ivOffset.toByteArray()
            val skippedIvSpec = if (ivOffsetBytes.size < cipher.blockSize) {
                    val resizedIvOffsetBytes = ByteArray(cipher.blockSize)
                    System.arraycopy(
                        ivOffsetBytes,
                        0,
                        resizedIvOffsetBytes,
                        cipher.blockSize - ivOffsetBytes.size,
                        ivOffsetBytes.size
                    )
                    IvParameterSpec(resizedIvOffsetBytes)
                } else {
                    IvParameterSpec(
                        ivOffsetBytes,
                        ivOffsetBytes.size - cipher.blockSize,
                        cipher.blockSize
                    )
                }

            cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, skippedIvSpec)
            val skipBuffer = ByteArray(skipOverFlow)
            cipher.update(skipBuffer, 0, skipOverFlow, skipBuffer)
        } catch (e: java.lang.Exception) {
            e.printStackTrace()
            return 0
        }
        return skipped
    }


    @Throws(IOException::class)
    override fun available(): Int {
        return upstream.available()
    }
}

Third: Implement a custom DataSource.Factory which instantiates and returns the previous FileCipherEncryptedDataSource:

class EncryptedDataSourceFactory(
    private val key: ByteArray,
    private val iv: ByteArray,
) : DataSource.Factory {

    override fun createDataSource(): DataSource = 
        FileCipherEncryptedDataSource(key, iv)
}

Fourth an the final: Inject your DataSource.Factory inside MediaSourceFactory of ExoPlayer:

private fun setStreamingSourceFactory(key: ByteArray, iv: ByteArray) {
val mediaItem = MediaItem.Builder()
                .setMimeType(MimeTypes.BASE_TYPE_AUDIO)// or BASE_TYPE_VIDEO
                .setUri(url) // your file's url
                .build()
    val dataSourceFactory = EncryptedDataSourceFactory(key, iv)
    val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory)
        .createMediaSource(mediaItem)
    exoPlayer?.setMediaSource(mediaSource)
    exoPlayer?.prepare()
}

You can also find the full implementation in my GitHup page.

Upvotes: 0

mbmc
mbmc

Reputation: 5105

here's the solution. Might need some adjustments to handle skipping frames, fast forward etc, but this plays an AES/ECB/PKCS5Padding encrypted video

class EncryptedDataSourceFactory(
    private val key: String
) : DataSource.Factory {
    override fun createDataSource(): EncryptedDataSource =
        EncryptedDataSource(key)
}
class EncryptedDataSource(private val key: String) : DataSource {
    private var inputStream: CipherInputStream? = null
    private lateinit var uri: Uri

    override fun addTransferListener(transferListener: TransferListener) {}

    override fun open(dataSpec: DataSpec): Long {
        uri = dataSpec.uri
        try {
            val file = File(uri.path)
            val skeySpec = SecretKeySpec(key.toByteArray(), KeyProperties.KEY_ALGORITHM_AES)
            val cipher = Cipher.getInstance("AES/ECB/PKCS5Padding")
            cipher.init(Cipher.DECRYPT_MODE, skeySpec)
            inputStream = CipherInputStream(file.inputStream(), cipher)
        } catch (e: Exception) {
            
        }
        return dataSpec.length
    }

    @Throws(IOException::class)
    override fun read(buffer: ByteArray, offset: Int, readLength: Int): Int =
        if (readLength == 0) {
            0
        } else {
            inputStream?.read(buffer, offset, readLength) ?: 0
        }

    override fun getUri(): Uri? =
        uri

    @Throws(IOException::class)
    override fun close() {
        inputStream?.close()
    }
}
    private fun playVideo(key: String) {
        val player = SimpleExoPlayer.Builder(this).build()
        playerView.player = player

        val dataSourceFactory: DataSource.Factory = EncryptedDataSourceFactory(key)
        val extractorsFactory: ExtractorsFactory = DefaultExtractorsFactory()
        try {
            val uri = Uri.fromFile(video)
            val videoSource: MediaSource = ExtractorMediaSource(uri, dataSourceFactory, extractorsFactory, null, null)
            player.prepare(videoSource)
            player.playWhenReady = true
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }

Upvotes: 6

Related Questions