Skip to content
Draft
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
309 changes: 295 additions & 14 deletions app/src/main/java/net/kollnig/missioncontrol/vpn/VpnFragment.java

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package net.kollnig.missioncontrol.wg.proton

import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceManager
import kotlinx.coroutines.runBlocking
import net.kollnig.missioncontrol.wgbridge.Wgbridge
import org.json.JSONArray
import org.json.JSONObject
import java.util.Locale

/** Bridges the Proton API prototype to the Android profile UI. Passwords are never persisted. */
class ProtonAccountManager @JvmOverloads constructor(
context: Context,
private val authClient: ProtonAuthClient = ProtonAuthClient(),
private val vpnClient: ProtonVpnClient = ProtonVpnClient(),
private val keyFactory: ProtonKeyFactory = NativeProtonKeyFactory,
private val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
) {
data class Country(val serverId: String, val code: String, val name: String)

fun login(username: String, password: CharArray): ProtonLoginResult = runBlocking {
authClient.login(username.trim(), password)
}.also { result ->
if (result is ProtonLoginResult.Authenticated)
saveAuthenticated(username, result.session)
}

fun completeTwoFactor(username: String, pending: ProtonSession, code: String): ProtonSession =
runBlocking { authClient.completeTwoFactor(pending, code) }
.also { saveAuthenticated(username, it) }

fun fetchCountries(): List<Country> {
val session = requireSession()
return runBlocking { vpnClient.fetchLogicalServers(session) }
.asSequence()
.filter { server -> server.exitCountry.isNotBlank() }
.groupBy { server -> server.exitCountry.uppercase(Locale.ROOT) }
.map { (code, servers) ->
val server = servers.first()
val countryName = Locale.Builder().setRegion(code).build().displayCountry
Country(server.id, code, countryName.ifBlank { code })
}
.sortedBy { it.name }
}

fun generateProfile(preferredServerId: String?): ProtonGeneratedProfile {
val session = requireSession()
val keys = loadOrCreateKeys()
val refresher = ProtonProfileRefresher(authClient, vpnClient, session, keys)
return runBlocking { refresher.refresh(preferredServerId) }.also {
saveSession(refresher.currentSession())
}
}

fun hasSession(): Boolean = loadSession() != null

fun username(): String = prefs.getString(PREF_USERNAME, "").orEmpty()

fun clear() {
prefs.edit()
.remove(PREF_USERNAME)
.remove(PREF_SESSION)
.remove(PREF_PRIVATE_KEY)
.remove(PREF_PUBLIC_KEY_PEM)
.apply()
}

private fun saveAuthenticated(username: String, session: ProtonSession) {
prefs.edit().putString(PREF_USERNAME, username.trim()).apply()
saveSession(session)
}

private fun saveSession(session: ProtonSession) {
prefs.edit().putString(PREF_SESSION, JSONObject()
.put("uid", session.uid)
.put("userId", session.userId)
.put("accessToken", session.accessToken)
.put("refreshToken", session.refreshToken)
.put("tokenType", session.tokenType)
.put("scopes", JSONArray(session.scopes))
.toString()).apply()
}

private fun requireSession(): ProtonSession = loadSession()
?: throw IllegalStateException("Sign in to Proton VPN first")

private fun loadSession(): ProtonSession? {
return try {
val raw = prefs.getString(PREF_SESSION, "").orEmpty()
if (raw.isBlank()) return null
val json = JSONObject(raw)
ProtonSession(
uid = json.getString("uid"),
userId = json.getString("userId"),
accessToken = json.getString("accessToken"),
refreshToken = json.getString("refreshToken"),
tokenType = json.getString("tokenType"),
scopes = json.optJSONArray("scopes").strings()
)
} catch (_: Throwable) {
null
}
}

private fun loadOrCreateKeys(): ProtonKeyMaterial {
val privateKey = prefs.getString(PREF_PRIVATE_KEY, "").orEmpty()
val publicPem = prefs.getString(PREF_PUBLIC_KEY_PEM, "").orEmpty()
if (privateKey.isNotBlank() && publicPem.isNotBlank())
return ProtonKeyMaterial(privateKey, publicPem)

val generated = keyFactory.generate()
prefs.edit()
.putString(PREF_PRIVATE_KEY, generated.privateKey)
.putString(PREF_PUBLIC_KEY_PEM, generated.publicKeyPem)
.apply()
return generated
}

companion object {
const val PREF_USERNAME = "proton_username"
const val PREF_SESSION = "proton_session"
const val PREF_PRIVATE_KEY = "proton_private_key"
const val PREF_PUBLIC_KEY_PEM = "proton_public_key_pem"

}
}

fun interface ProtonKeyFactory {
fun generate(): ProtonKeyMaterial
}

private object NativeProtonKeyFactory : ProtonKeyFactory {
override fun generate(): ProtonKeyMaterial = Wgbridge.generateProtonKeyPair().let {
ProtonKeyMaterial(it.privateKey, it.publicKeyPem)
}
}

private fun JSONArray?.strings(): List<String> {
if (this == null) return emptyList()
return (0 until length()).mapNotNull { index -> optString(index).takeIf { it.isNotBlank() } }
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,31 @@ private Wgbridge() {
*/
public static native String publicKey(String privateKey);

/**
* Generates an Ed25519 PKIX public identity and its converted X25519
* WireGuard private key, as required by Proton's certificate API.
*/
public static Ed25519WireGuardKeyPair generateProtonKeyPair() {
String encoded = generateEd25519WireGuardKeyPair();
int separator = encoded.indexOf('\n');
if (separator <= 0)
throw new IllegalStateException("Invalid Ed25519 keypair from native bridge");
return new Ed25519WireGuardKeyPair(
encoded.substring(0, separator), encoded.substring(separator + 1));
}

private static native String generateEd25519WireGuardKeyPair();

public static final class Ed25519WireGuardKeyPair {
public final String privateKey;
public final String publicKeyPem;

private Ed25519WireGuardKeyPair(String privateKey, String publicKeyPem) {
this.privateKey = privateKey;
this.publicKeyPem = publicKeyPem;
}
}

/**
* Boots gotatun.
*
Expand Down
24 changes: 24 additions & 0 deletions app/src/main/res/layout/item_vpn_intro.xml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,30 @@
android:text="@string/vpn_enter_ivpn_account" />
</LinearLayout>

<TextView
android:id="@+id/vpnIntroProtonTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="@string/vpn_intro_proton_title"
android:textAppearance="@style/TextMedium" />

<TextView
android:id="@+id/vpnIntroProtonBody"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/vpn_intro_proton_body"
android:textAppearance="@style/TextSmall" />

<Button
android:id="@+id/vpnIntroProtonAction"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/vpn_sign_in_proton" />

<TextView
android:id="@+id/vpnIntroWireGuardTitle"
android:layout_width="match_parent"
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/res/layout/item_vpn_mode.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@
android:layout_weight="1"
android:text="@string/vpn_mode_ivpn" />

<com.google.android.material.button.MaterialButton
android:id="@+id/vpnModeProton"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/vpn_mode_proton" />

<com.google.android.material.button.MaterialButton
android:id="@+id/vpnModeWireGuard"
style="@style/Widget.Material3.Button.OutlinedButton"
Expand Down
14 changes: 14 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -244,23 +244,37 @@
<string name="vpn_choose_country">Choose country</string>
<string name="vpn_provider_mullvad">Mullvad VPN</string>
<string name="vpn_provider_ivpn">IVPN</string>
<string name="vpn_provider_proton">Proton VPN</string>
<string name="vpn_intro_title">VPN preview</string>
<string name="vpn_intro_body">A VPN sends your internet traffic through another server. This can hide your IP address from websites and your network provider, and lets you choose a country for your connection. This tab is new, so treat it as a preview.</string>
<string name="vpn_intro_mullvad_title">Mullvad</string>
<string name="vpn_intro_mullvad_body">A privacy-focused VPN provider. It costs €5 per month and uses account numbers instead of email addresses. Enter an account number, then choose a country in TrackerControl.</string>
<string name="vpn_intro_ivpn_title">IVPN</string>
<string name="vpn_intro_ivpn_body">A privacy-focused VPN provider with WireGuard support. Enter an IVPN account ID, then choose a country in TrackerControl.</string>
<string name="vpn_intro_proton_title">Proton VPN</string>
<string name="vpn_intro_proton_body">Sign in with a Proton account, then choose a country. Your password is used only for sign-in and is never saved.</string>
<string name="vpn_intro_wireguard_title">WireGuard</string>
<string name="vpn_intro_wireguard_body">Use this if you already have a WireGuard config from another VPN provider, your own server, or your workplace.</string>
<string name="vpn_enter_mullvad_account">Enter account number</string>
<string name="vpn_enter_ivpn_account">Enter account ID</string>
<string name="vpn_mode_mullvad">Mullvad</string>
<string name="vpn_mode_ivpn">IVPN</string>
<string name="vpn_mode_proton">Proton</string>
<string name="vpn_mode_wireguard">WireGuard</string>
<string name="vpn_settings">Mullvad settings</string>
<string name="vpn_settings_title">Mullvad settings</string>
<string name="vpn_ivpn_settings">IVPN settings</string>
<string name="vpn_ivpn_settings_title">IVPN settings</string>
<string name="vpn_proton_settings_title">Proton VPN settings</string>
<string name="vpn_proton_username_hint">Proton username or email</string>
<string name="vpn_proton_password_hint">Proton password</string>
<string name="vpn_proton_two_factor_hint">Two-factor code</string>
<string name="vpn_proton_signing_in">Signing in to Proton VPN…</string>
<string name="vpn_proton_signed_in">Signed in as %s</string>
<string name="vpn_proton_sign_out">Sign out</string>
<string name="vpn_proton_sign_in_failed">Proton VPN sign-in failed: %s</string>
<string name="vpn_sign_in_proton">Sign in to Proton VPN</string>
<string name="vpn_proton_profile_name">Proton VPN - %s</string>
<string name="vpn_account_number">Account: %s</string>
<string name="vpn_account_not_set">Account: not set</string>
<string name="vpn_account_show">Show</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package net.kollnig.missioncontrol.wg.proton

import android.content.Context
import androidx.preference.PreferenceManager
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 org.robolectric.RuntimeEnvironment

@RunWith(RobolectricTestRunner::class)
class ProtonAccountManagerTest {
private lateinit var server: MockWebServer
private lateinit var context: Context

@Before
fun setUp() {
server = MockWebServer()
server.start()
context = RuntimeEnvironment.getApplication()
PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit()
}

@After
fun tearDown() {
server.close()
}

@Test
fun loginPersistsSessionWithoutPassword() {
enqueueLogin()
val manager = manager()

manager.login("alice", "secret".toCharArray())

assertTrue(manager.hasSession())
assertEquals("alice", manager.username())
val stored = PreferenceManager.getDefaultSharedPreferences(context).all.toString()
assertTrue(!stored.contains("secret"))
}

@Test
fun countriesAndGeneratedProfileUsePersistedAccountAndKeys() {
enqueueLogin()
val manager = manager()
manager.login("alice", "secret".toCharArray())
enqueueServers()

val countries = manager.fetchCountries()
assertEquals(listOf("NL"), countries.map { it.code })

enqueueCertificateAndServers()
val profile = manager.generateProfile(countries.single().serverId)
assertTrue(profile.config.contains("PrivateKey = $KEY"))
assertTrue(profile.config.contains("Endpoint = 198.51.100.2:51820"))
server.takeRequest()
server.takeRequest()
server.takeRequest()
val certificateRequest = server.takeRequest()
assertEquals("/vpn/v1/certificate", certificateRequest.url.encodedPath)
}

private fun manager(): ProtonAccountManager {
val auth = ProtonAuthClient(
baseUrl = server.url("/"),
srpProofGenerator = ProtonSrpProofGenerator { _, _, _, _, _, _ ->
ProtonSrpProofs("ephemeral", "proof", "expected")
},
payloadFactory = { JSONObject().put("v", "test") }
)
return ProtonAccountManager(
context,
auth,
ProtonVpnClient(baseUrl = server.url("/")),
ProtonKeyFactory { ProtonKeyMaterial(KEY, PUBLIC_KEY_PEM) }
)
}

private fun enqueueLogin() {
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())
}

private fun enqueueCertificateAndServers() {
server.enqueue(MockResponse.Builder().code(200).body(
"""{"Code":1000,"Certificate":"cert","ExpirationTime":200,"RefreshTime":100}"""
).build())
enqueueServers()
}

private fun enqueueServers() {
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","EntryIP":"198.51.100.2","X25519PublicKey":"$KEY","Status":1}]}]}"""
).build())
}

companion object {
private const val KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
private const val PUBLIC_KEY_PEM =
"-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n-----END PUBLIC KEY-----"
}
}
2 changes: 2 additions & 0 deletions wgbridge-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions wgbridge-rs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ base64 = "0.22"
hex = "0.4"
ipnetwork = "0.21"
getrandom = "0.4"
curve25519-dalek = "4.1"
ring = "0.17"
libc = "0.2"
log = "0.4"

Expand Down
Loading