diff --git a/app/build.gradle b/app/build.gradle index 002b07e99..88c256552 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -214,6 +214,12 @@ dependencies { // OkHttp for DNS-over-HTTPS implementation 'com.squareup.okhttp3:okhttp:5.4.0' + // Proton's maintained SRP implementation for the optional Proton VPN + // account-login path. We deliberately do not pull in Proton Core's full + // account/Hilt graph; ProtonAuthClient owns only the protocol boundary. + implementation 'me.proton.core:crypto-android:36.6.1' + implementation 'me.proton.core:util-kotlin:36.6.1' + implementation 'me.proton.crypto:android-golib:2.9.0-2' implementation 'dnsjava:dnsjava:3.6.5' } diff --git a/app/src/main/java/net/kollnig/missioncontrol/wg/proton/ProtonAuthClient.kt b/app/src/main/java/net/kollnig/missioncontrol/wg/proton/ProtonAuthClient.kt new file mode 100644 index 000000000..849f24340 --- /dev/null +++ b/app/src/main/java/net/kollnig/missioncontrol/wg/proton/ProtonAuthClient.kt @@ -0,0 +1,242 @@ +package net.kollnig.missioncontrol.wg.proton + +import android.os.Build +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import me.proton.core.crypto.android.srp.GOpenPGPSrpCrypto +import me.proton.core.util.kotlin.DefaultDispatcherProvider +import net.kollnig.missioncontrol.BuildConfig +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.json.JSONObject +import java.security.MessageDigest +import java.util.Locale +import java.util.TimeZone + +/** + * Small, dependency-injected Proton authentication client. + * + * Proton Core's account graph is deliberately not embedded here. This class + * owns only the documented auth-v4 exchange and accepts an SRP implementation + * so the HTTP boundary can be tested without real credentials or crypto. + */ +class ProtonAuthClient( + private val httpClient: OkHttpClient = OkHttpClient(), + private val baseUrl: HttpUrl = DEFAULT_BASE_URL, + private val srpProofGenerator: ProtonSrpProofGenerator = Companion.defaultSrpProofGenerator(), + private val payloadFactory: () -> JSONObject = { ProtonChallengePayload.device() }, + private val appVersion: String = "android-vpn@${BuildConfig.VERSION_NAME}" +) { + suspend fun login(username: String, password: CharArray): ProtonLoginResult { + require(username.isNotBlank()) { "Proton username is required" } + require(password.isNotEmpty()) { "Proton password is required" } + + val info = postJson("auth/v4/info", JSONObject() + .put("Username", username) + .put("Intent", "proton")) + val version = requiredLong(info, "Version") + val salt = requiredString(info, "Salt") + val modulus = requiredString(info, "Modulus") + val serverEphemeral = requiredString(info, "ServerEphemeral") + val srpSession = requiredString(info, "SRPSession") + + val passwordBytes = password.concatToString().toByteArray(Charsets.UTF_8) + val proofs = try { + srpProofGenerator.generate( + username = username, + password = passwordBytes, + version = version, + salt = salt, + modulus = modulus, + serverEphemeral = serverEphemeral + ) + } finally { + passwordBytes.fill(0) + password.fill('\u0000') + } + + val response = postJson("auth/v4", JSONObject() + .put("Username", username) + .put("ClientEphemeral", proofs.clientEphemeral) + .put("ClientProof", proofs.clientProof) + .put("SRPSession", srpSession) + .put("Payload", JSONObject().put( + "vpn-android-v4-challenge-0", payloadFactory()))) + + val session = parseSession(response) + val serverProof = response.optString("ServerProof", "") + if (serverProof.isNotEmpty() && !constantTimeEquals(serverProof, proofs.expectedServerProof)) + throw ProtonApiException(200, null, "Proton server proof validation failed") + + val secondFactor = response.optJSONObject("2FA") + return if (secondFactor == null) { + ProtonLoginResult.Authenticated(session) + } else { + ProtonLoginResult.TwoFactorRequired( + pendingSession = session, + methods = secondFactorMethods(secondFactor) + ) + } + } + + suspend fun completeTwoFactor(pendingSession: ProtonSession, code: String): ProtonSession { + require(code.isNotBlank()) { "Proton two-factor code is required" } + val response = postJson( + path = "auth/v4/2fa", + body = JSONObject().put("TwoFactorCode", code), + session = pendingSession + ) + val scopes = response.optJSONArray("Scopes").strings() + return pendingSession.copy(scopes = if (scopes.isEmpty()) pendingSession.scopes else scopes) + } + + suspend fun refreshSession(session: ProtonSession): ProtonSession { + val response = postJson( + path = "auth/v4/refresh", + body = JSONObject() + .put("UID", session.uid) + .put("RefreshToken", session.refreshToken) + .put("ResponseType", "token") + .put("GrantType", "refresh_token") + .put("RedirectURI", "http://protonmail.ch"), + session = session + ) + return session.copy( + accessToken = requiredString(response, "AccessToken"), + refreshToken = requiredString(response, "RefreshToken"), + tokenType = requiredString(response, "TokenType"), + scopes = response.optJSONArray("Scopes").strings().ifEmpty { session.scopes } + ) + } + + private suspend fun postJson(path: String, body: JSONObject, session: ProtonSession? = null): JSONObject = + requestJson( + Request.Builder() + .url(resolve(path)) + .post(body.toString().toRequestBody(JSON)) + .apply { addHeaders(session) } + .build() + ) + + private suspend fun requestJson(request: Request): JSONObject = withContext(Dispatchers.IO) { + httpClient.newCall(request).execute().use { response -> + val text = response.body?.string().orEmpty() + val json = try { JSONObject(text) } catch (_: Throwable) { JSONObject() } + if (!response.isSuccessful) + throw ProtonApiException( + response.code, + if (json.has("Code")) json.optInt("Code") else null, + json.optString("Message", "Proton request failed (${response.code})") + ) + if (json.has("Code") && json.optInt("Code") != 1000) + throw ProtonApiException( + response.code, + json.optInt("Code"), + json.optString("Message", "Proton API rejected the request") + ) + json + } + } + + private fun Request.Builder.addHeaders(session: ProtonSession?) { + header("x-pm-appversion", appVersion) + header("x-pm-client", "android-vpn") + if (session != null) { + header("Authorization", session.authorizationHeader()) + header("x-pm-uid", session.uid) + } + } + + private fun resolve(path: String): HttpUrl = + requireNotNull(baseUrl.resolve(path)) { "Invalid Proton API path: $path" } + + private fun parseSession(json: JSONObject): ProtonSession = ProtonSession( + uid = requiredString(json, "UID"), + userId = requiredString(json, "UserID"), + accessToken = requiredString(json, "AccessToken"), + refreshToken = requiredString(json, "RefreshToken"), + tokenType = requiredString(json, "TokenType"), + scopes = json.optJSONArray("Scopes").strings() + ) + + companion object { + private val JSON = "application/json; charset=utf-8".toMediaType() + val DEFAULT_BASE_URL: HttpUrl = "https://vpn-api.proton.me/".toHttpUrlCompat() + + private fun defaultSrpProofGenerator() = ProtonSrpProofGenerator { username, password, + version, salt, + modulus, + serverEphemeral -> + val proofs = GOpenPGPSrpCrypto(DefaultDispatcherProvider()).generateSrpProofs( + username, password, version, salt, modulus, serverEphemeral) + ProtonSrpProofs( + proofs.clientEphemeral, + proofs.clientProof, + proofs.expectedServerProof + ) + } + + private fun secondFactorMethods(json: JSONObject): List { + val scopes = json.optJSONArray("Scopes").strings() + if (scopes.isNotEmpty()) return scopes + val enabled = json.optInt("Enabled", 0) + return buildList { + if (enabled and 0b01 != 0) add("totp") + if (enabled and 0b10 != 0) add("security-key") + } + } + + private fun String.toHttpUrlCompat(): HttpUrl = + this.toHttpUrl() + } +} + +private object ProtonChallengePayload { + private const val VERSION = "2.0.7" + + fun device(): JSONObject { + val timezone = TimeZone.getDefault() + val language = Locale.getDefault().toLanguageTag() + val region = Locale.getDefault().country + return JSONObject() + .put("v", VERSION) + .put("appLang", language) + .put("timezone", timezone.id) + .put("deviceName", Build.MODEL.hashCode().toLong()) + .put("regionCode", region) + .put("timezoneOffset", timezone.getOffset(System.currentTimeMillis()) / 60_000) + .put("isJailbreak", false) + .put("preferredContentSize", "1") + .put("storageCapacity", 0.0) + .put("isDarkmodeOn", false) + .put("keyboards", JSONArray()) + } +} + +private fun JSONArray?.strings(): List { + if (this == null) return emptyList() + val result = ArrayList(length()) + for (i in 0 until length()) optString(i).takeIf { it.isNotEmpty() }?.let(result::add) + return result +} + +private fun requiredString(json: JSONObject, name: String): String = + json.optString(name, "").takeIf { it.isNotBlank() } + ?: throw ProtonApiException(200, null, "Proton response omitted $name") + +private fun requiredLong(json: JSONObject, name: String): Long { + val value = if (json.has(name)) { + json.optLong(name, Long.MIN_VALUE).takeIf { it != Long.MIN_VALUE } + } else { + null + } + return value ?: throw ProtonApiException(200, null, "Proton response omitted $name") +} + +private fun constantTimeEquals(left: String, right: String): Boolean = + MessageDigest.isEqual(left.toByteArray(Charsets.UTF_8), right.toByteArray(Charsets.UTF_8)) diff --git a/app/src/main/java/net/kollnig/missioncontrol/wg/proton/ProtonModels.kt b/app/src/main/java/net/kollnig/missioncontrol/wg/proton/ProtonModels.kt new file mode 100644 index 000000000..95cca6923 --- /dev/null +++ b/app/src/main/java/net/kollnig/missioncontrol/wg/proton/ProtonModels.kt @@ -0,0 +1,89 @@ +package net.kollnig.missioncontrol.wg.proton + +/** The minimum session state needed by the Proton VPN API. */ +data class ProtonSession( + val uid: String, + val userId: String, + val accessToken: String, + val refreshToken: String, + val tokenType: String, + val scopes: List +) { + fun authorizationHeader(): String = "$tokenType $accessToken" +} + +data class ProtonSrpProofs( + val clientEphemeral: String, + val clientProof: String, + val expectedServerProof: String +) + +fun interface ProtonSrpProofGenerator { + suspend fun generate( + username: String, + password: ByteArray, + version: Long, + salt: String, + modulus: String, + serverEphemeral: String + ): ProtonSrpProofs +} + +sealed class ProtonLoginResult { + data class Authenticated(val session: ProtonSession) : ProtonLoginResult() + + /** Login succeeded at the SRP layer but Proton requires a second factor. */ + data class TwoFactorRequired( + val pendingSession: ProtonSession, + val methods: List + ) : ProtonLoginResult() +} + +data class ProtonCertificate( + val certificate: String, + val expirationTimeMs: Long, + val refreshTimeMs: Long +) + +data class ProtonKeyMaterial( + val privateKey: String, + val publicKeyPem: String +) + +data class ProtonConnectingDomain( + val id: String, + val domain: String, + val entryIp: String?, + val publicKeyX25519: String?, + val online: Boolean, + val wireGuardPorts: List +) + +data class ProtonLogicalServer( + val id: String, + val name: String, + val entryCountry: String, + val exitCountry: String, + val domains: List +) + +data class ProtonWireGuardEndpoint( + val serverId: String, + val serverName: String, + val publicKey: String, + val endpointHost: String, + val endpointPort: Int +) + +data class ProtonGeneratedProfile( + val config: String, + val certificate: ProtonCertificate, + val endpoint: ProtonWireGuardEndpoint, + val keyMaterial: ProtonKeyMaterial +) + +class ProtonApiException( + val statusCode: Int, + val apiCode: Int?, + override val message: String +) : java.io.IOException(message) diff --git a/app/src/main/java/net/kollnig/missioncontrol/wg/proton/ProtonProfileRefresher.kt b/app/src/main/java/net/kollnig/missioncontrol/wg/proton/ProtonProfileRefresher.kt new file mode 100644 index 000000000..72b7b008d --- /dev/null +++ b/app/src/main/java/net/kollnig/missioncontrol/wg/proton/ProtonProfileRefresher.kt @@ -0,0 +1,35 @@ +package net.kollnig.missioncontrol.wg.proton + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Refreshes a Proton WireGuard profile as one operation. + * + * The mutex prevents simultaneous connectivity-failure callbacks from + * rotating the client certificate or replacing the profile twice. A 401 + * refreshes the Proton session once and retries the VPN request; other API + * errors are surfaced to the caller so WgEgress can retain its fail-closed + * behavior. + */ +class ProtonProfileRefresher( + private val authClient: ProtonAuthClient, + private val vpnClient: ProtonVpnClient, + initialSession: ProtonSession, + private val keyMaterial: ProtonKeyMaterial +) { + private val mutex = Mutex() + @Volatile private var session: ProtonSession = initialSession + + fun currentSession(): ProtonSession = session + + suspend fun refresh(preferredServerId: String? = null): ProtonGeneratedProfile = mutex.withLock { + try { + vpnClient.refreshProfile(session, keyMaterial, preferredServerId) + } catch (error: ProtonApiException) { + if (error.statusCode != 401) throw error + session = authClient.refreshSession(session) + vpnClient.refreshProfile(session, keyMaterial, preferredServerId) + } + } +} diff --git a/app/src/main/java/net/kollnig/missioncontrol/wg/proton/ProtonVpnClient.kt b/app/src/main/java/net/kollnig/missioncontrol/wg/proton/ProtonVpnClient.kt new file mode 100644 index 000000000..e14368df4 --- /dev/null +++ b/app/src/main/java/net/kollnig/missioncontrol/wg/proton/ProtonVpnClient.kt @@ -0,0 +1,182 @@ +package net.kollnig.missioncontrol.wg.proton + +import android.os.Build +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.json.JSONObject +import net.kollnig.missioncontrol.BuildConfig + +/** VPN-specific certificate/server API and standard WireGuard profile builder. */ +class ProtonVpnClient( + private val httpClient: OkHttpClient = OkHttpClient(), + private val baseUrl: HttpUrl = ProtonAuthClient.DEFAULT_BASE_URL, + private val deviceName: String = Build.MODEL ?: "Android" +) { + suspend fun fetchCertificate( + session: ProtonSession, + publicKeyPem: String + ): ProtonCertificate { + val response = request( + Request.Builder() + .url(resolve("vpn/v1/certificate")) + .post(JSONObject() + .put("ClientPublicKey", publicKeyPem) + .put("ClientPublicKeyMode", "EC") + .put("DeviceName", deviceName) + .put("Mode", "session") + .put("Features", JSONArray()) + .toString().toRequestBody(JSON)) + .authenticated(session) + .build() + ) + return ProtonCertificate( + certificate = requiredString(response, "Certificate"), + expirationTimeMs = response.optLong("ExpirationTime") * 1000L, + refreshTimeMs = response.optLong("RefreshTime") * 1000L + ) + } + + suspend fun fetchLogicalServers(session: ProtonSession): List { + val url = resolve("vpn/v2/logicals").newBuilder() + .addQueryParameter("WithEntriesForProtocols", "WireGuardUDP") + .addQueryParameter("WithState", "true") + .build() + val response = request( + Request.Builder().url(url).authenticated(session).get().build() + ) + val servers = response.optJSONArray("LogicalServers") ?: return emptyList() + return (0 until servers.length()).mapNotNull { parseLogicalServer(servers.optJSONObject(it)) } + } + + suspend fun refreshProfile( + session: ProtonSession, + keyMaterial: ProtonKeyMaterial, + preferredServerId: String? = null + ): ProtonGeneratedProfile { + val certificate = fetchCertificate(session, keyMaterial.publicKeyPem) + val servers = fetchLogicalServers(session) + val endpoint = chooseEndpoint(servers, preferredServerId) + return ProtonGeneratedProfile( + config = buildWireGuardConfig(keyMaterial.privateKey, endpoint), + certificate = certificate, + endpoint = endpoint, + keyMaterial = keyMaterial + ) + } + + fun buildWireGuardConfig( + privateKey: String, + endpoint: ProtonWireGuardEndpoint, + address: String = "10.2.0.2/32", + dns: String = "10.2.0.1" + ): String = buildString { + appendLine("[Interface]") + appendLine("PrivateKey = $privateKey") + appendLine("Address = $address") + appendLine("DNS = $dns") + appendLine() + appendLine("[Peer]") + appendLine("PublicKey = ${endpoint.publicKey}") + appendLine("AllowedIPs = 0.0.0.0/0, ::/0") + appendLine("Endpoint = ${formatEndpointHost(endpoint.endpointHost)}:${endpoint.endpointPort}") + appendLine("PersistentKeepalive = 60") + } + + private fun parseLogicalServer(json: JSONObject?): ProtonLogicalServer? { + if (json == null) return null + val domainsJson = json.optJSONArray("Servers") ?: return null + val domains = (0 until domainsJson.length()).mapNotNull { parseDomain(domainsJson.optJSONObject(it)) } + return ProtonLogicalServer( + id = json.optString("ID"), + name = json.optString("Name"), + entryCountry = json.optString("EntryCountry"), + exitCountry = json.optString("ExitCountry"), + domains = domains + ).takeIf { it.id.isNotBlank() && it.domains.isNotEmpty() } + } + + private fun parseDomain(json: JSONObject?): ProtonConnectingDomain? { + if (json == null) return null + val protocolEntries = json.optJSONObject("EntryPerProtocol") + val protocol = protocolEntries?.keys()?.asSequence() + ?.firstOrNull { it.equals("WireGuardUDP", ignoreCase = true) || + it.equals("wireguard", ignoreCase = true) } + ?.let { protocolEntries.optJSONObject(it) } + val ports = protocol?.optJSONArray("Ports")?.let { array -> + (0 until array.length()).mapNotNull { array.optInt(it).takeIf { port -> port > 0 } } + } ?: emptyList() + return ProtonConnectingDomain( + id = json.optString("ID"), + domain = json.optString("Domain"), + entryIp = protocol?.optString("IPv4")?.takeIf { it.isNotBlank() } + ?: json.optString("EntryIP").takeIf { it.isNotBlank() } + ?: json.optString("Domain").takeIf { it.isNotBlank() }, + publicKeyX25519 = json.optString("X25519PublicKey").takeIf { it.isNotBlank() }, + online = json.optInt("Status", 1) != 0, + wireGuardPorts = ports + ) + } + + private fun chooseEndpoint( + servers: List, + preferredServerId: String? + ): ProtonWireGuardEndpoint { + val candidates = servers.asSequence() + .flatMap { server -> server.domains.asSequence().map { server to it } } + .filter { (_, domain) -> domain.online && !domain.entryIp.isNullOrBlank() && + !domain.publicKeyX25519.isNullOrBlank() } + .toList() + val allCandidates = candidates.toList() + val (server, domain) = allCandidates.firstOrNull { (server, _) -> + preferredServerId != null && server.id == preferredServerId + } ?: allCandidates.firstOrNull() + ?: throw ProtonApiException(200, null, "Proton returned no online WireGuard server") + return ProtonWireGuardEndpoint( + serverId = server.id, + serverName = server.name, + publicKey = domain.publicKeyX25519!!, + endpointHost = domain.entryIp!!, + endpointPort = domain.wireGuardPorts.firstOrNull() ?: 51820 + ) + } + + private suspend fun request(request: Request): JSONObject = withContext(Dispatchers.IO) { + httpClient.newCall(request).execute().use { response -> + val text = response.body?.string().orEmpty() + val json = try { JSONObject(text) } catch (_: Throwable) { JSONObject() } + if (!response.isSuccessful) + throw ProtonApiException(response.code, json.optInt("Code"), + json.optString("Message", "Proton VPN request failed (${response.code})")) + if (json.has("Code") && json.optInt("Code") != 1000) + throw ProtonApiException(response.code, json.optInt("Code"), + json.optString("Message", "Proton VPN API rejected the request")) + json + } + } + + private fun Request.Builder.authenticated(session: ProtonSession): Request.Builder = + header("Authorization", session.authorizationHeader()) + .header("x-pm-uid", session.uid) + .header("x-pm-appversion", "android-vpn@${BuildConfig.VERSION_NAME}") + .header("x-pm-client", "android-vpn") + + private fun resolve(path: String): HttpUrl = + requireNotNull(baseUrl.resolve(path)) { "Invalid Proton API path: $path" } + + companion object { + private val JSON = "application/json; charset=utf-8".toMediaType() + } +} + +private fun formatEndpointHost(host: String): String = + if (host.contains(':') && !host.startsWith('[')) "[$host]" else host + +private fun requiredString(json: JSONObject, name: String): String = + json.optString(name, "").takeIf { it.isNotBlank() } + ?: throw ProtonApiException(200, null, "Proton response omitted $name") diff --git a/app/src/test/java/net/kollnig/missioncontrol/wg/proton/ProtonAuthClientTest.kt b/app/src/test/java/net/kollnig/missioncontrol/wg/proton/ProtonAuthClientTest.kt new file mode 100644 index 000000000..582529902 --- /dev/null +++ b/app/src/test/java/net/kollnig/missioncontrol/wg/proton/ProtonAuthClientTest.kt @@ -0,0 +1,126 @@ +package net.kollnig.missioncontrol.wg.proton + +import kotlinx.coroutines.runBlocking +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ProtonAuthClientTest { + private lateinit var server: MockWebServer + + @Before + fun setUp() { + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.close() + } + + @Test + fun loginUsesProtonSrpExchangeAndValidatesProof() = runBlocking { + server.enqueue(MockResponse.Builder().code(200).body( + """{"Code":1000,"Version":4,"Salt":"salt","Modulus":"modulus","ServerEphemeral":"ephemeral","SRPSession":"srp"}""" + ).build()) + server.enqueue(MockResponse.Builder().code(200).body( + """{"Code":1000,"AccessToken":"access","RefreshToken":"refresh","TokenType":"Bearer","UID":"uid","UserID":"user","Scopes":["vpn"],"ServerProof":"expected"}""" + ).build()) + + var receivedPassword = byteArrayOf() + val client = ProtonAuthClient( + baseUrl = server.url("/"), + srpProofGenerator = ProtonSrpProofGenerator { _, password, version, salt, modulus, ephemeral -> + receivedPassword = password.copyOf() + assertEquals(4L, version) + assertEquals("salt", salt) + assertEquals("modulus", modulus) + assertEquals("ephemeral", ephemeral) + ProtonSrpProofs("client-ephemeral", "client-proof", "expected") + }, + payloadFactory = { JSONObject().put("v", "test") }, + appVersion = "android-vpn@test" + ) + + val result = client.login("alice", "secret".toCharArray()) + assertTrue(result is ProtonLoginResult.Authenticated) + val session = (result as ProtonLoginResult.Authenticated).session + assertEquals("uid", session.uid) + assertEquals("Bearer access", session.authorizationHeader()) + assertEquals("secret", String(receivedPassword, Charsets.UTF_8)) + + val info = server.takeRequest() + assertEquals("/auth/v4/info", info.url.encodedPath) + assertTrue(info.body!!.utf8().contains("\"Username\":\"alice\"")) + assertEquals("android-vpn@test", info.headers["x-pm-appversion"]) + + val login = server.takeRequest() + assertEquals("/auth/v4", login.url.encodedPath) + val body = JSONObject(login.body!!.utf8()) + assertEquals("client-proof", body.getString("ClientProof")) + assertEquals("test", body.getJSONObject("Payload") + .getJSONObject("vpn-android-v4-challenge-0").getString("v")) + } + + @Test + fun loginRejectsUnexpectedServerProof() { + server.enqueue(MockResponse.Builder().code(200).body( + """{"Code":1000,"Version":4,"Salt":"salt","Modulus":"modulus","ServerEphemeral":"ephemeral","SRPSession":"srp"}""" + ).build()) + server.enqueue(MockResponse.Builder().code(200).body( + """{"Code":1000,"AccessToken":"access","RefreshToken":"refresh","TokenType":"Bearer","UID":"uid","UserID":"user","Scopes":[],"ServerProof":"wrong"}""" + ).build()) + + assertThrows(ProtonApiException::class.java) { + runBlocking { + ProtonAuthClient( + baseUrl = server.url("/"), + srpProofGenerator = ProtonSrpProofGenerator { _, _, _, _, _, _ -> + ProtonSrpProofs("e", "p", "expected") + } + ).login("alice", "secret".toCharArray()) + } + } + } + + @Test + fun twoFactorAndRefreshUseAuthenticatedSession() = runBlocking { + server.enqueue(MockResponse.Builder().code(200).body( + """{"Code":1000,"Version":4,"Salt":"salt","Modulus":"modulus","ServerEphemeral":"ephemeral","SRPSession":"srp"}""" + ).build()) + server.enqueue(MockResponse.Builder().code(200).body( + """{"Code":1000,"AccessToken":"access","RefreshToken":"refresh","TokenType":"Bearer","UID":"uid","UserID":"user","Scopes":[],"ServerProof":"expected","2FA":{"Scopes":["totp"]}}""" + ).build()) + server.enqueue(MockResponse.Builder().code(200).body( + """{"Code":1000,"Scope":"totp","Scopes":["vpn"]}""" + ).build()) + + val client = ProtonAuthClient( + baseUrl = server.url("/") , + srpProofGenerator = ProtonSrpProofGenerator { _, _, _, _, _, _ -> + ProtonSrpProofs("e", "p", "expected") + } + ) + val pending = client.login("alice", "secret".toCharArray()) as ProtonLoginResult.TwoFactorRequired + val authenticated = client.completeTwoFactor(pending.pendingSession, "123456") + assertEquals(listOf("vpn"), authenticated.scopes) + + val request = server.takeRequest() + server.takeRequest() // login + val secondFactor = server.takeRequest() + assertEquals("/auth/v4/2fa", secondFactor.url.encodedPath) + assertEquals("Bearer access", secondFactor.headers["Authorization"]) + assertEquals("123456", JSONObject(secondFactor.body!!.utf8()).getString("TwoFactorCode")) + assertEquals("/auth/v4/info", request.url.encodedPath) + } +} diff --git a/app/src/test/java/net/kollnig/missioncontrol/wg/proton/ProtonVpnClientTest.kt b/app/src/test/java/net/kollnig/missioncontrol/wg/proton/ProtonVpnClientTest.kt new file mode 100644 index 000000000..426afe8aa --- /dev/null +++ b/app/src/test/java/net/kollnig/missioncontrol/wg/proton/ProtonVpnClientTest.kt @@ -0,0 +1,101 @@ +package net.kollnig.missioncontrol.wg.proton + +import kotlinx.coroutines.runBlocking +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import net.kollnig.missioncontrol.wg.WgConfigParser + +@RunWith(RobolectricTestRunner::class) +class ProtonVpnClientTest { + private lateinit var server: MockWebServer + + @Before + fun setUp() { + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.close() + } + + @Test + fun refreshProfileFetchesCertificateAndWireGuardServer() = runBlocking { + server.enqueue(MockResponse.Builder().code(200).body( + """{"Code":1000,"Certificate":"cert","ExpirationTime":200,"RefreshTime":100}""" + ).build()) + server.enqueue(MockResponse.Builder().code(200).body( + """{"Code":1000,"LogicalServers":[{"ID":"server-1","Name":"NL-FREE#1","EntryCountry":"NL","ExitCountry":"NL","Servers":[{"ID":"entry-1","Domain":"entry.example","EntryPerProtocol":{"wireguard":{"IPv4":"198.51.100.2","Ports":[51820]}},"X25519PublicKey":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=","Status":1}]}]}""" + ).build()) + + val session = ProtonSession("uid", "user", "access", "refresh", "Bearer", listOf("vpn")) + val keys = ProtonKeyMaterial( + privateKey = KEY, + publicKeyPem = "public-pem" + ) + val profile = ProtonVpnClient(baseUrl = server.url("/")).refreshProfile(session, keys) + + assertEquals("cert", profile.certificate.certificate) + assertEquals("198.51.100.2", profile.endpoint.endpointHost) + assertEquals(51820, profile.endpoint.endpointPort) + val parsed = WgConfigParser.parse(profile.config) + assertEquals(KEY, parsed.privateKey) + assertEquals("198.51.100.2:51820", parsed.peers.first().endpoint) + assertTrue(parsed.peers.first().allowedIPs.contains("0.0.0.0/0")) + + val certificateRequest = server.takeRequest() + assertEquals("/vpn/v1/certificate", certificateRequest.url.encodedPath) + assertEquals("Bearer access", certificateRequest.headers["Authorization"]) + assertEquals("public-pem", JSONObject(certificateRequest.body!!.utf8()) + .getString("ClientPublicKey")) + val logicalRequest = server.takeRequest() + assertEquals("/vpn/v2/logicals", logicalRequest.url.encodedPath) + assertEquals("WireGuardUDP", logicalRequest.url.queryParameter("WithEntriesForProtocols")) + } + + @Test + fun profileRefresherRenewsSessionAfterUnauthorizedResponse() = runBlocking { + server.enqueue(MockResponse.Builder().code(401).body( + """{"Code":1003,"Message":"Expired session"}""" + ).build()) + server.enqueue(MockResponse.Builder().code(200).body( + """{"Code":1000,"AccessToken":"new-access","RefreshToken":"new-refresh","TokenType":"Bearer","UID":"uid","Scopes":["vpn"]}""" + ).build()) + server.enqueue(MockResponse.Builder().code(200).body( + """{"Code":1000,"Certificate":"cert","ExpirationTime":200,"RefreshTime":100}""" + ).build()) + server.enqueue(MockResponse.Builder().code(200).body( + """{"Code":1000,"LogicalServers":[{"ID":"server-1","Name":"NL-FREE#1","EntryCountry":"NL","ExitCountry":"NL","Servers":[{"ID":"entry-1","Domain":"entry.example","EntryIP":"198.51.100.2","X25519PublicKey":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=","Status":1}]}]}""" + ).build()) + + val initial = ProtonSession("uid", "user", "old-access", "old-refresh", "Bearer", listOf("vpn")) + val refresher = ProtonProfileRefresher( + ProtonAuthClient(baseUrl = server.url("/")), + ProtonVpnClient(baseUrl = server.url("/")), + initial, + ProtonKeyMaterial(KEY, "public-pem") + ) + val profile = refresher.refresh() + + assertEquals("new-access", refresher.currentSession().accessToken) + assertEquals("198.51.100.2:51820", WgConfigParser.parse(profile.config) + .peers.first().endpoint) + assertEquals("/vpn/v1/certificate", server.takeRequest().url.encodedPath) + assertEquals("/auth/v4/refresh", server.takeRequest().url.encodedPath) + assertEquals("/vpn/v1/certificate", server.takeRequest().url.encodedPath) + assertEquals("/vpn/v2/logicals", server.takeRequest().url.encodedPath) + } + + companion object { + private const val KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + } +}