Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
Return-Path: <denbond7@flowcrypt.test>
Delivered-To: default@flowcrypt.test
Message-ID: <signed-plaintext-unsigned-html-alt@flowcrypt.test>
Date: Sat, 20 Jun 2026 12:00:00 +0000
MIME-Version: 1.0
To: default@flowcrypt.test
From: DenBond7 <denbond7@flowcrypt.test>
Subject: Signed plaintext alternative, unsigned HTML alternative
Content-Type: multipart/alternative; boundary="fc-alt-signed-plain-unsigned-html"

--fc-alt-signed-plain-unsigned-html
Content-Type: text/plain; charset=UTF-8; format=flowed
Content-Transfer-Encoding: 7bit

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512

It's a cleartext signed message
-----BEGIN PGP SIGNATURE-----
Version: PGPainless

iHUEARYKACcFAmLs1kUJEMMgic1q+NbOFiEEwWQo1gHLOTeJz2HUwyCJzWr41s4A
AG8wAP44Q3Zzr7sDM9V34A3CdDxMjUFSJcOsPZUuUi1kIZzv6wD/cxdthjQpC7uN
Hwvo1OGuQBE7TENCj1cW4P9AdOFxSAQ=
=McWq
-----END PGP SIGNATURE-----

--fc-alt-signed-plain-unsigned-html
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 7bit

<html>
<body>
<h1>Unsigned HTML rendered by the message view</h1>
<p>This visible HTML is not covered by the PGP signature in the text/plain alternative.</p>
<p>Example impact text: please approve the new payment destination ATTACKER-ACCOUNT.</p>
</body>
</html>

--fc-alt-signed-plain-unsigned-html--
Original file line number Diff line number Diff line change
Expand Up @@ -997,6 +997,40 @@ class MessageDetailsFlowTest : BaseMessageDetailsFlowTest() {
)
}

@Test
fun testPrioritizesSignedPlainTextOverUnsignedHtmlAlternative() {
PrivateKeysManager.savePubKeyToDatabase("pgp/denbond7@flowcrypt.test_pub_primary.asc")

val msgInfo = getMsgInfo(
path = "messages/info/standard_msg_info_plaintext.json",
mimeMsgPath = "messages/mime/signed-plaintext-unsigned-html-alternative.eml",
accountEntity = addAccountToDatabaseRule.accountEntityWithDecryptedInfo
)
baseCheck(msgInfo, checkWebContent = false)

onWebView(withId(R.id.emailWebView)).forceJavascriptEnabled()
onWebView(withId(R.id.emailWebView))
.check(
webContent(
elementByXPath(
"/html/body",
withTextContent(
allOf(
containsString("It's a cleartext signed message"),
not(containsString("ATTACKER-ACCOUNT"))
)
)
)
)
)

testPgpBadges(
2,
PgpBadgeListAdapter.PgpBadge.Type.NOT_ENCRYPTED,
PgpBadgeListAdapter.PgpBadge.Type.SIGNED
)
}

@Test
fun testSignatureVerificationCleartextOnlySignedPartially() {
PrivateKeysManager.savePubKeyToDatabase("pgp/denbond7@flowcrypt.test_pub_primary.asc")
Expand Down
208 changes: 149 additions & 59 deletions FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpMsg.kt
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.nio.charset.StandardCharsets
import java.util.IdentityHashMap
import java.util.Properties
import kotlin.random.Random

Expand Down Expand Up @@ -835,51 +836,59 @@ object PgpMsg {
var isPartialSigned = false
val verifiedSignatures = mutableListOf<SignatureVerification>()
val keyIdOfSigningKeys = mutableSetOf<Long>()
val alternativeContentResolver = AlternativeContentResolver()

filterBlocksViaTree(msgBlocks.toList()) { innerBlock ->
innerBlock.type in MsgBlock.Type.SIGNED_BLOCK_TYPES
}.forEach { pgpBlock ->
analyzeBlockForPgp(pgpBlock) { hasEncryptedContent, _, _, _, _ ->
if (!isEncrypted) {
isEncrypted = hasEncryptedContent
}
}
}

for (block in msgBlocks) {
// We don't need Base64 correction here, fromAttachment() does this for us
// We also seem to don't need to make correction between raw and utf8
// But I'd prefer MsgBlock.content to be ByteArray
// So, at least meanwhile, not porting this:
// block.content = isContentBlock(block.type)
// ? block.content.toUtfStr() : block.content.toRawBytesStr();
val displayedBlocks = alternativeContentResolver.getDisplayedBlocks(msgBlocks.toList())
displayedBlocks.filter { innerBlock ->
innerBlock.type in MsgBlock.Type.SIGNED_BLOCK_TYPES
}.forEach { pgpBlock ->
analyzeBlockForPgp(pgpBlock) { _,
hasSignedContent,
hasInvalidSignatures,
keyIdsOfSigningKeys,
verifiedSignaturesList ->
if (hasSignedContent) {
signedBlockCount++
}

filterBlocksViaTree(listOf(block)) { innerBlock ->
innerBlock.type in MsgBlock.Type.SIGNED_BLOCK_TYPES
}.forEach { pgpBlock ->
analyzeBlockForPgp(pgpBlock) { hasEncryptedContent,
hasSignedContent,
hasInvalidSignatures,
keyIdsOfSigningKeys,
verifiedSignaturesList ->
if (!isEncrypted) {
isEncrypted = hasEncryptedContent
}
if (!hasBadSignatures) {
hasBadSignatures = hasInvalidSignatures
}

if (hasSignedContent) {
signedBlockCount++
}
keyIdOfSigningKeys.addAll(keyIdsOfSigningKeys)

if (!hasBadSignatures) {
hasBadSignatures = hasInvalidSignatures
if (verifiedSignatures.isEmpty()) {
verifiedSignatures.addAll(verifiedSignaturesList)
} else {
val keyIdsOfAllVerifiedSignatures = verifiedSignatures.map { it.signingKey.keyId }
val keyIdsOfCurrentVerifiedSignatures = verifiedSignaturesList.map {
it.signingKey.keyId
}

keyIdOfSigningKeys.addAll(keyIdsOfSigningKeys)

if (verifiedSignatures.isEmpty()) {
if (keyIdsOfAllVerifiedSignatures != keyIdsOfCurrentVerifiedSignatures) {
hasMixedSignatures = true
verifiedSignatures.addAll(verifiedSignaturesList)
} else {
val keyIdsOfAllVerifiedSignatures = verifiedSignatures.map { it.signingKey.keyId }
val keyIdsOfCurrentVerifiedSignatures = verifiedSignaturesList.map {
it.signingKey.keyId
}
if (keyIdsOfAllVerifiedSignatures != keyIdsOfCurrentVerifiedSignatures) {
hasMixedSignatures = true
verifiedSignatures.addAll(verifiedSignaturesList)
}
}
}
}
}

for (block in msgBlocks) {
// We don't need Base64 correction here, fromAttachment() does this for us
// We also seem to don't need to make correction between raw and utf8
// But I'd prefer MsgBlock.content to be ByteArray
// So, at least meanwhile, not porting this:
// block.content = isContentBlock(block.type)
// ? block.content.toUtfStr() : block.content.toRawBytesStr();

when {
block is DecryptedAndOrSignedContentMsgBlock -> {
Expand All @@ -903,11 +912,14 @@ object PgpMsg {
}
}

val fmtRes = prepareFormattedContentBlock(contentBlocks)
val fmtRes = prepareFormattedContentBlock(
allContentBlocks = contentBlocks,
alternativeContentResolver = alternativeContentResolver
)
resultBlocks.add(0, fmtRes.contentBlock)

if (signedBlockCount > 0 &&
signedBlockCount != msgBlocks.filter { it.type != MsgBlock.Type.ENCRYPTED_SUBJECT }.size
signedBlockCount != displayedBlocks.count { it.type != MsgBlock.Type.ENCRYPTED_SUBJECT }
) {
isPartialSigned = true
}
Expand Down Expand Up @@ -1243,13 +1255,13 @@ object PgpMsg {

private fun prepareFormattedContentBlock(
allContentBlocks: List<MsgBlock>,
alternativeContentResolver: AlternativeContentResolver,
stripHtmlRootTags: Boolean = false
): FormattedContentBlockResult {
val inlineImagesByCid = mutableMapOf<String, MsgBlock>()
Comment thread
DenBond7 marked this conversation as resolved.
val imagesAtTheBottom = mutableListOf<MsgBlock>()
val plainImageBlocks = filterBlocksViaTree(allContentBlocks) {
MimeUtils.isPlainImgAtt(it)
}
val plainImageBlocks =
alternativeContentResolver.getInlineImageBlocksForRendering(allContentBlocks)
for (plainImageBlock in plainImageBlocks) {
var contentId = (plainImageBlock as AttMsgBlock).attMeta.contentId ?: ""
if (contentId.isNotEmpty()) {
Expand Down Expand Up @@ -1282,32 +1294,21 @@ object PgpMsg {
for (block in allContentBlocks.filterNot { MimeUtils.isPlainImgAtt(it) }) {
when (block) {
is AlternativeContentMsgBlock -> {
if (block.plainBlocks.size > 1) {
val alternativeContentSelection = alternativeContentResolver.select(block)
if (alternativeContentSelection.usePlainVersionForRendering) {
prepareFormattedContentBlock(
allContentBlocks = block.plainBlocks,
allContentBlocks = alternativeContentSelection.displayedBlocks,
alternativeContentResolver = alternativeContentResolver,
stripHtmlRootTags = true
).apply {
msgContentAsHtml.append(contentBlock.content)
msgContentAsText.append(text).append('\n')
}

//we skip otherBlocks if we have more than one plain block
//we skip otherBlocks if the plain version was selected for rendering
continue
} else {
val singlePlainBlock = block.plainBlocks.first()
val singlePlainVersionHasDecryptedContent =
singlePlainBlock is DecryptedAndOrSignedContentMsgBlock
if (singlePlainVersionHasDecryptedContent) {
prepareFormattedContentBlock(
allContentBlocks = singlePlainBlock.blocks,
stripHtmlRootTags = true
).apply {
msgContentAsHtml.append(contentBlock.content)
msgContentAsText.append(text).append('\n')
}
//we skip otherBlocks if plain version has decrypted content
continue
} else {
block.plainBlocks.firstOrNull()?.let { singlePlainBlock ->
collectDataFromMsgBlock(
block = singlePlainBlock,
useHtml = false,
Expand All @@ -1316,7 +1317,7 @@ object PgpMsg {
}
}

val htmlVersionBlock = block.otherBlocks.firstOrNull()
val htmlVersionBlock = alternativeContentSelection.displayedBlocks.firstOrNull()
htmlVersionBlock?.let { htmlBlock ->
collectDataFromMsgBlock(
block = htmlBlock,
Expand All @@ -1329,6 +1330,7 @@ object PgpMsg {
is DecryptedAndOrSignedContentMsgBlock -> {
prepareFormattedContentBlock(
allContentBlocks = block.blocks,
alternativeContentResolver = alternativeContentResolver,
stripHtmlRootTags = true
).apply {
msgContentAsHtml.append(contentBlock.content)
Expand Down Expand Up @@ -1413,6 +1415,89 @@ object PgpMsg {
}
}

private class AlternativeContentResolver {
private val selectionCache =
IdentityHashMap<AlternativeContentMsgBlock, AlternativeContentSelection>()

fun getDisplayedBlocks(blocks: List<MsgBlock>): List<MsgBlock> =
blocks.flatMap { block ->
if (block is AlternativeContentMsgBlock) {
getDisplayedBlocks(select(block).displayedBlocks)
} else {
listOf(block)
}
}

fun select(block: AlternativeContentMsgBlock): AlternativeContentSelection =
selectionCache.getOrPut(block) {
selectNotCached(block)
}

fun getInlineImageBlocksForRendering(blocks: List<MsgBlock>): List<MsgBlock> =
blocks.flatMap { block ->
when {
block is AlternativeContentMsgBlock -> {
val selection = select(block)
val blocksToInspect = when {
hasSignedDisplayedContent(selection.displayedBlocks) -> {
// Do not mix unsigned images from a rejected alternative into signed content.
selection.displayedBlocks + block.otherBlocks.drop(1).filter {
MimeUtils.isPlainImgAtt(it) && it.isOpenPGPMimeSigned
}
}

selection.usePlainVersionForRendering -> block.allBlocks
else -> block.otherBlocks
}
getInlineImageBlocksForRendering(blocksToInspect)
}

MimeUtils.isPlainImgAtt(block) -> listOf(block)
else -> emptyList()
}
}

private fun hasSignedDisplayedContent(blocks: List<MsgBlock>): Boolean =
getDisplayedBlocks(blocks).any {
it.type in MsgBlock.Type.SIGNED_BLOCK_TYPES || it.isOpenPGPMimeSigned
}

private fun selectNotCached(
block: AlternativeContentMsgBlock
): AlternativeContentSelection {
val hasSignedPlainBlocks = hasSignedDisplayedContent(block.plainBlocks)
val hasSignedDisplayedOtherBlock = hasSignedDisplayedContent(block.otherBlocks.take(1))

return when {
hasSignedPlainBlocks && !hasSignedDisplayedOtherBlock -> AlternativeContentSelection(
displayedBlocks = block.plainBlocks,
usePlainVersionForRendering = true
)

block.plainBlocks.size > 1 -> AlternativeContentSelection(
displayedBlocks = block.plainBlocks,
usePlainVersionForRendering = true
)

block.plainBlocks.singleOrNull() is DecryptedAndOrSignedContentMsgBlock ->
AlternativeContentSelection(
displayedBlocks = block.plainBlocks,
usePlainVersionForRendering = true
)

block.otherBlocks.isNotEmpty() -> AlternativeContentSelection(
displayedBlocks = listOf(block.otherBlocks.first()),
usePlainVersionForRendering = false
)

else -> AlternativeContentSelection(
displayedBlocks = block.plainBlocks,
usePlainVersionForRendering = true
)
}
}
}

private fun handleMsgBlock(
block: MsgBlock,
inlineImagesByCid: MutableMap<String, MsgBlock>,
Expand Down Expand Up @@ -1751,6 +1836,11 @@ object PgpMsg {
val contentBlock: MsgBlock
)

private data class AlternativeContentSelection(
val displayedBlocks: List<MsgBlock>,
val usePlainVersionForRendering: Boolean
)

private enum class FrameColor {
GREEN,
GRAY,
Expand Down
Loading
Loading