diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt index 127f707d4c..b16f261e85 100644 --- a/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/LcpPublicationRetriever.kt @@ -7,11 +7,15 @@ package org.readium.r2.lcp import android.content.Context +import java.io.File +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch import org.readium.r2.lcp.license.container.createLicenseContainer import org.readium.r2.lcp.license.model.LicenseDocument +import org.readium.r2.lcp.util.sha256 import org.readium.r2.shared.extensions.tryOrLog import org.readium.r2.shared.util.AbsoluteUrl import org.readium.r2.shared.util.ErrorException @@ -173,28 +177,44 @@ public class LcpPublicationRetriever( private inner class DownloadListener : DownloadManager.Listener { + @OptIn(ExperimentalEncodingApi::class, ExperimentalStdlibApi::class) override fun onDownloadCompleted( requestId: DownloadManager.RequestId, download: DownloadManager.Download ) { coroutineScope.launch { val lcpRequestId = RequestId(requestId.value) - val listenersForId = checkNotNull(listeners[lcpRequestId]) + val listenersForId = checkNotNull(listeners.remove(lcpRequestId)) + + fun failWithError(error: LcpError) { + listenersForId.forEach { + it.onAcquisitionFailed(lcpRequestId, error) + } + tryOrLog { download.file.delete() } + } val license = downloadsRepository.retrieveLicense(requestId.value) ?.let { LicenseDocument(it) } + .also { downloadsRepository.removeDownload(requestId.value) } ?: run { - listenersForId.forEach { - it.onAcquisitionFailed( - lcpRequestId, - LcpError.wrap( - Exception("Couldn't retrieve license from local storage.") - ) + failWithError( + LcpError.wrap( + Exception("Couldn't retrieve license from local storage.") ) - } + ) + return@launch + } + + license.publicationLink.hash + ?.takeIf { download.file.checkSha256(it) == false } + ?.run { + failWithError( + LcpError.Network( + Exception("Digest mismatch: download looks corrupted.") + ) + ) return@launch } - downloadsRepository.removeDownload(requestId.value) val format = assetRetriever.sniffFormat( @@ -206,15 +226,23 @@ public class LcpPublicationRetriever( ) ) ).getOrElse { - Format( - specification = FormatSpecification( - ZipSpecification, - EpubSpecification, - LcpSpecification - ), - mediaType = MediaType.EPUB, - fileExtension = FileExtension("epub") - ) + when (it) { + is AssetRetriever.RetrieveError.Reading -> { + failWithError(LcpError.wrap(ErrorException(it))) + return@launch + } + is AssetRetriever.RetrieveError.FormatNotSupported -> { + Format( + specification = FormatSpecification( + ZipSpecification, + EpubSpecification, + LcpSpecification + ), + mediaType = MediaType.EPUB, + fileExtension = FileExtension("epub") + ) + } + } } try { @@ -222,10 +250,7 @@ public class LcpPublicationRetriever( val container = createLicenseContainer(download.file, format.specification) container.write(license) } catch (e: Exception) { - tryOrLog { download.file.delete() } - listenersForId.forEach { - it.onAcquisitionFailed(lcpRequestId, LcpError.wrap(e)) - } + failWithError(LcpError.wrap(e)) return@launch } @@ -239,7 +264,6 @@ public class LcpPublicationRetriever( listenersForId.forEach { it.onAcquisitionCompleted(lcpRequestId, acquiredPublication) } - listeners.remove(lcpRequestId) } } @@ -288,4 +312,21 @@ public class LcpPublicationRetriever( listeners.remove(lcpRequestId) } } + + /** + * Checks that the sha256 sum of file content matches the expected one. + * Returns null if we can't decide. + */ + @OptIn(ExperimentalEncodingApi::class, ExperimentalStdlibApi::class) + private fun File.checkSha256(expected: String): Boolean? { + val actual = sha256() ?: return null + + // Supports hexadecimal encoding for compatibility. + // See https://github.com/readium/lcp-specs/issues/52 + return when (expected.length) { + 44 -> Base64.encode(actual) == expected + 64 -> actual.toHexString() == expected + else -> null + } + } } diff --git a/readium/lcp/src/main/java/org/readium/r2/lcp/util/Digest.kt b/readium/lcp/src/main/java/org/readium/r2/lcp/util/Digest.kt new file mode 100644 index 0000000000..5365e51b68 --- /dev/null +++ b/readium/lcp/src/main/java/org/readium/r2/lcp/util/Digest.kt @@ -0,0 +1,28 @@ +/* + * 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.util + +import java.io.File +import java.security.MessageDigest +import org.readium.r2.shared.extensions.tryOrNull + +/** + * Returns the SHA-256 sum of file content or null if computation failed. + */ +internal fun File.sha256(): ByteArray? = + tryOrNull { + val md = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + inputStream().use { + var bytes = it.read(buffer) + while (bytes >= 0) { + md.update(buffer, 0, bytes) + bytes = it.read(buffer) + } + } + return md.digest() + } diff --git a/readium/lcp/src/test/java/org/readium/r2/lcp/util/DigestTest.kt b/readium/lcp/src/test/java/org/readium/r2/lcp/util/DigestTest.kt new file mode 100644 index 0000000000..30ff7f7c2d --- /dev/null +++ b/readium/lcp/src/test/java/org/readium/r2/lcp/util/DigestTest.kt @@ -0,0 +1,31 @@ +/* + * 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.util + +import java.io.File +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import org.junit.Test + +class DigestTest { + + private val file: File = + File(DigestTest::class.java.getResource("a-fc.jpg")!!.path) + + @OptIn(ExperimentalEncodingApi::class, ExperimentalStdlibApi::class) + @Test + fun `sha256 is correct`() { + val digest = assertNotNull(file.sha256()) + assertEquals("GI42TOamBYJ4q4KKBcmMzlkfvld8bTVRcbjjQ20OvLI=", Base64.encode(digest)) + assertEquals( + "188e364ce6a6058278ab828a05c98cce591fbe577c6d355171b8e3436d0ebcb2", + digest.toHexString() + ) + } +} diff --git a/readium/lcp/src/test/resources/org/readium/r2/lcp/util/a-fc.jpg b/readium/lcp/src/test/resources/org/readium/r2/lcp/util/a-fc.jpg new file mode 100644 index 0000000000..8455b3d4ad Binary files /dev/null and b/readium/lcp/src/test/resources/org/readium/r2/lcp/util/a-fc.jpg differ