From 0c43360e52b151e050376592bb492e49fd366eba Mon Sep 17 00:00:00 2001 From: Ivan Pizhenko Date: Sun, 4 Jul 2021 20:32:22 +0300 Subject: [PATCH 1/6] issue #1057 Parsing/decrypting of complex PGP messages in Kotlin --- FlowCrypt/build.gradle | 2 + .../retrofit/response/model/node/AttMeta.kt | 30 +- .../response/model/node/AttMsgBlock.kt | 11 + .../model/node/DecryptErrorMsgBlock.kt | 11 +- .../model/node/DecryptedAttMsgBlock.kt | 4 +- .../model/node/EncryptedAttLinkMsgBlock.kt | 42 + .../model/node/EncryptedAttMsgBlock.kt | 6 +- .../retrofit/response/model/node/MsgBlock.kt | 11 +- .../response/model/node/MsgBlockFactory.kt | 40 +- .../response/model/node/PlainAttMsgBlock.kt | 4 +- .../{SignedBlock.kt => SignedMsgBlock.kt} | 2 +- .../com/flowcrypt/email/core/msg/MimeUtils.kt | 51 + .../email/core/msg/MsgBlockParser.kt | 97 +- .../extensions/java/io/InputStreamExt.kt | 32 + .../MimePartExt.kt} | 8 +- .../email/extensions/kotlin/ByteExt.kt | 6 + .../email/extensions/kotlin/StringExt.kt | 118 ++- .../org/owasp/html/HtmlPolicyBuilderExt.kt | 21 + .../flowcrypt/email/security/pgp/PgpArmor.kt | 2 +- .../flowcrypt/email/security/pgp/PgpKey.kt | 97 +- .../flowcrypt/email/security/pgp/PgpMsg.kt | 928 ++++++++++++++++-- .../email/core/msg/MsgBlockParserTest.kt | 4 +- .../email/security/pgp/PgpKeyTest.kt | 6 +- .../email/security/pgp/PgpMsgTest.kt | 203 +++- .../flowcrypt/email/security/pgp/TestKeys.kt | 66 +- ...direct-encrypted-pgpmime-special-chars.txt | 38 + .../compat/direct-encrypted-pgpmime.txt | 36 + .../direct-encrypted-text-special-chars.txt | 15 + .../compat/direct-encrypted-text.txt | 14 + .../mime-email-encrypted-inline-pgpmime.txt | 48 + .../mime-email-encrypted-inline-text-2.txt | 45 + .../mime-email-encrypted-inline-text.txt | 26 + .../compat/mime-email-plain-html.txt | 12 + .../compat/mime-email-plain-with-pubkey.txt | 47 + .../PgpMsgTest/compat/mime-email-plain.txt | 14 + .../other/plain-inline-image-html-content.txt | 1 + .../PgpMsgTest/other/plain-inline-image.txt | 37 + .../signed-message-preserve-newlines.txt | 28 + 38 files changed, 1987 insertions(+), 176 deletions(-) create mode 100644 FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/AttMsgBlock.kt create mode 100644 FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/EncryptedAttLinkMsgBlock.kt rename FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/{SignedBlock.kt => SignedMsgBlock.kt} (98%) create mode 100644 FlowCrypt/src/main/java/com/flowcrypt/email/core/msg/MimeUtils.kt create mode 100644 FlowCrypt/src/main/java/com/flowcrypt/email/extensions/java/io/InputStreamExt.kt rename FlowCrypt/src/main/java/com/flowcrypt/email/extensions/javax/mail/{BodyPartExt.kt => internet/MimePartExt.kt} (61%) create mode 100644 FlowCrypt/src/main/java/com/flowcrypt/email/extensions/org/owasp/html/HtmlPolicyBuilderExt.kt create mode 100644 FlowCrypt/src/test/resources/PgpMsgTest/compat/direct-encrypted-pgpmime-special-chars.txt create mode 100644 FlowCrypt/src/test/resources/PgpMsgTest/compat/direct-encrypted-pgpmime.txt create mode 100644 FlowCrypt/src/test/resources/PgpMsgTest/compat/direct-encrypted-text-special-chars.txt create mode 100644 FlowCrypt/src/test/resources/PgpMsgTest/compat/direct-encrypted-text.txt create mode 100644 FlowCrypt/src/test/resources/PgpMsgTest/compat/mime-email-encrypted-inline-pgpmime.txt create mode 100644 FlowCrypt/src/test/resources/PgpMsgTest/compat/mime-email-encrypted-inline-text-2.txt create mode 100644 FlowCrypt/src/test/resources/PgpMsgTest/compat/mime-email-encrypted-inline-text.txt create mode 100644 FlowCrypt/src/test/resources/PgpMsgTest/compat/mime-email-plain-html.txt create mode 100644 FlowCrypt/src/test/resources/PgpMsgTest/compat/mime-email-plain-with-pubkey.txt create mode 100644 FlowCrypt/src/test/resources/PgpMsgTest/compat/mime-email-plain.txt create mode 100644 FlowCrypt/src/test/resources/PgpMsgTest/other/plain-inline-image-html-content.txt create mode 100644 FlowCrypt/src/test/resources/PgpMsgTest/other/plain-inline-image.txt create mode 100644 FlowCrypt/src/test/resources/PgpMsgTest/other/signed-message-preserve-newlines.txt diff --git a/FlowCrypt/build.gradle b/FlowCrypt/build.gradle index add4efa3c6..bdcf65110a 100644 --- a/FlowCrypt/build.gradle +++ b/FlowCrypt/build.gradle @@ -456,6 +456,8 @@ dependencies { implementation 'net.openid:appauth:0.9.1' implementation 'org.bitbucket.b_c:jose4j:0.7.8' implementation 'me.everything:overscroll-decor-android:1.1.0' + implementation 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20200713.1' + implementation 'org.jsoup:jsoup:1.13.1' implementation ('org.pgpainless:pgpainless-core:0.2.3') { //exclude group: 'org.bouncycastle' because we will specify it manually diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/AttMeta.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/AttMeta.kt index 67d6ec8d17..e0fc9125f8 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/AttMeta.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/AttMeta.kt @@ -1,6 +1,8 @@ /* * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com - * Contributors: DenBond7 + * Contributors: + * DenBond7 + * Ivan Pizhenko */ package com.flowcrypt.email.api.retrofit.response.model.node @@ -8,6 +10,7 @@ package com.flowcrypt.email.api.retrofit.response.model.node import android.os.Parcel import android.os.Parcelable import com.google.gson.annotations.Expose +import org.json.JSONObject /** * @author Denis Bondarenko @@ -19,14 +22,27 @@ data class AttMeta( @Expose val name: String?, @Expose var data: String?, @Expose val length: Long, - @Expose val type: String? + @Expose val type: String?, + @Expose val contentId: String?, + @Expose val url: String? = null ) : Parcelable { + constructor(source: JSONObject) : this( + name = if (source.has("name")) source.getString("name") else null, + data = if (source.has("data")) source.getString("data") else null, + length = if (source.has("size")) source.getLong("size") else 0L, + type = if (source.has("type")) source.getString("type") else null, + contentId = if (source.has("contentId")) source.getString("contentId") else null, + url = if (source.has("url")) source.getString("url") else null + ) + constructor(source: Parcel) : this( - source.readString(), - source.readString(), - source.readLong(), - source.readString() + name = source.readString(), + data =source.readString(), + length = source.readLong(), + type = source.readString(), + contentId = source.readString(), + url = source.readString() ) override fun describeContents() = 0 @@ -36,6 +52,8 @@ data class AttMeta( writeString(data) writeLong(length) writeString(type) + writeString(contentId) + writeString(url) } companion object { diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/AttMsgBlock.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/AttMsgBlock.kt new file mode 100644 index 0000000000..374364b4f3 --- /dev/null +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/AttMsgBlock.kt @@ -0,0 +1,11 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: + * Ivan Pizhenko + */ + +package com.flowcrypt.email.api.retrofit.response.model.node + +interface AttMsgBlock : MsgBlock { + val attMeta: AttMeta +} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/DecryptErrorMsgBlock.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/DecryptErrorMsgBlock.kt index 00eb5a73de..c97267ebaf 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/DecryptErrorMsgBlock.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/DecryptErrorMsgBlock.kt @@ -7,6 +7,7 @@ package com.flowcrypt.email.api.retrofit.response.model.node import android.os.Parcel import android.os.Parcelable +import com.flowcrypt.email.security.pgp.PgpMsg import com.google.gson.annotations.Expose import com.google.gson.annotations.SerializedName @@ -19,7 +20,9 @@ import com.google.gson.annotations.SerializedName data class DecryptErrorMsgBlock( @Expose override val content: String?, @Expose override val complete: Boolean, - @SerializedName("decryptErr") @Expose val error: DecryptError? + @SerializedName("decryptErr") @Expose val error: DecryptError?, + // TODO: remove above and change name of below when finally dropping Node + @SerializedName("kotlinDecryptErr") @Expose val kotlinError: PgpMsg.DecryptionError? = null ) : MsgBlock { @Expose @@ -28,7 +31,8 @@ data class DecryptErrorMsgBlock( constructor(source: Parcel) : this( source.readString(), 1 == source.readInt(), - source.readParcelable(DecryptError::class.java.classLoader) + source.readParcelable(DecryptError::class.java.classLoader), + source.readParcelable(PgpMsg.DecryptionError::class.java.classLoader) ) override fun describeContents(): Int { @@ -39,8 +43,9 @@ data class DecryptErrorMsgBlock( with(dest) { writeParcelable(type, flags) writeString(content) - writeInt((if (complete) 1 else 0)) + writeInt(if (complete) 1 else 0) writeParcelable(error, flags) + writeParcelable(kotlinError, flags) } companion object { diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/DecryptedAttMsgBlock.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/DecryptedAttMsgBlock.kt index 37fe0be83b..1c001e7d2e 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/DecryptedAttMsgBlock.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/DecryptedAttMsgBlock.kt @@ -20,9 +20,9 @@ import com.google.gson.annotations.SerializedName data class DecryptedAttMsgBlock( @Expose override val content: String?, @Expose override val complete: Boolean, - @Expose val attMeta: AttMeta, + @Expose override val attMeta: AttMeta, @SerializedName("decryptErr") @Expose val error: DecryptError? -) : MsgBlock { +) : AttMsgBlock { var fileUri: Uri? = null diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/EncryptedAttLinkMsgBlock.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/EncryptedAttLinkMsgBlock.kt new file mode 100644 index 0000000000..c4a8b05556 --- /dev/null +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/EncryptedAttLinkMsgBlock.kt @@ -0,0 +1,42 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: + * Ivan Pizhenko + */ + +package com.flowcrypt.email.api.retrofit.response.model.node + +import android.os.Parcel +import android.os.Parcelable +import com.google.gson.annotations.Expose + +class EncryptedAttLinkMsgBlock( + @Expose override val attMeta: AttMeta, +) : AttMsgBlock { + + @Expose override val content: String = "" + @Expose override val type: MsgBlock.Type = MsgBlock.Type.ENCRYPTED_ATT_LINK + @Expose override val complete: Boolean = true + + constructor(source: Parcel) : this( + source.readParcelable(AttMeta::class.java.classLoader)!! + ) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeParcelable(attMeta, flags) + } + + override fun describeContents(): Int { + return 0 + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): EncryptedAttLinkMsgBlock { + return EncryptedAttLinkMsgBlock(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } +} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/EncryptedAttMsgBlock.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/EncryptedAttMsgBlock.kt index 403cdeb4f3..d1945a0568 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/EncryptedAttMsgBlock.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/EncryptedAttMsgBlock.kt @@ -11,8 +11,10 @@ import android.os.Parcel import android.os.Parcelable import com.google.gson.annotations.Expose -data class EncryptedAttMsgBlock(@Expose override val content: String?, - @Expose val attMeta: AttMeta) : MsgBlock { +data class EncryptedAttMsgBlock( + @Expose override val content: String?, + @Expose override val attMeta: AttMeta +) : AttMsgBlock { var fileUri: Uri? = null diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/MsgBlock.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/MsgBlock.kt index 9adb8cfc21..612ea84634 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/MsgBlock.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/MsgBlock.kt @@ -76,7 +76,10 @@ interface MsgBlock : Parcelable { SIGNED_TEXT, @SerializedName("signedHtml") - SIGNED_HTML; + SIGNED_HTML, + + @SerializedName("verifiedMsg") + VERIFIED_MSG; override fun describeContents(): Int { return 0 @@ -86,6 +89,8 @@ interface MsgBlock : Parcelable { dest.writeInt(ordinal) } + fun isContentBlockType() : Boolean = contentBlockTypes.contains(this) + companion object { @JvmField val CREATOR: Parcelable.Creator = object : Parcelable.Creator { @@ -105,6 +110,10 @@ interface MsgBlock : Parcelable { val signedBlocks = setOf(SIGNED_TEXT, SIGNED_HTML, SIGNED_MSG) + val contentBlockTypes = setOf( + PLAIN_TEXT, PLAIN_HTML, DECRYPTED_TEXT, DECRYPTED_HTML, SIGNED_MSG, VERIFIED_MSG + ) + fun ofSerializedName(serializedName: String): Type { for (v in values()) { val field = Type::class.java.getField(v.name) diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/MsgBlockFactory.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/MsgBlockFactory.kt index 7067a71f32..215fce74e1 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/MsgBlockFactory.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/MsgBlockFactory.kt @@ -6,9 +6,15 @@ package com.flowcrypt.email.api.retrofit.response.model.node import android.os.Parcel +import com.flowcrypt.email.extensions.java.io.toBase64EncodedString +import com.flowcrypt.email.extensions.kotlin.toInputStream +import com.flowcrypt.email.extensions.org.bouncycastle.openpgp.toPgpKeyDetails +import org.bouncycastle.openpgp.jcajce.JcaPGPPublicKeyRingCollection +import java.io.InputStream +import java.util.Base64 +import javax.mail.internet.MimePart object MsgBlockFactory { - @JvmStatic val supportedMsgBlockTypes = listOf( MsgBlock.Type.PUBLIC_KEY, MsgBlock.Type.DECRYPT_ERROR, @@ -18,7 +24,6 @@ object MsgBlockFactory { MsgBlock.Type.SIGNED_TEXT ) - @JvmStatic fun fromParcel(type: MsgBlock.Type, source: Parcel): MsgBlock { return when (type) { MsgBlock.Type.PUBLIC_KEY -> PublicKeyMsgBlock(source) @@ -26,13 +31,12 @@ object MsgBlockFactory { MsgBlock.Type.DECRYPTED_ATT -> DecryptedAttMsgBlock(source) MsgBlock.Type.ENCRYPTED_ATT -> EncryptedAttMsgBlock(source) MsgBlock.Type.SIGNED_TEXT, MsgBlock.Type.SIGNED_HTML, MsgBlock.Type.SIGNED_MSG -> { - SignedBlock(source) + SignedMsgBlock(source) } else -> GenericMsgBlock(type, source) } } - @JvmStatic fun fromContent( type: MsgBlock.Type, content: String?, @@ -41,20 +45,38 @@ object MsgBlockFactory { ): MsgBlock { val complete = !missingEnd return when (type) { - MsgBlock.Type.PUBLIC_KEY -> PublicKeyMsgBlock(content, complete, null) + MsgBlock.Type.PUBLIC_KEY -> { + val keyDetails = if (content != null && complete) { + JcaPGPPublicKeyRingCollection(content.toInputStream()).keyRings.next().toPgpKeyDetails() + } else null + PublicKeyMsgBlock(content, complete, keyDetails) + } MsgBlock.Type.DECRYPT_ERROR -> DecryptErrorMsgBlock(content, complete, null) MsgBlock.Type.SIGNED_TEXT -> { - SignedBlock(SignedBlock.Type.SIGNED_TEXT, content, complete, signature) + SignedMsgBlock(SignedMsgBlock.Type.SIGNED_TEXT, content, complete, signature) } MsgBlock.Type.SIGNED_HTML -> { - SignedBlock(SignedBlock.Type.SIGNED_HTML, content, complete, signature) + SignedMsgBlock(SignedMsgBlock.Type.SIGNED_HTML, content, complete, signature) } else -> GenericMsgBlock(type, content, complete) } } - @JvmStatic - fun fromAttachment(type: MsgBlock.Type, content: String?, attMeta: AttMeta): MsgBlock { + fun fromAttachment(type: MsgBlock.Type, attachment: MimePart): MsgBlock { + val attContent = attachment.content + val data: String? = when (attContent) { + is String -> Base64.getEncoder().encodeToString(attachment.inputStream.readBytes()) + is InputStream -> attContent.toBase64EncodedString() + else -> null + } + val attMeta = AttMeta( + name = attachment.fileName, + data = data, + length = attachment.size.toLong(), + type = attachment.contentType, + contentId = attachment.contentID + ) + val content = if (attContent is String) attachment.content as String else null return when (type) { MsgBlock.Type.DECRYPTED_ATT -> DecryptedAttMsgBlock(content, true, attMeta, null) MsgBlock.Type.ENCRYPTED_ATT -> EncryptedAttMsgBlock(content, attMeta) diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/PlainAttMsgBlock.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/PlainAttMsgBlock.kt index 43552b7850..9833814bf8 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/PlainAttMsgBlock.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/PlainAttMsgBlock.kt @@ -13,8 +13,8 @@ import com.google.gson.annotations.Expose data class PlainAttMsgBlock( @Expose override val content: String?, - @Expose val attMeta: AttMeta -) : MsgBlock { + @Expose override val attMeta: AttMeta +) : AttMsgBlock { var fileUri: Uri? = null diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/SignedBlock.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/SignedMsgBlock.kt similarity index 98% rename from FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/SignedBlock.kt rename to FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/SignedMsgBlock.kt index 0e4820416f..ad06c38dcc 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/SignedBlock.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/SignedMsgBlock.kt @@ -13,7 +13,7 @@ import com.google.gson.annotations.Expose /** * Message block which represents content with a signature. */ -data class SignedBlock( +data class SignedMsgBlock( @Expose val signedType: Type, @Expose override val content: String?, @Expose override val complete: Boolean, diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/core/msg/MimeUtils.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/core/msg/MimeUtils.kt new file mode 100644 index 0000000000..e23a92160e --- /dev/null +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/core/msg/MimeUtils.kt @@ -0,0 +1,51 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: + * Ivan Pizhenko + */ + +package com.flowcrypt.email.core.msg + +import com.flowcrypt.email.api.retrofit.response.model.node.MsgBlock +import com.flowcrypt.email.api.retrofit.response.model.node.PlainAttMsgBlock +import com.flowcrypt.email.extensions.kotlin.toInputStream +import java.nio.charset.StandardCharsets +import java.util.Locale +import java.util.Properties +import javax.mail.Session +import javax.mail.internet.MimeMessage + +object MimeUtils { + fun resemblesMsg(msg: ByteArray?): Boolean { + if (msg == null) return false + val firstChars = msg.copyOfRange(0, msg.size.coerceAtMost(1000)) + .toString(StandardCharsets.US_ASCII) + .toLowerCase(Locale.ROOT) + val contentType = contentTypeRegex.find(firstChars) ?: return false + return contentTransferEncodingRegex.containsMatchIn(firstChars) + || contentDispositionRegex.containsMatchIn(firstChars) + || firstChars.contains(kBoundary1) + || firstChars.contains(kCharset) + || (contentType.range.first == 0 && firstChars.contains(kBoundary2)) + } + + fun isPlainImgAtt(block: MsgBlock): Boolean { + return (block is PlainAttMsgBlock) + && block.attMeta.type != null + && imageContentTypes.contains(block.attMeta.type) + } + + fun mimeTextToMimeMessage(mimeText: String) : MimeMessage { + return MimeMessage(Session.getInstance(Properties()), mimeText.toInputStream()) + } + + private val imageContentTypes = setOf( + "image/jpeg", "image/jpg", "image/bmp", "image/png", "image/svg+xml" + ) + private val contentTypeRegex = Regex("content-type: +[0-9a-z\\-/]+") + private val contentTransferEncodingRegex = Regex("content-transfer-encoding: +[0-9a-z\\-/]+") + private val contentDispositionRegex = Regex("content-disposition: +[0-9a-z\\-/]+") + private const val kBoundary1 = "; boundary=" + private const val kBoundary2 = "boundary=" + private const val kCharset = "; charset=" +} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/core/msg/MsgBlockParser.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/core/msg/MsgBlockParser.kt index 886bd5fb06..92a376ce41 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/core/msg/MsgBlockParser.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/core/msg/MsgBlockParser.kt @@ -1,33 +1,42 @@ /* * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com - * Contributors: Ivan Pizhenko + * Contributors: + * Ivan Pizhenko */ package com.flowcrypt.email.core.msg import com.flowcrypt.email.api.retrofit.response.model.node.MsgBlock import com.flowcrypt.email.api.retrofit.response.model.node.MsgBlockFactory +import com.flowcrypt.email.extensions.kotlin.toEscapedHtml import com.flowcrypt.email.extensions.kotlin.normalize import com.flowcrypt.email.security.pgp.PgpArmor +import com.flowcrypt.email.security.pgp.PgpMsg +import java.util.Properties +import javax.mail.Session +import javax.mail.internet.MimeMessage @Suppress("unused") object MsgBlockParser { private const val ARMOR_HEADER_MAX_LENGTH = 50 - @JvmStatic - fun detectBlocks(text: String): List { + data class NormalizedTextAndBlocks( + val normalized: String, + val blocks: List + ) + + fun detectBlocks(text: String): NormalizedTextAndBlocks { val normalized = text.normalize() val blocks = mutableListOf() var startAt = 0 while (true) { val continueAt = detectNextBlock(normalized, startAt, blocks) - if (startAt >= continueAt) return blocks + if (startAt >= continueAt) return NormalizedTextAndBlocks(normalized, blocks) startAt = continueAt } } - @JvmStatic private fun detectNextBlock(text: String, startAt: Int, blocks: MutableList): Int { val initialBlockCount = blocks.size var continueAt = -1 @@ -35,10 +44,8 @@ object MsgBlockParser { PgpArmor.ARMOR_HEADER_DICT[MsgBlock.Type.UNKNOWN]!!.begin, startAt ) if (beginIndex != -1) { // found - val potentialHeaderBegin = text.substring( - beginIndex, - (beginIndex + ARMOR_HEADER_MAX_LENGTH).coerceAtMost(text.length) - ) + val endIndex = (beginIndex + ARMOR_HEADER_MAX_LENGTH).coerceAtMost(text.length) + val potentialHeaderBegin = text.substring(beginIndex, endIndex) for (blockHeaderKvp in PgpArmor.ARMOR_HEADER_DICT) { val blockHeaderDef = blockHeaderKvp.value if (!blockHeaderDef.replace || potentialHeaderBegin.indexOf(blockHeaderDef.begin) != 0) { @@ -70,17 +77,12 @@ object MsgBlockParser { if (endHeaderIndex != -1) { // identified end of the same block continueAt = endHeaderIndex + endHeaderLength - blocks.add( - MsgBlockFactory.fromContent( - blockHeaderKvp.key, - text.substring(beginIndex, continueAt).trim() - ) - ) + val content = text.substring(beginIndex, continueAt).trim() + blocks.add(MsgBlockFactory.fromContent(blockHeaderKvp.key, content)) } else { // corresponding end not found - blocks.add( - MsgBlockFactory.fromContent(blockHeaderKvp.key, text.substring(beginIndex), true) - ) + val content = text.substring(beginIndex) + blocks.add(MsgBlockFactory.fromContent(blockHeaderKvp.key, content, true)) } break } @@ -96,4 +98,63 @@ object MsgBlockParser { return continueAt } + + data class SanitizedBlocks( + val blocks: List, + val subject: String?, + val isRichText: Boolean + ) + + fun fmtDecryptedAsSanitizedHtmlBlocks(decryptedContent: ByteArray?): SanitizedBlocks { + if (decryptedContent == null) return SanitizedBlocks(emptyList(), null, false) + val blocks = mutableListOf() + if (MimeUtils.resemblesMsg(decryptedContent)) { + val decoded = PgpMsg.decodeMimeMessage( + MimeMessage(Session.getInstance(Properties()), decryptedContent.inputStream()) + ) + var isRichText = false + when { + decoded.html != null -> { + // sanitized html + val sanitizedHtml = PgpMsg.sanitizeHtmlKeepBasicTags(decoded.html) + blocks.add(MsgBlockFactory.fromContent(MsgBlock.Type.DECRYPTED_HTML, sanitizedHtml)) + isRichText = true + } + decoded.text != null -> { + // escaped text as html + val html = decoded.text.toEscapedHtml() + blocks.add(MsgBlockFactory.fromContent(MsgBlock.Type.DECRYPTED_HTML, html)) + } + else -> { + // escaped mime text as html + val html = String(decryptedContent).toEscapedHtml() + blocks.add(MsgBlockFactory.fromContent(MsgBlock.Type.DECRYPTED_HTML, html)) + } + } + + for (attachment in decoded.attachments) { + blocks.add( + if (PgpMsg.treatAs(attachment) == PgpMsg.TreatAs.PUBLIC_KEY) { + val content = String(attachment.inputStream.readBytes()) + MsgBlockFactory.fromContent(MsgBlock.Type.PUBLIC_KEY, content) + } else { + MsgBlockFactory.fromAttachment(MsgBlock.Type.DECRYPTED_ATT, attachment) + } + ) + } + + return SanitizedBlocks(blocks, decoded.subject, isRichText) + } else { + val armoredKeys = mutableListOf() + val content = PgpMsg.stripPublicKeys( + PgpMsg.stripFcReplyToken(PgpMsg.extractFcAttachments(String(decryptedContent), blocks)), + armoredKeys + ).toEscapedHtml() + blocks.add(MsgBlockFactory.fromContent(MsgBlock.Type.DECRYPTED_HTML, content)) + for (armoredKey in armoredKeys) { + blocks.add(MsgBlockFactory.fromContent(MsgBlock.Type.PUBLIC_KEY, armoredKey)) + } + return SanitizedBlocks(blocks, null, false) + } + } } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/java/io/InputStreamExt.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/java/io/InputStreamExt.kt new file mode 100644 index 0000000000..0ee2f57ce7 --- /dev/null +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/java/io/InputStreamExt.kt @@ -0,0 +1,32 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: + * Ivan Pizhenko + */ + +package com.flowcrypt.email.extensions.java.io + +import java.io.BufferedInputStream +import java.io.InputStream +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import java.util.Base64 + +fun InputStream.readText(charset: Charset = StandardCharsets.UTF_8): String + = String(readBytes(), charset) + +// See https://stackoverflow.com/a/39099064/1540501 +fun InputStream.toBase64EncodedString(): String { + val bufferSize = 3 * 1024 + BufferedInputStream(this, bufferSize).use { stream -> + val encoder = Base64.getEncoder() + val result = StringBuilder() + val chunk = ByteArray(bufferSize) + var len = 0 + while (stream.read(chunk).also { len = it } == bufferSize) { + result.append(encoder.encodeToString(chunk)) + } + if (len > 0) result.append(encoder.encodeToString(chunk.copyOf(len))) + return result.toString() + } +} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/javax/mail/BodyPartExt.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/javax/mail/internet/MimePartExt.kt similarity index 61% rename from FlowCrypt/src/main/java/com/flowcrypt/email/extensions/javax/mail/BodyPartExt.kt rename to FlowCrypt/src/main/java/com/flowcrypt/email/extensions/javax/mail/internet/MimePartExt.kt index 31e6ba5243..01e1192ce3 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/javax/mail/BodyPartExt.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/javax/mail/internet/MimePartExt.kt @@ -1,15 +1,15 @@ /* * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com * Contributors: - * Ivan Pizhenko + * Ivan Pizhenko */ -package com.flowcrypt.email.extensions.javax.mail +package com.flowcrypt.email.extensions.javax.mail.internet -import javax.mail.BodyPart import javax.mail.MessagingException +import javax.mail.internet.MimePart -fun BodyPart.hasFileName(): Boolean { +fun MimePart.hasFileName(): Boolean { return try { this.fileName != null } catch (ex: MessagingException) { diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/kotlin/ByteExt.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/kotlin/ByteExt.kt index b3f1cb35ce..f364798461 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/kotlin/ByteExt.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/kotlin/ByteExt.kt @@ -10,6 +10,7 @@ object ByteExtHelper { const val space = ' '.toByte() const val cr = '\r'.toByte() const val lf = '\n'.toByte() + const val hexTable = "0123456789ABCDEF" } val Byte.isLineEnding: Boolean @@ -21,3 +22,8 @@ val Byte.isWhiteSpace: Boolean get() { return isLineEnding || this == ByteExtHelper.tab || this == ByteExtHelper.space } + +fun Byte.toUrlHex(): String { + return "%${ByteExtHelper.hexTable[this.toInt() and 15]}" + + "${ByteExtHelper.hexTable[(this.toInt() shr 4) and 15]}" +} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/kotlin/StringExt.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/kotlin/StringExt.kt index add95444dd..9681b6f83b 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/kotlin/StringExt.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/kotlin/StringExt.kt @@ -1,26 +1,32 @@ /* * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com - * Contributors: Ivan Pizhenko + * Contributors: + * Ivan Pizhenko */ package com.flowcrypt.email.extensions.kotlin -object StringExtensionsHelper { - @JvmStatic - val char160 = 160.toChar() - - @JvmStatic - val dashesRegex = Regex("^—–|—–$") -} +import org.json.JSONObject +import java.io.InputStream +import java.io.UnsupportedEncodingException +import java.net.URLDecoder +import java.net.URLEncoder +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import java.util.Base64 fun String.normalizeDashes(): String { - return this.replace(StringExtensionsHelper.dashesRegex, "-----") + return this.replace(dashesRegex, "-----") } +private val dashesRegex = Regex("^—–|—–$") + fun String.normalizeSpaces(): String { - return this.replace(StringExtensionsHelper.char160, ' ') + return this.replace(char160, ' ') } +private const val char160 = 160.toChar() + fun String.normalize(): String { return this.normalizeSpaces().normalizeDashes() } @@ -52,3 +58,95 @@ fun String.normalizeEol(): String { fun String.removeUtf8Bom(): String { return if (this.startsWith("\uFEFF")) this.substring(1) else this } + +fun String.toEscapedHtml(): String { + return this.replace("&", "&") + .replace("\"", """) + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + .replace("/", "/") + .replace("\n", "
") +} + +fun String.unescapeHtml(): String { + // Comment from Typescript: + // the   at the end is replaced with an actual NBSP character, not a space character. + // IDE won't show you the difference. Do not change. + return replace("/", "/") + .replace(""", "\"") + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") + .replace(" ", " ") +} + +fun String.escapeHtmlAttr(): String { + return replace("&", "&") + .replace("\"", """) + .replace("'", "'") + .replace("<", "<") + .replace("<", ">") + .replace("/", "/") +} + +fun String.stripHtmlRootTags(): String { + // Typescript comment: todo - this is very rudimentary, use a proper parser + return replace(htmlTagRegex, "") // remove opening and closing html tags + .replace(headSectionRegex, "") // remove the whole head section + .replace(bodyTagRegex, "") // remove opening and closing body tags + .trim() +} + +private val htmlTagRegex = Regex("]*>") +private val headSectionRegex = Regex("]*>.*") +private val bodyTagRegex = Regex("]*>") + +fun String.decodeFcHtmlAttr(): JSONObject? { + return try { + JSONObject(decodeBase64Url()) + } catch (e: Exception) { + e.printStackTrace() + null + } +} + +fun String.decodeBase64Url(): String { + return this.replace('+', '-').replace('_', '/').decodeBase64() + .joinToString { it.toUrlHex() }.decodeUriComponent() +} + +fun String.decodeBase64(): ByteArray { + return Base64.getDecoder().decode(this) +} + +// see https://stackoverflow.com/a/611117/1540501 +fun String.decodeUriComponent(): String { + return try { + URLDecoder.decode(this, "UTF-8") + } catch (e: UnsupportedEncodingException) { + // should never happen + throw IllegalStateException(e) + } +} + +// see https://stackoverflow.com/a/611117/1540501 +fun String.encodeUriComponent(): String { + return try { + URLEncoder.encode(this, "UTF-8") + .replace("+", "%20") + .replace("%21", "!") + .replace("%27", "'") + .replace("%28", "(") + .replace("%29", ")") + .replace("%7E", "~") + } catch (e: UnsupportedEncodingException) { + // should never happen + throw IllegalStateException(e) + } +} + +fun String.toInputStream(charset: Charset = StandardCharsets.UTF_8): InputStream { + return toByteArray(charset).inputStream() +} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/org/owasp/html/HtmlPolicyBuilderExt.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/org/owasp/html/HtmlPolicyBuilderExt.kt new file mode 100644 index 0000000000..a2f119e09f --- /dev/null +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/org/owasp/html/HtmlPolicyBuilderExt.kt @@ -0,0 +1,21 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: + * Ivan Pizhenko + */ + +package com.flowcrypt.email.extensions.org.owasp.html + +import org.owasp.html.HtmlPolicyBuilder + +// NOTE: This can't be just allowAttributesOnElements() because this would interfere +// with the same named private method in the HtmlPolicyBuilder +fun HtmlPolicyBuilder.allowAttributesOnElementsExt( + elementsToAttributesMap: Map> +): HtmlPolicyBuilder { + var builder = this + for (elementAttrs in elementsToAttributesMap) { + builder = builder.allowAttributes(*elementAttrs.value).onElements(elementAttrs.key) + } + return builder +} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpArmor.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpArmor.kt index 95f39cb192..f0609ed1df 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpArmor.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpArmor.kt @@ -159,7 +159,7 @@ object PgpArmor { @Suppress("ArrayInDataClass") data class CleartextSignedMessage( val content: ByteArrayOutputStream, - val signature: Any? + val signature: String? ) // Based on this example: diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpKey.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpKey.kt index e63f7c0843..19abbb6bac 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpKey.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpKey.kt @@ -5,14 +5,26 @@ package com.flowcrypt.email.security.pgp +import com.flowcrypt.email.api.retrofit.response.model.node.MsgBlock +import com.flowcrypt.email.extensions.kotlin.toInputStream import com.flowcrypt.email.extensions.org.bouncycastle.openpgp.armor import com.flowcrypt.email.extensions.org.bouncycastle.openpgp.toPgpKeyDetails +import org.bouncycastle.bcpg.ArmoredInputStream import org.bouncycastle.openpgp.PGPKeyRing +import org.bouncycastle.openpgp.PGPObjectFactory +import org.bouncycastle.openpgp.PGPPublicKey +import org.bouncycastle.openpgp.PGPPublicKeyRing +import org.bouncycastle.openpgp.PGPSecretKey import org.bouncycastle.openpgp.PGPSecretKeyRing +import org.bouncycastle.openpgp.PGPSignature +import org.bouncycastle.openpgp.jcajce.JcaPGPPublicKeyRingCollection +import org.bouncycastle.openpgp.jcajce.JcaPGPSecretKeyRingCollection +import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator import org.pgpainless.PGPainless import org.pgpainless.key.collection.PGPKeyRingCollection import org.pgpainless.util.Passphrase import java.io.InputStream +import java.nio.charset.StandardCharsets @Suppress("unused") object PgpKey { @@ -47,7 +59,7 @@ object PgpKey { } fun parseKeys(source: String, throwExceptionIfUnknownSource: Boolean = true): ParseKeyResult { - return parseKeys(source.toByteArray().inputStream(), throwExceptionIfUnknownSource) + return parseKeys(source.toInputStream(), throwExceptionIfUnknownSource) } fun parseKeys(source: ByteArray, throwExceptionIfUnknownSource: Boolean = true): ParseKeyResult { @@ -94,7 +106,7 @@ object PgpKey { return encryptKey(decryptKey(key, oldPassphrase), newPassphrase) } - private fun extractSecretKeyRing(armored: String): PGPSecretKeyRing { + fun extractSecretKeyRing(armored: String): PGPSecretKeyRing { val parseKeyResult = parseKeys(armored) if (parseKeyResult.getAllKeys().isEmpty()) { throw IllegalArgumentException("Keys not found") @@ -112,4 +124,85 @@ object PgpKey { fun toPgpKeyDetailsList() = getAllKeys().map { it.toPgpKeyDetails() } } + + // Restored here some previous code. Not sure if PGPainless can help with this. + fun parseAndNormalizeKeyRings(armored: String): List { + val normalizedArmored = PgpArmor.normalize(armored, MsgBlock.Type.UNKNOWN) + val keys = mutableListOf() + if (PgpArmor.ARMOR_HEADER_DICT_REGEX[MsgBlock.Type.PUBLIC_KEY]!! + .beginRegexp.containsMatchIn(normalizedArmored) + ) { + // In BC 1.69 the order of keys is finally correct, so no need to use reflection + val keyRingCollection = JcaPGPPublicKeyRingCollection( + ArmoredInputStream(normalizedArmored.toByteArray(StandardCharsets.UTF_8).inputStream()) + ) + keys.addAll(keyRingCollection) + } else if (PgpArmor.ARMOR_HEADER_DICT_REGEX[MsgBlock.Type.PRIVATE_KEY]!! + .beginRegexp.containsMatchIn(normalizedArmored) + ) { + // In BC 1.69 the order of keys is finally correct, so no need to use reflection + val keyRingCollection = JcaPGPSecretKeyRingCollection( + ArmoredInputStream(normalizedArmored.toByteArray(StandardCharsets.UTF_8).inputStream()) + ) + keys.addAll(keyRingCollection) + } else if (PgpArmor.ARMOR_HEADER_DICT_REGEX[MsgBlock.Type.ENCRYPTED_MSG]!! + .beginRegexp.containsMatchIn(normalizedArmored) + ) { + val objectFactory = PGPObjectFactory( + ArmoredInputStream(normalizedArmored.toByteArray(StandardCharsets.UTF_8).inputStream()), + JcaKeyFingerprintCalculator() + ) + while (true) { + val obj = objectFactory.nextObject() ?: break + if (obj is PGPKeyRing) { + keys.add(obj) + } + } + } + + // Prevent key bloat by removing all non-self certifications + for ((keyRingIndex, keyRing) in keys.withIndex()) { + val primaryKeyID = keyRing.publicKey.keyID + if (keyRing is PGPPublicKeyRing) { + var replacementKeyRing: PGPPublicKeyRing = keyRing + for (publicKey in keyRing.publicKeys) { + var replacementKey = publicKey + for (sig in publicKey.signatures.asSequence().map { it as PGPSignature }.filter { + it.isCertification && it.keyID != primaryKeyID + }) { + replacementKey = PGPPublicKey.removeCertification(replacementKey, sig) + } + if (replacementKey !== publicKey) { + replacementKeyRing = PGPPublicKeyRing.insertPublicKey( + replacementKeyRing, replacementKey + ) + } + } + if (replacementKeyRing !== keyRing) { + keys[keyRingIndex] = replacementKeyRing + } + } else if (keyRing is PGPSecretKeyRing) { + var replacementKeyRing: PGPSecretKeyRing = keyRing + for (secretKey in keyRing.secretKeys) { + val publicKey = secretKey.publicKey + var replacementPublicKey = publicKey + for (sig in publicKey.signatures.asSequence().map { it as PGPSignature }.filter { + it.isCertification && it.keyID != primaryKeyID + }) { + replacementPublicKey = PGPPublicKey.removeCertification(replacementPublicKey, sig) + } + if (replacementPublicKey !== publicKey) { + val replacementKey = PGPSecretKey.replacePublicKey(secretKey, replacementPublicKey) + replacementKeyRing = PGPSecretKeyRing.insertSecretKey( + replacementKeyRing, replacementKey + ) + } + } + if (replacementKeyRing !== keyRing) { + keys[keyRingIndex] = replacementKeyRing + } + } + } + return keys + } } 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 bf396e9727..efdd0092d6 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 @@ -5,39 +5,65 @@ package com.flowcrypt.email.security.pgp +import android.os.Parcel +import android.os.Parcelable import com.flowcrypt.email.api.email.JavaEmailConstants import com.flowcrypt.email.api.retrofit.response.model.node.AttMeta +import com.flowcrypt.email.api.retrofit.response.model.node.AttMsgBlock +import com.flowcrypt.email.api.retrofit.response.model.node.DecryptErrorMsgBlock +import com.flowcrypt.email.api.retrofit.response.model.node.EncryptedAttLinkMsgBlock +import com.flowcrypt.email.api.retrofit.response.model.node.EncryptedAttMsgBlock import com.flowcrypt.email.api.retrofit.response.model.node.MsgBlock import com.flowcrypt.email.api.retrofit.response.model.node.MsgBlockFactory +import com.flowcrypt.email.api.retrofit.response.model.node.PublicKeyMsgBlock +import com.flowcrypt.email.api.retrofit.response.model.node.SignedMsgBlock +import com.flowcrypt.email.core.msg.MimeUtils import com.flowcrypt.email.core.msg.MsgBlockParser -import com.flowcrypt.email.extensions.javax.mail.hasFileName +import com.flowcrypt.email.extensions.java.io.readText +import com.flowcrypt.email.extensions.javax.mail.internet.hasFileName import com.flowcrypt.email.extensions.javax.mail.isInline +import com.flowcrypt.email.extensions.kotlin.decodeFcHtmlAttr +import com.flowcrypt.email.extensions.kotlin.toEscapedHtml +import com.flowcrypt.email.extensions.kotlin.escapeHtmlAttr +import com.flowcrypt.email.extensions.kotlin.stripHtmlRootTags +import com.flowcrypt.email.extensions.kotlin.unescapeHtml +import com.flowcrypt.email.extensions.org.bouncycastle.openpgp.armor +import com.flowcrypt.email.extensions.org.bouncycastle.openpgp.toPgpKeyDetails +import com.flowcrypt.email.extensions.org.owasp.html.allowAttributesOnElementsExt import com.flowcrypt.email.security.pgp.PgpArmor.ARMOR_HEADER_DICT import org.bouncycastle.bcpg.ArmoredInputStream import org.bouncycastle.bcpg.PacketTags import org.bouncycastle.openpgp.PGPDataValidationException import org.bouncycastle.openpgp.PGPException +import org.bouncycastle.openpgp.PGPKeyRing import org.bouncycastle.openpgp.PGPPublicKeyRingCollection import org.bouncycastle.openpgp.PGPSecretKeyRing import org.bouncycastle.openpgp.PGPSecretKeyRingCollection +import org.json.JSONObject +import org.jsoup.Jsoup +import org.owasp.html.HtmlPolicyBuilder import org.pgpainless.PGPainless import org.pgpainless.exception.MessageNotIntegrityProtectedException import org.pgpainless.exception.ModificationDetectionException import org.pgpainless.key.info.KeyRingInfo import org.pgpainless.key.protection.UnprotectedKeysProtector import org.pgpainless.util.Passphrase +import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.InputStream +import java.io.ObjectInputStream +import java.io.ObjectOutputStream import java.nio.charset.StandardCharsets -import java.util.* +import java.util.Locale import javax.mail.Address -import javax.mail.BodyPart import javax.mail.Message import javax.mail.Multipart import javax.mail.Part import javax.mail.internet.MimeMessage +import javax.mail.internet.MimePart import kotlin.experimental.and import kotlin.math.min +import kotlin.random.Random object PgpMsg { /** @@ -73,7 +99,7 @@ object PgpMsg { // only interested in the first 50 bytes // use ASCII, it never fails String(source.copyOfRange(0, min(50, source.size)), StandardCharsets.US_ASCII).trim() - ) + ).blocks if (blocks.size == 1 && !blocks[0].complete && MsgBlock.Type.wellKnownBlockTypes.contains(blocks[0].type) ) { @@ -98,7 +124,60 @@ object PgpMsg { val type: PgpDecrypt.DecryptionErrorType, val message: String, val cause: Throwable? = null - ) + ) : Parcelable { + constructor(parcel: Parcel) : this( + PgpDecrypt.DecryptionErrorType.valueOf(parcel.readString() ?: ""), + parcel.readString() ?: "", + readCause(parcel) + ) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeString(type.toString()) + parcel.writeString(message) + if (cause == null) { + parcel.writeInt(0) + } else { + val baStream = ByteArrayOutputStream() + ObjectOutputStream(baStream).use { it.writeObject(cause) } + val ba = baStream.toByteArray() + parcel.writeInt(ba.size) + parcel.writeByteArray(ba) + } + } + + override fun describeContents(): Int { + return 0 + } + + companion object { + @JvmField + val CREATOR = object : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): DecryptionError { + return DecryptionError(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + + private fun readCause(parcel: Parcel): Throwable? { + val size = parcel.readInt() + if (size == 0) return null + + val ba = ByteArray(size) + parcel.readByteArray(ba) + + val obj: Any? + ByteArrayInputStream(ba).use { + ObjectInputStream(it).use { it2 -> + obj = it2.readObject() + } + } + return obj as Throwable + } + } + } data class DecryptionResult( // provided if decryption was successful @@ -114,7 +193,7 @@ object PgpMsg { val filename: String? = null, // todo later - signature verification not supported on Android yet - val signature: Any? = null, + val signature: String? = null, // provided if error happens val error: DecryptionError? = null @@ -128,7 +207,7 @@ object PgpMsg { return DecryptionResult(error = DecryptionError(type, message, cause)) } - fun withCleartext(cleartext: ByteArrayOutputStream, signature: Any?): DecryptionResult { + fun withCleartext(cleartext: ByteArrayOutputStream, signature: String?): DecryptionResult { return DecryptionResult(content = cleartext, signature = signature) } @@ -144,11 +223,11 @@ object PgpMsg { ) fun decrypt( - data: ByteArray, + data: ByteArray?, keys: List, - pgpPublicKeyRingCollection: PGPPublicKeyRingCollection? // for verification + pgpPublicKeyRingCollection: PGPPublicKeyRingCollection? = null // for verification ): DecryptionResult { - if (data.isEmpty()) { + if (data == null || data.isEmpty()) { return DecryptionResult.withError( type = PgpDecrypt.DecryptionErrorType.FORMAT, message = "Can't decrypt empty message" @@ -271,7 +350,7 @@ object PgpMsg { @Suppress("ArrayInDataClass") data class MimeContent( - val attachments: List, + val attachments: List, var signature: String?, val subject: String, val html: String?, @@ -285,8 +364,9 @@ object PgpMsg { @Suppress("ArrayInDataClass") data class MimeProcessedMsg( val blocks: List, - val from: Array
?, - val to: Array
? + val from: Array
? = null, + val to: Array
? = null, + val subject: String? = null ) // Typescript: public static decode = async (mimeMsg: Uint8Array): Promise @@ -297,13 +377,13 @@ object PgpMsg { var signature: String? = null var html: StringBuilder? = null var text: StringBuilder? = null - val attachments = mutableListOf() + val attachments = mutableListOf() val stack = ArrayDeque() - stack.push(msg) + stack.addFirst(msg) while (stack.isNotEmpty()) { - val part = stack.pop() + val part = stack.removeFirst() if (part.isMimeType(JavaEmailConstants.MIME_TYPE_MULTIPART)) { // multi-part, break down into separate parts val multipart = part.content as Multipart @@ -313,11 +393,11 @@ object PgpMsg { list.add(multipart.getBodyPart(i)) } for (i in 0 until n) { - stack.push(list[n - i - 1]) + stack.addFirst(list[n - i - 1]) } } else { // single part, analyze content type and extract some data - part as BodyPart + part as MimePart // println("parse: '${part.contentType}' $contentType '${part.fileName}' ${part.size}") when (part.contentType.split(';').first().trim()) { "application/pgp-signature" -> { @@ -326,7 +406,7 @@ object PgpMsg { // this one was not in the Typescript, but I had to add it to pass some tests "message/rfc822" -> { - stack.push(part.content as Part) + stack.addFirst(part.content as Part) } "text/html" -> { @@ -347,7 +427,8 @@ object PgpMsg { } } - "text/rfc822-headers" -> {} // skip + "text/rfc822-headers" -> { + } // skip else -> { attachments.add(part) @@ -355,6 +436,7 @@ object PgpMsg { } } } + return MimeContent( attachments = attachments, signature = signature, @@ -372,17 +454,15 @@ object PgpMsg { fun processDecodedMimeMessage(decoded: MimeContent): MimeProcessedMsg { val blocks = analyzeDecodedTextAndHtml(decoded) var signature: String? = decoded.signature - for (att in decoded.attachments) { var content = att.content // println("att: ${att.contentType} '${att.fileName}' ${att.size} -> $treatedAs") when (treatAs(att)) { - TreatAs.HIDDEN -> {} // ignore + TreatAs.HIDDEN -> { + } // ignore TreatAs.ENCRYPTED_MSG -> { - if (content is InputStream) { - content = String(content.readBytes(), StandardCharsets.US_ASCII) - } + if (content is InputStream) content = content.readText(StandardCharsets.US_ASCII) if (content is String) { val armored = PgpArmor.clip(content) if (armored != null) { @@ -392,69 +472,39 @@ object PgpMsg { } TreatAs.SIGNATURE -> { - if (content is InputStream) { - content = String(content.readBytes(), StandardCharsets.US_ASCII) - } - if (content is String && signature == null) { - signature = content - } + if (content is InputStream) content = content.readText(StandardCharsets.US_ASCII) + if (content is String && signature == null) signature = content } TreatAs.PUBLIC_KEY -> { - if (content is InputStream) { - content = String(content.readBytes(), StandardCharsets.US_ASCII) - } - if (content is String) { - blocks.addAll(MsgBlockParser.detectBlocks(content)) - } + if (content is InputStream) content = content.readText(StandardCharsets.US_ASCII) + if (content is String) blocks.addAll(MsgBlockParser.detectBlocks(content).blocks) } TreatAs.PRIVATE_KEY -> { - if (content is InputStream) { - content = String(content.readBytes(), StandardCharsets.US_ASCII) - } - if (content is String) { - blocks.addAll(MsgBlockParser.detectBlocks(content)) - } + if (content is InputStream) content = content.readText(StandardCharsets.US_ASCII) + if (content is String) blocks.addAll(MsgBlockParser.detectBlocks(content).blocks) } TreatAs.ENCRYPTED_FILE -> { - if (content is String) { - blocks.add( - MsgBlockFactory.fromAttachment( - MsgBlock.Type.ENCRYPTED_ATT, - null, - AttMeta(att.fileName, content, att.size.toLong(), att.contentType) - ) - ) - } + blocks.add(MsgBlockFactory.fromAttachment(MsgBlock.Type.ENCRYPTED_ATT, att)) } TreatAs.PLAIN_FILE -> { - if (content is String) { - blocks.add( - MsgBlockFactory.fromAttachment( - MsgBlock.Type.PLAIN_ATT, - null, - AttMeta(att.fileName, content, att.size.toLong(), att.contentType) - ) - ) - } + blocks.add(MsgBlockFactory.fromAttachment(MsgBlock.Type.PLAIN_ATT, att)) } } } - if (signature != null) { - fixSignedBlocks(blocks, signature) - } + if (signature != null) fixSignedBlocks(blocks, signature) - return MimeProcessedMsg(blocks, decoded.from, decoded.to) + return MimeProcessedMsg(blocks, decoded.from, decoded.to, decoded.subject) } private fun analyzeDecodedTextAndHtml(decoded: MimeContent): MutableList { val blocks = mutableListOf() if (decoded.text != null) { - val blocksFromTextPart = MsgBlockParser.detectBlocks(decoded.text) + val blocksFromTextPart = MsgBlockParser.detectBlocks(decoded.text).blocks val suitableBlock = blocksFromTextPart.firstOrNull { it.type in MsgBlock.Type.wellKnownBlockTypes } @@ -500,12 +550,13 @@ object PgpMsg { signature = signature ) } - else -> {} + else -> { + } } } } - private enum class TreatAs { + enum class TreatAs { HIDDEN, ENCRYPTED_MSG, SIGNATURE, @@ -515,7 +566,7 @@ object PgpMsg { PLAIN_FILE } - private fun treatAs(att: BodyPart): TreatAs { + fun treatAs(att: MimePart): TreatAs { val name = att.fileName ?: "" val type = att.contentType val length = att.size @@ -526,7 +577,7 @@ object PgpMsg { } else if (name == "signature.asc" || type == "application/pgp-signature") { return TreatAs.SIGNATURE } else if (name == "" && !type.startsWith("image/")) { - return if (length < 100) TreatAs.SIGNATURE else TreatAs.ENCRYPTED_MSG + return if (length < 100) TreatAs.SIGNATURE else TreatAs.ENCRYPTED_MSG } else if (name == "msg.asc" && length < 100 && type == "application/pgp-encrypted") { // mail.ch does this - although it looks like encrypted msg, // it will just contain PGP version eg "Version: 1" @@ -583,4 +634,741 @@ object PgpMsg { private val privateKeyRegex = Regex("(cryptup|flowcrypt)-backup-[a-z0-9]+\\.(key|asc)\$") private val publicKeyRegex1 = Regex("^(0|0x)?[A-F0-9]{8}([A-F0-9]{8})?.*\\.asc\$") private val publicKeyRegex2 = Regex("[A-F0-9]{8}.*\\.asc\$") + private val publicKeyRegex3 = Regex("^(0x)?[A-Fa-f0-9]{16,40}\\.asc\\.pgp$") + + data class ParseDecryptResult( + val subject: String?, + val isReplyEncrypted: Boolean, + val text: String, + val blocks: List + ) + + fun parseDecryptMsg( + content: String, + isEmail: Boolean, + keys: List + ): ParseDecryptResult { + return if (isEmail) { + parseDecryptMsg(MimeUtils.mimeTextToMimeMessage(content), keys) + } else { + val blocks = listOf(MsgBlockFactory.fromContent(MsgBlock.Type.ENCRYPTED_MSG, content)) + parseDecryptProcessedMsg(MimeProcessedMsg(blocks), keys) + } + } + + fun parseDecryptMsg(msg: MimeMessage, keys: List): ParseDecryptResult { + val decoded = decodeMimeMessage(msg) + val processed = processDecodedMimeMessage(decoded) + return parseDecryptProcessedMsg(processed, keys) + } + + private fun parseDecryptProcessedMsg( + msg: MimeProcessedMsg, + keys: List + ): ParseDecryptResult { + var subject = msg.subject + val sequentialProcessedBlocks = mutableListOf() + for (rawBlock in msg.blocks) { + if ( + (rawBlock.type == MsgBlock.Type.SIGNED_MSG || rawBlock.type == MsgBlock.Type.SIGNED_HTML) + && (rawBlock as SignedMsgBlock).signature != null + ) { + when (rawBlock.type) { + MsgBlock.Type.SIGNED_MSG -> { + // skip verification for now + sequentialProcessedBlocks.add( + MsgBlockFactory.fromContent( + type = MsgBlock.Type.VERIFIED_MSG, + content = rawBlock.content?.toEscapedHtml(), + signature = rawBlock.signature + ) + ) + } + + MsgBlock.Type.SIGNED_HTML -> { + // skip verification for now + sequentialProcessedBlocks.add( + MsgBlockFactory.fromContent( + type = MsgBlock.Type.VERIFIED_MSG, + content = sanitizeHtmlKeepBasicTags(rawBlock.content), + signature = rawBlock.signature + ) + ) + } + + else -> { + } // make IntelliJ happy + } // when + } else if ( + rawBlock.type == MsgBlock.Type.SIGNED_MSG || rawBlock.type == MsgBlock.Type.ENCRYPTED_MSG + ) { + val decryptionResult = decrypt(rawBlock.content?.toByteArray(), keys) + if (decryptionResult.error == null) { + if (decryptionResult.isEncrypted) { + val decrypted = decryptionResult.content?.toByteArray() + val formatted = MsgBlockParser.fmtDecryptedAsSanitizedHtmlBlocks(decrypted) + if (subject == null) subject = formatted.subject + sequentialProcessedBlocks.addAll(formatted.blocks) + } else { + // ------------------------------------------------------------------------------------ + // Comment from TS code: + // ------------------------------------------------------------------------------------ + // treating as text, converting to html - what about plain signed html? + // This could produce html tags although hopefully, that would, typically, result in + // the `(rawBlock.type === 'signedMsg' || rawBlock.type === 'signedHtml')` block above + // the only time I can imagine it screwing up down here is if it was a signed-only + // message that was actually fully armored (text not visible) with a mime msg inside + // ... -> in which case the user would I think see full mime content? + // ------------------------------------------------------------------------------------ + sequentialProcessedBlocks.add( + MsgBlockFactory.fromContent( + type = MsgBlock.Type.VERIFIED_MSG, + content = decryptionResult.content?.toString("UTF-8")?.toEscapedHtml(), + signature = decryptionResult.signature + ) + ) + } + } else { + sequentialProcessedBlocks.add( + DecryptErrorMsgBlock( + content = null, + complete = true, + error = null, + kotlinError = decryptionResult.error + ) + ) + } + } else if ( + rawBlock.type == MsgBlock.Type.ENCRYPTED_ATT + && (rawBlock as EncryptedAttMsgBlock).attMeta.name != null + && publicKeyRegex3.matches(rawBlock.attMeta.name!!) + ) { + // encrypted public key attached + val decryptionResult = decrypt( + data = rawBlock.content?.toByteArray(StandardCharsets.UTF_8), + keys = keys + ) + if (decryptionResult.content != null) { + val content = decryptionResult.content.toString("UTF-8") + sequentialProcessedBlocks.add( + MsgBlockFactory.fromContent(MsgBlock.Type.PUBLIC_KEY, content) + ) + } else { + // will show as encrypted attachment + sequentialProcessedBlocks.add(rawBlock) + } + } else { + sequentialProcessedBlocks.add(rawBlock) + } + } + + var isReplyEncrypted = false + val contentBlocks = mutableListOf() + val resultBlocks = mutableListOf() + + for (block in sequentialProcessedBlocks) { + // 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(); + + if ( + block.type == MsgBlock.Type.DECRYPTED_HTML + || block.type == MsgBlock.Type.DECRYPTED_TEXT + || block.type == MsgBlock.Type.DECRYPTED_ATT + ) { + isReplyEncrypted = true + } + + if (block.type == MsgBlock.Type.PUBLIC_KEY) { + var keyRings: List? = null + try { + keyRings = PgpKey.parseAndNormalizeKeyRings(block.content!!) + } catch (ex: Exception) { + ex.printStackTrace() + } + if (keyRings != null && keyRings.isNotEmpty()) { + resultBlocks.addAll( + keyRings.map { PublicKeyMsgBlock(it.armor(null), true, it.toPgpKeyDetails()) } + ) + } else { + resultBlocks.add( + DecryptErrorMsgBlock( + block.content, + true, + null, + DecryptionError(PgpDecrypt.DecryptionErrorType.FORMAT, "Badly formatted public key") + ) + ) + } + } else if (block.type.isContentBlockType() || MimeUtils.isPlainImgAtt(block)) { + contentBlocks.add(block) + } else if (block.type != MsgBlock.Type.PLAIN_ATT) { + resultBlocks.add(block) + } + } + + val fmtRes = fmtContentBlock(contentBlocks) + resultBlocks.add(0, fmtRes.contentBlock) + + return ParseDecryptResult( + subject = subject, + isReplyEncrypted = isReplyEncrypted, + text = fmtRes.text, + blocks = resultBlocks + ) + } + + private data class FormatContentBlockResult( + val text: String, + val contentBlock: MsgBlock + ) + + private fun fmtContentBlock(allContentBlocks: List): FormatContentBlockResult { + val inlineImagesByCid = mutableMapOf() + val imagesAtTheBottom = mutableListOf() + for (plainImageBlock in allContentBlocks.filter { MimeUtils.isPlainImgAtt(it) }) { + var contentId = (plainImageBlock as AttMsgBlock).attMeta.contentId ?: "" + if (contentId.isNotEmpty()) { + contentId = contentId.replace(cidCorrectionRegex1, "").replace(cidCorrectionRegex2, "") + inlineImagesByCid[contentId] = plainImageBlock + } else { + imagesAtTheBottom.add(plainImageBlock) + } + } + + val msgContentAsHtml = StringBuilder() + val msgContentAsText = StringBuilder() + for (block in allContentBlocks.filterNot { MimeUtils.isPlainImgAtt(it) }) { + if (block.content != null) { + when (block.type) { + MsgBlock.Type.DECRYPTED_TEXT -> { + val html = fmtMsgContentBlockAsHtml(block.content?.toEscapedHtml(), FrameColor.GREEN) + msgContentAsHtml.append(html) + msgContentAsText.append(block.content ?: "").append('\n') + } + + MsgBlock.Type.DECRYPTED_HTML -> { + // Typescript comment: todo: add support for inline imgs? when included using cid + var html = block.content!!.stripHtmlRootTags() + html = fmtMsgContentBlockAsHtml(html, FrameColor.GREEN) + msgContentAsHtml.append(html) + msgContentAsText + .append(sanitizeHtmlStripAllTags(block.content)?.unescapeHtml()) + .append('\n') + } + + MsgBlock.Type.PLAIN_TEXT -> { + val html = fmtMsgContentBlockAsHtml(block.content.toString().toEscapedHtml(), FrameColor.PLAIN) + msgContentAsHtml.append(html) + msgContentAsText.append(block.content).append('\n') + } + + MsgBlock.Type.PLAIN_HTML -> { + val stripped = block.content!!.stripHtmlRootTags() + val dirtyHtmlWithImgs = fillInlineHtmlImages(stripped, inlineImagesByCid) + msgContentAsHtml.append(fmtMsgContentBlockAsHtml(dirtyHtmlWithImgs, FrameColor.PLAIN)) + val text = sanitizeHtmlStripAllTags(dirtyHtmlWithImgs)?.unescapeHtml() + msgContentAsText.append(text).append('\n') + } + + MsgBlock.Type.VERIFIED_MSG -> { + msgContentAsHtml.append(fmtMsgContentBlockAsHtml(block.content, FrameColor.GRAY)) + msgContentAsText.append(sanitizeHtmlStripAllTags(block.content)).append('\n') + } + + else -> { + msgContentAsHtml.append(fmtMsgContentBlockAsHtml(block.content, FrameColor.PLAIN)) + msgContentAsText.append(block.content).append('\n') + } + } + } + } + + imagesAtTheBottom.addAll(inlineImagesByCid.values) + for (inlineImg in imagesAtTheBottom) { + inlineImg as AttMsgBlock + val imageName = inlineImg.attMeta.name ?: "(unnamed image)" + val imageLengthKb = inlineImg.attMeta.length / 1024 + val alt = "$imageName - $imageLengthKb Kb" + val inlineImgTag = "\"${alt.escapeHtmlAttr()}\"" + msgContentAsHtml.append(fmtMsgContentBlockAsHtml(inlineImgTag, FrameColor.PLAIN)) + msgContentAsText.append("[image: ${alt}]\n") + } + + return FormatContentBlockResult( + text = msgContentAsText.toString().trim(), + contentBlock = MsgBlockFactory.fromContent( + type = MsgBlock.Type.PLAIN_HTML, + """ + + + + + $msgContentAsHtml +""" + ) + ) + } + + private val cidCorrectionRegex1 = Regex(">$") + private val cidCorrectionRegex2 = Regex("^<") + + /** + * replace content of images: + */ + private fun fillInlineHtmlImages( + htmlContent: String, + inlineImagesByCid: MutableMap + ): String { + val usedCids = mutableSetOf() + val result = StringBuilder() + var startPos = 0 + while (true) { + val match = imgSrcWithCidRegex.find(htmlContent, startPos) + if (match == null) { + result.append(htmlContent.substring(startPos, htmlContent.length)) + break + } + if (match.range.first > startPos) { + result.append(htmlContent.substring(startPos, match.range.first)) + } + val cid = match.groupValues[0] + val img = inlineImagesByCid[cid] + if (img != null) { + img as AttMsgBlock + // Typescript comment: + // in current usage, as used by `endpoints.ts`: `block.attMeta!.data` + // actually contains base64 encoded data, not Uint8Array as the type claims + result.append("src=\"data:${img.attMeta.type ?: ""};base64,${img.attMeta.data ?: ""}\"") + // Typescript comment: + // Delete to find out if any imgs were unused. Later we can add the unused ones + // at the bottom (though as implemented will cause issues if the same cid is reused + // in several places in html - which is theoretically valid - only first will get replaced) + // Kotlin: + // Collect used CIDs and delete later + usedCids.add(cid) + } else { + result.append(htmlContent.substring(match.range)) + } + startPos = match.range.last + 1 + } + for (cid in usedCids) { + inlineImagesByCid.remove(cid) + } + return result.toString() + } + + private val imgSrcWithCidRegex = Regex("src=\"cid:([^\"]+)\"") + + + private enum class FrameColor { + GREEN, + GRAY, + RED, + PLAIN + } + + private const val generalCss = + "background: white;padding-left: 8px;min-height: 50px;padding-top: 4px;" + + "padding-bottom: 4px;width: 100%;" + + private const val seamlessLockBg = "iVBORw0KGgoAAAANSUhEUgAAAFoAAABaCAMAAAAPdrEwAAAAh1BMVEXw" + + "8PD////w8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PD" + + "w8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8P" + + "Dw8PD7MuHIAAAALXRSTlMAAAECBAcICw4QEhUZIyYqMTtGTV5kdn2Ii5mfoKOqrbG0uL6/xcnM0NTX2t1l7cN4A" + + "AAB0UlEQVR4Ae3Y3Y4SQRCG4bdHweFHRBTBH1FRFLXv//qsA8kmvbMdXhh2Q0KfknpSCQc130c67s22+e9+v/+d" + + "84fxkSPH0m/+5P9vN7vRV0vPfx7or1NB23e99KAHuoXOOc6moQsBwNN1Q9g4Wdh1uq3MA7Qn0+2ylAt7WbWpyT+" + + "Wo8roKH6v2QhZ2ghZ2ghZ2ghZ2ghZ2ghZ2ghZ2ghZ2ghZ2ghZ2ghZ2ghZ2ghZ2gjZ2AUNOLmwgQdogEJ2dnF3UJ" + + "dU3WjqO/u96aYtVd/7jqvIyu76G5se6GaY7tNNcy5d7se7eWVnDz87fMkuVuS8epF6f9NPObPY5re9y4N1/vya9" + + "Gr3se2bfvl9M0mkyZdv077p+a/3z4Meby5Br4NWiV51BaiUqfLro9I3WiR61RVcffwfXI7u5zZ20EOA82Uu8x3S" + + "lrSwXQuBSvSqK0AletUVoBK96gpIwlZy0MJWctDCVnLQwlZy0MJWctDCVnLQwlZy0MJWctDCVnLQwlZy0MJWctD" + + "CVnLQwlZy0MJWckIletUVIJJxITN6wtZd2EI+0NquyIJOnUpFVvRpcwmV6FVXgEr0qitAJXrVFaASveoKUIledQ" + + "WoRK+6AlSiV13BP+/VVbky7Xq1AAAAAElFTkSuQmCC" + + private val frameCssMap = mapOf( + FrameColor.GREEN to "border: 1px solid #f0f0f0;border-left: 8px solid #31A217;" + + "border-right: none;background-image: url(data:image/png;base64,${seamlessLockBg});", + FrameColor.GRAY to "border: 1px solid #f0f0f0;border-left: 8px solid #989898;" + + "border-right: none;", + FrameColor.RED to "border: 1px solid #f0f0f0;border-left: 8px solid #d14836;" + + "border-right: none;", + FrameColor.PLAIN to "border: none;" + ) + + private fun fmtMsgContentBlockAsHtml(dirtyContent: String?, frameColor: FrameColor): String { + return if (dirtyContent == null) "" + else "
" + + "${sanitizeHtmlKeepBasicTags(dirtyContent)}
\n" + } + + /** + * Used whenever untrusted remote content (eg html email) is rendered, but we still want to + * preserve HTML. "imgToLink" is ignored. Remote links are replaced with , and local images + * are preserved. + */ + fun sanitizeHtmlKeepBasicTags(dirtyHtml: String?): String? { + if (dirtyHtml == null) return null + val imgContentReplaceable = "IMG_ICON_${generateRandomSuffix()}" + var remoteContentReplacedWithLink = false + val policyFactory = HtmlPolicyBuilder() + .allowElements( + { elementName, attrs -> + // Remove tiny elements (often contain hidden content, tracking pixels, etc) + for (i in 0 until attrs.size / 2) { + val j = i * 2 + if ( + (attrs[j] == "width" && attrs[j + 1] == "1") + || (attrs[j] == "height" && attrs[j + 1] == "1" && elementName != "hr") + ) { + return@allowElements null + } + } + + // let the browser/web view decide how big should elements be, based on their content + var i = 0 + while (i < attrs.size) { + if ( + (attrs[i] == "width" && attrs[i + 1] != "1" && elementName != "img") + || (attrs[i] == "height" && attrs[i + 1] != "1" && elementName != "img") + ) { + attrs.removeAt(i) + attrs.removeAt(i) + } else { + i += 2 + } + } + + var newElementName = elementName + if (elementName == "img") { + val srcAttr = getAttribute(attrs, "src", "")!! + val altAttr = getAttribute(attrs, "alt") + when { + srcAttr.startsWith("data:") -> { + attrs.clear() + attrs.add("src") + attrs.add(srcAttr) + if (altAttr != null) { + attrs.add("alt") + attrs.add(altAttr) + } + } + + (srcAttr.startsWith("http://") || srcAttr.startsWith("https://")) -> { + // Orignal typecript: + // return { tagName: 'a', attribs: { href: String(attribs.src), target: "_blank" }, + // text: imgContentReplaceable }; + // Github: https://github.com/OWASP/java-html-sanitizer/issues/230 + // SO: https://stackoverflow.com/questions/67976114 + // There is no way to achieve this with OWASP sanitizer, so we do it with Jsoup + // as post-processing step + remoteContentReplacedWithLink = true + newElementName = "a" + attrs.clear() + attrs.add("href") + attrs.add(srcAttr) + attrs.add("target") + attrs.add("_blank") + attrs.add(innerTextTypeAttr) + attrs.add("1") + } + + else -> { + newElementName = "a" + val titleAttr = getAttribute(attrs, "title") + attrs.clear() + if (altAttr != null) { + attrs.add("alt") + attrs.add(altAttr) + } + if (titleAttr != null) { + attrs.add("title") + attrs.add(titleAttr) + } + attrs.add(innerTextTypeAttr) + attrs.add("2") + } + } + attrs.add(fromImageAttr) + attrs.add(true.toString()) + } + + return@allowElements newElementName + }, + *ALLOWED_ELEMENTS + ) + .allowUrlProtocols(*ALLOWED_PROTOCOLS) + .allowAttributesOnElementsExt(ALLOWED_ATTRS) + .toFactory() + + val cleanHtml = policyFactory.sanitize(dirtyHtml) + val doc = Jsoup.parse(cleanHtml) + doc.outputSettings().prettyPrint(false) + for (element in doc.select("a")) { + if (element.hasAttr(innerTextTypeAttr)) { + val innerTextType = element.attr(innerTextTypeAttr) + element.attributes().remove(innerTextTypeAttr) + var innerText: String? = null + when (innerTextType) { + "1" -> innerText = imgContentReplaceable + "2" -> innerText = "[image]" + } + if (innerText != null) element.html(innerText) + } + } + var cleanHtml2 = doc.outerHtml() + + if (remoteContentReplacedWithLink) { + cleanHtml2 = htmlPolicyWithBasicTagsOnlyFactory.sanitize( + "[remote content blocked " + + "for your privacy]

$cleanHtml2" + ) + } + + return cleanHtml2.replace( + imgContentReplaceable, + "[img]" + ) + } + + private const val innerTextTypeAttr = "data-fc-inner-text-type" + private const val fromImageAttr = "data-fc-is-from-image" + + fun sanitizeHtmlStripAllTags(dirtyHtml: String?, outputNl: String = "\n"): String? { + val html = sanitizeHtmlKeepBasicTags(dirtyHtml) ?: return null + val randomSuffix = generateRandomSuffix() + val br = "CU_BR_$randomSuffix" + val blockStart = "CU_BS_$randomSuffix" + val blockEnd = "CU_BE_$randomSuffix" + + var text = html.replace(brRegex, br) + .replace("\n", "") + .replace(blockEndRegex, blockEnd) + .replace(blockStartRegex, blockStart) + .replace(Regex("($blockStart)+"), blockStart) + .replace(Regex("($blockEnd)+"), blockEnd) + + val policyFactory = HtmlPolicyBuilder() + .allowUrlProtocols(*ALLOWED_PROTOCOLS) + .allowElements( + { elementName, attrs -> + when (elementName) { + "img" -> { + var innerText = "no name" + val alt = getAttribute(attrs, "alt") + if (alt != null) { + innerText = alt + } else { + val title = getAttribute(attrs, "title") + if (title != null) innerText = title + } + attrs.clear() + attrs.add(innerTextTypeAttr) + attrs.add(innerText) + return@allowElements "span" + } + "a" -> { + val fromImage = getAttribute(attrs, fromImageAttr) + if (fromImage == true.toString()) { + var innerText = "[image]" + val alt = getAttribute(attrs, "alt") + if (alt != null) { + innerText = "[image: $alt]" + } + attrs.clear() + attrs.add(innerTextTypeAttr) + attrs.add(innerText) + return@allowElements "span" + } else { + return@allowElements elementName + } + } + else -> return@allowElements elementName + } + }, + "img", + "span", + "a" + ) + .allowAttributes("src", "alt", "title").onElements("img") + .allowAttributes(innerTextTypeAttr).onElements("span") + .allowAttributes("src", "alt", "title", fromImageAttr).onElements("a") + .toFactory() + + text = policyFactory.sanitize(text) + val doc = Jsoup.parse(text) + doc.outputSettings().prettyPrint(false) + for (element in doc.select("span")) { + if (element.hasAttr(innerTextTypeAttr)) { + val innerText = element.attr(innerTextTypeAttr) + element.attributes().remove(innerTextTypeAttr) + element.html(innerText) + } + } + + text = HtmlPolicyBuilder().toFactory().sanitize(doc.outerHtml()) + text = text.split(br + blockEnd + blockStart) + .joinToString(br) + .split(blockEnd + blockStart) + .joinToString(br) + .split(br + blockEnd) + .joinToString(br) + .split(br) + .joinToString("\n") + .split(blockStart) + .filter { it != "" } + .joinToString("\n") + .split(blockEnd) + .filter { it != "" } + .joinToString("\n") + .replace(multiNewLineRegex, "\n\n") + + if (outputNl != "\n") text = text.replace("\n", outputNl) + return text + } + + private val brRegex = Regex("]*>") + private val blockEndRegex = Regex( + "]*>" + ) + private val blockStartRegex = Regex( + "<(p|h1|h2|h3|h4|h5|h6|ol|ul|pre|address|blockquote|dl|div|fieldset|form|hr|table)[^>]*>" + ) + private val multiNewLineRegex = Regex("\\n{2,}") + + private fun getAttribute( + attrs: List, + attrName: String, + defaultValue: String? = null + ): String? { + val srcAttrIndex = attrs.withIndex().indexOfFirst { + it.index % 2 == 0 && it.value == attrName + } + return if (srcAttrIndex != -1) attrs[srcAttrIndex + 1] else defaultValue + } + + private fun generateRandomSuffix(length: Int = 5): String { + val rnd = Random(System.currentTimeMillis()) + var s = rnd.nextInt().toString(16) + while (s.length < length) s += rnd.nextInt().toString(16) + return s.substring(0, length) + } + + private val ALLOWED_ELEMENTS = arrayOf( + "p", + "div", + "br", + "u", + "i", + "em", + "b", + "ol", + "ul", + "pre", + "li", + "table", + "thead", + "tbody", + "tfoot", + "tr", + "td", + "th", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "address", + "blockquote", + "dl", + "fieldset", + "a", + "font", + "strong", + "strike", + "code", + "img" + ) + + private val ALLOWED_ATTRS = mapOf( + "a" to arrayOf("href", "name", "target", "data-x-fc-inner-text-type"), + "img" to arrayOf("src", "width", "height", "alt"), + "font" to arrayOf("size", "color", "face"), + "span" to arrayOf("color"), + "div" to arrayOf("color"), + "p" to arrayOf("color"), + "em" to arrayOf("style"), // Typescript: tests rely on this, could potentially remove + "td" to arrayOf("width", "height"), + "hr" to arrayOf("color", "height") + ) + + private val ALLOWED_PROTOCOLS = arrayOf("data", "http", "https", "mailto") + + private val htmlPolicyWithBasicTagsOnlyFactory = HtmlPolicyBuilder() + .allowElements(*ALLOWED_ELEMENTS) + .allowUrlProtocols(*ALLOWED_PROTOCOLS) + .allowAttributesOnElementsExt(ALLOWED_ATTRS) + .toFactory() + + fun extractFcAttachments(decryptedContent: String, blocks: MutableList): String { + // these tags were created by FlowCrypt exclusively, so the structure is fairly rigid + // `
${linkText}\n` + // thus we can use Regex + if (!decryptedContent.contains("class=\"cryptup_file\"")) return decryptedContent + var i = 0 + val result = java.lang.StringBuilder() + for (match in fcAttachmentRegex.findAll(decryptedContent)) { + if (match.range.first > i) { + result.append(decryptedContent.substring(i, match.range.first)) + i = match.range.last + 1 + } + val url = match.groups[1]!!.value + val attr = match.groups[2]!!.value.decodeFcHtmlAttr() + if (isFcAttachmentLinkData(attr)) { + attr!!.put("url", url) + blocks.add(EncryptedAttLinkMsgBlock(AttMeta(attr))) + } + } + if (i < decryptedContent.length) result.append(decryptedContent.substring(i)) + return result.toString() + } + + fun isFcAttachmentLinkData(obj: JSONObject?): Boolean { + return obj != null && obj.has("name") && obj.has("size") && obj.has("type") + } + + private val fcAttachmentRegex = Regex( + "[^<]+\\n?" + ) + + fun stripFcReplyToken(decryptedContent: String): String { + return decryptedContent.replace(fcReplyTokenRegex, "") + } + + private val fcReplyTokenRegex = Regex("]+class=\"cryptup_reply\"[^>]+>") + + fun stripPublicKeys(decryptedContent: String, foundPublicKeys: MutableList): String { + val normalizedTextAndBlocks = MsgBlockParser.detectBlocks(decryptedContent) + var result = normalizedTextAndBlocks.normalized + for (block in normalizedTextAndBlocks.blocks) { + if (block.type == MsgBlock.Type.PUBLIC_KEY && block.content != null) { + val content = block.content!! + foundPublicKeys.add(content) + result = result.replace(content, "") + } + } + return result + } } diff --git a/FlowCrypt/src/test/java/com/flowcrypt/email/core/msg/MsgBlockParserTest.kt b/FlowCrypt/src/test/java/com/flowcrypt/email/core/msg/MsgBlockParserTest.kt index 224a2c845f..b33c923a40 100644 --- a/FlowCrypt/src/test/java/com/flowcrypt/email/core/msg/MsgBlockParserTest.kt +++ b/FlowCrypt/src/test/java/com/flowcrypt/email/core/msg/MsgBlockParserTest.kt @@ -245,7 +245,7 @@ Ek0f+P9DgunMb5OtkDwm6WWxpzV150LJcA== val input = "Hello, these should get replaced:\n$prv\n\nAnd this one too:\n\n$pub" assertEquals(input, input.normalize()) - val blocks = MsgBlockParser.detectBlocks(input) + val blocks = MsgBlockParser.detectBlocks(input).blocks assertEquals(4, blocks.size) @@ -276,7 +276,7 @@ Ek0f+P9DgunMb5OtkDwm6WWxpzV150LJcA== private fun checkForSinglePlaintextBlock(input: String) { assertEquals(input, input.normalize()) - val blocks = MsgBlockParser.detectBlocks(input) + val blocks = MsgBlockParser.detectBlocks(input).blocks assertEquals(1, blocks.size) assertTrue(blocks[0] is GenericMsgBlock) val expectedBlock = GenericMsgBlock(MsgBlock.Type.PLAIN_TEXT, input.trimEnd(), true) diff --git a/FlowCrypt/src/test/java/com/flowcrypt/email/security/pgp/PgpKeyTest.kt b/FlowCrypt/src/test/java/com/flowcrypt/email/security/pgp/PgpKeyTest.kt index 51424b33c2..fb19820533 100644 --- a/FlowCrypt/src/test/java/com/flowcrypt/email/security/pgp/PgpKeyTest.kt +++ b/FlowCrypt/src/test/java/com/flowcrypt/email/security/pgp/PgpKeyTest.kt @@ -16,8 +16,7 @@ class PgpKeyTest { @Test fun testParseKeysWithNormalKey() { - val pubKey = TestKeys.KEYS["rsa1"]!!.publicKey - val result = PgpKey.parseKeys(pubKey.toByteArray()) + val result = PgpKey.parseKeys(TestKeys.KEYS["rsa1"]!!.publicKey) val expected = PgpKeyDetails( isFullyDecrypted = true, @@ -72,8 +71,7 @@ class PgpKeyTest { @Test fun testParseKeysWithExpiredKey() { - val pubKey = TestKeys.KEYS["expired"]!!.publicKey - val result = PgpKey.parseKeys(pubKey.toByteArray()) + val result = PgpKey.parseKeys(TestKeys.KEYS["expired"]!!.publicKey) // TODO update from output val expected = PgpKeyDetails( 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 424d58c93a..b4971b9224 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,7 +7,9 @@ package com.flowcrypt.email.security.pgp import com.flowcrypt.email.api.retrofit.response.model.node.MsgBlock -import com.flowcrypt.email.api.retrofit.response.model.node.SignedBlock +import com.flowcrypt.email.api.retrofit.response.model.node.SignedMsgBlock +import com.flowcrypt.email.core.msg.MimeUtils +import com.flowcrypt.email.extensions.kotlin.toEscapedHtml import com.flowcrypt.email.extensions.kotlin.normalizeEol import com.flowcrypt.email.extensions.kotlin.removeUtf8Bom import com.google.gson.JsonParser @@ -33,7 +35,7 @@ class PgpMsgTest { val quoted: Boolean? = null, val charset: String = "UTF-8" ) { - val armored: String = loadMessage("$key.txt") + val armored: String = loadResourceAsString("messages/$key.txt") } companion object { @@ -123,21 +125,13 @@ class PgpMsgTest { return PGPainless.readKeyRing().secretKeyRing(loadResourceAsString("keys/$file")) } - private fun loadMessage(file: String): String { - return loadResourceAsString("messages/$file") - } - - private fun loadComplexMessage(file: String): String { - return loadResourceAsString("complex_messages/$file") - } - private fun findMessage(key: String): MessageInfo { return messages.firstOrNull { it.key == key } ?: throw IllegalArgumentException("Message '$key' not found") } } - @Test // ok + @Test fun multipleDecryptionTest() { val keys = listOf( "decrypt - without a subject", @@ -158,7 +152,7 @@ class PgpMsgTest { } } - @Test // ok + @Test fun missingMdcTest() { val r = processMessage("decrypt - [security] mdc - missing - error") assertTrue("Message is returned when should not", r.content == null) @@ -166,7 +160,7 @@ class PgpMsgTest { assertTrue("Missing MDC not detected", r.error!!.type == PgpDecrypt.DecryptionErrorType.NO_MDC) } - @Test // ok + @Test fun badMdcTest() { val r = processMessage("decrypt - [security] mdc - modification detected - error") assertTrue("Message is returned when should not", r.content == null) @@ -317,8 +311,8 @@ class PgpMsgTest { private fun checkComplexMessage(fileName: String) { println("\n*** Processing '$fileName'") - - val rootObject = JsonParser.parseString(loadComplexMessage(fileName)).asJsonObject + val json = loadResourceAsString("complex_messages/$fileName") + val rootObject = JsonParser.parseString(json).asJsonObject val inputMsg = Base64.getDecoder().decode(rootObject["in"].asJsonObject["mimeMsg"].asString) val out = rootObject["out"].asJsonObject val expectedBlocks = out["blocks"].asJsonArray @@ -350,9 +344,186 @@ class PgpMsgTest { if (actualBlock.type in MsgBlock.Type.signedBlocks) { val expectedSignature = expectedBlock["signature"].asString.normalizeEol() - val actualSignature = ((actualBlock as SignedBlock).signature ?: "").normalizeEol() + val actualSignature = ((actualBlock as SignedMsgBlock).signature ?: "").normalizeEol() assertEquals(expectedSignature, actualSignature) } } } + + // ------------------------------------------------------------------------------------------- + + @Test + fun testParseDecryptMsgUnescapedSpecialCharactersInTextOriginallyTextPlain() { + val mimeText = "MIME-Version: 1.0\n" + + "Date: Fri, 6 Sep 2019 10:48:25 +0000\n" + + "Message-ID: \n" + + "Subject: plain text with special chars\n" + + "From: Human at FlowCrypt \n" + + "To: FlowCrypt Compatibility \n" + + "Content-Type: text/plain; charset=\"UTF-8\"\n" + + "\n" + textSpecialChars + val keys = TestKeys.KEYS["rsa1"]!!.listOfKeysWithPassPhrase + val result = PgpMsg.parseDecryptMsg(MimeUtils.mimeTextToMimeMessage(mimeText), keys) + assertEquals(textSpecialChars, result.text) + assertEquals(false, result.isReplyEncrypted) + assertEquals("plain text with special chars", result.subject) + assertEquals(1, result.blocks.size) + val block = result.blocks[0] + assertEquals(MsgBlock.Type.PLAIN_HTML, block.type) + checkRenderedBlock(block, listOf(RenderedBlock.normal(true, "PLAIN", textSpecialChars))) + } + + @Test + fun testParseDecryptMsgUnescapedSpecialCharactersInTextOriginallyTextHtml() { + val mimeText = "MIME-Version: 1.0\n" + + "Date: Fri, 6 Sep 2019 10:48:25 +0000\n" + + "Message-ID: \n" + + "Subject: plain text with special chars\n" + + "From: Human at FlowCrypt \n" + + "To: FlowCrypt Compatibility \n" + + "Content-Type: text/html; charset=\"UTF-8\"\n" + + "\n" + htmlSpecialChars + val keys = TestKeys.KEYS["rsa1"]!!.listOfKeysWithPassPhrase + val result = PgpMsg.parseDecryptMsg(MimeUtils.mimeTextToMimeMessage(mimeText), keys) + assertEquals(textSpecialChars, result.text) + assertEquals(false, result.isReplyEncrypted) + assertEquals("plain text with special chars", result.subject) + assertEquals(1, result.blocks.size) + val block = result.blocks[0] + assertEquals(MsgBlock.Type.PLAIN_HTML, block.type) + checkRenderedBlock(block, listOf(RenderedBlock.normal(true, "PLAIN", htmlSpecialChars))) + } + + @Test + fun testParseDecryptMsgUnescapedSpecialCharactersInEncryptedPgpMime() { + val text = loadResourceAsString("compat/direct-encrypted-pgpmime-special-chars.txt") + val keys = TestKeys.KEYS["rsa1"]!!.listOfKeysWithPassPhrase + val result = PgpMsg.parseDecryptMsg(text, false, keys) + assertEquals(textSpecialChars, result.text) + assertEquals(true, result.isReplyEncrypted) + assertEquals("direct encrypted pgpmime special chars", result.subject) + assertEquals(1, result.blocks.size) + val block = result.blocks[0] + assertEquals(MsgBlock.Type.PLAIN_HTML, block.type) + checkRenderedBlock(block, listOf(RenderedBlock.normal(true, "GREEN", htmlSpecialChars))) + } + + @Test + fun testParseDecryptMsgUnescapedSpecialCharactersInEncryptedText() { + val text = loadResourceAsString("compat/direct-encrypted-text-special-chars.txt") + val keys = TestKeys.KEYS["rsa1"]!!.listOfKeysWithPassPhrase + val result = PgpMsg.parseDecryptMsg(text, false, keys) + assertEquals(textSpecialChars, result.text) + assertEquals(true, result.isReplyEncrypted) + assertTrue(result.subject == null) + assertEquals(1, result.blocks.size) + val block = result.blocks[0] + assertEquals(MsgBlock.Type.PLAIN_HTML, block.type) + checkRenderedBlock(block, listOf(RenderedBlock.normal(true, "GREEN", htmlSpecialChars))) + } + + @Test + fun testParseDecryptMsgPlainInlineImage() { + val text = loadResourceAsString("other/plain-inline-image.txt") + val keys = TestKeys.KEYS["rsa1"]!!.listOfKeysWithPassPhrase + val result = PgpMsg.parseDecryptMsg(text, true, keys) + assertEquals("Below\n[image: image.png]\nAbove", result.text) + assertEquals(false, result.isReplyEncrypted) + assertEquals("tiny inline img plain", result.subject) + assertEquals(1, result.blocks.size) + val block = result.blocks[0] + assertEquals(MsgBlock.Type.PLAIN_HTML, block.type) + val htmlContent = loadResourceAsString("other/plain-inline-image-html-content.txt") + checkRenderedBlock(block, listOf(RenderedBlock.normal(true, "PLAIN", htmlContent))) + } + + @Test + fun testParseDecryptMsgSignedMessagePreserveNewlines() { + val text = loadResourceAsString("other/signed-message-preserve-newlines.txt") + val keys = TestKeys.KEYS["rsa1"]!!.listOfKeysWithPassPhrase + val result = PgpMsg.parseDecryptMsg(text, false, keys) + assertEquals( + "Standard message\n\nsigned inline\n\nshould easily verify\nThis is email footer", + result.text + ) + assertEquals(false, result.isReplyEncrypted) + assertTrue(result.subject == null) + assertEquals(1, result.blocks.size) + val block = result.blocks[0] + assertEquals(MsgBlock.Type.PLAIN_HTML, block.type) + checkRenderedBlock( + block, + listOf( + RenderedBlock.normal( + true, + "PLAIN", + "Standard message

signed inline

should easily verify
" + + "This is email footer" + ) + ) + ) + } + + private data class RenderedBlock( + val rendered: Boolean, + val frameColor: String?, + val htmlContent: String?, + val content: String?, + val error: String? + ) { + companion object{ + fun normal(rendered: Boolean, frameColor: String?, htmlContent: String?): RenderedBlock { + return RenderedBlock( + rendered = rendered, + frameColor = frameColor, + htmlContent = htmlContent, + content = null, + error = null + ) + } + + fun error(error: String, content: String): RenderedBlock { + return RenderedBlock( + rendered = false, + frameColor = null, + htmlContent = null, + content = content, + error = error + ) + } + } + } + + private fun checkRenderedBlock(block: MsgBlock, expectedRenderedBlocks: List) { + val parts = block.content!!.split(bodySplitRegex, 3) + val head = parts[0] + assertTrue(head.contains("")) + assertTrue(head.contains("