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 5d7e9c23f9..1fc789b32f 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 @@ -7,6 +7,7 @@ package com.flowcrypt.email.security.pgp import android.content.Context import android.util.Base64 +import androidx.core.util.PatternsCompat import com.flowcrypt.email.api.email.JavaEmailConstants import com.flowcrypt.email.api.retrofit.response.model.AttMeta import com.flowcrypt.email.api.retrofit.response.model.AttMsgBlock @@ -793,15 +794,12 @@ object PgpMsg { return decryptedContent.replace(FC_REPLY_TOKEN_REGEX, "") } - fun stripPublicKeys(decryptedContent: String, foundPublicKeys: MutableList): String { - val normalizedTextAndBlocks = RawBlockParser.detectBlocks(decryptedContent) - for (block in normalizedTextAndBlocks) { - if (block.type == RawBlockParser.RawBlockType.PGP_PUBLIC_KEY && block.content.isNotEmpty()) { - val content = block.content - foundPublicKeys.add(String(content)) - } + private fun extractPublicKeysIfFound(decryptedContent: String): List { + return RawBlockParser.detectBlocks(decryptedContent).filter { + it.type == RawBlockParser.RawBlockType.PGP_PUBLIC_KEY && it.content.isNotEmpty() + }.map { + String(it.content) } - return decryptedContent } private fun processExtractedMsgBlocks( @@ -1312,7 +1310,7 @@ object PgpMsg { private fun buildQuotes(originalContent: String, unwrapContent: Boolean = true): Element? { val content = if (unwrapContent) { //remove > at the beginning of all lines to define next quotes level - val patternQuotesSign = "^>([^\\S\\r\\n])?".toRegex(RegexOption.MULTILINE) + val patternQuotesSign = "^(\\s)?>([^\\S\\r\\n])?".toRegex(RegexOption.MULTILINE) originalContent.replace(patternQuotesSign, "") } else { originalContent @@ -1321,9 +1319,9 @@ object PgpMsg { val newLineStringPattern = "\\r\\n|\\r|\\n" val beforeQuotesHeaderStringPattern = "^.*:($newLineStringPattern){1,2}" val patternQuotes = (if (unwrapContent) { - "(^>.*\$($newLineStringPattern))+" + "(^(\\s)?>.*\$($newLineStringPattern)?)+" } else { - "($beforeQuotesHeaderStringPattern)(^>.*\$($newLineStringPattern))+" + "($beforeQuotesHeaderStringPattern)(^(\\s)?>.*\$($newLineStringPattern)?)+" }).toRegex(RegexOption.MULTILINE) val tagDiv = "div" val tagBlockquote = "blockquote" @@ -1354,7 +1352,10 @@ object PgpMsg { //for better UI experience we need to extract the quote header of the first quote //and add it separately val quotesHeader = - quotes.replace("(^>.*\$($newLineStringPattern))+".toRegex(RegexOption.MULTILINE), "") + quotes.replace( + "(^(\\s)?>.*\$($newLineStringPattern)?)+".toRegex(RegexOption.MULTILINE), + "" + ) append(prepareHtmlFromGivenText(quotesHeader)) appendChild( @@ -1379,8 +1380,9 @@ object PgpMsg { private fun prepareHtmlFromGivenText(content: String): String { val newLineStringPattern = "\\r\\n|\\r|\\n" val patternNewLine = "($newLineStringPattern)".toRegex() - val patternEscapedEmailAddress = "<(\\S+@\\S+)>".toRegex() - val emailAddressReplacement = "\$1" + val emailAddressPattern = PatternsCompat.EMAIL_ADDRESS.pattern() + val patternEscapedEmailAddress = "(<|<)?($emailAddressPattern)(>|>)?".toRegex() + val emailAddressReplacement = "\$1\$2\$4" val br = "
" return Entities //escape given text to fit HTML standard @@ -1501,11 +1503,10 @@ object PgpMsg { private fun fmtDecryptedAsSanitizedHtmlBlocks(decryptedContent: ByteArray?): Collection { if (decryptedContent == null) return emptyList() val blocks = mutableListOf() - val armoredKeys = mutableListOf() - val content = stripPublicKeys( - stripFcReplyToken(extractFcAttachments(String(decryptedContent), blocks)), - armoredKeys - ).toEscapedHtml() + val strippedContent = stripFcReplyToken(extractFcAttachments(String(decryptedContent), blocks)) + val armoredKeys = extractPublicKeysIfFound(strippedContent) + val content = + checkAndReturnQuotesFormatIfFound(strippedContent) ?: strippedContent.toEscapedHtml() blocks.add( MsgBlockFactory.fromContent( MsgBlock.Type.DECRYPTED_HTML, diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/util/BetterInternetAddress.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/util/BetterInternetAddress.kt index 2311f0dc5f..9524ce0a4d 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/util/BetterInternetAddress.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/util/BetterInternetAddress.kt @@ -1,7 +1,6 @@ /* - * © 2021-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com - * Contributors: - * Ivan Pizhenko + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: denbond7 */ package com.flowcrypt.email.util 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 8b8cc7b8f6..b10ca31641 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 @@ -742,6 +742,90 @@ class PgpMsgTest { assertTrue(quotes[2].text().startsWith("1 The Android build system")) } + @Test + fun testQuotesParsingAndHtmlManipulationForEncryptedMessages() { + val mimeMessageRaw = """ + Date: Tue, 4 Mar 2025 15:14:00 +0200 (GMT+02:00) + From: default@flowcrypt.test + To: default@flowcrypt.test + Message-ID: <255254603.1.1741101240147@flowcrypt.test> + Subject: TEst + Mime-Version: 1.0 + Content-Type: multipart/mixed; + boundary="----=_Part_0_124883294.1741101240093" + + ------=_Part_0_124883294.1741101240093 + Content-Type: text/plain; charset=us-ascii + Content-Transfer-Encoding: 7bit + + -----BEGIN PGP MESSAGE----- + Version: PGPainless + + wV4DTxRYvSK3u1MSAQdA0rQBDv6Qe3gj8IWoEkn0r6W7+Uz/zyz0YI6DCLA2h3Ew + bM+5OX93DKqTkaLWbV0VcuN4ACPOb+4nyWIhb/lQq468FO7y2rqMFah0LcTJfTWr + wV4DTxRYvSK3u1MSAQdA/iyqemW8rY+ka18ANSph7TD8u3INnwT6Wt5BhcHzOTQw + hPcE7cge7naa351khAsVRMgXZS4jzxR0WQw3E/truKHcprpCaQis0dgsiTxURTm+ + 0sCWAdMfNsQa1GUZVredhzYCVRg6iiieU1k42vhwxYe8GMO/s+aWQgwR2EhenqKz + 0utgBCc1uP0o0fzIKdyirdCmlrSwk1yAhiU1/mR0p7Yl29vDdhRWjz7UEV1nfbwg + 1CAh7028G0YTFItwnPCCYDYYP1R0pPSffnuVV/BLuEfk0JiXUaa1ar73v5Sepfkm + RKWX0PuM0IIY2+d78pCfr4puci2qwMGDX9hdTvp+qk+QolpFnVP7YUlDZTu5N8Q7 + GYrz0YJBXCvrkCNbu5UcssTRNyYyHB7sDT+XD+kE3uKcMat1cl190Gm6WFYB5I8x + H3jhdxj8HyrazAyI3Lra/rtLIRg0zFbQ8Q7nb96gAzi3KU0gTdXPJzbMetlwF0cT + Q027sUsEQ4L3PBVFNy6SYRRsQiS3o3CVaRkRlOEqsl+ix2S8bASh9bIl7Ode8+jz + l8r4umM+zyWe + =HJxV + -----END PGP MESSAGE----- + + ------=_Part_0_124883294.1741101240093-- + """.trimIndent() + + val privateKey = "-----BEGIN PGP PRIVATE KEY BLOCK-----\n" + + "Version: PGPainless\n" + + "\n" + + "lIYEYIq7phYJKwYBBAHaRw8BAQdAat45rrh+gvQwWwJw5eScq3Pdxt/8d+lWNVSm\n" + + "kImXcRP+CQMCvWfx3mzDdd5g6c59LcPqADK0p70/7ZmTkp3ZC1YViTprg4tQt/PF\n" + + "QJL+VPCG+BF9bWyFcfxKe+KAnXRTWml5O6xrv6ZkiNmAxoYyO1shzLQWZGVmYXVs\n" + + "dEBmbG93Y3J5cHQudGVzdIh4BBMWCgAgBQJgirumAhsDBRYCAwEABAsJCAcFFQoJ\n" + + "CAsCHgECGQEACgkQIl+AI8INCVcysgD/cu23M07rImuV5gIl98uOnSIR+QnHUD/M\n" + + "I34b7iY/iTQBALMIsqO1PwYl2qKwmXb5lSoMj5SmnzRRE2RwAFW3AiMCnIsEYIq7\n" + + "phIKKwYBBAGXVQEFAQEHQA8q7iPr+0OXqBGBSAL6WNDjzHuBsG7uiu5w8l/A6v8l\n" + + "AwEIB/4JAwK9Z/HebMN13mCOF6Wy/9oZK4d0DW9cNLuQDeRVZejxT8oFMm7G8iGw\n" + + "CGNjIWWcQSvctBZtHwgcMeplCW7tmzkD3Nq/ty50lCwQQd6gZSXMiHUEGBYKAB0F\n" + + "AmCKu6YCGwwFFgIDAQAECwkIBwUVCgkICwIeAQAKCRAiX4Ajwg0JV+sbAQCv4LVM\n" + + "0+AN54ivWa4vPRyYOfSQ1FqsipkYLJce+xwUeAD+LZpEVCypFtGWQVdeSJVxIHx3\n" + + "k40IfHsK0fGgR+NrRAw=\n" + + "=osuI\n" + + "-----END PGP PRIVATE KEY BLOCK-----" + + val secretKeyRing = PgpKey.extractSecretKeyRing(privateKey) + val processedMimeMessageResult = runBlocking { + PgpMsg.processMimeMessage( + MimeMessage(Session.getInstance(Properties()), mimeMessageRaw.toInputStream()), + PGPPublicKeyRingCollection(listOf()), + PGPSecretKeyRingCollection(listOf(secretKeyRing)), + SecretKeyRingProtector.unlockAnyKeyWith(Passphrase.fromPassword("android")), + ) + } + + assertEquals(1, processedMimeMessageResult.blocks.size) + + val plainHtmlBlock = processedMimeMessageResult.blocks.first { + it.type == MsgBlock.Type.PLAIN_HTML + } + + val document = Jsoup.parse(requireNotNull(plainHtmlBlock.content), "", Parser.xmlParser()) + assertNotNull(document.select("details").first()) + assertEquals(1, document.select("details").size) + assertNotNull(document.select("summary").first()) + assertEquals(1, document.select("summary").size) + + val quotes = document.select("blockquote") + assertEquals(3, quotes.size) + assertTrue(quotes[0].text().startsWith("Sender 2")) + assertTrue(quotes[1].text().startsWith("Reply 1")) + assertTrue(quotes[2].text().startsWith("Sender 1")) + } + private data class RenderedBlock( val rendered: Boolean, val frameColor: String?,