Skip to content

Commit 7adfc05

Browse files
authored
Add a fallback ContentProtection implementation for known DRM schemes (#161)
1 parent fc15e7b commit 7adfc05

File tree

4 files changed

+234
-2
lines changed

4 files changed

+234
-2
lines changed

readium/streamer/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ All notable changes to this project will be documented in this file.
99
### Added
1010

1111
* EPUB publications implement a `SearchService` to search through the content.
12+
* Known DRM schemes (LCP and Adobe ADEPT) are now sniffed by the `Streamer`, when no registered `ContentProtection` supports them.
13+
* This is helpful to present an error message when the user attempts to open a protected publication not supported by the app.
1214

1315
### Changed
1416

readium/streamer/r2-streamer/src/main/java/org/readium/r2/streamer/Streamer.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@ import org.readium.r2.shared.util.http.DefaultHttpClient
2222
import org.readium.r2.shared.util.logging.WarningLogger
2323
import org.readium.r2.shared.util.mediatype.MediaType
2424
import org.readium.r2.shared.util.pdf.PdfDocumentFactory
25+
import org.readium.r2.streamer.parser.FallbackContentProtection
2526
import org.readium.r2.streamer.parser.audio.AudioParser
2627
import org.readium.r2.streamer.parser.epub.EpubParser
2728
import org.readium.r2.streamer.parser.epub.setLayoutStyle
2829
import org.readium.r2.streamer.parser.image.ImageParser
2930
import org.readium.r2.streamer.parser.pdf.PdfParser
3031
import org.readium.r2.streamer.parser.pdf.PdfiumPdfDocumentFactory
3132
import org.readium.r2.streamer.parser.readium.ReadiumWebPubParser
32-
import kotlin.Exception
3333

3434
internal typealias PublicationTry<SuccessT> = Try<SuccessT, Publication.OpeningException>
3535

@@ -55,13 +55,16 @@ class Streamer constructor(
5555
context: Context,
5656
parsers: List<PublicationParser> = emptyList(),
5757
ignoreDefaultParsers: Boolean = false,
58-
private val contentProtections: List<ContentProtection> = emptyList(),
58+
contentProtections: List<ContentProtection> = emptyList(),
5959
private val archiveFactory: ArchiveFactory = DefaultArchiveFactory(),
6060
private val pdfFactory: PdfDocumentFactory = DefaultPdfDocumentFactory(context),
6161
private val httpClient: DefaultHttpClient = DefaultHttpClient(),
6262
private val onCreatePublication: Publication.Builder.() -> Unit = {}
6363
) {
6464

65+
private val contentProtections: List<ContentProtection> =
66+
contentProtections + listOf(FallbackContentProtection())
67+
6568
/**
6669
* Parses a [Publication] from the given asset.
6770
*
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
* Copyright 2021 Readium Foundation. All rights reserved.
3+
* Use of this source code is governed by the BSD-style license
4+
* available in the top-level LICENSE file of the project.
5+
*/
6+
7+
package org.readium.r2.streamer.parser
8+
9+
import org.readium.r2.shared.UserException
10+
import org.readium.r2.shared.fetcher.Fetcher
11+
import org.readium.r2.shared.publication.ContentProtection
12+
import org.readium.r2.shared.publication.ContentProtection.Scheme
13+
import org.readium.r2.shared.publication.Publication
14+
import org.readium.r2.shared.publication.asset.PublicationAsset
15+
import org.readium.r2.shared.publication.services.ContentProtectionService
16+
import org.readium.r2.shared.publication.services.contentProtectionServiceFactory
17+
import org.readium.r2.shared.util.Try
18+
import org.readium.r2.shared.util.mediatype.MediaType
19+
import org.readium.r2.streamer.extensions.readAsJsonOrNull
20+
import org.readium.r2.streamer.extensions.readAsXmlOrNull
21+
import org.readium.r2.streamer.parser.epub.EncryptionParser
22+
import org.readium.r2.streamer.parser.epub.Namespaces
23+
24+
/**
25+
* [ContentProtection] implementation used as a fallback by the Streamer to detect known DRM
26+
* schemes (e.g. LCP or ADEPT), if they are not supported by the app.
27+
*/
28+
internal class FallbackContentProtection : ContentProtection {
29+
30+
class Service(override val scheme: Scheme?) : ContentProtectionService {
31+
32+
override val isRestricted: Boolean = true
33+
override val credentials: String? = null
34+
override val rights = ContentProtectionService.UserRights.AllRestricted
35+
override val error: UserException = ContentProtection.Exception.SchemeNotSupported(scheme)
36+
37+
companion object {
38+
39+
fun createFactory(scheme: Scheme?): (Publication.Service.Context) -> Service =
40+
{ Service(scheme) }
41+
}
42+
43+
}
44+
45+
override suspend fun open(
46+
asset: PublicationAsset,
47+
fetcher: Fetcher,
48+
credentials: String?,
49+
allowUserInteraction: Boolean,
50+
sender: Any?
51+
): Try<ContentProtection.ProtectedAsset, Publication.OpeningException>? {
52+
val scheme: Scheme = sniffScheme(fetcher, asset.mediaType())
53+
?: return null
54+
55+
val protectedFile = ContentProtection.ProtectedAsset(
56+
asset = asset,
57+
fetcher = fetcher,
58+
onCreatePublication = {
59+
servicesBuilder.contentProtectionServiceFactory = Service.createFactory(scheme)
60+
}
61+
)
62+
63+
return Try.success(protectedFile)
64+
}
65+
66+
internal suspend fun sniffScheme(fetcher: Fetcher, mediaType: MediaType): Scheme? =
67+
when {
68+
fetcher.readAsJsonOrNull("/license.lcpl") != null ->
69+
Scheme.Lcp
70+
71+
mediaType.matches(MediaType.EPUB) -> {
72+
val rightsXml = fetcher.readAsXmlOrNull("/META-INF/rights.xml")
73+
val encryptionXml = fetcher.readAsXmlOrNull("/META-INF/encryption.xml")
74+
val encryption = encryptionXml?.let { EncryptionParser.parse(it) }
75+
76+
when {
77+
(
78+
fetcher.readAsJsonOrNull("/META-INF/license.lcpl") != null ||
79+
encryption?.any { it.value.scheme == "http://readium.org/2014/01/lcp" } == true
80+
) -> Scheme.Lcp
81+
82+
(
83+
encryptionXml != null && (
84+
rightsXml?.namespace == "http://ns.adobe.com/adept" ||
85+
encryptionXml
86+
.get("EncryptedData", Namespaces.ENC)
87+
.flatMap { it.get("KeyInfo", Namespaces.SIG) }
88+
.flatMap { it.get("resource", "http://ns.adobe.com/adept")}
89+
.isNotEmpty()
90+
)
91+
) -> Scheme.Adept
92+
93+
// A file with only obfuscated fonts might still have an `encryption.xml` file.
94+
// To make sure that we don't lock a readable publication, we ignore unknown
95+
// encryption.xml schemes.
96+
else -> null
97+
}
98+
}
99+
100+
else -> null
101+
}
102+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package org.readium.r2.streamer.parser
2+
3+
import kotlinx.coroutines.runBlocking
4+
import org.junit.Assert.*
5+
import org.junit.Test
6+
import org.junit.runner.RunWith
7+
import org.readium.r2.shared.fetcher.FailureResource
8+
import org.readium.r2.shared.fetcher.Fetcher
9+
import org.readium.r2.shared.fetcher.Resource
10+
import org.readium.r2.shared.fetcher.StringResource
11+
import org.readium.r2.shared.publication.ContentProtection.Scheme
12+
import org.readium.r2.shared.publication.Link
13+
import org.readium.r2.shared.util.mediatype.MediaType
14+
import org.robolectric.RobolectricTestRunner
15+
16+
@RunWith(RobolectricTestRunner::class)
17+
class FallbackContentProtectionTest {
18+
19+
@Test
20+
fun `Sniff no content protection`() {
21+
assertNull(sniff(mediaType = MediaType.EPUB, resources = emptyMap()))
22+
}
23+
24+
@Test
25+
fun `Sniff EPUB with empty encryption xml`() {
26+
assertNull(sniff(mediaType = MediaType.EPUB, resources = mapOf(
27+
"/META-INF/encryption.xml" to """<?xml version='1.0' encoding='utf-8'?><encryption xmlns="urn:oasis:names:tc:opendocument:xmlns:container" xmlns:enc="http://www.w3.org/2001/04/xmlenc#"></encryption>"""
28+
)))
29+
}
30+
31+
@Test
32+
fun `Sniff LCP protected package`() {
33+
assertEquals(Scheme.Lcp, sniff(
34+
mediaType = MediaType.ZIP,
35+
resources = mapOf(
36+
"/license.lcpl" to "{}"
37+
)
38+
))
39+
}
40+
41+
@Test
42+
fun `Sniff LCP protected EPUB`() {
43+
assertEquals(Scheme.Lcp, sniff(
44+
mediaType = MediaType.EPUB,
45+
resources = mapOf(
46+
"/META-INF/license.lcpl" to "{}"
47+
)
48+
))
49+
}
50+
51+
@Test
52+
fun `Sniff LCP protected EPUB missing the license`() {
53+
assertEquals(Scheme.Lcp, sniff(
54+
mediaType = MediaType.EPUB,
55+
resources = mapOf(
56+
"/META-INF/encryption.xml" to """<?xml version="1.0" encoding="UTF-8"?>
57+
<encryption xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
58+
<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#">
59+
<EncryptionMethod xmlns="http://www.w3.org/2001/04/xmlenc#" Algorithm="http://www.w3.org/2001/04/xmlenc#aes256-cbc"></EncryptionMethod>
60+
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
61+
<RetrievalMethod xmlns="http://www.w3.org/2000/09/xmldsig#" URI="license.lcpl#/encryption/content_key" Type="http://readium.org/2014/01/lcp#EncryptedContentKey"></RetrievalMethod>
62+
</KeyInfo>
63+
<CipherData xmlns="http://www.w3.org/2001/04/xmlenc#">
64+
<CipherReference xmlns="http://www.w3.org/2001/04/xmlenc#" URI="OPS/chapter_001.xhtml"></CipherReference>
65+
</CipherData>
66+
<EncryptionProperties xmlns="http://www.w3.org/2001/04/xmlenc#">
67+
<EncryptionProperty xmlns="http://www.w3.org/2001/04/xmlenc#">
68+
<Compression xmlns="http://www.idpf.org/2016/encryption#compression" Method="8" OriginalLength="13877"></Compression>
69+
</EncryptionProperty>
70+
</EncryptionProperties>
71+
</EncryptedData>
72+
</encryption>"""
73+
)
74+
))
75+
}
76+
77+
@Test
78+
fun `Sniff Adobe ADEPT`() {
79+
assertEquals(Scheme.Adept, sniff(
80+
mediaType = MediaType.EPUB,
81+
resources = mapOf(
82+
"/META-INF/encryption.xml" to """<?xml version="1.0"?><encryption xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
83+
<EncryptedData xmlns="http://www.w3.org/2001/04/xmlenc#">
84+
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes128-cbc"></EncryptionMethod>
85+
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
86+
<resource xmlns="http://ns.adobe.com/adept">urn:uuid:2c43729c-b985-4531-8e86-ae75ce5e5da9</resource>
87+
</KeyInfo>
88+
<CipherData>
89+
<CipherReference URI="OEBPS/stylesheet.css"></CipherReference>
90+
</CipherData>
91+
</EncryptedData>
92+
</encryption>"""
93+
)
94+
))
95+
}
96+
97+
@Test
98+
fun `Sniff Adobe ADEPT from rights xml`() {
99+
assertEquals(Scheme.Adept, sniff(
100+
mediaType = MediaType.EPUB,
101+
resources = mapOf(
102+
"/META-INF/encryption.xml" to """<?xml version='1.0' encoding='utf-8'?><encryption xmlns="urn:oasis:names:tc:opendocument:xmlns:container" xmlns:enc="http://www.w3.org/2001/04/xmlenc#"></encryption>""",
103+
"/META-INF/rights.xml" to """<?xml version="1.0"?><adept:rights xmlns:adept="http://ns.adobe.com/adept"></adept:rights>"""
104+
)
105+
))
106+
}
107+
108+
private fun sniff(mediaType: MediaType, resources: Map<String, String>): Scheme? = runBlocking {
109+
FallbackContentProtection().sniffScheme(
110+
fetcher = TestFetcher(resources),
111+
mediaType = mediaType
112+
)
113+
}
114+
}
115+
116+
class TestFetcher(private val resources: Map<String, String> = emptyMap()) : Fetcher {
117+
118+
override suspend fun links(): List<Link> = resources.map { Link(href = it.key) }
119+
120+
override fun get(link: Link): Resource =
121+
resources[link.href]?.let { StringResource(link, it) }
122+
?: FailureResource(link, Resource.Exception.NotFound())
123+
124+
override suspend fun close() {}
125+
}

0 commit comments

Comments
 (0)