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
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,15 @@ import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.security.cert.CertPathValidatorException
import java.security.cert.CertificateException
import java.security.cert.CertificateExpiredException
import java.security.cert.CertificateNotYetValidException
import javax.net.ssl.SSLHandshakeException
import javax.net.ssl.SSLPeerUnverifiedException

fun Throwable.toUserMessage(fallback: String = "Something went wrong — please try again"): String =
when (this) {
certificateProblem() ?: when (this) {
is HttpException -> {
val body = runCatching { response()?.errorBody()?.string() ?: "" }.getOrDefault("")
if (isCloudflareResponse(body)) {
Expand All @@ -23,6 +29,27 @@ fun Throwable.toUserMessage(fallback: String = "Something went wrong — please
else -> fallback
}

// These are IOExceptions too, so without this they read as "check your
// connection", which sends people debugging their network. Release builds only;
// debug builds accept any certificate.
private fun Throwable.certificateProblem(): String? {
if (this is SSLPeerUnverifiedException) {
return "The server's certificate doesn't match this address. Use the address the " +
"certificate was issued for"
}
if (this !is SSLHandshakeException) return null
val causes = generateSequence(cause) { it.cause }
return when {
causes.any { it is CertificateExpiredException || it is CertificateNotYetValidException } ->
"The server's certificate has expired or isn't valid yet. Check this device's date " +
"and time, or renew the certificate"
causes.any { it is CertPathValidatorException || it is CertificateException } ->
"The server's certificate isn't trusted. Sheaf doesn't use certificates installed " +
"on this device, so the server needs one from a public certificate authority"
else -> null
}
}

private fun isCloudflareResponse(body: String): Boolean {
val lower = body.lowercase()
return "cloudflare" in lower || "cf-ray" in lower
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.security.cert.CertPathValidatorException
import java.security.cert.CertificateException
import java.security.cert.CertificateExpiredException
import javax.net.ssl.SSLHandshakeException
import javax.net.ssl.SSLPeerUnverifiedException
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
Expand Down Expand Up @@ -46,6 +51,54 @@ class ErrorMessagesTest {
)
}

// Shaped like what Android throws: the handshake exception wraps the
// validator's verdict a level or two down.
private fun handshake(cause: Throwable) =
SSLHandshakeException("handshake failed").apply { initCause(cause) }

@Test
fun `untrusted certificate says so instead of blaming the network`() {
val e = handshake(
CertificateException(CertPathValidatorException("Trust anchor for certification path not found.")),
)
assertEquals(
"The server's certificate isn't trusted. Sheaf doesn't use certificates installed " +
"on this device, so the server needs one from a public certificate authority",
e.toUserMessage(),
)
}

@Test
fun `expired certificate is not reported as untrusted`() {
val e = handshake(
CertificateException(
CertPathValidatorException("timestamp check failed", CertificateExpiredException()),
),
)
assertEquals(
"The server's certificate has expired or isn't valid yet. Check this device's date " +
"and time, or renew the certificate",
e.toUserMessage(),
)
}

@Test
fun `certificate for another hostname says the address does not match`() {
assertEquals(
"The server's certificate doesn't match this address. Use the address the " +
"certificate was issued for",
SSLPeerUnverifiedException("Hostname 10.0.0.5 not verified").toUserMessage(),
)
}

@Test
fun `a handshake failure with no certificate cause stays a network error`() {
assertEquals(
IOException("x").toUserMessage(),
SSLHandshakeException("Connection closed by peer").toUserMessage(),
)
}

@Test
fun `unmapped exception falls back to default`() {
assertEquals(
Expand Down
Loading