Skip to content
This repository has been archived by the owner on Jul 29, 2022. It is now read-only.

Implement Streamer API and ContentProtection proposals #79

Merged
merged 8 commits into from
Jul 10, 2020
Merged
69 changes: 69 additions & 0 deletions r2-lcp/src/main/java/org/readium/r2/lcp/LCPContentProtection.kt
Original file line number Diff line number Diff line change
@@ -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<ContentProtection.ProtectedFile, Publication.OpeningError>? {

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)
}
Original file line number Diff line number Diff line change
@@ -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) }

}
}
169 changes: 169 additions & 0 deletions r2-lcp/src/main/java/org/readium/r2/lcp/LCPDecryptor.kt
Original file line number Diff line number Diff line change
@@ -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<Long> =
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<Long> =
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<ByteArray> {
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<ByteArray> =
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"
29 changes: 29 additions & 0 deletions r2-lcp/src/main/java/org/readium/r2/lcp/LCPService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -33,12 +37,37 @@ interface LCPService {
*/
fun importPublication(lcpl: ByteArray, authentication: LCPAuthenticating?, completion: (LCPImportedPublication?, LCPError?) -> Unit)

suspend fun importPublication(lcpl: ByteArray, authentication: LCPAuthenticating?): Try<LCPImportedPublication, LCPError> =
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<LCPLicense, LCPError>? =
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
)
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}


Expand Down Expand Up @@ -200,8 +201,8 @@ internal class LicenseValidation(
transitionTo(State.failure(it.error))
}
on<Event.cancelled> {
if (DEBUG) Timber.tag("LicenseValidation").d("State.start)")
transitionTo(State.start)
if (DEBUG) Timber.tag("LicenseValidation").d("State.cancelled)")
transitionTo(State.cancelled)
}
}
state<State.validateIntegrity> {
Expand Down Expand Up @@ -248,6 +249,8 @@ internal class LicenseValidation(
// throw error
}
}
state<State.cancelled> {
}
onTransition { transition ->
val validTransition = transition as? StateMachine.Transition.Valid
validTransition?.let {
Expand Down Expand Up @@ -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) {
Expand Down