Support other cipher modes with AesCipherDataSource
@tonihei is already working on this.
Since Dec 6, 2023.
- Dominant language
- Java
- Stars
- 3k
- Forks
- 955
- Avg merge
- 12d 14h
- Merged PRs (30d)
- 2
Description
Use case description
I want to be able to playback encrypted files. This explicitly is not m3u8 files with DRM protected chuncks. To emphasize it, cause it will confuse some people. This is NOT to decrypt DRM protected content!
The case is full-file-encryption where you'd want to playback the file without decrypting it first. So you want to let the media player do the decyrption for you in the background. My specific usecase is using a public data network where i happily story my content in encrypted form.
An example of how this full file encryption works using OpenSSL and mpv:
AES_KEY=$(openssl rand -hex 16)
AES_IV=$(openssl rand -hex 16)
openssl enc -aes-128-cbc -K $AES_KEY -iv $AES_IV -in my_video.mkv -out my_video.mkv.enc
This encrypts your my_video.mkv file as my_video.mkv.enc
You can now play it using:
mpv lavf://crypto:my_video.mkv.enc --stream-lavf-o=key=${AES_KEY},iv=${AES_IV}
This works!
So what's my problem with this? Why not use this?
Couple reasons.
- While MPV is a great media player, it is lacking on the android side of things. I want to be able to play this on android!
- I found out that the media3 stack (this repo) does a very good job in video+audio playback using gpu extensions where possible.
- The media3 stack, unlike others, has a very solid download caching strategy that prevents buffering issues (mostly). And if that isn't sufficient then it's api allows to customize it.
So i want to use a media3-based player on the android side of things. Hence i'm looking into this media3 stack to try and hack my way into supporting encrypted content. But after literally weeks of testing, looking though millions of lines of debug lines and not getting the result i want, makes me open a ticket here to call for help.
Proposed solution
I'm looking for a - preferably - generic solution within media3. For example, it could well be a new "DataSource" class (call it AesDataSource or whatever) that acts as proxy between your input and your resulting media. I did go for this approach but couldn't get it working.
Couple notes before the diff of what i currently have:
- The keys are randomly generated, change for your own keys. Yes, in an app using media3 those keys would come from the UI or from a data format. They would never be in code as plain-text like this.
- For testing purposes i'm considering the file "my_video.mkv.enc" to be the encrypted file. Change for your purposes, obviously.
- I'm having a lot of trouble with the AES decipher. I don't get it's claimed "bytes decrypted" output. The first 16 byte of the decrypted blob isn't counted. I tried it and the first 16 bytes are for sure decrypted. As an example, say i decrypt 32 bytes. The first 16 bytes - while they are decrypted - are for whatever reason just ignored in the return counter. Why?
- I know i should be using the
cipher.final(...)for the last block. This version is one of many hundreds of iterations, i just didn't implement that part yet. Another version that was usingCipherInputStreamdid but that version made me crazy in different ways. Regardless, i'm not getting decryption errors yet so my error isn't in the final handling. - I tried this all with http-urls, you should too.
diff --git a/libraries/datasource/src/main/java/androidx/media3/datasource/DefaultDataSource.java b/libraries/datasource/src/main/java/androidx/media3/datasource/DefaultDataSource.java
index 83de6a66e2..2ee67e55a7 100644
--- a/libraries/datasource/src/main/java/androidx/media3/datasource/DefaultDataSource.java
+++ b/libraries/datasource/src/main/java/androidx/media3/datasource/DefaultDataSource.java
@@ -24,11 +24,24 @@ import androidx.media3.common.util.Log;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import java.io.ByteArrayInputStream;
import java.io.IOException;
+import java.io.InputStream;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.InvalidKeyException;
+import java.security.Key;
+import java.security.NoSuchAlgorithmException;
+import java.security.spec.AlgorithmParameterSpec;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
+import javax.crypto.Cipher;
+import javax.crypto.NoSuchPaddingException;
+import javax.crypto.ShortBufferException;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
/**
* A {@link DataSource} that supports multiple URI schemes. The supported schemes are:
@@ -141,9 +154,14 @@ public final class DefaultDataSource implements DataSource {
@Nullable private DataSource udpDataSource;
@Nullable private DataSource dataSchemeDataSource;
@Nullable private DataSource rawResourceDataSource;
+ @Nullable private DataSource encryptedResourceDataSource;
@Nullable private DataSource dataSource;
+ @Nullable private Cipher cipher;
+ @Nullable private InputStream inputStream;
+ private final byte[] globalBuffer = new byte[65536];
+
/**
* Constructs a new instance, optionally configured to follow cross-protocol redirects.
*
@@ -238,6 +256,7 @@ public final class DefaultDataSource implements DataSource {
maybeAddListenerToDataSource(udpDataSource, transferListener);
maybeAddListenerToDataSource(dataSchemeDataSource, transferListener);
maybeAddListenerToDataSource(rawResourceDataSource, transferListener);
+ maybeAddListenerToDataSource(encryptedResourceDataSource, transferListener);
}
@UnstableApi
@@ -268,6 +287,31 @@ public final class DefaultDataSource implements DataSource {
} else {
dataSource = baseDataSource;
}
+
+ if (dataSpec.key.endsWith("my_video.mkv.enc")) {
+ try {
+ cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException(e);
+ } catch (NoSuchPaddingException e) {
+ throw new RuntimeException(e);
+ }
+ byte[] encryptionKey = hexToByteArray("946d879dad6430ed70a34d439e8aba53");
+ byte[] encryptionIv = hexToByteArray("649237bf7afeb2e1e0254b72d085e0ad");
+
+ Key cipherKey = new SecretKeySpec(encryptionKey, "AES/CBC/PKCS5Padding");
+ AlgorithmParameterSpec cipherIV = new IvParameterSpec(encryptionIv);
+
+ try {
+ cipher.init(Cipher.DECRYPT_MODE, cipherKey, cipherIV);
+ } catch (InvalidKeyException | InvalidAlgorithmParameterException e) {
+ throw new RuntimeException(e);
+ }
+
+ inputStream = new ByteArrayInputStream(globalBuffer);
+ inputStream.skip(globalBuffer.length); // Set it to the end to be sure there's no byte to read.
+ }
+
// Open the source and return.
return dataSource.open(dataSpec);
}
@@ -275,7 +319,42 @@ public final class DefaultDataSource implements DataSource {
@UnstableApi
@Override
public int read(byte[] buffer, int offset, int length) throws IOException {
- return Assertions.checkNotNull(dataSource).read(buffer, offset, length);
+ if (cipher != null) {
+ if (inputStream.available() < 16000) {
+
+
+ // Copy still available bytes
+ int stillAvailableBytes = inputStream.available();
+ byte[] stillAvailable = new byte[stillAvailableBytes];
+ inputStream.read(stillAvailable);
+ int availableRoom = this.globalBuffer.length - stillAvailableBytes;
+ int alignedAvailable = (int) Math.floor(availableRoom / 16) * 16;
+ byte[] tempBuffer = new byte[alignedAvailable];
+
+ // Clear the global buffer. Not really needed but eases debugging.
+ Arrays.fill(this.globalBuffer, (byte)0);
+
+ // Copy the remaining bytes back into this buffer
+ System.arraycopy(stillAvailable, 0, this.globalBuffer, 0, stillAvailable.length);
+
+ // Read the new - 16-byte aligned - data from the source
+ int ret = dataSource.read(tempBuffer, 0, tempBuffer.length);
+ System.out.println("Line n-bytes read:" + ret);
+
+ try {
+ // Decrypt the aligned data and stuff it in the global buffer.
+ cipher.update(tempBuffer, 0, tempBuffer.length, this.globalBuffer, stillAvailableBytes);
+ } catch (ShortBufferException e) {
+ throw new RuntimeException(e);
+ }
+ inputStream = new ByteArrayInputStream(this.globalBuffer);
+ }
+
+ return inputStream.read(buffer, offset, length);
+
+ } else {
+ return Assertions.checkNotNull(dataSource).read(buffer, offset, length);
+ }
}
@UnstableApi
@@ -371,6 +450,19 @@ public final class DefaultDataSource implements DataSource {
return rawResourceDataSource;
}
+ private byte[] hexToByteArray(String hex) {
+ hex = hex.length()%2 != 0?"0"+hex:hex;
+
+ byte[] b = new byte[hex.length() / 2];
+
+ for (int i = 0; i < b.length; i++) {
+ int index = i * 2;
+ int v = Integer.parseInt(hex.substring(index, index + 2), 16);
+ b[i] = (byte) v;
+ }
+ return b;
+ }
+
private void addListenersToDataSource(DataSource dataSource) {
for (int i = 0; i < transferListeners.size(); i++) {
dataSource.addTransferListener(transferListeners.get(i));
The above code assumes you get 16-byte aligned block back from the underlying datasource. By default that isn't the case with the cronet source (haven't tried the others). So i modified cronet to give me the data i'm asking.
diff --git a/libraries/datasource_cronet/src/main/java/androidx/media3/datasource/cronet/CronetDataSource.java b/libraries/datasource_cronet/src/main/java/androidx/media3/datasource/cronet/CronetDataSource.java
index e5d93dab8f..74b5973bc2 100644
--- a/libraries/datasource_cronet/src/main/java/androidx/media3/datasource/cronet/CronetDataSource.java
+++ b/libraries/datasource_cronet/src/main/java/androidx/media3/datasource/cronet/CronetDataSource.java
@@ -686,6 +686,21 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
}
ByteBuffer readBuffer = getOrCreateReadBuffer();
+
+ // Fill this buffer with the requested data
+ int innerOffset = offset;
+ int innerLength = length;
+ int totalBytesRead = 0;
+
+ if (readBuffer.remaining() < length && readBuffer.hasRemaining()) {
+ int bytesRead = readBuffer.remaining();
+ readBuffer.get(buffer, innerOffset, bytesRead);
+ bytesRemaining -= bytesRead;
+ innerOffset += bytesRead;
+ innerLength -= bytesRead;
+ totalBytesRead += bytesRead;
+ }
+
if (!readBuffer.hasRemaining()) {
// Fill readBuffer with more data from Cronet.
operation.close();
@@ -710,15 +725,23 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
Longs.min(
bytesRemaining != C.LENGTH_UNSET ? bytesRemaining : Long.MAX_VALUE,
readBuffer.remaining(),
- length);
+ innerLength);
- readBuffer.get(buffer, offset, bytesRead);
+ readBuffer.get(buffer, innerOffset, bytesRead);
if (bytesRemaining != C.LENGTH_UNSET) {
bytesRemaining -= bytesRead;
}
- bytesTransferred(bytesRead);
- return bytesRead;
+
+ totalBytesRead += bytesRead;
+
+// byte[] slice = Arrays.copyOfRange(buffer, offset, offset + totalBytesRead);
+// long totalSize = 3518267384L;
+// System.out.println("AAA CRONET Byte pos: " + (totalSize - bytesRemaining) + " Bytes READ: " + totalBytesRead + " " + Arrays.toString(slice));
+
+ bytesTransferred(totalBytesRead);
+
+ return totalBytesRead;
}
/**
Do note that playing normal http media with the above cronet patch does still play that just fine. Leading me to think that the cronet side of things (with this patch) is working properly.
Alternatives considered
A couple but nothing supports playback of encrypted media so none are applicable.
I'm hoping someone here with both knowledge of this library and encryption has some ideas to get this working.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.