Skip to content

Commit

Permalink
Check hash of LCP acquisitions (readium#439)
Browse files Browse the repository at this point in the history
  • Loading branch information
qnga committed Jan 12, 2024
1 parent 09e338b commit 97b9ec0
Show file tree
Hide file tree
Showing 4 changed files with 123 additions and 23 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -206,26 +226,31 @@ 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 {
// Saves the License Document into the downloaded publication
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
}

Expand All @@ -239,7 +264,6 @@ public class LcpPublicationRetriever(
listenersForId.forEach {
it.onAcquisitionCompleted(lcpRequestId, acquiredPublication)
}
listeners.remove(lcpRequestId)
}
}

Expand Down Expand Up @@ -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
}
}
}
28 changes: 28 additions & 0 deletions readium/lcp/src/main/java/org/readium/r2/lcp/util/Digest.kt
Original file line number Diff line number Diff line change
@@ -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<ByteArray> {
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()
}
31 changes: 31 additions & 0 deletions readium/lcp/src/test/java/org/readium/r2/lcp/util/DigestTest.kt
Original file line number Diff line number Diff line change
@@ -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()
)
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 97b9ec0

Please sign in to comment.