Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/migration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ dependencies {
}
```

### Publication assets

In most cases, you no longer need to manually create a `PublicationAsset` to open a publication with
the streamer. You can use the overloaded open method taking a `Url` as argument instead.

```kotlin
streamer.open(file.toUrl(), ...)
```

## 2.3.0

### `Decoration.extras`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
package org.readium.r2.lcp

import org.readium.r2.lcp.auth.LcpPassphraseAuthentication
import org.readium.r2.shared.fetcher.Fetcher
import org.readium.r2.lcp.service.LcpLicensedAsset
import org.readium.r2.shared.fetcher.TransformingFetcher
import org.readium.r2.shared.publication.ContentProtection
import org.readium.r2.shared.publication.Publication
import org.readium.r2.shared.publication.asset.FileAsset
import org.readium.r2.shared.publication.asset.PublicationAsset
import org.readium.r2.shared.publication.asset.RemoteAsset
import org.readium.r2.shared.publication.services.contentProtectionServiceFactory
import org.readium.r2.shared.util.Try

Expand All @@ -23,31 +24,72 @@ internal class LcpContentProtection(

override suspend fun open(
asset: PublicationAsset,
fetcher: Fetcher,
credentials: String?,
allowUserInteraction: Boolean,
sender: Any?
): Try<ContentProtection.ProtectedAsset, Publication.OpeningException>? {
if (asset !is FileAsset) {
return null
}
val license = retrieveLicense(asset, credentials, allowUserInteraction, sender)
?: return null
return createProtectedAsset(asset, license)
}

if (!lcpService.isLcpProtected(asset.file)) {
return null
}
/* Returns null if the publication is not protected by LCP. */
private suspend fun retrieveLicense(
asset: PublicationAsset,
credentials: String?,
allowUserInteraction: Boolean,
sender: Any?
): Try<LcpLicense, LcpException>? {

val authentication = credentials?.let { LcpPassphraseAuthentication(it, fallback = this.authentication) }
val authentication = credentials
?.let { LcpPassphraseAuthentication(it, fallback = this.authentication) }
?: this.authentication

val license = lcpService
.retrieveLicense(asset.file, authentication, allowUserInteraction, sender)
val license = when (asset) {
is FileAsset ->
lcpService.retrieveLicense(asset.file, authentication, allowUserInteraction, sender)
is RemoteAsset ->
lcpService.retrieveLicense(asset.fetcher, asset.mediaType, authentication, allowUserInteraction, sender)
is LcpLicensedAsset ->
asset.license
?.let { Try.success(it) }
?: lcpService.retrieveLicense(asset.licenseFile, authentication, allowUserInteraction, sender)
else ->
null
}

return license?.takeUnless { result ->
result is Try.Failure<*, *> && result.exception is LcpException.Container
}
}

private fun createProtectedAsset(
originalAsset: PublicationAsset,
license: Try<LcpLicense, LcpException>,
): Try<ContentProtection.ProtectedAsset, Publication.OpeningException> {
val serviceFactory = LcpContentProtectionService
.createFactory(license?.getOrNull(), license?.exceptionOrNull())
.createFactory(license.getOrNull(), license.exceptionOrNull())

val newFetcher = TransformingFetcher(
originalAsset.fetcher,
LcpDecryptor(license.getOrNull())::transform
)

val newAsset = when (originalAsset) {
is FileAsset -> {
originalAsset.copy(fetcher = newFetcher)
}
is RemoteAsset -> {
originalAsset.copy(fetcher = newFetcher)
}
is LcpLicensedAsset -> {
originalAsset.copy(fetcher = newFetcher)
}
else -> throw IllegalStateException()
}

val protectedFile = ContentProtection.ProtectedAsset(
asset = asset,
fetcher = TransformingFetcher(fetcher, LcpDecryptor(license?.getOrNull())::transform),
asset = newAsset,
onCreatePublication = {
servicesBuilder.contentProtectionServiceFactory = serviceFactory
}
Expand Down
57 changes: 50 additions & 7 deletions readium/lcp/src/main/java/org/readium/r2/lcp/LcpDecryptor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,23 @@ internal class LcpDecryptor(val license: LcpLicense?) {
private val license: LcpLicense
) : Resource {

lateinit var _length: ResourceTry<Long>
private class Cache(
var startIndex: Int? = null,
val data: ByteArray = ByteArray(3 * AES_BLOCK_SIZE)
)

private lateinit var _length: ResourceTry<Long>

/*
* Decryption needs to look around the data strictly matching the content to decipher.
* That means that in case of contiguous read requests, data fetched from the underlying
* resource are not contiguous. Every request to the underlying resource starts slightly
* before the end of the previous one. This is an issue with remote publications because
* you have to make a new HTTP request every time instead of reusing the previous one.
* To alleviate this, we cache the three last bytes read in each call and reuse them
* in the next call if possible.
*/
private val _cache: Cache = Cache()

override suspend fun link(): Link = resource.link()

Expand All @@ -78,7 +94,15 @@ internal class LcpDecryptor(val license: LcpLicense?) {
if (::_length.isInitialized)
return _length

_length = resource.length().flatMapCatching { length ->
_length = resource.link().properties.encryption?.originalLength
?.let { Try.success(it) }
?: lengthFromPadding()

return _length
}

private suspend fun lengthFromPadding(): ResourceTry<Long> =
resource.length().flatMapCatching { length ->
if (length < 2 * AES_BLOCK_SIZE) {
throw Exception("Invalid CBC-encrypted stream")
}
Expand All @@ -96,9 +120,6 @@ internal class LcpDecryptor(val license: LcpLicense?) {
}
}

return _length
}

override suspend fun read(range: LongRange?): ResourceTry<ByteArray> {
if (range == null)
return license.decryptFully(resource.read(), isDeflated = false)
Expand All @@ -112,13 +133,19 @@ internal class LcpDecryptor(val license: LcpLicense?) {
return Try.success(ByteArray(0))

return resource.length().flatMapCatching { encryptedLength ->

// encrypted data is shifted by AES_BLOCK_SIZE because of IV and
// the previous block must be provided to perform XOR on intermediate blocks
val encryptedStart = range.first.floorMultipleOf(AES_BLOCK_SIZE.toLong())
val encryptedEndExclusive = (range.last + 1).ceilMultipleOf(AES_BLOCK_SIZE.toLong()) + AES_BLOCK_SIZE

resource.read(encryptedStart until encryptedEndExclusive).mapCatching { encryptedData ->
getEncryptedData(encryptedStart until encryptedEndExclusive).mapCatching { encryptedData ->
if (encryptedData.size >= _cache.data.size) {
// cache the three last encrypted blocks that have been read for future use
val cacheStart = encryptedData.size - _cache.data.size
_cache.startIndex = (encryptedEndExclusive - _cache.data.size).toInt()
encryptedData.copyInto(_cache.data, 0, cacheStart)
}

val bytes = license.decrypt(encryptedData)
.getOrElse { throw IOException("Can't decrypt the content at: ${link().href}", it) }

Expand All @@ -144,6 +171,22 @@ internal class LcpDecryptor(val license: LcpLicense?) {
}
}

private suspend fun getEncryptedData(range: LongRange): ResourceTry<ByteArray> {
val cacheStartIndex = _cache.startIndex
?.takeIf { cacheStart ->
val cacheEnd = cacheStart + _cache.data.size
cacheStart <= range.first && cacheEnd <= range.last + 1
} ?: return resource.read(range)

return resource.read(range.first + _cache.data.size..range.last).map {
val bytes = ByteArray(range.last.toInt() - range.first.toInt() + 1)
val offsetInCache = (range.first - cacheStartIndex).toInt()
_cache.data.copyInto(bytes, 0, offsetInCache)
it.copyInto(bytes, _cache.data.size)
bytes
}
}

override suspend fun close() = resource.close()

companion object {
Expand Down
60 changes: 56 additions & 4 deletions readium/lcp/src/main/java/org/readium/r2/lcp/LcpService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,15 @@ import org.readium.r2.lcp.service.LicensesService
import org.readium.r2.lcp.service.NetworkService
import org.readium.r2.lcp.service.PassphrasesRepository
import org.readium.r2.lcp.service.PassphrasesService
import org.readium.r2.shared.fetcher.Fetcher
import org.readium.r2.shared.publication.ContentProtection
import org.readium.r2.shared.publication.asset.PublicationAsset
import org.readium.r2.shared.util.Try
import org.readium.r2.shared.util.archive.ArchiveFactory
import org.readium.r2.shared.util.archive.DefaultArchiveFactory
import org.readium.r2.shared.util.http.DefaultHttpClient
import org.readium.r2.shared.util.http.HttpClient
import org.readium.r2.shared.util.mediatype.MediaType

/**
* Service used to acquire and open publications protected with LCP.
Expand Down Expand Up @@ -76,7 +83,28 @@ interface LcpService {
authentication: LcpAuthenticating = LcpDialogAuthentication(),
allowUserInteraction: Boolean,
sender: Any? = null
): Try<LcpLicense, LcpException>?
): Try<LcpLicense, LcpException>

/**
* Opens the LCP license of a protected publication, to access its DRM metadata and decipher
* its content. As the updated license cannot be stored through a [Fetcher],
* you'll get an exception if the license points to a LSD server that cannot be reached,
* for instance because no Internet gateway is available.
*
* @param authentication Used to retrieve the user passphrase if it is not already known.
* The request will be cancelled if no passphrase is found in the LCP passphrase storage
* and the provided [authentication].
* @param allowUserInteraction Indicates whether the user can be prompted for their passphrase.
* @param sender Free object that can be used by reading apps to give some UX context when
* presenting dialogs with [LcpAuthenticating].
*/
suspend fun retrieveLicense(
fetcher: Fetcher,
mediaType: MediaType,
authentication: LcpAuthenticating = LcpDialogAuthentication(),
allowUserInteraction: Boolean,
sender: Any? = null
): Try<LcpLicense, LcpException>

/**
* Creates a [ContentProtection] instance which can be used with a Streamer to unlock
Expand All @@ -89,6 +117,11 @@ interface LcpService {
fun contentProtection(authentication: LcpAuthenticating = LcpDialogAuthentication()): ContentProtection =
LcpContentProtection(this, authentication)

/**
* Builds a [PublicationAsset] to open a LCP-protected publication from its license file.
*/
suspend fun remoteAssetForLicense(license: File): Try<PublicationAsset, LcpException>

/**
* Information about an acquired publication protected with LCP.
*
Expand All @@ -110,7 +143,11 @@ interface LcpService {
/**
* LCP service factory.
*/
operator fun invoke(context: Context): LcpService? {
operator fun invoke(
context: Context,
archiveFactory: ArchiveFactory = DefaultArchiveFactory(),
httpClient: HttpClient = DefaultHttpClient()
): LcpService? {
if (!LcpClient.isAvailable())
return null

Expand All @@ -122,7 +159,16 @@ interface LcpService {
val device = DeviceService(repository = deviceRepository, network = network, context = context)
val crl = CRLService(network = network, context = context)
val passphrases = PassphrasesService(repository = passphraseRepository)
return LicensesService(licenses = licenseRepository, crl = crl, device = device, network = network, passphrases = passphrases, context = context)
return LicensesService(
licenses = licenseRepository,
crl = crl,
device = device,
network = network,
passphrases = passphrases,
context = context,
archiveFactory = archiveFactory,
httpClient = httpClient
)
}

@Deprecated("Use `LcpService()` instead", ReplaceWith("LcpService(context)"), level = DeprecationLevel.ERROR)
Expand Down Expand Up @@ -151,7 +197,13 @@ interface LcpService {
completion: (LcpLicense?, LcpException?) -> Unit
) {
GlobalScope.launch {
val result = retrieveLicense(File(publication), authentication ?: LcpDialogAuthentication(), allowUserInteraction = true)
val result =
try {
retrieveLicense(File(publication), authentication ?: LcpDialogAuthentication(), allowUserInteraction = true)
} catch (e: CancellationException) {
null
}

if (result == null) {
completion(null, null)
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* Copyright 2023 Readium Foundation. All rights reserved.
* Use of this source code is governed by the BSD-style license
* available in the top-level LICENSE file of the project.
*/

package org.readium.r2.lcp.auth

import org.readium.r2.lcp.LcpAuthenticating

internal class LcpDumbAuthentication : LcpAuthenticating {

override suspend fun retrievePassphrase(
license: LcpAuthenticating.AuthenticatedLicense,
reason: LcpAuthenticating.AuthenticationReason,
allowUserInteraction: Boolean,
sender: Any?
): String? = null
}
Loading