diff --git a/r2-lcp/src/main/java/org/readium/r2/lcp/LCPContentProtection.kt b/r2-lcp/src/main/java/org/readium/r2/lcp/LCPContentProtection.kt new file mode 100644 index 0000000..c5b3849 --- /dev/null +++ b/r2-lcp/src/main/java/org/readium/r2/lcp/LCPContentProtection.kt @@ -0,0 +1,69 @@ +/* + * Module: r2-lcp-kotlin + * Developers: Quentin Gliosca + * + * Copyright (c) 2020. Readium Foundation. All rights reserved. + * Use of this source code is governed by a BSD-style license which is detailed in the + * LICENSE file present in the project repository where this source code is maintained. + */ + +package org.readium.r2.lcp + +import org.readium.r2.shared.fetcher.Fetcher +import org.readium.r2.shared.fetcher.TransformingFetcher +import org.readium.r2.shared.format.Format +import org.readium.r2.shared.publication.ContentProtection +import org.readium.r2.shared.publication.OnAskCredentials +import org.readium.r2.shared.publication.Publication +import org.readium.r2.shared.util.File +import org.readium.r2.shared.util.Try + +class LCPContentProtection( + private val lcpService: LCPService, + private val lcpAuthenticating: LCPAuthenticating +) : ContentProtection { + + override suspend fun open( + file: File, + fetcher: Fetcher, + askCredentials: Boolean, + credentials: String?, + sender: Any?, + onAskCredentials: OnAskCredentials? + ): Try? { + + val isProtectedWithLcp = when (file.format()) { + Format.EPUB -> fetcher.get("/META-INF/license.lcpl").use { it.length().isSuccess } + else -> fetcher.get("/license.lcpl").use { it.length().isSuccess } + } + + if (!isProtectedWithLcp) + return null + + return try { + val license = lcpService + .retrieveLicense(file, lcpAuthenticating.takeIf { askCredentials }) + ?.getOrThrow() + val protectedFile = ContentProtection.ProtectedFile( + file, + TransformingFetcher(fetcher, LCPDecryptor(license)::transform), + LCPContentProtectionService.createFactory(license) + ) + Try.success(protectedFile) + + } catch (e: LCPError) { + Try.failure(e.toOpeningError()) + } + } +} + +private fun LCPError.toOpeningError() = when (this) { + is LCPError.licenseIsBusy, + is LCPError.network, + is LCPError.licenseContainer-> + Publication.OpeningError.Unavailable(this) + is LCPError.licenseStatus -> + Publication.OpeningError.Forbidden(this) + else -> + Publication.OpeningError.ParsingFailed(this) +} \ No newline at end of file diff --git a/r2-lcp/src/main/java/org/readium/r2/lcp/LCPContentProtectionService.kt b/r2-lcp/src/main/java/org/readium/r2/lcp/LCPContentProtectionService.kt new file mode 100644 index 0000000..a1edad4 --- /dev/null +++ b/r2-lcp/src/main/java/org/readium/r2/lcp/LCPContentProtectionService.kt @@ -0,0 +1,58 @@ +/* + * Module: r2-lcp-kotlin + * Developers: Quentin Gliosca + * + * Copyright (c) 2020. Readium Foundation. All rights reserved. + * Use of this source code is governed by a BSD-style license which is detailed in the + * LICENSE file present in the project repository where this source code is maintained. + */ + +package org.readium.r2.lcp + +import org.readium.r2.shared.publication.LocalizedString +import org.readium.r2.shared.publication.Publication +import org.readium.r2.shared.publication.services.ContentProtectionService + +internal class LCPContentProtectionService(license: LCPLicense?) : ContentProtectionService { + + override val isRestricted: Boolean = license != null + + override val credentials: String? = null + + override val rights: ContentProtectionService.UserRights = + license?.let { LCPUserRights(it) } + ?: ContentProtectionService.UserRights.AllRestricted + + override val name: LocalizedString = LocalizedString("Readium LCP") + + private class LCPUserRights(val license: LCPLicense) : ContentProtectionService.UserRights { + + override val canCopy: Boolean + get() = license.canCopy + + override fun canCopy(text: String): Boolean = + license.charactersToCopyLeft?.let { it <= text.length } + ?: true + + override fun copy(text: String): Boolean = + canCopy(text).also { if (it) license.copy(text) } + + override val canPrint: Boolean + get() = license.canPrint + + override fun canPrint(pageCount: Int): Boolean = + license.pagesToPrintLeft?.let { it <= pageCount } + ?: true + + override fun print(pageCount: Int): Boolean = + canPrint(pageCount).also { if (it) license.print(pageCount) } + + } + + companion object { + + fun createFactory(license: LCPLicense?): (Publication.Service.Context) -> LCPContentProtectionService = + { LCPContentProtectionService(license) } + + } +} diff --git a/r2-lcp/src/main/java/org/readium/r2/lcp/LCPDecryptor.kt b/r2-lcp/src/main/java/org/readium/r2/lcp/LCPDecryptor.kt new file mode 100644 index 0000000..db050fd --- /dev/null +++ b/r2-lcp/src/main/java/org/readium/r2/lcp/LCPDecryptor.kt @@ -0,0 +1,169 @@ +/* + * Module: r2-lcp-kotlin + * Developers: Mickaƫl Menu + * + * Copyright (c) 2020. Readium Foundation. All rights reserved. + * Use of this source code is governed by a BSD-style license which is detailed in the + * LICENSE file present in the project repository where this source code is maintained. + */ + +package org.readium.r2.lcp + +import org.readium.r2.shared.drm.DRMLicense +import org.readium.r2.shared.extensions.inflate +import org.readium.r2.shared.fetcher.BytesResource +import org.readium.r2.shared.fetcher.FailureResource +import org.readium.r2.shared.fetcher.Resource +import org.readium.r2.shared.fetcher.ResourceTry +import org.readium.r2.shared.fetcher.LazyResource +import org.readium.r2.shared.fetcher.flatMapCatching +import org.readium.r2.shared.fetcher.mapCatching +import org.readium.r2.shared.publication.Link +import org.readium.r2.shared.publication.encryption.encryption +import org.readium.r2.shared.util.Try +import java.io.IOException + +/** + * Decrypts a resource protected with LCP. + */ +internal class LCPDecryptor(val license: LCPLicense?) { + + fun transform(resource: Resource): Resource = LazyResource { + // Checks if the resource is encrypted and whether the encryption schemes of the resource + // and the DRM license are the same. + val link = resource.link() + val encryption = link.properties.encryption + if (encryption == null || encryption.scheme != "http://readium.org/2014/01/lcp") + return@LazyResource resource + + when { + license == null -> FailureResource(link, Resource.Error.Forbidden) + link.isDeflated || !link.isCbcEncrypted -> FullLcpResource(resource, license) + else -> CbcLcpResource(resource, license) + } + } + + /** + * A LCP resource that is read, decrypted and cached fully before reading requested ranges. + * + * Can be used when it's impossible to map a read range (byte range request) to the encrypted + * resource, for example when the resource is deflated before encryption. + */ + private class FullLcpResource( + private val resource: Resource, + private val license: DRMLicense + ) : BytesResource( { Pair(resource.link(), license.decryptFully(resource)) } ) { + + override suspend fun length(): ResourceTry = + resource.link().properties.encryption?.originalLength + ?.let { Try.success(it) } + ?: super.length() + } + + /** + * A LCP resource used to read content encrypted with the CBC algorithm. + * + * Supports random access for byte range requests, but the resource MUST NOT be deflated. + */ + private class CbcLcpResource( + private val resource: Resource, + private val license: DRMLicense + ) : Resource { + + override suspend fun link(): Link = resource.link() + + /** Plain text size. */ + override suspend fun length(): ResourceTry = + resource.length().flatMapCatching { length -> + if (length < 2 * AES_BLOCK_SIZE) { + throw Exception("Invalid CBC-encrypted stream") + } + + val readOffset = length - (2 * AES_BLOCK_SIZE) + resource.read(readOffset..length) + .mapCatching { bytes -> + val decryptedBytes = license.decipher(bytes) + ?: throw Exception("Can't decrypt trailing size of CBC-encrypted stream") + + return@mapCatching length - + AES_BLOCK_SIZE - // Minus IV or previous block + (AES_BLOCK_SIZE - decryptedBytes.size) % AES_BLOCK_SIZE // Minus padding part + } + } + + override suspend fun read(range: LongRange?): ResourceTry { + return if (range == null) { + license.decryptFully(resource) + } else { + resource.length().flatMapCatching { length -> + val blockPosition = range.first % AES_BLOCK_SIZE + + // For beginning of the cipher text, IV used for XOR. + // For cipher text in the middle, previous block used for XOR. + val readPosition = range.first - blockPosition + + // Count blocks to read. + // First block for IV or previous block to perform XOR. + var blocksCount: Long = 1 + var bytesInFirstBlock = (AES_BLOCK_SIZE - blockPosition) % AES_BLOCK_SIZE + if (length < bytesInFirstBlock) { + bytesInFirstBlock = 0 + } + if (bytesInFirstBlock > 0) { + blocksCount += 1 + } + + blocksCount += (length - bytesInFirstBlock) / AES_BLOCK_SIZE + if ((length - bytesInFirstBlock) % AES_BLOCK_SIZE != 0L) { + blocksCount += 1 + } + + val readSize = blocksCount * AES_BLOCK_SIZE + resource.read(readPosition..(readPosition + readSize)) + .mapCatching { + var bytes = license.decipher(it) + ?: throw IOException("Can't decrypt the content at: ${link().href}") + + if (bytes.size > length) { + bytes = bytes.copyOfRange(0, length.toInt()) + } + + bytes + } + } + } + } + + override suspend fun close() = resource.close() + + companion object { + private const val AES_BLOCK_SIZE = 16 // bytes + } + + } +} + +private suspend fun DRMLicense.decryptFully(resource: Resource): ResourceTry = + resource.read().mapCatching { it -> + // Decrypts the resource. + var bytes = decipher(it) + ?.takeIf { b -> b.isNotEmpty() } + ?: throw Exception("Failed to decrypt the resource") + + // Removes the padding. + val padding = bytes.last().toInt() + bytes = bytes.copyOfRange(0, bytes.size - padding) + + // If the ressource was compressed using deflate, inflates it. + if (resource.link().isDeflated) { + bytes = bytes.inflate(nowrap = true) + } + + bytes + } + +private val Link.isDeflated: Boolean get() = + properties.encryption?.compression?.toLowerCase(java.util.Locale.ROOT) == "deflate" + +private val Link.isCbcEncrypted: Boolean get() = + properties.encryption?.algorithm == "http://www.w3.org/2001/04/xmlenc#aes256-cbc" diff --git a/r2-lcp/src/main/java/org/readium/r2/lcp/LCPService.kt b/r2-lcp/src/main/java/org/readium/r2/lcp/LCPService.kt index ddd73f9..b40d9de 100644 --- a/r2-lcp/src/main/java/org/readium/r2/lcp/LCPService.kt +++ b/r2-lcp/src/main/java/org/readium/r2/lcp/LCPService.kt @@ -16,8 +16,12 @@ import org.readium.r2.lcp.license.model.StatusDocument import org.readium.r2.lcp.persistence.Database import org.readium.r2.lcp.service.* import org.readium.r2.shared.drm.DRMLicense +import org.readium.r2.shared.util.File +import org.readium.r2.shared.util.Try import java.io.Serializable import java.net.URL +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine /** * Service used to fulfill and access protected publications. @@ -33,12 +37,37 @@ interface LCPService { */ fun importPublication(lcpl: ByteArray, authentication: LCPAuthenticating?, completion: (LCPImportedPublication?, LCPError?) -> Unit) + suspend fun importPublication(lcpl: ByteArray, authentication: LCPAuthenticating?): Try = + suspendCoroutine { cont -> + importPublication(lcpl, authentication) { publication, error -> + cont.resume( + if (publication == null) + Try.failure(error!!) + else + Try.success(publication) + ) + } + } + /** * Opens the LCP license of a protected publication, to access its DRM metadata and decipher * its content. */ fun retrieveLicense(publication: String, authentication: LCPAuthenticating?, completion: (LCPLicense?, LCPError?) -> Unit) + suspend fun retrieveLicense(file: File, authentication: LCPAuthenticating?): Try? = + suspendCoroutine { cont -> + retrieveLicense(file.path, authentication) { license, error -> + cont.resume( + if (license != null) + Try.success(license) + else if (error != null) + Try.failure(error) + else + null + ) + } + } } /** diff --git a/r2-lcp/src/main/java/org/readium/r2/lcp/license/LicenseValidation.kt b/r2-lcp/src/main/java/org/readium/r2/lcp/license/LicenseValidation.kt index 8a8b793..2691d35 100644 --- a/r2-lcp/src/main/java/org/readium/r2/lcp/license/LicenseValidation.kt +++ b/r2-lcp/src/main/java/org/readium/r2/lcp/license/LicenseValidation.kt @@ -64,6 +64,7 @@ internal sealed class State { data class registerDevice(val documents: ValidatedDocuments, val link: Link) : State() data class valid(val documents: ValidatedDocuments) : State() data class failure(val error: Exception) : State() + object cancelled : State() } @@ -200,8 +201,8 @@ internal class LicenseValidation( transitionTo(State.failure(it.error)) } on { - if (DEBUG) Timber.tag("LicenseValidation").d("State.start)") - transitionTo(State.start) + if (DEBUG) Timber.tag("LicenseValidation").d("State.cancelled)") + transitionTo(State.cancelled) } } state { @@ -248,6 +249,8 @@ internal class LicenseValidation( // throw error } } + state { + } onTransition { transition -> val validTransition = transition as? StateMachine.Transition.Valid validTransition?.let { @@ -390,6 +393,7 @@ internal class LicenseValidation( when (licenseValidation.stateMachine.state) { is State.valid -> observer((licenseValidation.stateMachine.state as State.valid).documents, null) is State.failure -> observer(null, (licenseValidation.stateMachine.state as State.failure).error) + is State.cancelled -> observer(null, null) else -> notified = false } if (notified && policy != ObserverPolicy.always) {