diff --git a/CHANGELOG.md b/CHANGELOG.md index 4844dd3..8326259 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to this project will be documented in this file. * LCP implementation of the [Content Protection API](https://github.com/readium/architecture/blob/master/proposals/006-content-protection.md) to work with the new [Streamer API](https://github.com/readium/architecture/blob/master/proposals/005-streamer-api.md) (contributed by [@qnga](https://github.com/readium/r2-lcp-kotlin/pull/79)). * It is highly recommended that you upgrade to the new `Streamer` API to open publications, which will simplify DRM unlocking. * `LcpService::isLcpProtected()` provides a way to check if a file is protected with LCP. +* `LcpService::addPassphrase()` can be used to preload LCP passphrases, for example when using [LCP Automatic Key Retrieval](https://readium.org/lcp-specs/notes/lcp-key-retrieval.html). ### Changed @@ -19,6 +20,9 @@ All notable changes to this project will be documented in this file. * Follow the deprecation warnings to upgrade to the new names. * `LcpAuthenticating` is now provided with more information and you will need to update your implementation. * Publications are now downloaded to a temporary location, to make sure disk storage can be recovered automatically by the system. After acquiring the publication, you need to move the downloaded file to another permanent location. +* The private `liblcp` dependency is now accessed through reflection, to allow switching LCP dynamically (contributed by [@qnga](https://github.com/readium/r2-lcp-kotlin/pull/87)). + * You need to add `implementation "readium:liblcp:1.0.0@aar"` to your `build.gradle`. + * `LcpService::create()` returns `null` if `lcplib` is not found. ### Fixed diff --git a/r2-lcp/build.gradle b/r2-lcp/build.gradle index b49ce95..7ab78fe 100644 --- a/r2-lcp/build.gradle +++ b/r2-lcp/build.gradle @@ -44,9 +44,6 @@ dependencies { implementation "com.github.readium:r2-shared-kotlin:2.0.0-alpha.1" } - //noinspection GradleDependency - implementation "readium:liblcp:1.0.0@aar" - implementation('com.mcxiaoke.koi:core:0.5.5') { exclude module: 'support-v4' } diff --git a/r2-lcp/src/main/java/org/readium/r2/lcp/LcpException.kt b/r2-lcp/src/main/java/org/readium/r2/lcp/LcpException.kt index 6dff648..15eb8a3 100644 --- a/r2-lcp/src/main/java/org/readium/r2/lcp/LcpException.kt +++ b/r2-lcp/src/main/java/org/readium/r2/lcp/LcpException.kt @@ -10,7 +10,6 @@ package org.readium.r2.lcp import org.joda.time.DateTime -import org.readium.lcp.sdk.DRMException import java.net.SocketTimeoutException sealed class LcpException(message: String? = null, cause: Throwable? = null) : Exception(message, cause) { @@ -36,7 +35,7 @@ sealed class LcpException(message: String? = null, cause: Throwable? = null) : E class Runtime(override val message: String) : LcpException() /** An unknown low-level exception was reported. */ - class Unknown(override val cause: Exception?) : LcpException() + class Unknown(override val cause: Throwable?) : LcpException() /** @@ -179,20 +178,6 @@ sealed class LcpException(message: String? = null, cause: Throwable? = null) : E internal fun wrap(e: Exception?): LcpException = when (e) { is LcpException -> e is SocketTimeoutException -> Network(e) - is DRMException -> when(e.drmError.code) { - // Error code 11 should never occur since we check the start/end date before calling createContext - 11 -> Runtime("License is out of date (check start and end date).") - 101 -> LicenseIntegrity.CertificateRevoked - 102 -> LicenseIntegrity.CertificateSignatureInvalid - 111 -> LicenseIntegrity.LicenseSignatureDateInvalid - 112 -> LicenseIntegrity.LicenseSignatureInvalid - // Error code 121 seems to be unused in the C++ lib. - 121 -> Runtime("The drm context is invalid.") - 131 -> Decryption.ContentKeyDecryptError - 141 -> LicenseIntegrity.UserKeyCheckInvalid - 151 -> Decryption.ContentDecryptError - else -> Unknown(e) - } else -> Unknown(e) } } 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 f70bef5..2aec7ee 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 @@ -95,7 +95,10 @@ interface LcpService { /** * LCP service factory. */ - fun create(context: Context): LcpService { + fun create(context: Context): LcpService? { + if (!LcpClient.isAvailable()) + return null + val db = Database(context) val network = NetworkService() val device = DeviceService(repository = db.licenses, network = network, context = context) @@ -135,7 +138,7 @@ interface LcpService { @Deprecated("Renamed to `LcpService.create()`", replaceWith = ReplaceWith("LcpService.create")) fun R2MakeLCPService(context: Context): LcpService = - LcpService.create(context) + LcpService.create(context) ?: throw Exception("liblcp is missing on the classpath") @Deprecated("Renamed to `LcpService.AcquiredPublication`", replaceWith = ReplaceWith("LcpService.AcquiredPublication")) typealias LCPImportedPublication = LcpService.AcquiredPublication diff --git a/r2-lcp/src/main/java/org/readium/r2/lcp/license/License.kt b/r2-lcp/src/main/java/org/readium/r2/lcp/license/License.kt index 122e349..447b8da 100644 --- a/r2-lcp/src/main/java/org/readium/r2/lcp/license/License.kt +++ b/r2-lcp/src/main/java/org/readium/r2/lcp/license/License.kt @@ -13,12 +13,12 @@ package org.readium.r2.lcp.license import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.joda.time.DateTime -import org.readium.lcp.sdk.Lcp import org.readium.r2.lcp.* import org.readium.r2.lcp.BuildConfig.DEBUG import org.readium.r2.lcp.license.model.LicenseDocument import org.readium.r2.lcp.license.model.StatusDocument import org.readium.r2.lcp.service.DeviceService +import org.readium.r2.lcp.service.LcpClient import org.readium.r2.lcp.service.LicensesRepository import org.readium.r2.lcp.service.NetworkService import org.readium.r2.lcp.service.URLParameters @@ -47,7 +47,7 @@ internal class License( Try.success(ByteArray(0)) } else { val context = documents.getContext() - val decryptedData = Lcp().decrypt(context, data) + val decryptedData = LcpClient.decrypt(context, data) Try.success(decryptedData) } 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 ceaaecc..df6d724 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 @@ -11,8 +11,6 @@ package org.readium.r2.lcp.license import kotlinx.coroutines.runBlocking import org.joda.time.DateTime -import org.readium.lcp.sdk.DRMContext -import org.readium.lcp.sdk.Lcp import org.readium.r2.lcp.BuildConfig.DEBUG import org.readium.r2.lcp.LcpAuthenticating import org.readium.r2.lcp.LcpException @@ -21,6 +19,7 @@ import org.readium.r2.lcp.license.model.StatusDocument import org.readium.r2.lcp.license.model.components.Link import org.readium.r2.lcp.service.CRLService import org.readium.r2.lcp.service.DeviceService +import org.readium.r2.lcp.service.LcpClient import org.readium.r2.lcp.service.NetworkService import org.readium.r2.lcp.service.PassphrasesService import timber.log.Timber @@ -32,7 +31,7 @@ internal sealed class Either { private val supportedProfiles = listOf("http://readium.org/lcp/basic-profile", "http://readium.org/lcp/profile-1.0") -internal typealias Context = Either +internal typealias Context = Either internal typealias Observer = (ValidatedDocuments?, Exception?) -> Unit @@ -44,7 +43,7 @@ internal enum class ObserverPolicy { } internal data class ValidatedDocuments constructor(val license: LicenseDocument, private val context: Context, val status: StatusDocument? = null) { - fun getContext(): DRMContext { + fun getContext(): LcpClient.Context { when (context) { is Either.Left -> return context.left is Either.Right -> throw context.right @@ -75,7 +74,7 @@ internal sealed class Event { data class validatedStatus(val status: StatusDocument) : Event() data class checkedLicenseStatus(val error: LcpException.LicenseStatus?) : Event() data class retrievedPassphrase(val passphrase: String) : Event() - data class validatedIntegrity(val context: DRMContext) : Event() + data class validatedIntegrity(val context: LcpClient.Context) : Event() data class registeredDevice(val statusData: ByteArray?) : Event() data class failed(val error: Exception) : Event() object cancelled : Event() @@ -118,7 +117,7 @@ internal class LicenseValidation( val prodLicense = LicenseDocument(data = prodLicenseInput.readBytes()) val passphrase = "7B7602FEF5DEDA10F768818FFACBC60B173DB223B7E66D8B2221EBE2C635EFAD" try { - Lcp().findOneValidPassphrase(prodLicense.json.toString(), listOf(passphrase).toTypedArray()) == passphrase + LcpClient.findOneValidPassphrase(prodLicense.json.toString(), listOf(passphrase)) == passphrase } catch (e: Exception) { false } @@ -374,7 +373,7 @@ internal class LicenseValidation( if (!supportedProfiles.contains(profile)) { throw LcpException.LicenseProfileNotSupported } - val context = Lcp().createContext(license.json.toString(), passphrase, crl.retrieve()) + val context = LcpClient.createContext(license.json.toString(), passphrase, crl.retrieve()) raise(Event.validatedIntegrity(context)) } diff --git a/r2-lcp/src/main/java/org/readium/r2/lcp/service/LcpClient.kt b/r2-lcp/src/main/java/org/readium/r2/lcp/service/LcpClient.kt new file mode 100644 index 0000000..628eb3b --- /dev/null +++ b/r2-lcp/src/main/java/org/readium/r2/lcp/service/LcpClient.kt @@ -0,0 +1,107 @@ +package org.readium.r2.lcp.service + +import org.readium.r2.lcp.LcpException +import org.readium.r2.shared.extensions.tryOr +import java.lang.reflect.InvocationTargetException + +internal object LcpClient { + + data class Context( + val hashedPassphrase: String, + val encryptedContentKey: String, + val token: String, + val profile: String + ) { + companion object { + + fun fromDRMContext(drmContext: Any): Context = + with(Class.forName("org.readium.lcp.sdk.DRMContext")) { + val encryptedContentKey = getMethod("getEncryptedContentKey").invoke(drmContext) as String + val hashedPassphrase = getMethod("getHashedPassphrase").invoke(drmContext) as String + val profile = getMethod("getProfile").invoke(drmContext) as String + val token = getMethod("getToken").invoke(drmContext) as String + Context(hashedPassphrase, encryptedContentKey, token, profile) + } + } + + fun toDRMContext(): Any = + Class.forName("org.readium.lcp.sdk.DRMContext") + .getConstructor(String::class.java, String::class.java, String::class.java, String::class.java) + .newInstance(hashedPassphrase, encryptedContentKey, token, profile) + } + + private val instance: Any by lazy { + klass.newInstance() + } + + private val klass: Class<*> by lazy { + Class.forName("org.readium.lcp.sdk.Lcp") + } + + fun isAvailable(): Boolean = tryOr(false) { + instance + true + } + + fun createContext(jsonLicense: String, hashedPassphrases: String, pemCrl: String): Context = + try { + val drmContext = klass + .getMethod("createContext", String::class.java, String::class.java, String::class.java) + .invoke(instance, jsonLicense, hashedPassphrases, pemCrl)!! + + Context.fromDRMContext(drmContext) + } catch (e: InvocationTargetException) { + throw mapException(e.targetException) + } + + fun decrypt(context: Context, encryptedData: ByteArray): ByteArray = + try { + klass + .getMethod("decrypt", Class.forName("org.readium.lcp.sdk.DRMContext"), ByteArray::class.java) + .invoke(instance, context.toDRMContext(), encryptedData) + as ByteArray + } catch (e: InvocationTargetException) { + throw mapException(e.targetException) + } + + fun findOneValidPassphrase(jsonLicense: String, hashedPassphrases: List): String = + try { + klass + .getMethod("findOneValidPassphrase", String::class.java, Array::class.java) + .invoke(instance, jsonLicense, hashedPassphrases.toTypedArray()) as String + } catch (e: InvocationTargetException) { + throw mapException(e.targetException) + } + + private fun mapException(e: Throwable): LcpException { + + val drmExceptionClass = Class.forName("org.readium.lcp.sdk.DRMException") + + if (!drmExceptionClass.isInstance(e)) + return LcpException.Runtime("the Lcp client threw an unhandled exception") + + val drmError = drmExceptionClass + .getMethod("getDrmError") + .invoke(e) + + val errorCode = Class + .forName("org.readium.lcp.sdk.DRMError") + .getMethod("getCode") + .invoke(drmError) as Int + + return when (errorCode) { + // Error code 11 should never occur since we check the start/end date before calling createContext + 11 -> LcpException.Runtime("License is out of date (check start and end date).") + 101 -> LcpException.LicenseIntegrity.CertificateRevoked + 102 -> LcpException.LicenseIntegrity.CertificateSignatureInvalid + 111 -> LcpException.LicenseIntegrity.LicenseSignatureDateInvalid + 112 -> LcpException.LicenseIntegrity.LicenseSignatureInvalid + // Error code 121 seems to be unused in the C++ lib. + 121 -> LcpException.Runtime("The drm context is invalid.") + 131 -> LcpException.Decryption.ContentKeyDecryptError + 141 -> LcpException.LicenseIntegrity.UserKeyCheckInvalid + 151 -> LcpException.Decryption.ContentDecryptError + else -> LcpException.Unknown(e) + } + } +} \ No newline at end of file diff --git a/r2-lcp/src/main/java/org/readium/r2/lcp/service/PassphrasesService.kt b/r2-lcp/src/main/java/org/readium/r2/lcp/service/PassphrasesService.kt index ec51c8e..1dea9c0 100644 --- a/r2-lcp/src/main/java/org/readium/r2/lcp/service/PassphrasesService.kt +++ b/r2-lcp/src/main/java/org/readium/r2/lcp/service/PassphrasesService.kt @@ -10,7 +10,6 @@ package org.readium.r2.lcp.service import com.mcxiaoke.koi.HASH -import org.readium.lcp.sdk.Lcp import org.readium.r2.lcp.LcpAuthenticating import org.readium.r2.lcp.license.model.LicenseDocument @@ -19,7 +18,7 @@ internal class PassphrasesService(private val repository: PassphrasesRepository) suspend fun request(license: LicenseDocument, authentication: LcpAuthenticating?, allowUserInteraction: Boolean, sender: Any?): String? { val candidates = this@PassphrasesService.possiblePassphrasesFromRepository(license) val passphrase = try { - Lcp().findOneValidPassphrase(license.json.toString(), candidates.toTypedArray()) + LcpClient.findOneValidPassphrase(license.json.toString(), candidates) } catch (e: Exception) { null } @@ -43,7 +42,7 @@ internal class PassphrasesService(private val repository: PassphrasesRepository) } return try { - val passphrase = Lcp().findOneValidPassphrase(license.json.toString(), passphrases.toTypedArray()) + val passphrase = LcpClient.findOneValidPassphrase(license.json.toString(), passphrases) addPassphrase(passphrase, true, license.id, license.provider, license.user.id) passphrase } catch (e: Exception) {