diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 1cc4d658c9..0344b3112a 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -53,6 +53,8 @@ `BaseRenderer.onStreamChanged`. * HLS: Fix playlist loading error propagation when the current selection does not include all of the playlist's variants. +* Fix ClearKey decryption error if the key contains a forward slash + ([#4075](https://github.com/google/ExoPlayer/issues/4075)). * Fix IllegalStateException when switching surface on Huawei P9 Lite ([#4084](https://github.com/google/ExoPlayer/issues/4084)). diff --git a/library/core/src/main/java/com/google/android/exoplayer2/drm/ClearKeyUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/drm/ClearKeyUtil.java index ee337dcc51..87dbc7a65c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/drm/ClearKeyUtil.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/drm/ClearKeyUtil.java @@ -17,8 +17,6 @@ package com.google.android.exoplayer2.drm; import android.util.Log; import com.google.android.exoplayer2.util.Util; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -29,7 +27,6 @@ import org.json.JSONObject; /* package */ final class ClearKeyUtil { private static final String TAG = "ClearKeyUtil"; - private static final Pattern REQUEST_KIDS_PATTERN = Pattern.compile("\"kids\":\\[\"(.*?)\"]"); private ClearKeyUtil() {} @@ -43,21 +40,12 @@ import org.json.JSONObject; if (Util.SDK_INT >= 27) { return request; } - // Prior to O-MR1 the ClearKey CDM encoded the values in the "kids" array using Base64 rather - // than Base64Url. See [Internal: b/64388098]. Any "/" characters that ended up in the request - // as a result were not escaped as "\/". We know the exact request format from the platform's - // InitDataParser.cpp, so we can use a regexp rather than parsing the JSON. + // Prior to O-MR1 the ClearKey CDM encoded the values in the "kids" array using Base64 encoding + // rather than Base64Url encoding. See [Internal: b/64388098]. We know the exact request format + // from the platform's InitDataParser.cpp. Since there aren't any "+" or "/" symbols elsewhere + // in the request, it's safe to fix the encoding by replacement through the whole request. String requestString = Util.fromUtf8Bytes(request); - Matcher requestKidsMatcher = REQUEST_KIDS_PATTERN.matcher(requestString); - if (!requestKidsMatcher.find()) { - Log.e(TAG, "Failed to adjust request data: " + requestString); - return request; - } - int kidsStartIndex = requestKidsMatcher.start(1); - int kidsEndIndex = requestKidsMatcher.end(1); - StringBuilder adjustedRequestBuilder = new StringBuilder(requestString); - base64ToBase64Url(adjustedRequestBuilder, kidsStartIndex, kidsEndIndex); - return Util.getUtf8Bytes(adjustedRequestBuilder.toString()); + return Util.getUtf8Bytes(base64ToBase64Url(requestString)); } /** @@ -71,39 +59,39 @@ import org.json.JSONObject; return response; } // Prior to O-MR1 the ClearKey CDM expected Base64 encoding rather than Base64Url encoding for - // the "k" and "kid" strings. See [Internal: b/64388098]. + // the "k" and "kid" strings. See [Internal: b/64388098]. We know that the ClearKey CDM only + // looks at the k, kid and kty parameters in each key, so can ignore the rest of the response. try { JSONObject responseJson = new JSONObject(Util.fromUtf8Bytes(response)); + StringBuilder adjustedResponseBuilder = new StringBuilder("{\"keys\":["); JSONArray keysArray = responseJson.getJSONArray("keys"); for (int i = 0; i < keysArray.length(); i++) { + if (i != 0) { + adjustedResponseBuilder.append(","); + } JSONObject key = keysArray.getJSONObject(i); - key.put("k", base64UrlToBase64(key.getString("k"))); - key.put("kid", base64UrlToBase64(key.getString("kid"))); + adjustedResponseBuilder.append("{\"k\":\""); + adjustedResponseBuilder.append(base64UrlToBase64(key.getString("k"))); + adjustedResponseBuilder.append("\",\"kid\":\""); + adjustedResponseBuilder.append(base64UrlToBase64(key.getString("kid"))); + adjustedResponseBuilder.append("\",\"kty\":\""); + adjustedResponseBuilder.append(key.getString("kty")); + adjustedResponseBuilder.append("\"}"); } - return Util.getUtf8Bytes(responseJson.toString()); + adjustedResponseBuilder.append("]}"); + return Util.getUtf8Bytes(adjustedResponseBuilder.toString()); } catch (JSONException e) { Log.e(TAG, "Failed to adjust response data: " + Util.fromUtf8Bytes(response), e); return response; } } - private static void base64ToBase64Url(StringBuilder base64, int startIndex, int endIndex) { - for (int i = startIndex; i < endIndex; i++) { - switch (base64.charAt(i)) { - case '+': - base64.setCharAt(i, '-'); - break; - case '/': - base64.setCharAt(i, '_'); - break; - default: - break; - } - } + private static String base64ToBase64Url(String base64) { + return base64.replace('+', '-').replace('/', '_'); } - private static String base64UrlToBase64(String base64) { - return base64.replace('-', '+').replace('_', '/'); + private static String base64UrlToBase64(String base64Url) { + return base64Url.replace('-', '+').replace('_', '/'); } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/drm/ClearKeyUtilTest.java b/library/core/src/test/java/com/google/android/exoplayer2/drm/ClearKeyUtilTest.java index c84ca6182c..460a237698 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/drm/ClearKeyUtilTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/drm/ClearKeyUtilTest.java @@ -17,8 +17,7 @@ package com.google.android.exoplayer2.drm; import static com.google.common.truth.Truth.assertThat; -import com.google.android.exoplayer2.C; -import java.nio.charset.Charset; +import com.google.android.exoplayer2.util.Util; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; @@ -27,37 +26,115 @@ import org.robolectric.annotation.Config; /** * Unit test for {@link ClearKeyUtil}. */ -// TODO: When API level 27 is supported, add tests that check the adjust methods are no-ops. @RunWith(RobolectricTestRunner.class) public final class ClearKeyUtilTest { + private static final byte[] SINGLE_KEY_RESPONSE = + Util.getUtf8Bytes( + "{" + + "\"keys\":[" + + "{" + + "\"k\":\"abc_def-\"," + + "\"kid\":\"ab_cde-f\"," + + "\"kty\":\"o_c-t\"," + + "\"ignored\":\"ignored\"" + + "}" + + "]," + + "\"ignored\":\"ignored\"" + + "}"); + private static final byte[] MULTI_KEY_RESPONSE = + Util.getUtf8Bytes( + "{" + + "\"keys\":[" + + "{" + + "\"k\":\"abc_def-\"," + + "\"kid\":\"ab_cde-f\"," + + "\"kty\":\"oct\"," + + "\"ignored\":\"ignored\"" + + "},{" + + "\"k\":\"ghi_jkl-\"," + + "\"kid\":\"gh_ijk-l\"," + + "\"kty\":\"oct\"" + + "}" + + "]," + + "\"ignored\":\"ignored\"" + + "}"); + private static final byte[] KEY_REQUEST = + Util.getUtf8Bytes( + "{" + + "\"kids\":[" + + "\"abc+def/\"," + + "\"ab+cde/f\"" + + "]," + + "\"type\":\"temporary\"" + + "}"); + @Config(sdk = 26) @Test - public void testAdjustResponseDataV26() { - byte[] data = ("{\"keys\":[{" - + "\"k\":\"abc_def-\"," - + "\"kid\":\"ab_cde-f\"}]," - + "\"type\":\"abc_def-" - + "\"}").getBytes(Charset.forName(C.UTF8_NAME)); - // We expect "-" and "_" to be replaced with "+" and "\/" (forward slashes need to be escaped in - // JSON respectively, for "k" and "kid" only. - byte[] expected = ("{\"keys\":[{" - + "\"k\":\"abc\\/def+\"," - + "\"kid\":\"ab\\/cde+f\"}]," - + "\"type\":\"abc_def-" - + "\"}").getBytes(Charset.forName(C.UTF8_NAME)); - assertThat(ClearKeyUtil.adjustResponseData(data)).isEqualTo(expected); + public void testAdjustSingleKeyResponseDataV26() { + // Everything but the keys should be removed. Within each key only the k, kid and kty parameters + // should remain. Any "-" and "_" characters in the k and kid values should be replaced with "+" + // and "/". + byte[] expected = + Util.getUtf8Bytes( + "{" + + "\"keys\":[" + + "{" + + "\"k\":\"abc/def+\",\"kid\":\"ab/cde+f\",\"kty\":\"o_c-t\"" + + "}" + + "]" + + "}"); + assertThat(ClearKeyUtil.adjustResponseData(SINGLE_KEY_RESPONSE)).isEqualTo(expected); + } + + @Config(sdk = 26) + @Test + public void testAdjustMultiKeyResponseDataV26() { + // Everything but the keys should be removed. Within each key only the k, kid and kty parameters + // should remain. Any "-" and "_" characters in the k and kid values should be replaced with "+" + // and "/". + byte[] expected = + Util.getUtf8Bytes( + "{" + + "\"keys\":[" + + "{" + + "\"k\":\"abc/def+\",\"kid\":\"ab/cde+f\",\"kty\":\"oct\"" + + "},{" + + "\"k\":\"ghi/jkl+\",\"kid\":\"gh/ijk+l\",\"kty\":\"oct\"" + + "}" + + "]" + + "}"); + assertThat(ClearKeyUtil.adjustResponseData(MULTI_KEY_RESPONSE)).isEqualTo(expected); + } + + @Config(sdk = 27) + @Test + public void testAdjustResponseDataV27() { + // Response should be unchanged. + assertThat(ClearKeyUtil.adjustResponseData(SINGLE_KEY_RESPONSE)).isEqualTo(SINGLE_KEY_RESPONSE); } @Config(sdk = 26) @Test public void testAdjustRequestDataV26() { - byte[] data = "{\"kids\":[\"abc+def/\",\"ab+cde/f\"],\"type\":\"abc+def/\"}" - .getBytes(Charset.forName(C.UTF8_NAME)); // We expect "+" and "/" to be replaced with "-" and "_" respectively, for "kids". - byte[] expected = "{\"kids\":[\"abc-def_\",\"ab-cde_f\"],\"type\":\"abc+def/\"}" - .getBytes(Charset.forName(C.UTF8_NAME)); - assertThat(ClearKeyUtil.adjustRequestData(data)).isEqualTo(expected); + byte[] expected = + Util.getUtf8Bytes( + "{" + + "\"kids\":[" + + "\"abc-def_\"," + + "\"ab-cde_f\"" + + "]," + + "\"type\":\"temporary\"" + + "}"); + assertThat(ClearKeyUtil.adjustRequestData(KEY_REQUEST)).isEqualTo(expected); + } + + @Config(sdk = 27) + @Test + public void testAdjustRequestDataV27() { + // Request should be unchanged. + assertThat(ClearKeyUtil.adjustRequestData(KEY_REQUEST)).isEqualTo(KEY_REQUEST); } }