使用 ExoPlayer 再现加密视频

Reproducing encrypted video using ExoPlayer

我在 Android 中使用 ExoPlayer,我正在尝试重现本地存储的加密视频。

ExoPlayer 的模块化允许创建可以注入到 ExoPlayer 中的自定义组件,这似乎就是这种情况。事实上,经过一些研究我意识到为了完成这个任务我可以创建一个自定义数据源并覆盖 open()read()close().

我也找到了this solution,但实际上这里整个文件一步解密并存储在一个清晰的输入流中。这在很多情况下都很好。但是如果我需要复制大文件怎么办?

所以问题是:如何在 ExoPlayer 中重现加密视频,解密内容 "on-fly"(而不解密整个文件)?这可能吗?

我尝试创建一个具有 open() 方法的自定义数据源:

@Override
    public long open(DataSpec dataSpec) throws FileDataSourceException {
        try {
            File file = new File(dataSpec.uri.getPath());

            clearInputStream = new CipherInputStream(new FileInputStream(file), mCipher);

            long skipped = clearInputStream.skip(dataSpec.position);
            if (skipped < dataSpec.position) {
                throw new EOFException();
            }
            if (dataSpec.length != C.LENGTH_UNBOUNDED) {
                bytesRemaining = dataSpec.length;
            } else {
                bytesRemaining = clearInputStream.available();
                if (bytesRemaining == 0) {
                    bytesRemaining = C.LENGTH_UNBOUNDED;
                }
            }
        } catch (EOFException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }

        opened = true;
        if (listener != null) {
            listener.onTransferStart();
        }

        return bytesRemaining;
    }

这是 read() 方法:

@Override
public int read(byte[] buffer, int offset, int readLength) throws FileDataSourceException {
        if (bytesRemaining == 0) {
            return -1;
        } else {
            int bytesRead = 0;

                int bytesToRead = bytesRemaining == C.LENGTH_UNBOUNDED ? readLength
                        : (int) Math.min(bytesRemaining, readLength);
            try {
                bytesRead = clearInputStream.read(buffer, offset, bytesToRead);
            } catch (IOException e) {
                e.printStackTrace();
            }

            if (bytesRead > 0) {
                if (bytesRemaining != C.LENGTH_UNBOUNDED) {
                    bytesRemaining -= bytesRead;
                }
                if (listener != null) {
                    listener.onBytesTransferred(bytesRead);
                }
            }

            return bytesRead;
        }
    }

如果我传递的不是编码文件而是明文文件,并且只删除 CipherInputStream 部分,那么它工作正常,而不是加密文件我收到此错误:

    Unexpected exception loading stream
java.lang.IllegalStateException: Top bit not zero: -1195853062
at com.google.android.exoplayer.util.ParsableByteArray.readUnsignedIntToInt(ParsableByteArray.java:240)
at com.google.android.exoplayer.extractor.mp4.Mp4Extractor.readSample(Mp4Extractor.java:331)
at com.google.android.exoplayer.extractor.mp4.Mp4Extractor.read(Mp4Extractor.java:122)
at com.google.android.exoplayer.extractor.ExtractorSampleSource$ExtractingLoadable.load(ExtractorSampleSource.java:745)
at com.google.android.exoplayer.upstream.Loader$LoadTask.run(Loader.java:209)
at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:423)
at java.util.concurrent.FutureTask.run(FutureTask.java:237)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1113)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:588)
at java.lang.Thread.run(Thread.java:818)

编辑:

加密后的视频是这样生成的:

Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
SecretKeySpec keySpec = new SecretKeySpec("0123456789012345".getBytes(), "AES");
IvParameterSpec ivSpec = new IvParameterSpec("0123459876543210".getBytes());
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);

outputStream = new CipherOutputStream(output_stream, cipher);

然后将输出流保存到文件中。

根据以下配置检查您的代理。

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
                     ?

在 ExoPlayer 演示应用程序中 - DashRenderBuilder.java 有一个方法 'filterHdContent' 如果设备不是级别 1(假设它是 L3),它总是 return 为真。这会导致播放器在解析 mpd 时忽略 HD AdaptionSet。

如果您想播放高清内容,您可以始终将 filterHdContent 设置为 return false,但是内容所有者通常要求对高清内容实施 L1 Widevine。

查看此 link 了解更多 https://github.com/google/ExoPlayer/issues/1116 https://github.com/google/ExoPlayer/issues/1523

我认为 open/read/close 的自定义数据源不能满足您的需求。对于 'on-the-fly' 解密(对大文件有价值,但不仅如此),您必须设计一个流式架构。

已有与您相似的帖子。要找到它们,请不要查找 'exoplayer',而是查找 'videoview' 或 'mediaplayer'。答案应该兼容。

例如,Playing encrypted video files using VideoView

最终我找到了解决方案。

加密算法我用的是no-padding,这样:

cipher = Cipher.getInstance("AES/CTR/NoPadding", "BC");

使加密文件大小和明文文件大小保持一致。所以现在我创建了流:

cipherInputStream = new CipherInputStream(inputStream, cipher) {
    @Override
    public int available() throws IOException {
         return in.available();
    }
};

这是因为 Java 文档说 ChiperInputStream.available()

This method should be overriden

实际上我认为它更像是一个必须的,因为从该方法检索的值通常非常奇怪。

就是这样!现在完美运行了。

播放加密音频文件的例子,希望对大家有所帮助。 我在这里使用 Kotlin

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

在上面的函数中你需要获取用于加密的 Cipher 并初始化它: 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
}

下一步是为我们之前实施的DataSource创建DataSource.Factory

import com.google.android.exoplayer2.upstream.DataSource

class EncryptedFileDataSourceFactory(var dataSource: DataSource) : DataSource.Factory {

    override fun createDataSource(): DataSource {
        return EncryptedDataSource(dataSource)
    }
}

最后一步是玩家初始化

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

这个问题让我焦头烂额,所以我终于屈服并为 AES/CBC 实现了一个流式密码,让你可以跳过。 CBC理论上允许随机读取,你需要用前一个块的密文作为初始化向量来初始化密码,然后向前读取直到你需要的地方。完整实施的示例项目 here. 这是关键 类:

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