diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/Constants.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/Constants.kt index 8df3220a16..c5e1337224 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/Constants.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/Constants.kt @@ -75,11 +75,6 @@ class Constants { */ const val MAX_PUB_KEY_SIZE = 1024 * 256 - /** - * The max size off an attachment which can be decrypted via the app. - */ - const val MAX_ATTACHMENT_SIZE_WHICH_CAN_BE_DECRYPTED = 1024 * 1024 * 3 - const val PGP_CACHE_DIR = "PGP" const val FORWARDED_ATTACHMENTS_CACHE_DIR = "forwarded" const val ATTACHMENTS_CACHE_DIR = "attachments" diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/security/KeysStorageImpl.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/security/KeysStorageImpl.kt index 4bb258099d..c855019cf0 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/security/KeysStorageImpl.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/security/KeysStorageImpl.kt @@ -17,8 +17,11 @@ import com.flowcrypt.email.database.entity.KeyEntity import com.flowcrypt.email.extensions.org.pgpainless.key.longId import com.flowcrypt.email.model.KeysStorage import com.flowcrypt.email.node.Node +import com.flowcrypt.email.security.pgp.PgpDecrypt import com.flowcrypt.email.security.pgp.PgpKey +import com.flowcrypt.email.util.exception.DecryptionException import org.bouncycastle.bcpg.ArmoredInputStream +import org.bouncycastle.openpgp.PGPException import org.pgpainless.PGPainless import org.pgpainless.key.OpenPgpV4Fingerprint import org.pgpainless.key.protection.KeyRingProtectionSettings @@ -42,31 +45,37 @@ class KeysStorageImpl private constructor(context: Context) : KeysStorage { private var keys = mutableListOf() private var nodeKeyDetailsList = mutableListOf() - private val pureActiveAccountLiveData: LiveData = Transformations.switchMap(nodeLiveData) { - roomDatabase.accountDao().getActiveAccountLD() - } + private val pureActiveAccountLiveData: LiveData = + Transformations.switchMap(nodeLiveData) { + roomDatabase.accountDao().getActiveAccountLD() + } - private val encryptedKeysLiveData: LiveData> = Transformations.switchMap(pureActiveAccountLiveData) { - roomDatabase.keysDao().getAllKeysByAccountLD(it?.email ?: "") - } + private val encryptedKeysLiveData: LiveData> = + Transformations.switchMap(pureActiveAccountLiveData) { + roomDatabase.keysDao().getAllKeysByAccountLD(it?.email ?: "") + } private val keysLiveData = encryptedKeysLiveData.switchMap { list -> liveData { emit(list.map { it.copy( - privateKey = KeyStoreCryptoManager.decryptSuspend(it.privateKeyAsString).toByteArray(), - passphrase = KeyStoreCryptoManager.decryptSuspend(it.passphrase)) + privateKey = KeyStoreCryptoManager.decryptSuspend(it.privateKeyAsString).toByteArray(), + passphrase = KeyStoreCryptoManager.decryptSuspend(it.passphrase) + ) }) } } - val nodeKeyDetailsLiveData: LiveData> = Transformations.switchMap(keysLiveData) { - liveData { - emit(PgpKey.parseKeys( - it.joinToString(separator = "\n") { keyEntity -> keyEntity.privateKeyAsString }) - .toNodeKeyDetailsList()) + val nodeKeyDetailsLiveData: LiveData> = + Transformations.switchMap(keysLiveData) { + liveData { + emit( + PgpKey.parseKeys( + it.joinToString(separator = "\n") { keyEntity -> keyEntity.privateKeyAsString }) + .toNodeKeyDetailsList() + ) + } } - } init { keysLiveData.observeForever { @@ -135,8 +144,11 @@ class KeysStorageImpl private constructor(context: Context) : KeysStorage { val key = getPgpPrivateKey(openPgpV4Fingerprint.longId) if (key != null) { val passphrase: Passphrase - if (key.passphrase.isNullOrEmpty()) { - passphrase = Passphrase.emptyPassphrase() + if (key.passphrase == null) { + throw DecryptionException( + decryptionErrorType = PgpDecrypt.DecryptionErrorType.NEED_PASSPHRASE, + e = PGPException("flowcrypt: need passphrase") + ) } else { passphrase = Passphrase.fromPassword(key.passphrase) } @@ -161,11 +173,14 @@ class KeysStorageImpl private constructor(context: Context) : KeysStorage { */ suspend fun getLatestAllPgpPrivateKeys(): List { val account = pureActiveAccountLiveData.value - ?: roomDatabase.accountDao().getActiveAccountSuspend() + ?: roomDatabase.accountDao().getActiveAccountSuspend() account?.let { accountEntity -> val cachedKeysLongIds = keys.map { it.longId }.toSet() - val latestEncryptedKeys = roomDatabase.keysDao().getAllKeysByAccountSuspend(accountEntity.email) - val latestKeysLongIds = roomDatabase.keysDao().getAllKeysByAccountSuspend(accountEntity.email).map { it.longId }.toSet() + val latestEncryptedKeys = + roomDatabase.keysDao().getAllKeysByAccountSuspend(accountEntity.email) + val latestKeysLongIds = + roomDatabase.keysDao().getAllKeysByAccountSuspend(accountEntity.email).map { it.longId } + .toSet() if (cachedKeysLongIds == latestKeysLongIds) { return keys @@ -173,8 +188,9 @@ class KeysStorageImpl private constructor(context: Context) : KeysStorage { return latestEncryptedKeys.map { it.copy( - privateKey = KeyStoreCryptoManager.decryptSuspend(it.privateKeyAsString).toByteArray(), - passphrase = KeyStoreCryptoManager.decryptSuspend(it.passphrase)) + privateKey = KeyStoreCryptoManager.decryptSuspend(it.privateKeyAsString).toByteArray(), + passphrase = KeyStoreCryptoManager.decryptSuspend(it.passphrase) + ) } } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpDecrypt.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpDecrypt.kt new file mode 100644 index 0000000000..7b7bc0f42e --- /dev/null +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpDecrypt.kt @@ -0,0 +1,100 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: DenBond7 + */ + +package com.flowcrypt.email.security.pgp + +import com.flowcrypt.email.util.exception.DecryptionException +import org.bouncycastle.openpgp.PGPDataValidationException +import org.bouncycastle.openpgp.PGPException +import org.bouncycastle.openpgp.PGPSecretKeyRingCollection +import org.pgpainless.PGPainless +import org.pgpainless.decryption_verification.OpenPgpMetadata +import org.pgpainless.exception.MessageNotIntegrityProtectedException +import org.pgpainless.exception.ModificationDetectionException +import org.pgpainless.key.protection.SecretKeyRingProtector +import java.io.InputStream +import java.io.OutputStream + +/** + * @author Denis Bondarenko + * Date: 5/11/21 + * Time: 2:10 PM + * E-mail: DenBond7@gmail.com + */ +object PgpDecrypt { + val DETECT_SEPARATE_ENCRYPTED_ATTACHMENTS_PATTERN = + "(?i)(\\.pgp$)|(\\.gpg$)|(\\.[a-zA-Z0-9]{3,4}\\.asc$)".toRegex() + + fun decrypt( + srcInputStream: InputStream, + destOutputStream: OutputStream, + pgpSecretKeyRingCollection: PGPSecretKeyRingCollection, + protector: SecretKeyRingProtector + ): OpenPgpMetadata { + srcInputStream.use { srcStream -> + destOutputStream.use { outStream -> + try { + val decryptionStream = PGPainless.decryptAndOrVerify() + .onInputStream(srcStream) + .decryptWith(protector, pgpSecretKeyRingCollection) + .doNotVerify() + .build() + + decryptionStream.use { it.copyTo(outStream) } + return decryptionStream.result + } catch (e: Exception) { + throw processDecryptionException(e) + } + } + } + } + + private fun processDecryptionException(e: Exception): Exception { + return when (e) { + is PGPException -> { + when { + e.message == "checksum mismatch at 0 of 20" -> { + DecryptionException(DecryptionErrorType.WRONG_PASSPHRASE, e) + } + + e.message?.contains("exception decrypting session info") == true + || e.message?.contains("encoded length out of range") == true + || e.message?.contains("Exception recovering session info") == true + || e.message?.contains("No suitable decryption key") == true -> { + DecryptionException(DecryptionErrorType.KEY_MISMATCH, e) + } + + else -> DecryptionException(DecryptionErrorType.OTHER, e) + } + } + + is MessageNotIntegrityProtectedException -> { + DecryptionException(DecryptionErrorType.NO_MDC, e) + } + + is ModificationDetectionException -> { + DecryptionException(DecryptionErrorType.BAD_MDC, e) + } + + is PGPDataValidationException -> { + DecryptionException(DecryptionErrorType.KEY_MISMATCH, e) + } + + is DecryptionException -> e + + else -> DecryptionException(DecryptionErrorType.OTHER, e) + } + } + + enum class DecryptionErrorType { + KEY_MISMATCH, + WRONG_PASSPHRASE, + NO_MDC, + BAD_MDC, + NEED_PASSPHRASE, + FORMAT, + OTHER + } +} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpMsg.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpMsg.kt index 5ede1867a0..d8d25fd679 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpMsg.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpMsg.kt @@ -82,18 +82,8 @@ object PgpMsg { private val maxTagNumber = 20 - enum class DecryptionErrorType { - KEY_MISMATCH, - WRONG_PASSPHRASE, - NO_MDC, - BAD_MDC, - NEED_PASSPHRASE, - FORMAT, - OTHER - } - data class DecryptionError( - val type: DecryptionErrorType, + val type: PgpDecrypt.DecryptionErrorType, val message: String, val cause: Throwable? = null ) @@ -119,7 +109,7 @@ object PgpMsg { ) { companion object { fun withError( - type: DecryptionErrorType, + type: PgpDecrypt.DecryptionErrorType, message: String, cause: Throwable? = null ): DecryptionResult { @@ -147,7 +137,10 @@ object PgpMsg { pgpPublicKeyRingCollection: PGPPublicKeyRingCollection? // for verification ): DecryptionResult { if (data.isEmpty()) { - return DecryptionResult.withError(DecryptionErrorType.FORMAT, "Can't decrypt empty message") + return DecryptionResult.withError( + type = PgpDecrypt.DecryptionErrorType.FORMAT, + message = "Can't decrypt empty message" + ) } val chunk = data.copyOfRange(0, data.size.coerceAtMost(50)).toString(StandardCharsets.US_ASCII) @@ -162,13 +155,13 @@ object PgpMsg { ex is PGPException && ex.message != null && ex.message == "Cleartext format error" ) { DecryptionResult.withError( - type = DecryptionErrorType.FORMAT, + type = PgpDecrypt.DecryptionErrorType.FORMAT, message = ex.message!!, cause = ex.cause ) } else { DecryptionResult.withError( - type = DecryptionErrorType.OTHER, + type = PgpDecrypt.DecryptionErrorType.OTHER, message = "Decode cleartext error", cause = ex ) @@ -192,12 +185,12 @@ object PgpMsg { } catch (ex: PGPException) { if (ex.message == "flowcrypt: need passphrase") { return DecryptionResult.withError( - type = DecryptionErrorType.NEED_PASSPHRASE, + type = PgpDecrypt.DecryptionErrorType.NEED_PASSPHRASE, message = "Need passphrase" ) } return DecryptionResult.withError( - type = DecryptionErrorType.WRONG_PASSPHRASE, + type = PgpDecrypt.DecryptionErrorType.WRONG_PASSPHRASE, message = "Wrong passphrase", cause = ex ) @@ -222,20 +215,20 @@ object PgpMsg { return DecryptionResult.withDecrypted(output, streamResult.fileInfo?.fileName) } catch (ex: MessageNotIntegrityProtectedException) { return DecryptionResult.withError( - type = DecryptionErrorType.NO_MDC, + type = PgpDecrypt.DecryptionErrorType.NO_MDC, message = "Security threat! Message is missing integrity checks (MDC)." + " The sender should update their outdated software.", cause = ex ) } catch (ex: ModificationDetectionException) { return DecryptionResult.withError( - type = DecryptionErrorType.BAD_MDC, + type = PgpDecrypt.DecryptionErrorType.BAD_MDC, message = "Security threat! Integrity check failed.", cause = ex ) } catch (ex: PGPDataValidationException) { return DecryptionResult.withError( - type = DecryptionErrorType.KEY_MISMATCH, + type = PgpDecrypt.DecryptionErrorType.KEY_MISMATCH, message = "There is no matching key", cause = ex ) @@ -247,7 +240,7 @@ object PgpMsg { || ex.message?.contains("No suitable decryption key") == true ) { return DecryptionResult.withError( - type = DecryptionErrorType.KEY_MISMATCH, + type = PgpDecrypt.DecryptionErrorType.KEY_MISMATCH, message = "There is no suitable decryption key", cause = ex ) @@ -258,7 +251,7 @@ object PgpMsg { exception = ex } return DecryptionResult.withError( - type = DecryptionErrorType.OTHER, + type = PgpDecrypt.DecryptionErrorType.OTHER, message = "Decryption failed", cause = exception ) diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/service/attachment/AttachmentDownloadManagerService.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/service/attachment/AttachmentDownloadManagerService.kt index ab0d66d541..135ecbd1c6 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/service/attachment/AttachmentDownloadManagerService.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/service/attachment/AttachmentDownloadManagerService.kt @@ -32,20 +32,17 @@ import com.flowcrypt.email.api.email.gmail.GmailApiHelper import com.flowcrypt.email.api.email.model.AttachmentInfo import com.flowcrypt.email.api.email.protocol.ImapProtocolUtil import com.flowcrypt.email.api.email.protocol.OpenStoreHelper -import com.flowcrypt.email.api.retrofit.node.NodeRetrofitHelper -import com.flowcrypt.email.api.retrofit.node.NodeService -import com.flowcrypt.email.api.retrofit.request.node.DecryptFileRequest -import com.flowcrypt.email.api.retrofit.response.node.DecryptedFileResult import com.flowcrypt.email.database.FlowCryptRoomDatabase import com.flowcrypt.email.database.entity.AccountEntity import com.flowcrypt.email.extensions.kotlin.toHex import com.flowcrypt.email.jetpack.viewmodel.AccountViewModel import com.flowcrypt.email.security.KeysStorageImpl +import com.flowcrypt.email.security.pgp.PgpDecrypt +import com.flowcrypt.email.security.pgp.PgpKey import com.flowcrypt.email.util.FileAndDirectoryUtils import com.flowcrypt.email.util.GeneralUtil import com.flowcrypt.email.util.LogsUtil import com.flowcrypt.email.util.exception.ExceptionUtil -import com.flowcrypt.email.util.exception.FlowCryptLimitException import com.flowcrypt.email.util.exception.ManualHandledException import com.google.android.gms.common.util.CollectionUtils import com.sun.mail.imap.IMAPFolder @@ -63,17 +60,15 @@ import java.util.concurrent.Future import javax.mail.Folder /** - * This service will be use to download email attachments. To start load an attachment just run service via the intent + * This service can be used to download email attachments. + * To start loading an attachment just run service via the intent * [AttachmentDownloadManagerService.newIntent] * + * This service provides the following: * - * This service can do: - * - * - * - * * Can download simultaneously 3 attachments. The other attachments will be added to the queue. + * * Can download simultaneously 3 attachments. Other attachments will be added to the queue. * * All loading attachments will visible in the status bar - * * The user can stop loading an attachment at any time. + * * A user can stop loading an attachment at any time. * * * @author Denis Bondarenko @@ -84,18 +79,14 @@ import javax.mail.Folder class AttachmentDownloadManagerService : Service() { @Volatile - private var looper: Looper? = null + private lateinit var looper: Looper @Volatile private lateinit var workerHandler: ServiceWorkerHandler - private val replyMessenger: Messenger + private val replyMessenger: Messenger = Messenger(ReplyHandler(this)) private lateinit var attsNotificationManager: AttachmentNotificationManager - init { - this.replyMessenger = Messenger(ReplyHandler(this)) - } - override fun onCreate() { super.onCreate() LogsUtil.d(TAG, "onCreate") @@ -104,7 +95,7 @@ class AttachmentDownloadManagerService : Service() { handlerThread.start() looper = handlerThread.looper - workerHandler = ServiceWorkerHandler(looper!!, replyMessenger) + workerHandler = ServiceWorkerHandler(looper, replyMessenger) attsNotificationManager = AttachmentNotificationManager(applicationContext) } @@ -182,10 +173,13 @@ class AttachmentDownloadManagerService : Service() { * The incoming handler realization. This handler will be used to communicate with current * service and the worker thread. */ - private class ReplyHandler(attDownloadManagerService: AttachmentDownloadManagerService) - : Handler() { + //todo-denbond7 need to fix deprecation + private class ReplyHandler( + attDownloadManagerService: AttachmentDownloadManagerService + ) : Handler() { - private val weakRef: WeakReference = WeakReference(attDownloadManagerService) + private val weakRef: WeakReference = + WeakReference(attDownloadManagerService) override fun handleMessage(message: Message) { if (weakRef.get() != null) { @@ -196,11 +190,16 @@ class AttachmentDownloadManagerService : Service() { = message.obj as DownloadAttachmentTaskResult when (message.what) { - MESSAGE_EXCEPTION_HAPPENED -> notificationManager?.errorHappened(attDownloadManagerService, attInfo!!, - exception!!) + MESSAGE_EXCEPTION_HAPPENED -> notificationManager?.errorHappened( + attDownloadManagerService, attInfo!!, + exception!! + ) MESSAGE_TASK_ALREADY_EXISTS -> { - val msg = attDownloadManagerService?.getString(R.string.template_attachment_already_loading, attInfo!!.name) + val msg = attDownloadManagerService?.getString( + R.string.template_attachment_already_loading, + attInfo!!.name + ) Toast.makeText(attDownloadManagerService, msg, Toast.LENGTH_SHORT).show() } @@ -212,8 +211,10 @@ class AttachmentDownloadManagerService : Service() { MESSAGE_ATTACHMENT_ADDED_TO_QUEUE -> notificationManager?.attachmentAddedToLoadQueue(attDownloadManagerService, attInfo!!) - MESSAGE_PROGRESS -> notificationManager?.updateLoadingProgress(attDownloadManagerService, attInfo!!, - progressInPercentage, timeLeft) + MESSAGE_PROGRESS -> notificationManager?.updateLoadingProgress( + attDownloadManagerService, attInfo!!, + progressInPercentage, timeLeft + ) MESSAGE_RELEASE_RESOURCES -> attDownloadManagerService?.looper!!.quit() @@ -247,8 +248,8 @@ class AttachmentDownloadManagerService : Service() { * This handler will be used by the instance of [HandlerThread] to receive message from * the UI thread. */ - private class ServiceWorkerHandler(looper: Looper, private val messenger: Messenger) - : Handler(looper), OnDownloadAttachmentListener { + private class ServiceWorkerHandler(looper: Looper, private val messenger: Messenger) : + Handler(looper), OnDownloadAttachmentListener { private val executorService: ExecutorService = Executors.newFixedThreadPool(QUEUE_SIZE) @Volatile @@ -275,7 +276,13 @@ class AttachmentDownloadManagerService : Service() { attDownloadRunnable.setListener(this) futureMap[attInfo.uniqueStringId] = executorService.submit(attDownloadRunnable) val result = DownloadAttachmentTaskResult(attInfo) - messenger.send(Message.obtain(null, ReplyHandler.MESSAGE_ATTACHMENT_ADDED_TO_QUEUE, result)) + messenger.send( + Message.obtain( + null, + ReplyHandler.MESSAGE_ATTACHMENT_ADDED_TO_QUEUE, + result + ) + ) } else { taskAlreadyExists(attInfo) } @@ -345,8 +352,10 @@ class AttachmentDownloadManagerService : Service() { override fun onProgress(attInfo: AttachmentInfo, progressInPercentage: Int, timeLeft: Long) { try { - val result = DownloadAttachmentTaskResult(attInfo, progressInPercentage = progressInPercentage, - timeLeft = timeLeft) + val result = DownloadAttachmentTaskResult( + attInfo, progressInPercentage = progressInPercentage, + timeLeft = timeLeft + ) messenger.send(Message.obtain(null, ReplyHandler.MESSAGE_PROGRESS, result)) } catch (remoteException: RemoteException) { remoteException.printStackTrace() @@ -398,20 +407,21 @@ class AttachmentDownloadManagerService : Service() { } } - private class AttDownloadRunnable(private val context: Context, - private val att: AttachmentInfo) : Runnable { + private class AttDownloadRunnable( + private val context: Context, + private val att: AttachmentInfo + ) : Runnable { private var listener: OnDownloadAttachmentListener? = null private var attTempFile: File = File.createTempFile("tmp", null, context.externalCacheDir) override fun run() { if (GeneralUtil.isDebugBuild()) { - Thread.currentThread().name = AttDownloadRunnable::class.java.simpleName + "|" + att.getSafeName() + Thread.currentThread().name = + AttDownloadRunnable::class.java.simpleName + "|" + att.getSafeName() } val roomDatabase = FlowCryptRoomDatabase.getDatabase(context) try { - checkFileSize() - if (att.uri != null) { val inputStream = context.contentResolver.openInputStream(att.uri!!) if (inputStream != null) { @@ -427,7 +437,9 @@ class AttachmentDownloadManagerService : Service() { } val email = att.email ?: return - val account = AccountViewModel.getAccountEntityWithDecryptedInfo(roomDatabase.accountDao().getAccount(email)) + val account = AccountViewModel.getAccountEntityWithDecryptedInfo( + roomDatabase.accountDao().getAccount(email) + ) if (account == null) { listener?.onCanceled(this.att) @@ -439,9 +451,14 @@ class AttachmentDownloadManagerService : Service() { AccountEntity.ACCOUNT_TYPE_GOOGLE -> { val msg = GmailApiHelper.loadMsgFullInfo(context, account, att.uid.toHex()) val attPart = GmailApiHelper.getAttPartByPath(msg.payload, neededPath = att.path) - ?: throw ManualHandledException(context.getString(R.string.attachment_not_found)) - - GmailApiHelper.getAttInputStream(context, account, att.uid.toHex(), attPart.body.attachmentId).use { inputStream -> + ?: throw ManualHandledException(context.getString(R.string.attachment_not_found)) + + GmailApiHelper.getAttInputStream( + context, + account, + att.uid.toHex(), + attPart.body.attachmentId + ).use { inputStream -> handleAttachmentInputStream(inputStream) } } @@ -452,18 +469,20 @@ class AttachmentDownloadManagerService : Service() { val session = OpenStoreHelper.getAttsSess(context, account) OpenStoreHelper.openStore(context, account, session).use { store -> val label = roomDatabase.labelDao().getLabel(email, account.accountType, att.folder!!) - ?: if (roomDatabase.accountDao().getAccount(email) == null) { - listener?.onCanceled(this.att) - store.close() - return - } else throw ManualHandledException("Folder \"" + att.folder + "\" not found in the local cache") + ?: if (roomDatabase.accountDao().getAccount(email) == null) { + listener?.onCanceled(this.att) + store.close() + return + } else throw ManualHandledException("Folder \"" + att.folder + "\" not found in the local cache") store.getFolder(label.name).use { folder -> val remoteFolder = (folder as IMAPFolder).apply { open(Folder.READ_ONLY) } val msg = remoteFolder.getMessageByUID(att.uid) - ?: throw ManualHandledException(context.getString(R.string.no_message_with_this_attachment)) + ?: throw ManualHandledException(context.getString(R.string.no_message_with_this_attachment)) - ImapProtocolUtil.getAttPartByPath(msg, neededPath = this.att.path)?.inputStream?.let { inputStream -> + ImapProtocolUtil.getAttPartByPath( + msg, neededPath = this.att.path + )?.inputStream?.let { inputStream -> handleAttachmentInputStream(inputStream) } ?: throw ManualHandledException(context.getString(R.string.attachment_not_found)) } @@ -520,7 +539,8 @@ class AttachmentDownloadManagerService : Service() { requireNotNull(imageUri) //we should check maybe a file is already exist. Then we will use the file name from the system - val cursor = resolver.query(imageUri, arrayOf(MediaStore.DownloadColumns.DISPLAY_NAME), null, null, null) + val cursor = + resolver.query(imageUri, arrayOf(MediaStore.DownloadColumns.DISPLAY_NAME), null, null, null) cursor?.let { if (it.moveToFirst()) { val nameIndex = it.getColumnIndex(MediaStore.DownloadColumns.DISPLAY_NAME) @@ -534,7 +554,12 @@ class AttachmentDownloadManagerService : Service() { } cursor?.close() - IOUtils.copy(attFile.inputStream(), resolver.openOutputStream(imageUri)) + val srcInputStream = attFile.inputStream() + val destOutputStream = resolver.openOutputStream(imageUri) + ?: throw IllegalArgumentException("provided URI could not be opened") + srcInputStream.use { srcStream -> + destOutputStream.use { outStream -> srcStream.copyTo(outStream) } + } //notify the system that the file is ready resolver.update(imageUri, ContentValues().apply { @@ -559,7 +584,11 @@ class AttachmentDownloadManagerService : Service() { } att.name = sharedFile.name - IOUtils.copy(attFile.inputStream(), FileUtils.openOutputStream(sharedFile)) + val srcInputStream = attFile.inputStream() + val destOutputStream = FileUtils.openOutputStream(sharedFile) + srcInputStream.use { srcStream -> + destOutputStream.use { outStream -> srcStream.copyTo(outStream) } + } return FileProvider.getUriForFile(context, Constants.FILE_PROVIDER_AUTHORITY, sharedFile) } @@ -591,7 +620,8 @@ class AttachmentDownloadManagerService : Service() { outputStream.write(buffer, 0, numberOfReadBytes) count += numberOfReadBytes.toDouble() currentPercentage = (count / size * 100f).toInt() - val isUpdateNeeded = System.currentTimeMillis() - lastUpdateTime >= MIN_UPDATE_PROGRESS_INTERVAL + val isUpdateNeeded = + System.currentTimeMillis() - lastUpdateTime >= MIN_UPDATE_PROGRESS_INTERVAL if (currentPercentage - lastPercentage >= 1 && isUpdateNeeded) { lastPercentage = currentPercentage lastUpdateTime = System.currentTimeMillis() @@ -611,23 +641,7 @@ class AttachmentDownloadManagerService : Service() { } /** - * Check is decrypted file has size not more than - * [Constants.MAX_ATTACHMENT_SIZE_WHICH_CAN_BE_DECRYPTED]. If the file greater then - * [Constants.MAX_ATTACHMENT_SIZE_WHICH_CAN_BE_DECRYPTED] we throw an exception. This is only for files - * with the "pgp" extension. - */ - private fun checkFileSize() { - if ("pgp".equals(FilenameUtils.getExtension(att.name), ignoreCase = true)) { - if (att.encodedSize > Constants.MAX_ATTACHMENT_SIZE_WHICH_CAN_BE_DECRYPTED) { - val errorMsg = context.getString(R.string.template_warning_max_attachments_size_for_decryption, - FileUtils.byteCountToDisplaySize(Constants.MAX_ATTACHMENT_SIZE_WHICH_CAN_BE_DECRYPTED.toLong())) - throw FlowCryptLimitException(errorMsg) - } - } - } - - /** - * Do decryption of the downloaded file if it need. + * Do decryption of the downloaded file if needed. * * @param context Interface to global information about an application environment; * @param file The downloaded file which can be encrypted. @@ -638,40 +652,44 @@ class AttachmentDownloadManagerService : Service() { throw NullPointerException("Error. The file is missing") } - if (!"pgp".equals(FilenameUtils.getExtension(att.name), ignoreCase = true)) { + val regex = PgpDecrypt.DETECT_SEPARATE_ENCRYPTED_ATTACHMENTS_PATTERN + if (regex.find(att.name ?: "") == null) { return file } FileInputStream(file).use { inputStream -> - val decryptedFileResult = getDecryptedFileResult(context, inputStream) - val decryptedFile = File.createTempFile("tmp", null, context.externalCacheDir) att.name = FilenameUtils.getBaseName(att.name) - FileUtils.openOutputStream(decryptedFile).use { outputStream -> - IOUtils.write(decryptedFileResult.decryptedBytes, outputStream) - deleteTempFile(file) - return decryptedFile - } - } - } + val combinedSource = KeysStorageImpl.getInstance(context) + .getAllPgpPrivateKeys() + .joinToString(separator = "\n") { keyEntity -> keyEntity.privateKeyAsString } + val parseKeyResult = PgpKey.parseKeys(combinedSource) + val keys = parseKeyResult.pgpKeyRingCollection.pgpSecretKeyRingCollection + val protector = KeysStorageImpl.getInstance(context).getSecretKeyRingProtector() + + try { + val result = PgpDecrypt.decrypt( + srcInputStream = inputStream, + destOutputStream = decryptedFile.outputStream(), + pgpSecretKeyRingCollection = keys, + protector = protector + ) + + result.fileInfo?.fileName?.let { fileName -> + if (att.name == null) { + att.name = fileName + } + } - private fun getDecryptedFileResult(context: Context, inputStream: InputStream): DecryptedFileResult { - val keysStorage = KeysStorageImpl.getInstance(context) - val list = keysStorage.getAllPgpPrivateKeys() - val nodeService = NodeRetrofitHelper.getRetrofit()!!.create(NodeService::class.java) - val request = DecryptFileRequest(IOUtils.toByteArray(inputStream), list) - val response = nodeService.decryptFile(request).execute() - val result = response.body() ?: throw NullPointerException("Node.js returned an empty result") - if (result.apiError != null) { - var exceptionMsg = result.apiError.msg - if ("use_password" == result.apiError.type) { - exceptionMsg = context.getString(R.string.opening_password_encrypted_msg_not_implemented_yet) + return decryptedFile + } catch (e: Exception) { + deleteTempFile(decryptedFile) + throw e + } finally { + deleteTempFile(file) } - throw Exception(exceptionMsg) } - - return result } /** @@ -702,12 +720,18 @@ class AttachmentDownloadManagerService : Service() { } companion object { - const val ACTION_START_DOWNLOAD_ATTACHMENT = BuildConfig.APPLICATION_ID + ".ACTION_START_DOWNLOAD_ATTACHMENT" - const val ACTION_CANCEL_DOWNLOAD_ATTACHMENT = BuildConfig.APPLICATION_ID + ".ACTION_CANCEL_DOWNLOAD_ATTACHMENT" - const val ACTION_RETRY_DOWNLOAD_ATTACHMENT = BuildConfig.APPLICATION_ID + ".ACTION_RETRY_DOWNLOAD_ATTACHMENT" + const val ACTION_START_DOWNLOAD_ATTACHMENT = + BuildConfig.APPLICATION_ID + ".ACTION_START_DOWNLOAD_ATTACHMENT" + const val ACTION_CANCEL_DOWNLOAD_ATTACHMENT = + BuildConfig.APPLICATION_ID + ".ACTION_CANCEL_DOWNLOAD_ATTACHMENT" + const val ACTION_RETRY_DOWNLOAD_ATTACHMENT = + BuildConfig.APPLICATION_ID + ".ACTION_RETRY_DOWNLOAD_ATTACHMENT" val EXTRA_KEY_ATTACHMENT_INFO = - GeneralUtil.generateUniqueExtraKey("EXTRA_KEY_ATTACHMENT_INFO", AttachmentDownloadManagerService::class.java) + GeneralUtil.generateUniqueExtraKey( + "EXTRA_KEY_ATTACHMENT_INFO", + AttachmentDownloadManagerService::class.java + ) private val TAG = AttachmentDownloadManagerService::class.java.simpleName diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/util/exception/DecryptionException.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/util/exception/DecryptionException.kt new file mode 100644 index 0000000000..70c8bc4d45 --- /dev/null +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/util/exception/DecryptionException.kt @@ -0,0 +1,19 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: DenBond7 + */ + +package com.flowcrypt.email.util.exception + +import com.flowcrypt.email.security.pgp.PgpDecrypt + +/** + * @author Denis Bondarenko + * Date: 5/11/21 + * Time: 6:51 PM + * E-mail: DenBond7@gmail.com + */ +class DecryptionException( + val decryptionErrorType: PgpDecrypt.DecryptionErrorType, + val e: Exception +) : FlowCryptException(e) diff --git a/FlowCrypt/src/main/res/values/strings.xml b/FlowCrypt/src/main/res/values/strings.xml index 7a244d7d92..e1abd373f9 100644 --- a/FlowCrypt/src/main/res/values/strings.xml +++ b/FlowCrypt/src/main/res/values/strings.xml @@ -175,7 +175,6 @@ Simulate app crash Attachments The download attachments notification channel - The app cannot decrypt an attachment greater than %1$s Could not open this message with %1$s. Message is either badly formatted or not compatible with %1$s. Please write us at %1$s so that we can fix it for you. @@ -337,8 +336,6 @@ Could not decrypt this message due to error:\n\n %1$s Show aliases Show CC/BCC - Opening password encrypted messages not - implemented yet System Errors notifications The system notifications channel diff --git a/FlowCrypt/src/test/java/com/flowcrypt/email/extensions/TemporaryFolderExt.kt b/FlowCrypt/src/test/java/com/flowcrypt/email/extensions/TemporaryFolderExt.kt new file mode 100644 index 0000000000..150335d475 --- /dev/null +++ b/FlowCrypt/src/test/java/com/flowcrypt/email/extensions/TemporaryFolderExt.kt @@ -0,0 +1,49 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: DenBond7 + */ + +package com.flowcrypt.email.extensions + +import org.apache.commons.io.FileUtils +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.RandomAccessFile +import java.util.* + +/** + * @author Denis Bondarenko + * Date: 5/12/21 + * Time: 10:38 AM + * E-mail: DenBond7@gmail.com + */ +fun TemporaryFolder.createFileWithRandomData( + fileSizeInBytes: Long, + fileName: String = UUID.randomUUID().toString(), + bufferSize: Int = (FileUtils.ONE_KB * 8).toInt() +): File { + return newFile(fileName).apply { + RandomAccessFile(this, "rw").apply { + setLength(fileSizeInBytes) + outputStream().use { outStream -> + var overriddenBytesCount = 0L + val random = Random() + val buffer = ByteArray(bufferSize) + while (overriddenBytesCount != fileSizeInBytes) { + if (fileSizeInBytes - overriddenBytesCount <= bufferSize) { + val lastSegmentBufferSize = (fileSizeInBytes - overriddenBytesCount).toInt() + val lastSegmentBuffer = ByteArray(lastSegmentBufferSize) + random.nextBytes(lastSegmentBuffer) + outStream.write(lastSegmentBuffer) + overriddenBytesCount += lastSegmentBufferSize + } else { + random.nextBytes(buffer) + outStream.write(buffer) + overriddenBytesCount += bufferSize + } + } + outStream.flush() + } + } + } +} diff --git a/FlowCrypt/src/test/java/com/flowcrypt/email/security/pgp/PgpDecryptTest.kt b/FlowCrypt/src/test/java/com/flowcrypt/email/security/pgp/PgpDecryptTest.kt new file mode 100644 index 0000000000..c7dc575386 --- /dev/null +++ b/FlowCrypt/src/test/java/com/flowcrypt/email/security/pgp/PgpDecryptTest.kt @@ -0,0 +1,208 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: DenBond7 + */ + +package com.flowcrypt.email.security.pgp + +import com.flowcrypt.email.extensions.createFileWithRandomData +import com.flowcrypt.email.util.exception.DecryptionException +import org.apache.commons.io.FileUtils +import org.bouncycastle.openpgp.PGPPublicKeyRingCollection +import org.bouncycastle.openpgp.PGPSecretKeyRing +import org.bouncycastle.openpgp.PGPSecretKeyRingCollection +import org.junit.Assert +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.BeforeClass +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.pgpainless.PGPainless +import org.pgpainless.key.protection.KeyRingProtectionSettings +import org.pgpainless.key.protection.PasswordBasedSecretKeyRingProtector +import org.pgpainless.key.util.KeyRingUtils +import org.pgpainless.util.Passphrase +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.* + +/** + * @author Denis Bondarenko + * Date: 5/12/21 + * Time: 9:28 AM + * E-mail: DenBond7@gmail.com + */ +class PgpDecryptTest { + + @get:Rule + val temporaryFolder: TemporaryFolder = TemporaryFolder() + + @Test + @Ignore("need to add realization") + fun testDecryptJustSignedFile() { + + } + + @Test + fun testDecryptArmoredFileSuccess() { + testDecryptFileSuccess(true) + } + + @Test + fun testDecryptBinaryFileSuccess() { + testDecryptFileSuccess(false) + } + + @Test + @Ignore("need to add realization") + fun testDecryptionErrorKeyMismatch() { + + } + + @Test + fun testDecryptionErrorWrongPassphrase() { + val srcFile = temporaryFolder.createFileWithRandomData(fileSizeInBytes = FileUtils.ONE_MB) + val sourceBytes = srcFile.readBytes() + val outputStreamForEncryptedSource = ByteArrayOutputStream() + PgpEncrypt.encryptAndOrSign( + srcInputStream = ByteArrayInputStream(sourceBytes), + destOutputStream = outputStreamForEncryptedSource, + pgpPublicKeyRingCollection = PGPPublicKeyRingCollection( + listOf( + KeyRingUtils.publicKeyRingFrom(senderPGPSecretKeyRing), + KeyRingUtils.publicKeyRingFrom(receiverPGPSecretKeyRing) + ) + ), + doArmor = false + ) + + val encryptedBytes = outputStreamForEncryptedSource.toByteArray() + val outputStreamWithDecryptedData = ByteArrayOutputStream() + val exception = Assert.assertThrows(DecryptionException::class.java) { + PgpDecrypt.decrypt( + srcInputStream = ByteArrayInputStream(encryptedBytes), + destOutputStream = outputStreamWithDecryptedData, + pgpSecretKeyRingCollection = PGPSecretKeyRingCollection(listOf(receiverPGPSecretKeyRing)), + protector = PasswordBasedSecretKeyRingProtector.forKey( + receiverPGPSecretKeyRing, + Passphrase.fromPassword(UUID.randomUUID().toString()) + ) + ) + } + + Assert.assertEquals( + exception.decryptionErrorType, + PgpDecrypt.DecryptionErrorType.WRONG_PASSPHRASE + ) + } + + @Test + @Ignore("need to add realization") + fun testDecryptionErrorNoMdc() { + + } + + @Test + @Ignore("need to add realization") + fun testDecryptionErrorBadMdc() { + + } + + @Test + @Ignore("need to add realization") + fun testDecryptionErrorFormat() { + + } + + @Test + @Ignore("need to add realization") + fun testDecryptionErrorOther() { + + } + + @Test + fun testPatternToDetectEncryptedAtts() { + //"(?i)(\\.pgp$)|(\\.gpg$)|(\\.[a-zA-Z0-9]{3,4}\\.asc$)" + assertNotNull(PgpDecrypt.DETECT_SEPARATE_ENCRYPTED_ATTACHMENTS_PATTERN.find("file.pgp")) + assertNotNull(PgpDecrypt.DETECT_SEPARATE_ENCRYPTED_ATTACHMENTS_PATTERN.find("file.PgP")) + assertNotNull(PgpDecrypt.DETECT_SEPARATE_ENCRYPTED_ATTACHMENTS_PATTERN.find("file.gpg")) + assertNotNull(PgpDecrypt.DETECT_SEPARATE_ENCRYPTED_ATTACHMENTS_PATTERN.find("file.gPg")) + assertNotNull(PgpDecrypt.DETECT_SEPARATE_ENCRYPTED_ATTACHMENTS_PATTERN.find("d.fs12.asc")) + assertNotNull(PgpDecrypt.DETECT_SEPARATE_ENCRYPTED_ATTACHMENTS_PATTERN.find("d.fs12.ASC")) + assertNotNull(PgpDecrypt.DETECT_SEPARATE_ENCRYPTED_ATTACHMENTS_PATTERN.find("d.s12.asc")) + assertNotNull(PgpDecrypt.DETECT_SEPARATE_ENCRYPTED_ATTACHMENTS_PATTERN.find("d.ft2.ASC")) + assertNull(PgpDecrypt.DETECT_SEPARATE_ENCRYPTED_ATTACHMENTS_PATTERN.find("filepgp")) + assertNull(PgpDecrypt.DETECT_SEPARATE_ENCRYPTED_ATTACHMENTS_PATTERN.find("filePgP")) + assertNull(PgpDecrypt.DETECT_SEPARATE_ENCRYPTED_ATTACHMENTS_PATTERN.find("filegpg")) + assertNull(PgpDecrypt.DETECT_SEPARATE_ENCRYPTED_ATTACHMENTS_PATTERN.find("filegPg")) + assertNull(PgpDecrypt.DETECT_SEPARATE_ENCRYPTED_ATTACHMENTS_PATTERN.find("d.fs12asc")) + assertNull(PgpDecrypt.DETECT_SEPARATE_ENCRYPTED_ATTACHMENTS_PATTERN.find("d.fs12ASC")) + assertNull(PgpDecrypt.DETECT_SEPARATE_ENCRYPTED_ATTACHMENTS_PATTERN.find("d.s12asc")) + assertNull(PgpDecrypt.DETECT_SEPARATE_ENCRYPTED_ATTACHMENTS_PATTERN.find("d.ft2ASC")) + } + + private fun testDecryptFileSuccess(shouldSrcBeArmored: Boolean) { + val srcFile = temporaryFolder.createFileWithRandomData(fileSizeInBytes = FileUtils.ONE_MB) + val sourceBytes = srcFile.readBytes() + val outputStreamForEncryptedSource = ByteArrayOutputStream() + PgpEncrypt.encryptAndOrSign( + srcInputStream = ByteArrayInputStream(sourceBytes), + destOutputStream = outputStreamForEncryptedSource, + pgpPublicKeyRingCollection = PGPPublicKeyRingCollection( + listOf( + KeyRingUtils.publicKeyRingFrom(senderPGPSecretKeyRing), + KeyRingUtils.publicKeyRingFrom(receiverPGPSecretKeyRing) + ) + ), + doArmor = shouldSrcBeArmored + ) + + val encryptedBytes = outputStreamForEncryptedSource.toByteArray() + val outputStreamWithDecryptedData = ByteArrayOutputStream() + PgpDecrypt.decrypt( + srcInputStream = ByteArrayInputStream(encryptedBytes), + destOutputStream = outputStreamWithDecryptedData, + pgpSecretKeyRingCollection = PGPSecretKeyRingCollection(listOf(receiverPGPSecretKeyRing)), + protector = allPredefinedKeysProtector + ) + + val decryptedBytesForSender = outputStreamWithDecryptedData.toByteArray() + Assert.assertArrayEquals(sourceBytes, decryptedBytesForSender) + } + + companion object { + private lateinit var senderPGPSecretKeyRing: PGPSecretKeyRing + private lateinit var receiverPGPSecretKeyRing: PGPSecretKeyRing + private lateinit var allPredefinedKeysProtector: PasswordBasedSecretKeyRingProtector + + private const val SENDER_PASSWORD = "qwerty1234" + private const val RECEIVER_PASSWORD = "password1234" + + @BeforeClass + @JvmStatic + fun setUp() { + senderPGPSecretKeyRing = PGPainless.generateKeyRing() + .simpleEcKeyRing("sender@flowcrypt.test", SENDER_PASSWORD) + receiverPGPSecretKeyRing = PGPainless.generateKeyRing() + .simpleEcKeyRing("receiver@flowcrypt.test", RECEIVER_PASSWORD) + + allPredefinedKeysProtector = PasswordBasedSecretKeyRingProtector( + KeyRingProtectionSettings.secureDefaultSettings() + ) { keyId -> + senderPGPSecretKeyRing.publicKeys.forEach { publicKey -> + if (publicKey.keyID == keyId) { + return@PasswordBasedSecretKeyRingProtector Passphrase.fromPassword(SENDER_PASSWORD) + } + } + receiverPGPSecretKeyRing.publicKeys.forEach { publicKey -> + if (publicKey.keyID == keyId) { + return@PasswordBasedSecretKeyRingProtector Passphrase.fromPassword(RECEIVER_PASSWORD) + } + } + return@PasswordBasedSecretKeyRingProtector null + } + } + } +} diff --git a/FlowCrypt/src/test/java/com/flowcrypt/email/security/pgp/PgpMsgTest.kt b/FlowCrypt/src/test/java/com/flowcrypt/email/security/pgp/PgpMsgTest.kt index 2ac6b2f0e2..8f3502afe4 100644 --- a/FlowCrypt/src/test/java/com/flowcrypt/email/security/pgp/PgpMsgTest.kt +++ b/FlowCrypt/src/test/java/com/flowcrypt/email/security/pgp/PgpMsgTest.kt @@ -7,11 +7,10 @@ package com.flowcrypt.email.security.pgp import org.bouncycastle.openpgp.PGPSecretKeyRing -import org.junit.Test import org.junit.Assert.assertTrue +import org.junit.Test import org.pgpainless.PGPainless import org.pgpainless.util.Passphrase -import java.lang.IllegalArgumentException import java.nio.charset.Charset import java.nio.charset.StandardCharsets @@ -148,7 +147,7 @@ class PgpMsgTest { val r = processMessage("decrypt - [security] mdc - missing - error") assertTrue("Message is returned when should not", r.content == null) assertTrue("Error not returned", r.error != null) - assertTrue("Missing MDC not detected", r.error!!.type == PgpMsg.DecryptionErrorType.NO_MDC) + assertTrue("Missing MDC not detected", r.error!!.type == PgpDecrypt.DecryptionErrorType.NO_MDC) } @Test // ok @@ -156,7 +155,7 @@ class PgpMsgTest { val r = processMessage("decrypt - [security] mdc - modification detected - error") assertTrue("Message is returned when should not", r.content == null) assertTrue("Error not returned", r.error != null) - assertTrue("Bad MDC not detected", r.error!!.type == PgpMsg.DecryptionErrorType.BAD_MDC) + assertTrue("Bad MDC not detected", r.error!!.type == PgpDecrypt.DecryptionErrorType.BAD_MDC) } // TODO: Should there be any error? @@ -195,7 +194,7 @@ class PgpMsgTest { assertTrue("Error not returned", r.error != null) assertTrue( "Wrong passphrase not detected", - r.error!!.type == PgpMsg.DecryptionErrorType.WRONG_PASSPHRASE + r.error!!.type == PgpDecrypt.DecryptionErrorType.WRONG_PASSPHRASE ) } @@ -212,7 +211,7 @@ class PgpMsgTest { assertTrue("Error not returned", r.error != null) assertTrue( "Missing passphrase not detected", - r.error!!.type == PgpMsg.DecryptionErrorType.NEED_PASSPHRASE + r.error!!.type == PgpDecrypt.DecryptionErrorType.NEED_PASSPHRASE ) } @@ -227,7 +226,7 @@ class PgpMsgTest { assertTrue("Error not returned", r.error != null) assertTrue( "Key mismatch not detected", - r.error!!.type == PgpMsg.DecryptionErrorType.KEY_MISMATCH + r.error!!.type == PgpDecrypt.DecryptionErrorType.KEY_MISMATCH ) }