zcash-android-wallet-sdk/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/SaplingParamTool.kt

358 lines
16 KiB
Kotlin

package cash.z.ecc.android.sdk.internal
import android.content.Context
import cash.z.ecc.android.sdk.exception.TransactionEncoderException
import cash.z.ecc.android.sdk.internal.ext.deleteRecursivelySuspend
import cash.z.ecc.android.sdk.internal.ext.deleteSuspend
import cash.z.ecc.android.sdk.internal.ext.existsSuspend
import cash.z.ecc.android.sdk.internal.ext.getCacheDirSuspend
import cash.z.ecc.android.sdk.internal.ext.getSha1Hash
import cash.z.ecc.android.sdk.internal.ext.mkdirsSuspend
import cash.z.ecc.android.sdk.internal.ext.renameToSuspend
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
import java.io.IOException
import java.net.URL
import java.nio.channels.Channels
import kotlin.time.Duration.Companion.milliseconds
internal class SaplingParamTool(val properties: SaplingParamToolProperties) {
val spendParamsFile: File
get() = File(properties.paramsDirectory, SPEND_PARAM_FILE_NAME)
val outputParamsFile: File
get() = File(properties.paramsDirectory, OUTPUT_PARAM_FILE_NAME)
companion object {
/**
* Maximum file size for the sapling spend params - 50MB
*/
internal const val SPEND_PARAM_FILE_MAX_BYTES_SIZE = 50L * 1024L * 1024L
/**
* Maximum file size for the sapling spend params - 5MB
*/
internal const val OUTPUT_PARAM_FILE_MAX_BYTES_SIZE = 5L * 1024L * 1024L
/**
* Subdirectory name, in which are the sapling params files stored.
*/
internal const val SAPLING_PARAMS_LEGACY_SUBDIRECTORY = "params"
/**
* File name for the sapling spend params
*/
internal const val SPEND_PARAM_FILE_NAME = "sapling-spend.params"
/**
* File name for the sapling output params
*/
internal const val OUTPUT_PARAM_FILE_NAME = "sapling-output.params"
/**
* Temporary file prefix to fulfill atomicity requirement of file handling
*/
private const val TEMPORARY_FILE_NAME_PREFIX = "_"
/**
* File SHA1 hash for the sapling spend params
*/
internal const val SPEND_PARAM_FILE_SHA1_HASH = "a15ab54c2888880e53c823a3063820c728444126"
/**
* File SHA1 hash for the sapling output params
*/
internal const val OUTPUT_PARAM_FILE_SHA1_HASH = "0ebc5a1ef3653948e1c46cf7a16071eac4b7e352"
/**
* The Url that is used by default in zcashd
*/
private const val CLOUD_PARAM_DIR_URL = "https://z.cash/downloads/"
private val checkFilesMutex = Mutex()
/**
* Initialization of needed properties. This is necessary entry point for other operations from {@code
* SaplingParamTool}. This type of implementation also simplifies its testing.
*
* @param context
*/
internal suspend fun new(context: Context): SaplingParamTool {
val paramsDirectory = Files.getZcashNoBackupSubdirectory(context)
val toolProperties = SaplingParamToolProperties(
paramsDirectory = paramsDirectory,
paramsLegacyDirectory = File(context.getCacheDirSuspend(), SAPLING_PARAMS_LEGACY_SUBDIRECTORY),
saplingParams = listOf(
SaplingParameters(
paramsDirectory,
SPEND_PARAM_FILE_NAME,
SPEND_PARAM_FILE_MAX_BYTES_SIZE,
SPEND_PARAM_FILE_SHA1_HASH
),
SaplingParameters(
paramsDirectory,
OUTPUT_PARAM_FILE_NAME,
OUTPUT_PARAM_FILE_MAX_BYTES_SIZE,
OUTPUT_PARAM_FILE_SHA1_HASH
)
)
)
return SaplingParamTool(toolProperties)
}
/**
* Returns file object pointing to the parameters files parent directory. We need to check if the parameters
* files don't sit in the legacy folder first. If they do, then we move the files to the currently used
* directory and validate files hashes.
*
* @return params destination directory file
*/
internal suspend fun initAndGetParamsDestinationDir(toolProperties: SaplingParamToolProperties): File {
checkFilesMutex.withLock {
toolProperties.saplingParams.forEach {
val legacyFile = File(toolProperties.paramsLegacyDirectory, it.fileName)
val currentFile = File(toolProperties.paramsDirectory, it.fileName)
if (legacyFile.existsSuspend() && isFileHashValid(legacyFile, it.fileHash)) {
Twig.debug {
"Moving params file: ${it.fileName} from legacy folder to the currently used " +
"folder."
}
currentFile.parentFile?.mkdirsSuspend()
if (!renameParametersFile(legacyFile, currentFile)) {
Twig.debug {
"Failed while moving the params file: ${it.fileName} to the preferred " +
"location."
}
}
} else {
Twig.debug {
"Legacy file either does not exist or is not valid. Will be fetched to the preferred " +
"location."
}
}
}
// remove the params folder and its files - a new sapling files will be fetched to the preferred
// location
toolProperties.paramsLegacyDirectory.deleteRecursivelySuspend()
}
return toolProperties.paramsDirectory
}
/**
* Compares the input file parameter SHA1 hash with the given input hash.
*
* @param parametersFile file of which SHA1 hash will be checked
* @param fileHash hash to compare with
*
* @return true in case of hashes are the same, false otherwise
*/
private suspend fun isFileHashValid(parametersFile: File, fileHash: String): Boolean {
return try {
fileHash == parametersFile.getSha1Hash()
} catch (e: IOException) {
Twig.debug { "Failed in comparing file's hashes with: ${e.message}, caused by: ${e.cause}." }
false
}
}
/**
* The purpose of this function is to rename parameters file from the old name (given by the {@code
* fromParamFile} parameter) to the new name (given by {@code toParamFile}). This operation covers also the file
* move, if it's in a different location.
*
* @param fromParamFile the previously used file name/location
* @param toParamFile the newly used file name/location
*/
private suspend fun renameParametersFile(
fromParamFile: File,
toParamFile: File
): Boolean {
return runCatching {
return@runCatching fromParamFile.renameToSuspend(toParamFile)
}.onFailure {
Twig.debug(it) { "Failed while renaming parameters file" }
}.getOrDefault(false)
}
}
/**
* Checks the given directory for the output and spending params and calls [fetchParams] for those, which are
* missing.
*
* Note: Don't forget to call the entry point function {@code initSaplingParamTool} first. Make sure you also
* called {@code initAndGetParamsDestinationDir} previously, as it's always better to check the
* legacy destination folder first.
*
* @param destinationDir the directory where the params should be stored.
*
* @throws TransactionEncoderException.MissingParamsException in case of failure while checking sapling params
* @throws TransactionEncoderException.FetchParamsException
* @throws TransactionEncoderException.ValidateParamsException
* files
*/
@Throws(
TransactionEncoderException.ValidateParamsException::class,
TransactionEncoderException.FetchParamsException::class,
TransactionEncoderException.MissingParamsException::class
)
internal suspend fun ensureParams(destinationDir: File) {
properties.saplingParams.filter {
!File(it.destinationDirectory, it.fileName).existsSuspend()
}.forEach {
try {
Twig.debug { "Attempting to download missing params: ${it.fileName}." }
fetchParams(it)
} catch (e: TransactionEncoderException.FetchParamsException) {
Twig.debug {
"Failed to fetch param file ${it.fileName} due to: $e. The second attempt is starting with a " +
"little delay."
}
// Re-run the fetch with a little delay, if it failed previously (as it can be caused by network
// conditions). We do it only once, the next failure is delivered to the caller of this method.
delay(200.milliseconds)
fetchParams(it)
} catch (e: TransactionEncoderException.ValidateParamsException) {
Twig.debug {
"Failed to validate fetched param file ${it.fileName} due to: $e. The second attempt is starting" +
" now."
}
// Re-run the fetch for invalid param file immediately, if it failed previously. We do it again only
// once, the next failure is delivered to the caller of this method.
fetchParams(it)
}
}
if (!validate(destinationDir)) {
Twig.debug { "Fetching sapling params files failed." }
throw TransactionEncoderException.MissingParamsException
}
}
/**
* Download and store the params file into the given directory. It also checks the file size to eliminate the
* risk of downloading potentially large file from a malicious server.
*
* @param paramsToFetch parameters wrapper class, which holds information about it
*
* @throws TransactionEncoderException.FetchParamsException if any error while downloading the params file occurs
* @throws TransactionEncoderException.ValidateParamsException if a failure in validation of fetched file occurs
*/
@Throws(
TransactionEncoderException.ValidateParamsException::class,
TransactionEncoderException.FetchParamsException::class
)
internal suspend fun fetchParams(paramsToFetch: SaplingParameters) {
val url = URL("$CLOUD_PARAM_DIR_URL/${paramsToFetch.fileName}")
val temporaryFile = File(
paramsToFetch.destinationDirectory,
"$TEMPORARY_FILE_NAME_PREFIX${paramsToFetch.fileName}"
)
withContext(Dispatchers.IO) {
runCatching {
Channels.newChannel(url.openStream()).use { readableByteChannel ->
temporaryFile.outputStream().use { fileOutputStream ->
fileOutputStream.channel.use { fileChannel ->
// Transfers bytes from stream to file from position 0 to end position or to max
// file size limit. This eliminates the risk of downloading potentially large files
// from a malicious server. We need to make a check of the file hash then.
fileChannel.transferFrom(readableByteChannel, 0, paramsToFetch.fileMaxSizeBytes)
}
}
}
}.onFailure { exception ->
// IllegalArgumentException - If the preconditions on the parameters do not hold
// NonReadableChannelException - If the source channel was not opened for reading
// NonWritableChannelException - If this channel was not opened for writing
// ClosedChannelException - If either this channel or the source channel is closed
// AsynchronousCloseException - If another thread closes either channel while the transfer is
// in progress
// ClosedByInterruptException - If another thread interrupts the current thread while the
// transfer is in progress, thereby closing both channels and setting the current thread's
// interrupt status
// IOException - If some other I/O error occurs
finalizeAndReportError(
temporaryFile,
exception = TransactionEncoderException.FetchParamsException(
paramsToFetch,
"Error while fetching ${paramsToFetch.fileName}, caused by $exception."
)
)
}.onSuccess {
Twig.debug {
"Fetch and write of the temporary ${temporaryFile.name} succeeded. Validating and moving it to " +
"the final destination."
}
if (!isFileHashValid(temporaryFile, paramsToFetch.fileHash)) {
finalizeAndReportError(
temporaryFile,
exception = TransactionEncoderException.ValidateParamsException(
paramsToFetch,
"Failed while validating fetched param file: ${paramsToFetch.fileName}."
)
)
}
val resultFile = File(paramsToFetch.destinationDirectory, paramsToFetch.fileName)
if (!renameParametersFile(temporaryFile, resultFile)) {
finalizeAndReportError(
temporaryFile,
resultFile,
exception = TransactionEncoderException.ValidateParamsException(
paramsToFetch,
"Failed while renaming result param file: ${paramsToFetch.fileName}."
)
)
}
}
}
}
@Throws(TransactionEncoderException.FetchParamsException::class)
private suspend fun finalizeAndReportError(vararg files: File, exception: TransactionEncoderException) {
files.forEach {
it.deleteSuspend()
}
exception.also {
Twig.debug(it) { "Error while fetching sapling params files." }
throw it
}
}
internal suspend fun validate(destinationDir: File): Boolean {
return arrayOf(
SPEND_PARAM_FILE_NAME,
OUTPUT_PARAM_FILE_NAME
).all { paramFileName ->
File(destinationDir, paramFileName).existsSuspend()
}.also {
Twig.debug { "Param files ${if (!it) "did not" else ""} both exist!" }
}
}
}
/**
* Sapling file parameter class to hold each sapling file attributes.
*/
internal data class SaplingParameters(
val destinationDirectory: File,
val fileName: String,
val fileMaxSizeBytes: Long,
val fileHash: String
)
/**
* Sapling param tool helper properties. The goal of this implementation is to ease its testing.
*/
internal data class SaplingParamToolProperties(
val saplingParams: List<SaplingParameters>,
val paramsDirectory: File,
val paramsLegacyDirectory: File
)