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..bf190e5c1d --- /dev/null +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/retrofit/response/model/node/EncryptedAttLinkMsgBlock.kt @@ -0,0 +1,45 @@ +/* + * © 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 + +data 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..e3db377038 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 = CONTENT_BLOCK_TYPES.contains(this) + companion object { @JvmField val CREATOR: Parcelable.Creator = object : Parcelable.Creator { @@ -93,17 +98,21 @@ interface MsgBlock : Parcelable { override fun newArray(size: Int): Array = arrayOfNulls(size) } - val keyBlockTypes = setOf(PUBLIC_KEY, PRIVATE_KEY) + val KEY_BLOCK_TYPES = setOf(PUBLIC_KEY, PRIVATE_KEY) - val replaceableBlockTypes = setOf( + val REPLACEABLE_BLOCK_TYPES = setOf( PUBLIC_KEY, PRIVATE_KEY, SIGNED_MSG, ENCRYPTED_MSG, ENCRYPTED_MSG_LINK ) - val wellKnownBlockTypes = setOf( + val WELL_KNOWN_BLOCK_TYPES = setOf( PUBLIC_KEY, PRIVATE_KEY, SIGNED_MSG, ENCRYPTED_MSG ) - val signedBlocks = setOf(SIGNED_TEXT, SIGNED_HTML, SIGNED_MSG) + val SIGNED_BLOCK_TYPES = setOf(SIGNED_TEXT, SIGNED_HTML, SIGNED_MSG) + + val CONTENT_BLOCK_TYPES = setOf( + PLAIN_TEXT, PLAIN_HTML, DECRYPTED_TEXT, DECRYPTED_HTML, SIGNED_MSG, VERIFIED_MSG + ) fun ofSerializedName(serializedName: String): Type { for (v in values()) { 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..33918674dd 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,13 @@ 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.security.pgp.PgpKey +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 +22,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 +29,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 +43,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) { + PgpKey.parseKeys(content).toPgpKeyDetailsList().firstOrNull() + } 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..8554760a24 --- /dev/null +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/core/msg/MimeUtils.kt @@ -0,0 +1,52 @@ +/* + * © 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 = CONTENT_TYPE_REGEX.find(firstChars) ?: return false + return CONTENT_TRANSFER_ENCODING_REGEX.containsMatchIn(firstChars) + || CONTENT_DISPOSITION_REGEX.containsMatchIn(firstChars) + || firstChars.contains(BOUNDARY_1) + || firstChars.contains(CHARSET) + || (contentType.range.first == 0 && firstChars.contains(BOUNDARY_2)) + } + + 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 CONTENT_TYPE_REGEX = Regex("content-type: +[0-9a-z\\-/]+") + private val CONTENT_TRANSFER_ENCODING_REGEX = Regex("content-transfer-encoding: +[0-9a-z\\-/]+") + private val CONTENT_DISPOSITION_REGEX = Regex("content-disposition: +[0-9a-z\\-/]+") + private const val BOUNDARY_1 = "; boundary=" + private const val BOUNDARY_2 = "boundary=" + private const val CHARSET = "; 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..0572296ad5 --- /dev/null +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/extensions/java/io/InputStreamExt.kt @@ -0,0 +1,20 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: + * Ivan Pizhenko + */ + +package com.flowcrypt.email.extensions.java.io + +import org.apache.commons.codec.android.binary.Base64InputStream +import java.io.InputStream +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets + +fun InputStream.readText(charset: Charset = StandardCharsets.UTF_8): String = + String(readBytes(), charset) + +// See https://stackoverflow.com/a/39099064/1540501 +fun InputStream.toBase64EncodedString(): String { + return Base64InputStream(this).bufferedReader().use { it.readText() } +} 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..8ba1fa815e 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(DASHES_REGEX, "-----") } +private val DASHES_REGEX = Regex("^—–|—–$") + fun String.normalizeSpaces(): String { - return this.replace(StringExtensionsHelper.char160, ' ') + return this.replace(CHAR_160, ' ') } +private const val CHAR_160 = 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(HTML_TAG_REGEX, "") // remove opening and closing html tags + .replace(HTML_HEAD_SECTION_REGEX, "") // remove the whole head section + .replace(HTML_BODY_TAG_REGEX, "") // remove opening and closing body tags + .trim() +} + +private val HTML_TAG_REGEX = Regex("]*>") +private val HTML_HEAD_SECTION_REGEX = Regex("]*>.*") +private val HTML_BODY_TAG_REGEX = 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..a7a8d040d3 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 @@ -98,7 +98,7 @@ object PgpArmor { @JvmStatic fun normalize(armored: String, blockType: MsgBlock.Type): String { if (blockType != MsgBlock.Type.UNKNOWN - && !MsgBlock.Type.replaceableBlockTypes.contains(blockType) + && !MsgBlock.Type.REPLACEABLE_BLOCK_TYPES.contains(blockType) ) { throw IllegalArgumentException("Can't normalize block of type '$blockType'") } @@ -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/PgpDecrypt.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpDecrypt.kt index fe6d722e8e..9261f9c56c 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpDecrypt.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/security/pgp/PgpDecrypt.kt @@ -5,6 +5,8 @@ package com.flowcrypt.email.security.pgp +import android.os.Parcel +import android.os.Parcelable import com.flowcrypt.email.util.exception.DecryptionException import org.bouncycastle.openpgp.PGPDataValidationException import org.bouncycastle.openpgp.PGPSecretKeyRingCollection @@ -76,13 +78,26 @@ object PgpDecrypt { } } - enum class DecryptionErrorType { + enum class DecryptionErrorType : Parcelable { KEY_MISMATCH, WRONG_PASSPHRASE, NO_MDC, BAD_MDC, NEED_PASSPHRASE, FORMAT, - OTHER + OTHER; + + override fun describeContents(): Int { + return 0 + } + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeInt(ordinal) + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel) = values()[parcel.readInt()] + override fun newArray(size: Int): Array = arrayOfNulls(size) + } } } 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..26f0700ccf 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,10 +5,16 @@ 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.openpgp.PGPKeyRing +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.pgpainless.PGPainless import org.pgpainless.key.collection.PGPKeyRingCollection import org.pgpainless.util.Passphrase @@ -47,7 +53,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 +100,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 +118,55 @@ 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 = parseKeys(normalizedArmored, false).getAllKeys().toMutableList() + + // 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..bc5caad830 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,21 +5,43 @@ 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.escapeHtmlAttr +import com.flowcrypt.email.extensions.kotlin.stripHtmlRootTags +import com.flowcrypt.email.extensions.kotlin.toEscapedHtml +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 @@ -29,15 +51,16 @@ import org.pgpainless.util.Passphrase import java.io.ByteArrayOutputStream import java.io.InputStream 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 { /** @@ -60,7 +83,7 @@ object PgpMsg { // But it's a good indication that it may be return Pair( false, - if (messageTypes.contains(tagNumber)) { + if (MESSAGE_TYPES.contains(tagNumber)) { MsgBlock.Type.ENCRYPTED_MSG } else { MsgBlock.Type.PUBLIC_KEY @@ -73,9 +96,9 @@ 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) + && MsgBlock.Type.WELL_KNOWN_BLOCK_TYPES.contains(blocks[0].type) ) { return Pair(true, blocks[0].type) } @@ -84,7 +107,7 @@ object PgpMsg { return Pair(false, null) } - private val messageTypes = intArrayOf( + private val MESSAGE_TYPES = intArrayOf( PacketTags.SYM_ENC_INTEGRITY_PRO, PacketTags.MOD_DETECTION_CODE, 20, // SymEncryptedAEADProtected - no BouncyCastle constant for this one @@ -96,9 +119,29 @@ object PgpMsg { data class DecryptionError( val type: PgpDecrypt.DecryptionErrorType, - val message: String, - val cause: Throwable? = null - ) + val message: String? = null + ) : Parcelable { + constructor(parcel: Parcel) : this( + parcel.readParcelable( + PgpDecrypt.DecryptionErrorType::class.java.classLoader + )!!, + parcel.readString() + ) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeParcelable(type, flags) + parcel.writeString(message) + } + + override fun describeContents(): Int { + return 0 + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel) = DecryptionError(parcel) + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } data class DecryptionResult( // provided if decryption was successful @@ -114,7 +157,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 @@ -122,13 +165,12 @@ object PgpMsg { companion object { fun withError( type: PgpDecrypt.DecryptionErrorType, - message: String, - cause: Throwable? = null + message: String ): DecryptionResult { - return DecryptionResult(error = DecryptionError(type, message, cause)) + return DecryptionResult(error = DecryptionError(type, message)) } - fun withCleartext(cleartext: ByteArrayOutputStream, signature: Any?): DecryptionResult { + fun withCleartext(cleartext: ByteArrayOutputStream, signature: String?): DecryptionResult { return DecryptionResult(content = cleartext, signature = signature) } @@ -144,11 +186,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" @@ -168,14 +210,12 @@ object PgpMsg { ) { DecryptionResult.withError( type = PgpDecrypt.DecryptionErrorType.FORMAT, - message = ex.message!!, - cause = ex.cause + message = ex.message!! ) } else { DecryptionResult.withError( type = PgpDecrypt.DecryptionErrorType.OTHER, - message = "Decode cleartext error", - cause = ex + message = "Decode cleartext error" ) } } @@ -203,8 +243,7 @@ object PgpMsg { } return DecryptionResult.withError( type = PgpDecrypt.DecryptionErrorType.WRONG_PASSPHRASE, - message = "Wrong passphrase", - cause = ex + message = "Wrong passphrase" ) } @@ -229,20 +268,17 @@ object PgpMsg { return DecryptionResult.withError( type = PgpDecrypt.DecryptionErrorType.NO_MDC, message = "Security threat! Message is missing integrity checks (MDC)." + - " The sender should update their outdated software.", - cause = ex + " The sender should update their outdated software." ) } catch (ex: ModificationDetectionException) { return DecryptionResult.withError( type = PgpDecrypt.DecryptionErrorType.BAD_MDC, - message = "Security threat! Integrity check failed.", - cause = ex + message = "Security threat! Integrity check failed." ) } catch (ex: PGPDataValidationException) { return DecryptionResult.withError( type = PgpDecrypt.DecryptionErrorType.KEY_MISMATCH, - message = "There is no matching key", - cause = ex + message = "There is no matching key" ) } catch (ex: PGPException) { if ( @@ -253,25 +289,19 @@ object PgpMsg { ) { return DecryptionResult.withError( type = PgpDecrypt.DecryptionErrorType.KEY_MISMATCH, - message = "There is no suitable decryption key", - cause = ex + message = "There is no suitable decryption key" ) - } else { - exception = ex } - } catch (ex: Exception) { - exception = ex } return DecryptionResult.withError( type = PgpDecrypt.DecryptionErrorType.OTHER, - message = "Decryption failed", - cause = exception + message = "Decryption failed" ) } @Suppress("ArrayInDataClass") data class MimeContent( - val attachments: List, + val attachments: List, var signature: String?, val subject: String, val html: String?, @@ -285,8 +315,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 +328,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 +344,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 +357,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 +378,8 @@ object PgpMsg { } } - "text/rfc822-headers" -> {} // skip + "text/rfc822-headers" -> { + } // skip else -> { attachments.add(part) @@ -355,6 +387,7 @@ object PgpMsg { } } } + return MimeContent( attachments = attachments, signature = signature, @@ -372,17 +405,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,71 +423,41 @@ 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 + it.type in MsgBlock.Type.WELL_KNOWN_BLOCK_TYPES } when { suitableBlock != null -> { @@ -500,12 +501,13 @@ object PgpMsg { signature = signature ) } - else -> {} + else -> { + } } } } - private enum class TreatAs { + enum class TreatAs { HIDDEN, ENCRYPTED_MSG, SIGNATURE, @@ -515,37 +517,37 @@ 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 - if (hiddenFileNames.contains(name)) { + if (HIDDEN_FILE_NAMES.contains(name)) { // PGPexch.htm.pgp is html alternative of textual body content produced // by the PGP Desktop and GPG4o return TreatAs.HIDDEN } 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" return TreatAs.SIGNATURE - } else if (encryptedMsgNames.contains(name)) { + } else if (ENCRYPTED_MSG_NAMES.contains(name)) { return TreatAs.ENCRYPTED_MSG - } else if (encryptedFileRegex.containsMatchIn(name)) { + } else if (ENCRYPTED_FILE_REGEX.containsMatchIn(name)) { // ends with one of .gpg, .pgp, .???.asc, .????.asc return TreatAs.ENCRYPTED_FILE - } else if (privateKeyRegex.containsMatchIn(name)) { + } else if (PRIVATE_KEY_REGEX.containsMatchIn(name)) { return TreatAs.PRIVATE_KEY } else if (type == "application/pgp-keys") { return TreatAs.PUBLIC_KEY - } else if (publicKeyRegex1.containsMatchIn(name)) { + } else if (PUBLIC_KEY_REGEX_1.containsMatchIn(name)) { // name starts with a key id return TreatAs.PUBLIC_KEY } else if ( name.toLowerCase(Locale.ROOT).contains("public") && - publicKeyRegex2.containsMatchIn(name) + PUBLIC_KEY_REGEX_2.containsMatchIn(name) ) { // name contains the word "public", any key id and ends with .asc return TreatAs.PUBLIC_KEY @@ -567,20 +569,758 @@ object PgpMsg { return s.contains("-----BEGIN PGP PUBLIC KEY BLOCK-----") } - private val hiddenFileNames = setOf( + private val HIDDEN_FILE_NAMES = setOf( "PGPexch.htm.pgp", "PGPMIME version identification", "Version.txt", "PGPMIME Versions Identification" ) - private val encryptedMsgNames = setOf( + private val ENCRYPTED_MSG_NAMES = setOf( "message", "msg.asc", "message.asc", "encrypted.asc", "encrypted.eml.pgp", "Message.pgp", "openpgp-encrypted-message.asc" ) - private val encryptedFileRegex = Regex("(\\.pgp\$)|(\\.gpg\$)|(\\.[a-zA-Z0-9]{3,4}\\.asc\$)") - 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 ENCRYPTED_FILE_REGEX = Regex("(\\.pgp\$)|(\\.gpg\$)|(\\.[a-zA-Z0-9]{3,4}\\.asc\$)") + private val PRIVATE_KEY_REGEX = Regex("(cryptup|flowcrypt)-backup-[a-z0-9]+\\.(key|asc)\$") + private val PUBLIC_KEY_REGEX_1 = Regex("^(0|0x)?[A-F0-9]{8}([A-F0-9]{8})?.*\\.asc\$") + private val PUBLIC_KEY_REGEX_2 = Regex("[A-F0-9]{8}.*\\.asc\$") + private val PUBLIC_KEY_REGEX_3 = 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 + && PUBLIC_KEY_REGEX_3.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(CID_CORRECTION_REGEX_1, "").replace(CID_CORRECTION_REGEX_2, "") + 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 CID_CORRECTION_REGEX_1 = Regex(">$") + private val CID_CORRECTION_REGEX_2 = 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 = IMG_SRC_WITH_CID_REGEX.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 IMG_SRC_WITH_CID_REGEX = Regex("src=\"cid:([^\"]+)\"") + + private enum class FrameColor { + GREEN, + GRAY, + RED, + PLAIN + } + + private const val GENERAL_CSS = + "background: white;padding-left: 8px;min-height: 50px;padding-top: 4px;" + + "padding-bottom: 4px;width: 100%;" + + private const val SEAMLESS_LOCK_BG = "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 FRAME_CSS_MAP = mapOf( + FrameColor.GREEN to "border: 1px solid #f0f0f0;border-left: 8px solid #31A217;" + + "border-right: none;background-image: url(data:image/png;base64,${SEAMLESS_LOCK_BG});", + 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(INNER_TEXT_TYPE_ATTR) + 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(INNER_TEXT_TYPE_ATTR) + attrs.add("2") + } + } + attrs.add(FROM_IMAGE_ATTR) + 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(INNER_TEXT_TYPE_ATTR)) { + val innerTextType = element.attr(INNER_TEXT_TYPE_ATTR) + element.attributes().remove(INNER_TEXT_TYPE_ATTR) + 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 INNER_TEXT_TYPE_ATTR = "data-fc-inner-text-type" + private const val FROM_IMAGE_ATTR = "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(HTML_BR_REGEX, br) + .replace("\n", "") + .replace(BLOCK_END_REGEX, blockEnd) + .replace(BLOCK_START_REGEX, 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(INNER_TEXT_TYPE_ATTR) + attrs.add(innerText) + return@allowElements "span" + } + "a" -> { + val fromImage = getAttribute(attrs, FROM_IMAGE_ATTR) + if (fromImage == true.toString()) { + var innerText = "[image]" + val alt = getAttribute(attrs, "alt") + if (alt != null) { + innerText = "[image: $alt]" + } + attrs.clear() + attrs.add(INNER_TEXT_TYPE_ATTR) + attrs.add(innerText) + return@allowElements "span" + } else { + return@allowElements elementName + } + } + else -> return@allowElements elementName + } + }, + "img", + "span", + "a" + ) + .allowAttributes("src", "alt", "title").onElements("img") + .allowAttributes(INNER_TEXT_TYPE_ATTR).onElements("span") + .allowAttributes("src", "alt", "title", FROM_IMAGE_ATTR).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(INNER_TEXT_TYPE_ATTR)) { + val innerText = element.attr(INNER_TEXT_TYPE_ATTR) + element.attributes().remove(INNER_TEXT_TYPE_ATTR) + 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(MULTI_NEW_LINE_REGEX, "\n\n") + + if (outputNl != "\n") text = text.replace("\n", outputNl) + return text + } + + private val BLOCK_START_REGEX = Regex( + "<(p|h1|h2|h3|h4|h5|h6|ol|ul|pre|address|blockquote|dl|div|fieldset|form|hr|table)[^>]*>" + ) + private val BLOCK_END_REGEX = Regex( + "]*>" + ) + private val MULTI_NEW_LINE_REGEX = Regex("\\n{2,}") + private val HTML_BR_REGEX = Regex("]*>") + + 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 FC_ATT_REGEX.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 FC_ATT_REGEX = Regex( + "[^<]+\\n?" + ) + + fun stripFcReplyToken(decryptedContent: String): String { + return decryptedContent.replace(FC_REPLY_TOKEN_REGEX, "") + } + + private val FC_REPLY_TOKEN_REGEX = 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..9daa3c41cb 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 @@ -9,6 +9,7 @@ import com.flowcrypt.email.api.retrofit.response.model.node.GenericMsgBlock import com.flowcrypt.email.api.retrofit.response.model.node.MsgBlock import com.flowcrypt.email.api.retrofit.response.model.node.PublicKeyMsgBlock import com.flowcrypt.email.extensions.kotlin.normalize +import com.flowcrypt.email.security.pgp.PgpKey import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -245,7 +246,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) @@ -267,16 +268,17 @@ Ek0f+P9DgunMb5OtkDwm6WWxpzV150LJcA== blocks[2] as GenericMsgBlock ) + val pgpKeyDetails = PgpKey.parseKeys(pub).toPgpKeyDetailsList().firstOrNull() assertTrue(blocks[3] is PublicKeyMsgBlock) assertEquals( - PublicKeyMsgBlock(pub, true, null), + PublicKeyMsgBlock(pub, true, pgpKeyDetails), blocks[3] as PublicKeyMsgBlock ) } 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..e433230b6c 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,9 +7,11 @@ 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.normalizeEol import com.flowcrypt.email.extensions.kotlin.removeUtf8Bom +import com.flowcrypt.email.extensions.kotlin.toEscapedHtml import com.google.gson.JsonParser import org.bouncycastle.openpgp.PGPSecretKeyRing import org.junit.Assert.assertArrayEquals @@ -20,7 +22,8 @@ import org.pgpainless.PGPainless import org.pgpainless.util.Passphrase import java.nio.charset.Charset import java.nio.charset.StandardCharsets -import java.util.* +import java.util.Base64 +import java.util.Properties import javax.mail.Session import javax.mail.internet.InternetAddress import javax.mail.internet.MimeMessage @@ -33,11 +36,23 @@ 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 { - private val privateKeys = listOf( + private const val NEXT_MSG_BLOCK_DELIMITER = "\n" + + private val BODY_SPLIT_REGEX = Regex("") + + private val RENDERED_CONTENT_BLOCK_REGEX = Regex( + "
(.*)
" + ) + + private const val TEXT_SPECIAL_CHARS = "> special & other\n> second line" + + private val HTML_SPECIAL_CHARS = TEXT_SPECIAL_CHARS.toEscapedHtml() + + private val PRIVATE_KEYS = listOf( PgpMsg.KeyWithPassPhrase( passphrase = Passphrase.fromPassword("flowcrypt compatibility tests"), keyRing = loadSecretKey("key0.txt"), @@ -49,7 +64,7 @@ class PgpMsgTest { ) ) - private val messages = listOf( + private val MESSAGES = listOf( MessageInfo( key = "decrypt - without a subject", content = listOf("This is a compatibility test email") @@ -123,21 +138,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 } + 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 +165,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 +173,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) @@ -200,7 +207,7 @@ class PgpMsgTest { fun wrongPassphraseTest() { val messageInfo = findMessage("decrypt - without a subject") val wrongPassphrase = Passphrase.fromPassword("this is wrong passphrase for sure") - val privateKeysWithWrongPassPhrases = privateKeys.map { + val privateKeysWithWrongPassPhrases = PRIVATE_KEYS.map { PgpMsg.KeyWithPassPhrase(keyRing = it.keyRing, passphrase = wrongPassphrase) } val r = PgpMsg.decrypt( @@ -217,7 +224,7 @@ class PgpMsgTest { @Test fun missingPassphraseTest() { val messageInfo = findMessage("decrypt - without a subject") - val privateKeysWithMissingPassphrases = privateKeys.map { + val privateKeysWithMissingPassphrases = PRIVATE_KEYS.map { PgpMsg.KeyWithPassPhrase(keyRing = it.keyRing, passphrase = null) } val r = PgpMsg.decrypt( @@ -234,7 +241,7 @@ class PgpMsgTest { @Test fun wrongKeyTest() { val messageInfo = findMessage("decrypt - without a subject") - val wrongKey = listOf(privateKeys[1]) + val wrongKey = listOf(PRIVATE_KEYS[1]) val r = PgpMsg.decrypt( messageInfo.armored.toByteArray(), wrongKey, null ) @@ -264,15 +271,11 @@ class PgpMsgTest { private fun processMessage(messageKey: String): PgpMsg.DecryptionResult { val messageInfo = findMessage(messageKey) - val result = PgpMsg.decrypt(messageInfo.armored.toByteArray(), privateKeys, null) + val result = PgpMsg.decrypt(messageInfo.armored.toByteArray(), PRIVATE_KEYS, null) if (result.content != null) { val s = String(result.content!!.toByteArray(), Charset.forName(messageInfo.charset)) println("=========\n$s\n=========") } - if (result.error != null && result.error!!.cause != null) { - println("CAUSE:") - result.error!!.cause!!.printStackTrace(System.out) - } return result } @@ -317,8 +320,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 @@ -348,11 +351,180 @@ class PgpMsgTest { assertEquals(expectedComplete, actualBlock.complete) assertEquals(expectedContent, actualContent) - if (actualBlock.type in MsgBlock.Type.signedBlocks) { + if (actualBlock.type in MsgBlock.Type.SIGNED_BLOCK_TYPES) { 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" + TEXT_SPECIAL_CHARS + val keys = TestKeys.KEYS["rsa1"]!!.listOfKeysWithPassPhrase + val result = PgpMsg.parseDecryptMsg(MimeUtils.mimeTextToMimeMessage(mimeText), keys) + assertEquals(TEXT_SPECIAL_CHARS, 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", TEXT_SPECIAL_CHARS))) + } + + @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" + HTML_SPECIAL_CHARS + val keys = TestKeys.KEYS["rsa1"]!!.listOfKeysWithPassPhrase + val result = PgpMsg.parseDecryptMsg(MimeUtils.mimeTextToMimeMessage(mimeText), keys) + assertEquals(TEXT_SPECIAL_CHARS, 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", HTML_SPECIAL_CHARS))) + } + + @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(TEXT_SPECIAL_CHARS, 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", HTML_SPECIAL_CHARS))) + } + + @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(TEXT_SPECIAL_CHARS, 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", HTML_SPECIAL_CHARS))) + } + + @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(BODY_SPLIT_REGEX, 3) + val head = parts[0] + assertTrue(head.contains("")) + assertTrue(head.contains("