diff --git a/FlowCrypt/build.gradle b/FlowCrypt/build.gradle index 32460611f6..5fe473b18d 100644 --- a/FlowCrypt/build.gradle +++ b/FlowCrypt/build.gradle @@ -458,6 +458,7 @@ dependencies { 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.14.1' + implementation 'com.sandinh:zbase32-commons-codec_2.12:1.0.0' implementation ('org.pgpainless:pgpainless-core:0.2.6') { // exclude group: 'org.bouncycastle' because we will specify it manually diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/api/wkd/WkdClient.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/api/wkd/WkdClient.kt new file mode 100644 index 0000000000..2426ee5066 --- /dev/null +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/api/wkd/WkdClient.kt @@ -0,0 +1,95 @@ +/* + * © 2021-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: + * Ivan Pizhenko + */ + +package com.flowcrypt.email.api.wkd + +import com.flowcrypt.email.extensions.kotlin.isValidEmail +import com.flowcrypt.email.util.BetterInternetAddress +import okhttp3.OkHttpClient +import okhttp3.Request +import org.apache.commons.codec.binary.ZBase32 +import org.apache.commons.codec.digest.DigestUtils +import org.bouncycastle.openpgp.PGPPublicKeyRingCollection +import org.bouncycastle.openpgp.jcajce.JcaPGPPublicKeyRingCollection +import java.net.URLEncoder +import java.net.UnknownHostException +import java.util.Locale +import java.util.concurrent.TimeUnit + +object WkdClient { + private const val DEFAULT_REQUEST_TIMEOUT = 4 + + fun lookupEmail( + email: String, + timeout: Int = DEFAULT_REQUEST_TIMEOUT, + wkdPort: Int? = null + ): PGPPublicKeyRingCollection? { + val keys = rawLookupEmail(email, timeout, wkdPort) ?: return null + val lowerCaseEmail = email.toLowerCase(Locale.ROOT) + val matchingKeys = keys.keyRings.asSequence().filter { + for (userId in it.publicKey.userIDs) { + try { + val parsed = BetterInternetAddress(userId) + if (parsed.emailAddress.toLowerCase(Locale.ROOT) == lowerCaseEmail) return@filter true + } catch (ex: Exception) { + // ignore + } + } + false + }.toList() + return if (matchingKeys.isNotEmpty()) PGPPublicKeyRingCollection(matchingKeys) else null + } + + @Suppress("private") + fun rawLookupEmail( + email: String, + timeout: Int = DEFAULT_REQUEST_TIMEOUT, + wkdPort: Int? = null + ): PGPPublicKeyRingCollection? { + if (!email.isValidEmail()) throw IllegalArgumentException("Invalid email address") + val parts = email.split('@') + val user = parts[0].toLowerCase(Locale.ROOT) + val hu = ZBase32().encodeAsString(DigestUtils.sha1(user.toByteArray())) + val directDomain = parts[1].toLowerCase(Locale.ROOT) + val advancedDomainPrefix = if (directDomain == "localhost") "" else "openpgpkey." + val directHost = if (wkdPort == null) directDomain else "${directDomain}:${wkdPort}" + val advancedHost = "$advancedDomainPrefix$directHost" + val advancedUrl = "https://${advancedHost}/.well-known/openpgpkey/${directDomain}" + val directUrl = "https://${directHost}/.well-known/openpgpkey" + val userPart = "hu/$hu?l=${URLEncoder.encode(user, "UTF-8")}" + try { + val result = urlLookup(advancedUrl, userPart, timeout) + // Do not retry "direct" if "advanced" had a policy file + if (result.hasPolicy) return result.keys + } catch (ex: Exception) { + // ignore + } + return try { + urlLookup(directUrl, userPart, timeout).keys + } catch (ex: UnknownHostException) { + null + } + } + + private data class UrlLookupResult( + val hasPolicy: Boolean = false, + val keys: PGPPublicKeyRingCollection? = null + ) + + private fun urlLookup(methodUrlBase: String, userPart: String, timeout: Int): UrlLookupResult { + val httpClient = OkHttpClient.Builder().callTimeout(timeout.toLong(), TimeUnit.SECONDS).build() + val policyRequest = Request.Builder().url("$methodUrlBase/policy").build() + httpClient.newCall(policyRequest).execute().use { policyResponse -> + if (policyResponse.code != 200) return UrlLookupResult() + } + val userRequest = Request.Builder().url("$methodUrlBase/$userPart").build() + httpClient.newCall(userRequest).execute().use { userResponse -> + if (userResponse.code != 200 || userResponse.body == null) return UrlLookupResult(true) + val keys = JcaPGPPublicKeyRingCollection(userResponse.body!!.byteStream()) + return UrlLookupResult(true, keys) + } + } +} 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 8ba1fa815e..f8a53d3655 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 @@ -6,6 +6,7 @@ package com.flowcrypt.email.extensions.kotlin +import com.flowcrypt.email.util.BetterInternetAddress import org.json.JSONObject import java.io.InputStream import java.io.UnsupportedEncodingException @@ -150,3 +151,14 @@ fun String.encodeUriComponent(): String { fun String.toInputStream(charset: Charset = StandardCharsets.UTF_8): InputStream { return toByteArray(charset).inputStream() } + +fun String.stripTrailing(ch: Char): String { + if (isEmpty()) return this + var pos = length - 1 + while (pos >= 0 && this[pos] == ch) --pos + return if (pos != -1) substring(0, pos + 1) else "" +} + +fun String.isValidEmail(): Boolean { + return BetterInternetAddress.isValidEmail(this) +} diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/util/BetterEmailAddress.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/util/BetterEmailAddress.kt new file mode 100644 index 0000000000..e24e153229 --- /dev/null +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/util/BetterEmailAddress.kt @@ -0,0 +1,70 @@ +/* + * © 2021-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: + * Ivan Pizhenko + */ + +package com.flowcrypt.email.util + +import com.flowcrypt.email.extensions.kotlin.isValidEmail + +// https://en.wikipedia.org/wiki/Email_address#Internationalization_examples +class BetterInternetAddress(str: String, verifySpecialCharacters: Boolean = true) { + + companion object { + const val alphanum = "\\p{L}\\u0900-\\u097F0-9" + const val validEmail = "(?:[${alphanum}!#\$%&'*+/=?^_`{|}~-]+(?:\\.[${alphanum}!#\$%&'*+/=?^" + + "_`{|}~-]+)*|\"(?:[\\x01-\\x08" + + "\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f" + + "])*\")@(?:(?:[${alphanum}](?:[${alphanum}-]*[${alphanum}])?\\.)+[${alphanum}](?:[" + + "${alphanum}-]*[${alphanum}])?|\\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:" + + "25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[${alphanum}-]*[${alphanum}]:(?:[\\x01-\\x08\\x0b" + + "\\x0c\\x0e-\\x1f\\x21-\\x5a\\x53-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)])" + private const val validPersonalNameWithEmail = + "([$alphanum\\p{Punct}\\p{Space}]*)<($validEmail)>" + + private val validEmailRegex = validEmail.toRegex() + private val validPersonalNameWithEmailRegex = validPersonalNameWithEmail.toRegex() + // if these appear in the display-name they must be double quoted + private val containsSpecialCharacterRegex = ".*[()<>\\[\\]:;@\\\\,.\"].*".toRegex() + // double quotes at ends only + private val doubleQuotedTextRegex = "\"[^\"]*\"".toRegex() + + fun isValidEmail(email: String): Boolean { + return validEmailRegex.matchEntire(email) != null + } + + fun areValidEmails(emails: Iterable): Boolean { + return emails.all { it.isValidEmail() } + } + } + + val personalName: String? + val emailAddress: String + + init { + val personalNameWithEmailMatch = validPersonalNameWithEmailRegex.find(str) + val emailMatch = str.matches(validEmailRegex) + when { + personalNameWithEmailMatch != null -> { + val group = personalNameWithEmailMatch.groupValues + personalName = group[1].trim() + emailAddress = group[2] + if ( + verifySpecialCharacters && + personalName.matches(containsSpecialCharacterRegex) && + !personalName.matches(doubleQuotedTextRegex) + ) { + throw IllegalArgumentException( + "Invalid email $str - display name containing special characters must be fully double quoted" + ) + } + } + emailMatch -> { + personalName = null + emailAddress = str + } + else -> throw IllegalArgumentException("Invalid email $str") + } + } +} diff --git a/FlowCrypt/src/test/java/com/flowcrypt/email/api/email/WkdClientTest.kt b/FlowCrypt/src/test/java/com/flowcrypt/email/api/email/WkdClientTest.kt new file mode 100644 index 0000000000..79bd41c3f0 --- /dev/null +++ b/FlowCrypt/src/test/java/com/flowcrypt/email/api/email/WkdClientTest.kt @@ -0,0 +1,38 @@ +/* + * © 2021-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: + * Ivan Pizhenko + */ + +package com.flowcrypt.email.api.email + +import com.flowcrypt.email.api.wkd.WkdClient +import org.junit.Assert.assertTrue +import org.junit.Test + +class WkdClientTest { + @Test + fun existingEmailTest() { + val keys = WkdClient.lookupEmail("human@flowcrypt.com") + assertTrue("Key not found", keys != null) + assertTrue("There are no keys in the key collection", keys!!.keyRings.hasNext()) + } + + @Test + fun nonExistingEmailTest1() { + val keys = WkdClient.lookupEmail("no.such.email.for.sure@flowcrypt.com") + assertTrue("Key found for non-existing email", keys == null) + } + + @Test + fun nonExistingEmailTest2() { + val keys = WkdClient.lookupEmail("doesnotexist@google.com") + assertTrue("Key found for non-existing email", keys == null) + } + + @Test + fun nonExistingDomainTest() { + val keys = WkdClient.lookupEmail("doesnotexist@thisdomaindoesnotexist.test") + assertTrue("Key found for non-existing email", keys == null) + } +}