diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/util/ErrorMessages.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/util/ErrorMessages.kt index 0dbd77d..1cc7e1f 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/util/ErrorMessages.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/util/ErrorMessages.kt @@ -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)) { @@ -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 diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/util/ErrorMessagesTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/util/ErrorMessagesTest.kt index 9a8c82e..9563c89 100644 --- a/sheaf/app/src/test/java/systems/lupine/sheaf/util/ErrorMessagesTest.kt +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/util/ErrorMessagesTest.kt @@ -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 @@ -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(