diff --git a/proton/vpn/backend/networkmanager/protocol/protun/protun.py b/proton/vpn/backend/networkmanager/protocol/protun/protun.py index 52ee6c8..717ac80 100644 --- a/proton/vpn/backend/networkmanager/protocol/protun/protun.py +++ b/proton/vpn/backend/networkmanager/protocol/protun/protun.py @@ -59,7 +59,7 @@ logger = logging.getLogger(__name__) SERVICE_TYPE = "org.freedesktop.NetworkManager.protun" -STORE_PRIVATE_KEY_IN_KEYRING = "1" +SYSTEM_OWNED_PRIVATE_KEY = str(int(NM.SettingSecretFlags.NONE)) PRIVATE_KEY = "private-key" PRIVATE_KEY_FLAGS = "private-key-flags" @@ -142,7 +142,9 @@ def setup(self) -> Future: """Creates and registers the NM VPN connection.""" self._generate_connection() self._modify_connection() - return self.nm_client.add_connection_async(self.connection) + return self.nm_client.add_connection_async( + self.connection, save_to_disk=False + ) def start_connection_async(self, connection: NM.Connection) -> Future: """Activates the ProTun VPN plugin, explicitly passing the best physical @@ -304,16 +306,18 @@ def _set_vpn_settings(self): vpn_settings.add_data_item("settings", settings_str) - # The WireGuard private key is stored as a VPN secret. - # NM passes it to the protun auth-dialog which forwards it to the plugin. + # The WireGuard private key is stored as a VPN secret. NM passes it + # directly to the plugin for this unsaved connection. vpn_settings.add_secret( PRIVATE_KEY, self._vpncredentials.pubkey_credentials.wg_private_key ) - # Use the keyring to store the connection private key. + # Keep the key in NetworkManager instead of delegating it to a desktop + # secret agent. The profile is added with save_to_disk=False, so the + # key remains tied to the transient connection. vpn_settings.add_data_item(PRIVATE_KEY_FLAGS, - STORE_PRIVATE_KEY_IN_KEYRING) + SYSTEM_OWNED_PRIVATE_KEY) self.connection.add_setting(vpn_settings) diff --git a/tests/python/networkmanager/protun/test_protun.py b/tests/python/networkmanager/protun/test_protun.py index 6d38f81..658b6e1 100644 --- a/tests/python/networkmanager/protun/test_protun.py +++ b/tests/python/networkmanager/protun/test_protun.py @@ -18,11 +18,17 @@ """ from datetime import datetime from pathlib import Path +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch +import gi import pytest +gi.require_version("NM", "1.0") +from gi.repository import NM # pylint: disable=wrong-import-position + from proton.vpn.backend.networkmanager.protocol.protun.protun import ( + PRIVATE_KEY, PRIVATE_KEY_FLAGS, SYSTEM_OWNED_PRIVATE_KEY, Protun, ProtunUDP, ProtunTCP, ProtunTLS, ProtunSmart, generate_capture_path, ) from proton.vpn.backend.networkmanager.core.networkmanager import LinuxNetworkManager @@ -35,6 +41,7 @@ def test_generate_capture_path(): result = generate_capture_path("/tmp", datetime(2026, 4, 30, 14, 30, 45)) assert result == Path("/tmp/proton_vpn__2026_04_30__14_30_45.pcap") + # ─── Protun class methods ───────────────────────────────────────────────────── @pytest.fixture(autouse=True) @@ -101,6 +108,42 @@ def _make_instance(mode, directory_path="/tmp", max_bytes=512 * 1024 * 1024, pro return instance +def test_vpn_private_key_is_owned_by_transient_networkmanager_profile(): + instance = object.__new__(ProtunUDP) + instance.connection = NM.SimpleConnection.new() + instance._vpnserver = SimpleNamespace( + server_name="CH#1", + server_ip="192.0.2.1", + x25519pk="public-key", + wireguard_ports=SimpleNamespace(udp=[51820]), + ) + instance._vpncredentials = SimpleNamespace( + pubkey_credentials=SimpleNamespace(wg_private_key="private-key") + ) + + instance._set_vpn_settings() + + vpn_settings = instance.connection.get_setting_vpn() + assert vpn_settings.get_secret(PRIVATE_KEY) == "private-key" + assert vpn_settings.get_data_item(PRIVATE_KEY_FLAGS) == SYSTEM_OWNED_PRIVATE_KEY + assert SYSTEM_OWNED_PRIVATE_KEY == str(int(NM.SettingSecretFlags.NONE)) + + +def test_setup_explicitly_adds_an_unsaved_profile(): + instance = object.__new__(ProtunUDP) + instance.connection = MagicMock() + instance._generate_connection = MagicMock() + instance._modify_connection = MagicMock() + instance._LinuxNetworkManager__nm_client = MagicMock() + + result = instance.setup() + + assert result is instance.nm_client.add_connection_async.return_value + instance.nm_client.add_connection_async.assert_called_once_with( + instance.connection, save_to_disk=False + ) + + # ─── start_packet_capture ──────────────────────────────────────────────────── @pytest.mark.asyncio