Skip to content
Open
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
16 changes: 10 additions & 6 deletions proton/vpn/backend/networkmanager/protocol/protun/protun.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
43 changes: 43 additions & 0 deletions tests/python/networkmanager/protun/test_protun.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down