diff --git a/CHANGELOG.md b/CHANGELOG.md index 0117ae7a61..e6d6658693 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ All notable changes to this project will be documented in this file. Take a look * :warning: The PDFium adapter now defaults to a horizontal paginated layout, instead of a vertical continuous scroll. Set `PdfiumDefaults(scroll = true)` to restore the previous behavior. See [the migration guide](docs/migration-guide.md). +#### LCP + +* Opening an LCP publication is no longer delayed by the CRL used to validate its license. The CRL is now downloaded when creating the `LcpService`, and an expired one is refreshed in the background instead of making the user wait for the response. + ### Fixed #### Navigator @@ -26,6 +30,10 @@ All notable changes to this project will be documented in this file. Take a look * Fixed the PDFium adapter reporting page positions off by one: `currentLocator` was one page ahead of the visible page, the first page was never reported, and the last page never updated `currentLocator` (contributed by [@huttarl](https://github.com/readium/kotlin-toolkit/pull/812)). * :warning: You must migrate the `Locator` objects created by the PDFium adapter and persisted in your database (e.g. bookmarks, reading progression), as their positions were one page too high. Use the new `Publication.migrateLegacyPdfiumLocator()` helper and take a look at [the migration guide](docs/migration-guide.md). +#### LCP + +* [#832](https://github.com/readium/kotlin-toolkit/issues/832) The CRL used to validate LCP licenses is now checked to be a genuine X.509 CRL before being cached. Networks with a captive portal (e.g. on a plane) could return their login page with a `200 OK` status, which was then cached for seven days and prevented opening LCP publications. An invalid CRL cached by a previous version is now ignored instead of waiting for its expiration. + #### Shared * EPUB HREFs that are not percent-encoded but carry a fragment or query (e.g. `chapter one.xhtml#section`, with a space in the filename) now keep their `#fragment`/`?query` instead of encoding the separators into the path. This fixes table of contents and Media Overlays links failing to resolve and navigate in poorly-authored EPUBs. diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpService.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpService.kt index d661248191..1ca3866db8 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpService.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpService.kt @@ -11,6 +11,9 @@ package org.readium.r2.lcp import android.content.Context import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import org.readium.r2.lcp.auth.LcpDialogAuthentication import org.readium.r2.lcp.license.model.LicenseDocument import org.readium.r2.lcp.persistence.LcpDatabase @@ -162,7 +165,14 @@ public interface LcpService { httpClient = httpClient, context = context ) - val crl = CRLService(httpClient = httpClient, context = context) + val crl = CRLService( + httpClient = httpClient, + context = context, + coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + ) + // Warms up the CRL cache, so that opening the first publication is not delayed by it. + crl.preload() + val passphrases = PassphrasesService(repository = passphraseRepository) return LicensesService( licenses = licenseRepository, diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/service/CRLService.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/service/CRLService.kt index e8a55e135c..44e313a53b 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/service/CRLService.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/service/CRLService.kt @@ -11,11 +11,16 @@ package org.readium.r2.lcp.service import android.content.Context import android.content.SharedPreferences -import android.os.Build import androidx.core.content.edit -import java.util.Base64 import kotlin.time.Clock import kotlin.time.Instant +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.datetime.TimeZone import kotlinx.datetime.daysUntil import org.readium.r2.lcp.BuildConfig.DEBUG @@ -28,62 +33,121 @@ import org.readium.r2.shared.util.http.HttpRequest import org.readium.r2.shared.util.http.fetch import timber.log.Timber -internal class CRLService(val httpClient: HttpClient, val context: Context) { +internal class CRLService( + private val httpClient: HttpClient, + private val context: Context, + private val coroutineScope: CoroutineScope, +) { + + companion object { + const val EXPIRATION = 7 + const val CRL_KEY = "org.readium.r2-lcp-swift.CRL" + const val DATE_KEY = "org.readium.r2-lcp-swift.CRLDate" + + private const val CRL_URL = "http://crl.edrlab.telesec.de/rl/EDRLab_CA.crl" + } private val preferences: SharedPreferences = context.getSharedPreferences( "org.readium.r2.lcp", Context.MODE_PRIVATE ) - companion object { - const val EXPIRATION = 7 - const val CRL_KEY = "org.readium.r2-lcp-swift.CRL" - const val DATE_KEY = "org.readium.r2-lcp-swift.CRLDate" + /** + * Guards [fetchJob], to make sure a single fetch is in flight at any time. + */ + private val fetchMutex = Mutex() + private var fetchJob: Deferred? = null + + /** + * Warms up the cache, so that opening a publication does not have to wait for the CRL. + */ + fun preload() { + refreshInBackground() } suspend fun retrieve(): String { val (localCRL, isExpired) = readLocal() - if (localCRL != null && !isExpired) { - return localCRL + + if (localCRL != null) { + if (isExpired) { + // Refreshing in the background instead of waiting for the response, as the expired + // CRL is good enough to open a publication right away. + refreshInBackground() + } + return localCRL.pem + } + + // Without any usable cached CRL, there is nothing to fall back on. + return fetchAndSave().pem + } + + /** + * Fetches and caches a fresh CRL in [coroutineScope], if the cached one is missing or expired. + * + * A failed refresh is not worth reporting, as the cached CRL is used instead and the next call + * will try again. + */ + private fun refreshInBackground() { + coroutineScope.launch { + try { + val (localCRL, isExpired) = readLocal() + if (localCRL == null || isExpired) { + fetchAndSave() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (DEBUG) Timber.e(e) + } } + } - return try { - fetch() - .also { saveLocal(it) } - } catch (e: Exception) { - if (DEBUG) Timber.e(e) - localCRL ?: throw e + /** + * Fetches the CRL and caches it. + */ + private suspend fun fetchAndSave(): Crl { + val job = fetchMutex.withLock { + fetchJob + ?.takeIf { it.isActive } + ?: coroutineScope + .async { fetch().also { saveLocal(it) } } + .also { fetchJob = it } } + + return job.await() } - private suspend fun fetch(): String { - val absoluteUrl = AbsoluteUrl(url = "http://crl.edrlab.telesec.de/rl/EDRLab_CA.crl")!! + private suspend fun fetch(): Crl { + val absoluteUrl = AbsoluteUrl(url = CRL_URL)!! val data = httpClient.fetch(HttpRequest(absoluteUrl)) .map { it.body } .getOrElse { throw LcpException(LcpError.CrlFetching) } - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - "-----BEGIN X509 CRL-----${Base64.getEncoder().encodeToString(data)}-----END X509 CRL-----" - } else { - "-----BEGIN X509 CRL-----${android.util.Base64.encodeToString( - data, - android.util.Base64.DEFAULT - )}-----END X509 CRL-----" - } + // The response might not be a CRL at all, for example when a captive portal returns its + // HTML login page with a 200 status. Caching it would break the license validation until + // the CRL expires, so we reject anything which is not a genuine X.509 CRL. + // See https://github.com/readium/kotlin-toolkit/issues/832 + return Crl.fromDer(data) + ?: run { + if (DEBUG) Timber.e("The fetched CRL is not a valid X.509 CRL") + throw LcpException(LcpError.CrlFetching) + } } // Returns (CRL, expired) - private fun readLocal(): Pair { + private fun readLocal(): Pair { val crl = preferences.getString(CRL_KEY, null) + ?.let { Crl.parsePem(it) } val date = preferences.getString(DATE_KEY, null)?.let { Instant.parse(input = it) } val expired = date?.let { daysSince(date) >= EXPIRATION } ?: true return Pair(crl, expired) } - private fun saveLocal(crl: String): String { - preferences.edit { putString(CRL_KEY, crl) } - preferences.edit { putString(DATE_KEY, Clock.System.now().toString()) } - return crl + private fun saveLocal(crl: Crl) { + preferences.edit(commit = true) { + putString(CRL_KEY, crl.pem) + putString(DATE_KEY, Clock.System.now().toString()) + } } private fun daysSince(date: Instant): Int { diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/service/Crl.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/service/Crl.kt new file mode 100644 index 0000000000..a55e51d126 --- /dev/null +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/service/Crl.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2026 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.service + +import java.io.ByteArrayInputStream +import java.security.cert.CertificateFactory +import kotlin.io.encoding.Base64 +import org.readium.r2.lcp.BuildConfig.DEBUG +import timber.log.Timber + +/** + * A genuine X.509 certificate revocation list, in its PEM encoding. + * + * An instance can only be created from a payload which was successfully parsed as an X.509 CRL. + */ +@JvmInline +internal value class Crl private constructor(val pem: String) { + + companion object { + private const val PEM_HEADER = "-----BEGIN X509 CRL-----" + private const val PEM_FOOTER = "-----END X509 CRL-----" + + /** + * Creates a [Crl] from its DER encoding, or returns null if [data] is not an X.509 CRL. + */ + fun fromDer(data: ByteArray): Crl? { + if (!isX509Crl(data)) { + return null + } + return Crl("$PEM_HEADER${Base64.encode(data)}$PEM_FOOTER") + } + + /** + * Parses a PEM-encoded [Crl], or returns null if [pem] is not an X.509 CRL. + */ + fun parsePem(pem: String): Crl? { + if (!pem.startsWith(PEM_HEADER) || !pem.endsWith(PEM_FOOTER)) { + return null + } + val base64 = pem.substring(PEM_HEADER.length, pem.length - PEM_FOOTER.length) + val data = try { + // Decoding with [Base64.Mime] instead of [Base64.Default], as it ignores the line + // breaks found in the CRLs cached by previous versions of the toolkit on API level + // 25 and below. + Base64.Mime.decode(base64) + } catch (e: IllegalArgumentException) { + if (DEBUG) Timber.e(e) + return null + } + if (!isX509Crl(data)) { + return null + } + return Crl(pem) + } + + /** + * Checks that [data] contains a DER-encoded X.509 CRL. + */ + private fun isX509Crl(data: ByteArray): Boolean = + try { + CertificateFactory.getInstance("X.509") + .generateCRL(ByteArrayInputStream(data)) != null + } catch (e: Exception) { + if (DEBUG) Timber.e(e) + false + } + } +} diff --git a/readium/lcp/src/test/java/org/readium/r2/lcp/service/CRLServiceTest.kt b/readium/lcp/src/test/java/org/readium/r2/lcp/service/CRLServiceTest.kt index f4f1f00aeb..d32aa2326b 100644 --- a/readium/lcp/src/test/java/org/readium/r2/lcp/service/CRLServiceTest.kt +++ b/readium/lcp/src/test/java/org/readium/r2/lcp/service/CRLServiceTest.kt @@ -4,16 +4,30 @@ * available in the top-level LICENSE file of the project. */ +@file:OptIn(ExperimentalCoroutinesApi::class) + package org.readium.r2.lcp.service import android.content.Context import java.io.ByteArrayInputStream import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.Duration.Companion.days +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith +import org.readium.r2.lcp.LcpException import org.readium.r2.shared.util.AbsoluteUrl import org.readium.r2.shared.util.Try import org.readium.r2.shared.util.http.HttpClient @@ -21,85 +35,251 @@ import org.readium.r2.shared.util.http.HttpRequest import org.readium.r2.shared.util.http.HttpResponse import org.readium.r2.shared.util.http.HttpStatus import org.readium.r2.shared.util.http.HttpStreamResponse +import org.readium.r2.shared.util.http.HttpTry import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment @RunWith(RobolectricTestRunner::class) class CRLServiceTest { - class TestHttpClient : HttpClient { + companion object { + /** + * The actual CRL served by http://crl.edrlab.telesec.de/rl/EDRLab_CA.crl, DER-encoded. + * + * Its validity dates are not checked when parsing, so this fixture will not expire. + */ + private val crlBytes: ByteArray = + CRLServiceTest::class.java.getResourceAsStream("edrlab-ca.crl")!! + .use { it.readBytes() } + + private val CRL_BASE64: String = + android.util.Base64.encodeToString(crlBytes, android.util.Base64.NO_WRAP) + + /** + * The same CRL, encoded the way versions of the toolkit running on API level 25 and below + * cached it: with line breaks every 76 characters. + * + * It is a valid CRL which is not equal to [CRL_BASE64], which makes it a convenient stand-in + * for a previously cached CRL when checking whether a refresh took place. + */ + private val STALE_CRL_BASE64: String = + android.util.Base64.encodeToString(crlBytes, android.util.Base64.DEFAULT) + + /** + * A captive portal login page, as returned with a 200 status by some Wi-Fi networks. + */ + private val captivePortalBytes: ByteArray = + "Please buy some Wi-Fi".toByteArray() + } + + class TestHttpClient(private val body: ByteArray) : HttpClient { var streamCallCount = 0 private set - override suspend fun stream(request: HttpRequest): Try { + var lastRequest: HttpRequest? = null + private set + + override suspend fun stream(request: HttpRequest): HttpTry { streamCallCount++ + lastRequest = request return Try.success( HttpStreamResponse( HttpResponse( - request = HttpRequest(AbsoluteUrl("http://crl.edrlab.telesec.de/rl/EDRLab_CA.crl")!!), - url = AbsoluteUrl("http://crl.edrlab.telesec.de/rl/EDRLab_CA.crl")!!, + request = request, + url = request.url, statusCode = HttpStatus.Success, headers = emptyMap(), mediaType = null ), - ByteArrayInputStream(ByteArray(0)) + ByteArrayInputStream(body) ) ) } } - @Test - fun `retrieve returns local CRL if not expired`() = runTest { - val context = RuntimeEnvironment.getApplication() - val preferences = context.getSharedPreferences( - "org.readium.r2.lcp", - Context.MODE_PRIVATE + private val context: Context get() = RuntimeEnvironment.getApplication() + + private val preferences + get() = context.getSharedPreferences("org.readium.r2.lcp", Context.MODE_PRIVATE) + + private fun saveLocalCrl(crl: String, age: kotlin.time.Duration = 2.days) { + preferences.edit() + .putString(CRLService.CRL_KEY, crl) + .putString(CRLService.DATE_KEY, (Clock.System.now() - age).toString()) + .apply() + } + + private fun pem(base64: String): String = + "-----BEGIN X509 CRL-----$base64-----END X509 CRL-----" + + /** + * Parent of the background work started by the service under test. + */ + private val serviceJob = SupervisorJob() + + private fun TestScope.createService(httpClient: HttpClient): CRLService = + CRLService( + httpClient = httpClient, + context = context, + coroutineScope = CoroutineScope(serviceJob + StandardTestDispatcher(testScheduler)) ) - val activeDate = (Clock.System.now() - 2.days).toString() - preferences.edit().putString(CRLService.CRL_KEY, "local_crl").apply() - preferences.edit().putString(CRLService.DATE_KEY, activeDate).apply() + /** + * Waits for the background refreshes started by the service. + * + * The test scheduler cannot be advanced to them instead, as fetching a response hops onto + * [Dispatchers.IO]. + */ + private suspend fun awaitBackgroundWork() { + serviceJob.children.toList().joinAll() + } + + @Test + fun `retrieve returns local CRL if not expired`() = runTest { + saveLocalCrl(pem(CRL_BASE64), age = 2.days) - val httpClient = TestHttpClient() - val service = CRLService(httpClient = httpClient, context = context) + val httpClient = TestHttpClient(crlBytes) + val service = createService(httpClient) val result = service.retrieve() - assertEquals(expected = "local_crl", actual = result) + assertEquals(expected = pem(CRL_BASE64), actual = result) // Ensure network wasn't called assertEquals(0, httpClient.streamCallCount) } @Test - fun `retrieve fetches from network if local CRL is expired`() = runTest { - val context = RuntimeEnvironment.getApplication() - val preferences = context.getSharedPreferences( - "org.readium.r2.lcp", - Context.MODE_PRIVATE - ) + fun `retrieve returns the expired local CRL and refreshes it in the background`() = runTest { + val staleCrl = pem(STALE_CRL_BASE64) + saveLocalCrl(staleCrl, age = 8.days) - val expiredDate = (Clock.System.now() - 8.days).toString() - preferences.edit().putString(CRLService.CRL_KEY, "old_crl").apply() - preferences.edit().putString(CRLService.DATE_KEY, expiredDate).apply() + val httpClient = TestHttpClient(crlBytes) + val service = createService(httpClient) - val httpClient = TestHttpClient() - val service = CRLService(httpClient = httpClient, context = context) + // Opening a publication is not delayed by the fetch. + assertEquals(expected = staleCrl, actual = service.retrieve()) + assertEquals(0, httpClient.streamCallCount) - service.retrieve() + awaitBackgroundWork() + + // The refreshed CRL is cached for the next opening. + assertEquals(1, httpClient.streamCallCount) + assertEquals(pem(CRL_BASE64), preferences.getString(CRLService.CRL_KEY, null)) + assertEquals(expected = pem(CRL_BASE64), actual = service.retrieve()) + } + + @Test + fun `retrieve keeps the expired local CRL when the background refresh fails`() = runTest { + val staleCrl = pem(STALE_CRL_BASE64) + saveLocalCrl(staleCrl, age = 8.days) + + val httpClient = TestHttpClient(captivePortalBytes) + val service = createService(httpClient) + + assertEquals(expected = staleCrl, actual = service.retrieve()) + awaitBackgroundWork() + + assertEquals(1, httpClient.streamCallCount) + assertEquals(staleCrl, preferences.getString(CRLService.CRL_KEY, null)) + // The next opening tries again, without failing. + assertEquals(expected = staleCrl, actual = service.retrieve()) + } + + @Test + fun `preload caches the CRL`() = runTest { + val httpClient = TestHttpClient(crlBytes) + val service = createService(httpClient) + + service.preload() + awaitBackgroundWork() - // Verify network fetch occurred because the previous one was expired assertEquals(1, httpClient.streamCallCount) + assertEquals(pem(CRL_BASE64), preferences.getString(CRLService.CRL_KEY, null)) + } + + @Test + fun `preload does not fetch when the local CRL is not expired`() = runTest { + saveLocalCrl(pem(CRL_BASE64), age = 2.days) + + val httpClient = TestHttpClient(crlBytes) + val service = createService(httpClient) + + service.preload() + awaitBackgroundWork() + + assertEquals(0, httpClient.streamCallCount) } @Test fun `retrieve fetches from network if local CRL does not exist`() = runTest { - val context = RuntimeEnvironment.getApplication() - val httpClient = TestHttpClient() - val service = CRLService(httpClient = httpClient, context = context) + val httpClient = TestHttpClient(crlBytes) + val service = createService(httpClient) + + val result = service.retrieve() + + assertEquals(1, httpClient.streamCallCount) + assertEquals(expected = pem(CRL_BASE64), actual = result) + assertEquals( + expected = AbsoluteUrl("http://crl.edrlab.telesec.de/rl/EDRLab_CA.crl"), + actual = httpClient.lastRequest?.url + ) + assertEquals(HttpRequest.Method.GET, httpClient.lastRequest?.method) + } + + @Test + fun `retrieve accepts the CRL served by the EDRLab server`() = runTest { + val httpClient = TestHttpClient(crlBytes) + val service = createService(httpClient) + + val result = service.retrieve() + + assertEquals(expected = pem(CRL_BASE64), actual = result) + // The fetched CRL is cached, and read back as valid on the next call. + assertEquals(pem(CRL_BASE64), preferences.getString(CRLService.CRL_KEY, null)) + assertEquals(expected = pem(CRL_BASE64), actual = service.retrieve()) + assertEquals(1, httpClient.streamCallCount) + } + + @Test + fun `retrieve accepts a cached CRL with line breaks`() = runTest { + // Versions of the toolkit running on API level 25 and below wrapped the base64 payload at + // 76 characters, so such CRLs must still be readable. + val wrapped = android.util.Base64.encodeToString(crlBytes, android.util.Base64.DEFAULT) + assertTrue(wrapped.contains("\n")) + saveLocalCrl(pem(wrapped), age = 2.days) + + val httpClient = TestHttpClient(crlBytes) + val service = createService(httpClient) + + assertEquals(expected = pem(wrapped), actual = service.retrieve()) + assertEquals(0, httpClient.streamCallCount) + } + + @Test + fun `retrieve rejects a response which is not a CRL`() = runTest { + val httpClient = TestHttpClient(captivePortalBytes) + val service = createService(httpClient) + + assertFailsWith { service.retrieve() } + + assertEquals(null, preferences.getString(CRLService.CRL_KEY, null)) + } + + @Test + fun `retrieve ignores a cached CRL which is not a CRL`() = runTest { + // Cached by a previous version of the toolkit, before the response was validated. + saveLocalCrl( + pem(android.util.Base64.encodeToString(captivePortalBytes, android.util.Base64.DEFAULT)), + age = 2.days + ) + + val httpClient = TestHttpClient(crlBytes) + val service = createService(httpClient) service.retrieve() assertEquals(1, httpClient.streamCallCount) + assertEquals(pem(CRL_BASE64), preferences.getString(CRLService.CRL_KEY, null)) } } diff --git a/readium/lcp/src/test/resources/org/readium/r2/lcp/service/edrlab-ca.crl b/readium/lcp/src/test/resources/org/readium/r2/lcp/service/edrlab-ca.crl new file mode 100644 index 0000000000..2840276328 Binary files /dev/null and b/readium/lcp/src/test/resources/org/readium/r2/lcp/service/edrlab-ca.crl differ