Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions FlowCrypt/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
@@ -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<String>): 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")
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great, should also test the following:

In both cases, it should return no pubkey without throwing or failing.

Timeout is 3 seconds when it cannot reach, then it returns 'no pubkey'.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tomholub
I've added tests with these emails.
In TypeScript timeout was 4 seconds, so I've used that.
Not sure how to test what will happen on timeout expiration.


@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)
}
}