diff --git a/app/src/main/java/net/kollnig/missioncontrol/vpn/VpnFragment.java b/app/src/main/java/net/kollnig/missioncontrol/vpn/VpnFragment.java index 2a145d51e..8cefe00f0 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/vpn/VpnFragment.java +++ b/app/src/main/java/net/kollnig/missioncontrol/vpn/VpnFragment.java @@ -44,6 +44,10 @@ import net.kollnig.missioncontrol.wg.MullvadProfileGenerator; import net.kollnig.missioncontrol.wg.WgConfigParser; import net.kollnig.missioncontrol.wg.WgProfileManager; +import net.kollnig.missioncontrol.wg.proton.ProtonAccountManager; +import net.kollnig.missioncontrol.wg.proton.ProtonGeneratedProfile; +import net.kollnig.missioncontrol.wg.proton.ProtonLoginResult; +import net.kollnig.missioncontrol.wg.proton.ProtonSession; import org.json.JSONException; @@ -62,16 +66,19 @@ public class VpnFragment extends Fragment implements SharedPreferences.OnSharedP private static final String PREF_VPN_MODE = "vpn_mode"; private static final String MODE_MULLVAD = "mullvad"; private static final String MODE_IVPN = "ivpn"; + private static final String MODE_PROTON = "proton"; private static final String MODE_WIREGUARD = "wireguard"; private SharedPreferences prefs; private WgProfileManager manager; + private ProtonAccountManager protonManager; private VpnAdapter adapter; private final Handler mainHandler = new Handler(Looper.getMainLooper()); private final ExecutorService executor = Executors.newSingleThreadExecutor(); private final List mullvadCountryCache = new ArrayList<>(); private final List ivpnCountryCache = new ArrayList<>(); + private final List protonCountryCache = new ArrayList<>(); private boolean loadingCountries; private boolean progressVisible; @@ -84,11 +91,17 @@ private static class VpnCountry { final String provider; final String code; final String name; + final String serverId; VpnCountry(String provider, String code, String name) { + this(provider, code, name, ""); + } + + VpnCountry(String provider, String code, String name, String serverId) { this.provider = provider; this.code = code == null ? "" : code.trim().toLowerCase(Locale.ROOT); this.name = name == null ? "" : name; + this.serverId = serverId == null ? "" : serverId; } } @@ -105,6 +118,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()); manager = new WgProfileManager(requireContext()); + protonManager = new ProtonAccountManager(requireContext()); manager.migrateIfNeeded(); RecyclerView list = view.findViewById(R.id.vpnList); @@ -153,8 +167,10 @@ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, Strin WgProfileManager.PREF_WG_PROFILES.equals(key) || PREF_VPN_MODE.equals(key) || WgProfileManager.PREF_MULLVAD_ACCOUNT.equals(key) || - WgProfileManager.PREF_IVPN_ACCOUNT.equals(key)) { - refreshUi(); + WgProfileManager.PREF_IVPN_ACCOUNT.equals(key) || + ProtonAccountManager.PREF_USERNAME.equals(key) || + ProtonAccountManager.PREF_SESSION.equals(key)) { + mainHandler.post(this::refreshUi); } } @@ -171,7 +187,7 @@ private void loadCountries(boolean force) { return; } - List cache = MODE_IVPN.equals(provider) ? ivpnCountryCache : mullvadCountryCache; + List cache = countryCache(provider); if (!force && !cache.isEmpty()) { adapter.setCountries(cache); return; @@ -225,7 +241,11 @@ private void loadCountries(boolean force) { private List fetchCountryOptions(String provider) throws Exception { List countries = new ArrayList<>(); - if (MODE_IVPN.equals(provider)) { + if (MODE_PROTON.equals(provider)) { + for (ProtonAccountManager.Country country : protonManager.fetchCountries()) + countries.add(new VpnCountry(MODE_PROTON, country.getCode(), country.getName(), + country.getServerId())); + } else if (MODE_IVPN.equals(provider)) { for (IvpnProfileGenerator.CountryOption country : new IvpnProfileGenerator().fetchCountryOptions()) countries.add(new VpnCountry(MODE_IVPN, country.code, country.name)); @@ -237,6 +257,12 @@ private List fetchCountryOptions(String provider) throws Exception { return countries; } + private List countryCache(String provider) { + if (MODE_PROTON.equals(provider)) + return protonCountryCache; + return MODE_IVPN.equals(provider) ? ivpnCountryCache : mullvadCountryCache; + } + private List savedProviderCountries(String provider) { Map saved = new LinkedHashMap<>(); for (WgProfileManager.Profile profile : manager.getProfiles()) @@ -256,20 +282,24 @@ private boolean hasProviderAccount(String provider) { return !TextUtils.isEmpty(manager.getLastIvpnAccount()); if (MODE_MULLVAD.equals(provider)) return !TextUtils.isEmpty(manager.getLastMullvadAccount()); + if (MODE_PROTON.equals(provider)) + return protonManager.hasSession(); return false; } private List customProfiles() { List profiles = new ArrayList<>(); for (WgProfileManager.Profile profile : manager.getProfiles()) - if (!"mullvad".equals(profile.provider) && !"ivpn".equals(profile.provider)) + if (!"mullvad".equals(profile.provider) && !"ivpn".equals(profile.provider) && + !"proton".equals(profile.provider)) profiles.add(profile); return profiles; } private String currentProviderMode() { String mode = prefs.getString(PREF_VPN_MODE, ""); - if (MODE_MULLVAD.equals(mode) || MODE_IVPN.equals(mode) || MODE_WIREGUARD.equals(mode)) + if (MODE_MULLVAD.equals(mode) || MODE_IVPN.equals(mode) || MODE_PROTON.equals(mode) || + MODE_WIREGUARD.equals(mode)) return mode; WgProfileManager.Profile active = manager.getActiveProfile(); @@ -278,12 +308,16 @@ private String currentProviderMode() { return MODE_MULLVAD; if ("ivpn".equals(active.provider)) return MODE_IVPN; + if ("proton".equals(active.provider)) + return MODE_PROTON; return MODE_WIREGUARD; } if (hasMullvadSetup()) return MODE_MULLVAD; if (hasIvpnSetup()) return MODE_IVPN; + if (hasProtonSetup()) + return MODE_PROTON; if (!customProfiles().isEmpty()) return MODE_WIREGUARD; return MODE_MULLVAD; @@ -301,8 +335,13 @@ private boolean isMullvadMode() { return MODE_MULLVAD.equals(currentProviderMode()); } + private boolean isProtonMode() { + return MODE_PROTON.equals(currentProviderMode()); + } + private boolean isFirstTimeVpnSetup() { - return customProfiles().isEmpty() && !hasMullvadSetup() && !hasIvpnSetup(); + return customProfiles().isEmpty() && !hasMullvadSetup() && !hasIvpnSetup() && + !hasProtonSetup(); } private boolean hasMullvadSetup() { @@ -329,9 +368,19 @@ private boolean hasIvpnSetup() { return false; } + private boolean hasProtonSetup() { + if (protonManager.hasSession()) + return true; + for (WgProfileManager.Profile profile : manager.getProfiles()) + if ("proton".equals(profile.provider) && !TextUtils.isEmpty(profile.config)) + return true; + return false; + } + private String providerDisplayName(String provider) { - return MODE_IVPN.equals(provider) - ? getString(R.string.vpn_provider_ivpn) + if (MODE_PROTON.equals(provider)) + return getString(R.string.vpn_provider_proton); + return MODE_IVPN.equals(provider) ? getString(R.string.vpn_provider_ivpn) : getString(R.string.vpn_provider_mullvad); } @@ -355,6 +404,11 @@ private void openIvpnAccountPage() { Uri.parse("https://www.ivpn.net/pricing/"))); } + private void openProtonSetup() { + prefs.edit().putString(PREF_VPN_MODE, MODE_PROTON).apply(); + showProtonSettingsDialog(); + } + private void openWireGuardProfiles() { prefs.edit().putString(PREF_VPN_MODE, MODE_WIREGUARD).apply(); startActivity(new Intent(requireContext(), ActivityWireGuardProfiles.class)); @@ -383,6 +437,10 @@ private void activateCustomProfile(WgProfileManager.Profile profile) { } private void generateCountry(VpnCountry country) { + if (MODE_PROTON.equals(country.provider)) { + generateProtonCountry(country); + return; + } if (MODE_IVPN.equals(country.provider)) { generateIvpnCountry(country, "", ""); return; @@ -390,6 +448,41 @@ private void generateCountry(VpnCountry country) { generateMullvadCountry(country); } + private void generateProtonCountry(VpnCountry country) { + if (!protonManager.hasSession()) { + showProtonSettingsDialog(); + return; + } + if (!TextUtils.isEmpty(generatingCountryCode)) + return; + + generatingCountryCode = country.code; + generatingProvider = MODE_PROTON; + setProgress(getString(R.string.vpn_generating)); + executor.execute(() -> { + try { + ProtonGeneratedProfile generated = protonManager.generateProfile(country.serverId); + WgConfigParser.INSTANCE.parse(generated.getConfig()); + mainHandler.post(() -> { + if (isAdded()) + saveGeneratedProtonProfile(generated, country); + }); + } catch (Throwable ex) { + mainHandler.post(() -> { + if (!isAdded()) + return; + generatingCountryCode = ""; + generatingProvider = ""; + clearProgress(); + refreshUi(); + Toast.makeText(requireContext(), + getString(R.string.vpn_generation_failed, ex.getMessage()), + Toast.LENGTH_LONG).show(); + }); + } + }); + } + private void generateMullvadCountry(VpnCountry country) { String account = manager.getLastMullvadAccount(); if (TextUtils.isEmpty(account)) { @@ -537,6 +630,35 @@ private void saveGeneratedIvpnProfile(IvpnProfileGenerator.GeneratedProfile gene } } + private void saveGeneratedProtonProfile(ProtonGeneratedProfile generated, VpnCountry country) { + try { + WgProfileManager.Profile existing = null; + for (WgProfileManager.Profile profile : manager.getProfiles()) + if (MODE_PROTON.equals(profile.provider) && country.code.equals(profile.countryCode)) { + existing = profile; + break; + } + manager.saveProfile(existing == null ? null : existing.id, + getString(R.string.vpn_proton_profile_name, country.name), + generated.getConfig(), MODE_PROTON, protonManager.username(), + country.code, country.name); + prefs.edit() + .putBoolean("wg_enabled", true) + .putString(PREF_VPN_MODE, MODE_PROTON) + .apply(); + ServiceSinkhole.reload("wireguard profile changed", requireContext(), false); + generatingCountryCode = ""; + generatingProvider = ""; + clearProgress(); + refreshUi(); + } catch (JSONException ex) { + generatingCountryCode = ""; + generatingProvider = ""; + clearProgress(); + Toast.makeText(requireContext(), ex.toString(), Toast.LENGTH_LONG).show(); + } + } + private void refreshUi() { if (!isAdded() || adapter == null) return; @@ -787,6 +909,149 @@ private void showIvpnSettingsDialog(@Nullable IvpnProfileGenerator.CaptchaRequir dialog.show(); } + private void showProtonSettingsDialog() { + boolean signedIn = protonManager.hasSession(); + LinearLayout form = new LinearLayout(requireContext()); + form.setOrientation(LinearLayout.VERTICAL); + int pad = (int) (20 * getResources().getDisplayMetrics().density); + form.setPadding(pad, pad / 2, pad, 0); + + TextView current = new TextView(requireContext()); + current.setText(signedIn + ? getString(R.string.vpn_proton_signed_in, protonManager.username()) + : getString(R.string.vpn_intro_proton_body)); + current.setTextAppearance(requireContext(), R.style.TextSmall); + form.addView(current); + + EditText username = new EditText(requireContext()); + username.setSingleLine(true); + username.setInputType(InputType.TYPE_CLASS_TEXT | + InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS); + username.setHint(R.string.vpn_proton_username_hint); + username.setVisibility(signedIn ? View.GONE : View.VISIBLE); + form.addView(username); + + EditText password = new EditText(requireContext()); + password.setSingleLine(true); + password.setInputType(InputType.TYPE_CLASS_TEXT | + InputType.TYPE_TEXT_VARIATION_PASSWORD); + password.setHint(R.string.vpn_proton_password_hint); + password.setVisibility(signedIn ? View.GONE : View.VISIBLE); + form.addView(password); + + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.vpn_proton_settings_title) + .setView(form) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(signedIn ? android.R.string.ok : R.string.vpn_sign_in_proton, + null); + if (signedIn) + builder.setNeutralButton(R.string.vpn_proton_sign_out, null); + AlertDialog dialog = builder.create(); + dialog.setOnShowListener(d -> { + if (signedIn) { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> dialog.dismiss()); + dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener(v -> { + protonManager.clear(); + protonCountryCache.clear(); + refreshUi(); + dialog.dismiss(); + }); + return; + } + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> { + String name = username.getText().toString().trim(); + String secret = password.getText().toString(); + if (TextUtils.isEmpty(name)) { + username.setError(getString(R.string.vpn_proton_username_hint)); + return; + } + if (TextUtils.isEmpty(secret)) { + password.setError(getString(R.string.vpn_proton_password_hint)); + return; + } + password.setText(""); + dialog.dismiss(); + authenticateProton(name, secret.toCharArray()); + }); + }); + dialog.show(); + } + + private void authenticateProton(String username, char[] password) { + setProgress(getString(R.string.vpn_proton_signing_in)); + executor.execute(() -> { + try { + ProtonLoginResult result = protonManager.login(username, password); + mainHandler.post(() -> { + if (!isAdded()) + return; + clearProgress(); + if (result instanceof ProtonLoginResult.TwoFactorRequired) { + ProtonLoginResult.TwoFactorRequired pending = + (ProtonLoginResult.TwoFactorRequired) result; + showProtonTwoFactorDialog(username, pending.getPendingSession()); + } else { + protonCountryCache.clear(); + loadCountries(true); + refreshUi(); + } + }); + } catch (Throwable ex) { + mainHandler.post(() -> showProtonFailure(ex)); + } + }); + } + + private void showProtonTwoFactorDialog(String username, ProtonSession pending) { + EditText code = new EditText(requireContext()); + code.setSingleLine(true); + code.setInputType(InputType.TYPE_CLASS_NUMBER); + code.setHint(R.string.vpn_proton_two_factor_hint); + AlertDialog dialog = new MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.vpn_proton_settings_title) + .setView(code) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, null) + .create(); + dialog.setOnShowListener(d -> dialog.getButton(AlertDialog.BUTTON_POSITIVE) + .setOnClickListener(v -> { + String value = code.getText().toString().trim(); + if (TextUtils.isEmpty(value)) { + code.setError(getString(R.string.vpn_proton_two_factor_hint)); + return; + } + dialog.dismiss(); + setProgress(getString(R.string.vpn_proton_signing_in)); + executor.execute(() -> { + try { + protonManager.completeTwoFactor(username, pending, value); + mainHandler.post(() -> { + if (!isAdded()) + return; + clearProgress(); + protonCountryCache.clear(); + loadCountries(true); + refreshUi(); + }); + } catch (Throwable ex) { + mainHandler.post(() -> showProtonFailure(ex)); + } + }); + })); + dialog.show(); + } + + private void showProtonFailure(Throwable error) { + if (!isAdded()) + return; + clearProgress(); + Toast.makeText(requireContext(), + getString(R.string.vpn_proton_sign_in_failed, error.getMessage()), + Toast.LENGTH_LONG).show(); + refreshUi(); + } + @Nullable private Bitmap decodeCaptcha(String captchaImage) { if (TextUtils.isEmpty(captchaImage)) @@ -934,6 +1199,7 @@ private void bindIntro(IntroViewHolder holder) { holder.mullvadAccount.setOnClickListener(v -> openMullvadAccountPage()); holder.ivpn.setOnClickListener(v -> openIvpnSetup()); holder.ivpnAccount.setOnClickListener(v -> openIvpnAccountPage()); + holder.proton.setOnClickListener(v -> openProtonSetup()); holder.wireGuard.setOnClickListener(v -> openWireGuardProfiles()); } @@ -952,6 +1218,8 @@ private void bindMode(ModeViewHolder holder) { int checkedId = R.id.vpnModeMullvad; if (isIvpnMode()) checkedId = R.id.vpnModeIvpn; + else if (isProtonMode()) + checkedId = R.id.vpnModeProton; else if (isWireGuardMode()) checkedId = R.id.vpnModeWireGuard; holder.toggle.check(checkedId); @@ -962,6 +1230,8 @@ else if (isWireGuardMode()) setProviderMode(MODE_WIREGUARD); else if (buttonId == R.id.vpnModeIvpn) setProviderMode(MODE_IVPN); + else if (buttonId == R.id.vpnModeProton) + setProviderMode(MODE_PROTON); else setProviderMode(MODE_MULLVAD); }); @@ -972,9 +1242,10 @@ private void bindStatus(StatusViewHolder holder) { WgProfileManager.Profile active = manager.getActiveProfile(); boolean activeMullvad = active != null && "mullvad".equals(active.provider); boolean activeIvpn = active != null && "ivpn".equals(active.provider); + boolean activeProton = active != null && "proton".equals(active.provider); String countryName = activeMullvad ? active.countryName : ""; String countryCode = activeMullvad ? active.countryCode : ""; - if (activeIvpn) { + if (activeIvpn || activeProton) { countryName = active.countryName; countryCode = active.countryCode; } @@ -992,6 +1263,8 @@ private void bindStatus(StatusViewHolder holder) { Toast.LENGTH_LONG).show(); } else if (isIvpnMode() && TextUtils.isEmpty(manager.getLastIvpnAccount())) { showIvpnSettingsDialog(null, "", "", null); + } else if (isProtonMode() && !protonManager.hasSession()) { + showProtonSettingsDialog(); } else if (isMullvadMode() && TextUtils.isEmpty(manager.getLastMullvadAccount())) { showMullvadSettingsDialog(); } else { @@ -1009,12 +1282,16 @@ private void bindStatus(StatusViewHolder holder) { if (!isFirstTimeVpnSetup()) { holder.settings.setContentDescription(getString(isWireGuardMode() ? R.string.menu_settings - : isIvpnMode() ? R.string.vpn_ivpn_settings : R.string.vpn_settings)); + : isIvpnMode() ? R.string.vpn_ivpn_settings + : isProtonMode() ? R.string.vpn_proton_settings_title + : R.string.vpn_settings)); holder.settings.setOnClickListener(v -> { if (isWireGuardMode()) startActivity(new Intent(requireContext(), ActivitySettings.class)); else if (isIvpnMode()) showIvpnSettingsDialog(null, "", "", null); + else if (isProtonMode()) + showProtonSettingsDialog(); else showMullvadSettingsDialog(); }); @@ -1022,14 +1299,16 @@ else if (isIvpnMode()) holder.settings.setOnClickListener(null); } - if (enabled && (activeMullvad || activeIvpn) && !TextUtils.isEmpty(countryName)) { + if (enabled && (activeMullvad || activeIvpn || activeProton) && + !TextUtils.isEmpty(countryName)) { holder.flag.setText(flagEmoji(countryCode)); holder.flag.setVisibility(View.VISIBLE); holder.title.setText(countryName); String summary = manager.getProfileSummary(active); holder.summary.setText(TextUtils.isEmpty(summary) - ? (activeIvpn ? getString(R.string.vpn_provider_ivpn) : - getString(R.string.vpn_status_mullvad)) + ? (activeIvpn ? getString(R.string.vpn_provider_ivpn) + : activeProton ? getString(R.string.vpn_provider_proton) + : getString(R.string.vpn_status_mullvad)) : getString(R.string.vpn_status_relay, summary)); holder.summary.setVisibility(TextUtils.isEmpty(holder.summary.getText()) ? View.GONE : View.VISIBLE); @@ -1135,6 +1414,7 @@ private static class IntroViewHolder extends RecyclerView.ViewHolder { final Button mullvadAccount; final Button ivpn; final Button ivpnAccount; + final Button proton; final Button wireGuard; IntroViewHolder(View itemView) { @@ -1143,6 +1423,7 @@ private static class IntroViewHolder extends RecyclerView.ViewHolder { mullvadAccount = itemView.findViewById(R.id.vpnIntroMullvadAccountAction); ivpn = itemView.findViewById(R.id.vpnIntroIvpnAction); ivpnAccount = itemView.findViewById(R.id.vpnIntroIvpnAccountAction); + proton = itemView.findViewById(R.id.vpnIntroProtonAction); wireGuard = itemView.findViewById(R.id.vpnIntroWireGuardAction); } } diff --git a/app/src/main/java/net/kollnig/missioncontrol/wg/proton/ProtonAccountManager.kt b/app/src/main/java/net/kollnig/missioncontrol/wg/proton/ProtonAccountManager.kt new file mode 100644 index 000000000..8ee5a613f --- /dev/null +++ b/app/src/main/java/net/kollnig/missioncontrol/wg/proton/ProtonAccountManager.kt @@ -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 { + 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 { + if (this == null) return emptyList() + return (0 until length()).mapNotNull { index -> optString(index).takeIf { it.isNotBlank() } } +} diff --git a/app/src/main/java/net/kollnig/missioncontrol/wgbridge/Wgbridge.java b/app/src/main/java/net/kollnig/missioncontrol/wgbridge/Wgbridge.java index f855b97f4..b3797e3ec 100644 --- a/app/src/main/java/net/kollnig/missioncontrol/wgbridge/Wgbridge.java +++ b/app/src/main/java/net/kollnig/missioncontrol/wgbridge/Wgbridge.java @@ -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. * diff --git a/app/src/main/res/layout/item_vpn_intro.xml b/app/src/main/res/layout/item_vpn_intro.xml index 452944cb9..d80703e16 100644 --- a/app/src/main/res/layout/item_vpn_intro.xml +++ b/app/src/main/res/layout/item_vpn_intro.xml @@ -101,6 +101,30 @@ android:text="@string/vpn_enter_ivpn_account" /> + + + + +