diff --git a/README.md b/README.md index 6679672..58938b8 100644 --- a/README.md +++ b/README.md @@ -105,3 +105,114 @@ To check: `ip link show tun0` or `sudo cat /tmp/vpn-sophos.log`. ### Account lockout Sophos locks the account after several failed AUTH attempts. Wait ~5 minutes before retrying after multiple failures. + +--- + +## vpn-clip.py — GUI Clipboard Tool + +PyQt6 tool to manage VPN profiles (password + TOTP) and copy credentials to the +Wayland clipboard. Useful when `vpn-connect.sh` is not an option (e.g. manual +OpenVPN clients, other VPN systems). + +### Prerequisites + +```bash +# PyQt6 (usually already present on KDE Plasma) +sudo pacman -S python-pyqt6 + +# oathtool and wl-clipboard (wl-copy) — already needed by vpn-connect.sh +sudo pacman -S oath-toolkit wl-clipboard + +# Optional but strongly recommended: encrypt stored profiles +sudo pacman -S python-cryptography +# or via pip inside a venv: +pip install cryptography +``` + +> **Without `cryptography`:** profiles are stored as plain base64-encoded JSON — +> anyone with file access can read them. A warning banner is shown in the UI. +> Install `python-cryptography` to get real Fernet/AES encryption with +> PBKDF2-derived keys. + +### Usage + +```bash +python3 /path/to/fastvpn/vpn-clip.py +``` + +- **First run:** no profiles file exists → prompted to set a master password, + then immediately asked to create the first profile. +- **Subsequent runs:** enter master password to decrypt and load profiles. +- Select profile from dropdown. +- Click **PW+OTP kopieren** to concatenate password + current TOTP and pipe it + to `wl-copy`. If no OTP secret is set, only the password is copied. +- The timer label shows seconds remaining in the current 30s TOTP window + (turns red below 8s — consider waiting for the next window). +- Click **Profil bearbeiten** to open the profile manager (add / edit / delete). + +### Profile storage + +Profiles are stored in `vpn-profiles.enc` next to the script. + +With `cryptography` installed the file layout is: + +``` +[2 bytes: salt length] [16 bytes: random salt] [Fernet token] +``` + +Key derivation: PBKDF2-HMAC-SHA256, 480 000 iterations, 32-byte key. + +### Installation as desktop app + +To make `vpn-clip.py` appear in the application launcher and allow pinning to the taskbar: + +```bash +# 1. Make executable +chmod +x /path/to/fastvpn/vpn-clip.py + +# 2. Create .desktop entry (adjust Exec= path) +cat > ~/.local/share/applications/vpn-clip.desktop << 'EOF' +[Desktop Entry] +Version=1.0 +Type=Application +Name=VPN Clip +Comment=VPN password+OTP clipboard tool +Exec=/path/to/fastvpn/vpn-clip.py +Icon=network-vpn +Terminal=false +Categories=Network;Security;Utility; +Keywords=vpn;otp;password;clipboard; +StartupNotify=true +EOF + +# 3. Refresh launcher database +update-desktop-database ~/.local/share/applications/ +``` + +After step 3 the app appears under **Network** / **Utilities** in the start menu. +Right-click the icon → **Pin to taskbar** (KDE: "Add to panel"). + +To use a custom icon instead of the system `network-vpn` icon, place a PNG at +`~/.local/share/icons/vpn-clip.png` and set `Icon=vpn-clip` in the `.desktop` file. + +### Pitfalls (vpn-clip) + +**`cryptography` not on PATH / wrong Python** +`pacman -S python-cryptography` installs for the system Python. If you run the +script with a venv or a different Python binary the package may not be found and +the fallback kicks in silently — check the warning banner in the UI. + +**wl-copy requires a running Wayland session** +Running the script over SSH without a forwarded Wayland socket will make +`wl-copy` fail. The error is shown in a dialog box. + +**OTP secret format** +`oathtool --totp -b` expects a base32-encoded secret (the "seed" shown by most +authenticator apps as a QR code alternative). Spaces in the secret are fine; +`oathtool` ignores them. + +**Password + OTP concatenation** +The tool concatenates password and OTP with no separator (e.g. `hunter2123456`). +Sophos and most other SSL VPN gateways expect exactly this format in the +password field. If your gateway uses a different format, edit `_copy()` in the +script. diff --git a/__pycache__/vpn-clip.cpython-314.pyc b/__pycache__/vpn-clip.cpython-314.pyc new file mode 100644 index 0000000..2d7c971 Binary files /dev/null and b/__pycache__/vpn-clip.cpython-314.pyc differ diff --git a/vpn-clip.py b/vpn-clip.py new file mode 100755 index 0000000..bd6ca9d --- /dev/null +++ b/vpn-clip.py @@ -0,0 +1,466 @@ +#!/usr/bin/env python3 +"""VPN clipboard tool — copies password+OTP for VPN profiles.""" + +import base64 +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtWidgets import ( + QApplication, + QComboBox, + QDialog, + QDialogButtonBox, + QFormLayout, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QVBoxLayout, + QHBoxLayout, + QWidget, +) + +# --------------------------------------------------------------------------- +# Crypto layer +# --------------------------------------------------------------------------- + +PROFILES_PATH = Path(__file__).parent / "vpn-profiles.enc" + +try: + from cryptography.fernet import Fernet, InvalidToken + import cryptography.hazmat.primitives.kdf.pbkdf2 as _pbkdf2 + from cryptography.hazmat.primitives import hashes as _hashes + HAVE_CRYPTO = True +except ImportError: + HAVE_CRYPTO = False + + +def _derive_key(password: str, salt: bytes) -> bytes: + """Derive a Fernet key from master password + salt.""" + from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + from cryptography.hazmat.primitives import hashes + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=salt, + iterations=480_000, + ) + return base64.urlsafe_b64encode(kdf.derive(password.encode())) + + +def encrypt_profiles(profiles: list, master_password: str) -> bytes: + """Serialize and encrypt profile list.""" + raw = json.dumps(profiles).encode() + if HAVE_CRYPTO: + salt = os.urandom(16) + key = _derive_key(master_password, salt) + token = Fernet(key).encrypt(raw) + # layout: 2-byte salt-len + salt + fernet token + return len(salt).to_bytes(2, "big") + salt + token + else: + # fallback: base64-encoded JSON (no real encryption) + return base64.b64encode(raw) + + +def decrypt_profiles(data: bytes, master_password: str) -> list: + """Decrypt and deserialize profile list. Raises ValueError on wrong password.""" + if HAVE_CRYPTO: + salt_len = int.from_bytes(data[:2], "big") + salt = data[2 : 2 + salt_len] + token = data[2 + salt_len :] + key = _derive_key(master_password, salt) + try: + raw = Fernet(key).decrypt(token) + except InvalidToken: + raise ValueError("Falsches Master-Passwort") + return json.loads(raw) + else: + try: + return json.loads(base64.b64decode(data)) + except Exception as exc: + raise ValueError(f"Datei konnte nicht gelesen werden: {exc}") from exc + + +# --------------------------------------------------------------------------- +# OTP helper +# --------------------------------------------------------------------------- + +def get_otp(secret: str) -> str | None: + """Return current TOTP value or None on failure.""" + try: + result = subprocess.run( + ["oathtool", "--totp", "-b", secret], + capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + return result.stdout.strip() + return None + except Exception: + return None + + +def otp_seconds_remaining() -> int: + """Seconds left in current 30s TOTP window.""" + return 30 - (int(time.time()) % 30) + + +# --------------------------------------------------------------------------- +# Clipboard helper +# --------------------------------------------------------------------------- + +def copy_to_clipboard(value: str) -> None: + subprocess.run(["wl-copy", value], check=True) + + +# --------------------------------------------------------------------------- +# Profile edit dialog +# --------------------------------------------------------------------------- + +class ProfileDialog(QDialog): + def __init__(self, parent=None, profile: dict | None = None): + super().__init__(parent) + self.setWindowTitle("Profil bearbeiten") + self._deleted = False + + layout = QFormLayout() + + self.name_edit = QLineEdit(profile["name"] if profile else "") + self.pw_edit = QLineEdit(profile.get("password", "") if profile else "") + self.pw_edit.setEchoMode(QLineEdit.EchoMode.Password) + self.otp_edit = QLineEdit(profile.get("otp_secret", "") if profile else "") + + layout.addRow("Name:", self.name_edit) + layout.addRow("Passwort:", self.pw_edit) + layout.addRow("OTP-Schlüssel (optional):", self.otp_edit) + + btn_box = QDialogButtonBox() + save_btn = btn_box.addButton("Speichern", QDialogButtonBox.ButtonRole.AcceptRole) + cancel_btn = btn_box.addButton("Abbrechen", QDialogButtonBox.ButtonRole.RejectRole) # noqa: F841 + delete_btn = btn_box.addButton("Löschen", QDialogButtonBox.ButtonRole.DestructiveRole) + + save_btn.clicked.connect(self._save) + delete_btn.clicked.connect(self._delete) + btn_box.rejected.connect(self.reject) + + # hide delete on new profile + delete_btn.setVisible(profile is not None) + + outer = QVBoxLayout() + outer.addLayout(layout) + outer.addWidget(btn_box) + self.setLayout(outer) + + def _save(self): + if not self.name_edit.text().strip(): + QMessageBox.warning(self, "Fehler", "Name darf nicht leer sein.") + return + if not self.pw_edit.text(): + QMessageBox.warning(self, "Fehler", "Passwort darf nicht leer sein.") + return + self.accept() + + def _delete(self): + if QMessageBox.question( + self, "Löschen", "Profil wirklich löschen?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) == QMessageBox.StandardButton.Yes: + self._deleted = True + self.accept() + + def result_profile(self) -> dict: + return { + "name": self.name_edit.text().strip(), + "password": self.pw_edit.text(), + "otp_secret": self.otp_edit.text().strip(), + } + + +# --------------------------------------------------------------------------- +# Profiles manager dialog (list + add/edit) +# --------------------------------------------------------------------------- + +class ProfileManagerDialog(QDialog): + def __init__(self, parent, profiles: list, master_password: str): + super().__init__(parent) + self.setWindowTitle("Profile verwalten") + self.profiles = list(profiles) + self.master_password = master_password + + self._list = QComboBox() + self._refresh_list() + + add_btn = QPushButton("Neu") + edit_btn = QPushButton("Bearbeiten") + close_btn = QPushButton("Schließen") + + add_btn.clicked.connect(self._add) + edit_btn.clicked.connect(self._edit) + close_btn.clicked.connect(self.accept) + + btn_row = QHBoxLayout() + btn_row.addWidget(add_btn) + btn_row.addWidget(edit_btn) + btn_row.addStretch() + btn_row.addWidget(close_btn) + + layout = QVBoxLayout() + layout.addWidget(QLabel("Profil:")) + layout.addWidget(self._list) + layout.addLayout(btn_row) + self.setLayout(layout) + + def _refresh_list(self): + self._list.clear() + for p in self.profiles: + self._list.addItem(p["name"]) + + def _add(self): + dlg = ProfileDialog(self) + if dlg.exec() == QDialog.DialogCode.Accepted and not dlg._deleted: + self.profiles.append(dlg.result_profile()) + self._save() + self._refresh_list() + self._list.setCurrentIndex(len(self.profiles) - 1) + + def _edit(self): + idx = self._list.currentIndex() + if idx < 0: + return + dlg = ProfileDialog(self, self.profiles[idx]) + if dlg.exec() == QDialog.DialogCode.Accepted: + if dlg._deleted: + self.profiles.pop(idx) + else: + self.profiles[idx] = dlg.result_profile() + self._save() + self._refresh_list() + + def _save(self): + data = encrypt_profiles(self.profiles, self.master_password) + PROFILES_PATH.write_bytes(data) + + +# --------------------------------------------------------------------------- +# Master password dialog +# --------------------------------------------------------------------------- + +class MasterPasswordDialog(QDialog): + def __init__(self, parent=None, confirm: bool = False): + super().__init__(parent) + self.setWindowTitle("Master-Passwort") + + self._pw = QLineEdit() + self._pw.setEchoMode(QLineEdit.EchoMode.Password) + self._pw.setPlaceholderText("Master-Passwort eingeben …") + + self._pw2: QLineEdit | None = None + layout = QFormLayout() + layout.addRow("Passwort:", self._pw) + + if confirm: + self._pw2 = QLineEdit() + self._pw2.setEchoMode(QLineEdit.EchoMode.Password) + self._pw2.setPlaceholderText("Wiederholen …") + layout.addRow("Bestätigung:", self._pw2) + + btn_box = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + btn_box.accepted.connect(self._check) + btn_box.rejected.connect(self.reject) + + outer = QVBoxLayout() + outer.addLayout(layout) + outer.addWidget(btn_box) + self.setLayout(outer) + self._pw.returnPressed.connect(btn_box.accepted.emit) + + def _check(self): + if not self._pw.text(): + QMessageBox.warning(self, "Fehler", "Passwort darf nicht leer sein.") + return + if self._pw2 is not None and self._pw.text() != self._pw2.text(): + QMessageBox.warning(self, "Fehler", "Passwörter stimmen nicht überein.") + return + self.accept() + + def password(self) -> str: + return self._pw.text() + + +# --------------------------------------------------------------------------- +# Main window +# --------------------------------------------------------------------------- + +class MainWindow(QWidget): + def __init__(self, profiles: list, master_password: str): + super().__init__() + self.profiles = profiles + self.master_password = master_password + + self.setWindowTitle("VPN Clip") + self.setWindowFlags( + Qt.WindowType.Window | Qt.WindowType.WindowStaysOnTopHint + ) + + self._combo = QComboBox() + self._timer_label = QLabel() + self._copy_btn = QPushButton("PW+OTP kopieren") + self._edit_btn = QPushButton("Profil bearbeiten") + + self._copy_btn.clicked.connect(self._copy) + self._edit_btn.clicked.connect(self._open_manager) + + layout = QVBoxLayout() + layout.addWidget(QLabel("Profil:")) + layout.addWidget(self._combo) + + row = QHBoxLayout() + row.addWidget(self._copy_btn) + row.addWidget(self._timer_label) + layout.addLayout(row) + layout.addWidget(self._edit_btn) + + if not HAVE_CRYPTO: + warn = QLabel( + "⚠ cryptography nicht installiert — Daten nur base64-kodiert gespeichert." + ) + warn.setStyleSheet("color: orange;") + warn.setWordWrap(True) + layout.addWidget(warn) + + self.setLayout(layout) + self._refresh_combo() + + self._tick_timer = QTimer(self) + self._tick_timer.timeout.connect(self._update_timer_label) + self._tick_timer.start(500) + self._update_timer_label() + + def _refresh_combo(self, select_name: str | None = None): + self._combo.clear() + for p in self.profiles: + self._combo.addItem(p["name"]) + if select_name: + idx = self._combo.findText(select_name) + if idx >= 0: + self._combo.setCurrentIndex(idx) + + def _current_profile(self) -> dict | None: + idx = self._combo.currentIndex() + if idx < 0 or idx >= len(self.profiles): + return None + return self.profiles[idx] + + def _update_timer_label(self): + profile = self._current_profile() + if profile and profile.get("otp_secret"): + secs = otp_seconds_remaining() + self._timer_label.setText(f"OTP gültig: {secs}s") + color = "green" if secs > 8 else "red" + self._timer_label.setStyleSheet(f"color: {color};") + else: + self._timer_label.setText("") + + def _copy(self): + profile = self._current_profile() + if not profile: + QMessageBox.warning(self, "Fehler", "Kein Profil ausgewählt.") + return + + password = profile.get("password", "") + secret = profile.get("otp_secret", "").strip() + + if secret: + otp = get_otp(secret) + if otp is None: + QMessageBox.critical( + self, "Fehler", + "OTP konnte nicht berechnet werden.\n" + "Prüfe ob oathtool installiert und der OTP-Schlüssel korrekt ist.", + ) + return + value = password + otp + else: + value = password + + try: + copy_to_clipboard(value) + except Exception as exc: + QMessageBox.critical(self, "Fehler", f"wl-copy fehlgeschlagen:\n{exc}") + return + + label = "PW+OTP" if secret else "Passwort" + self._copy_btn.setText(f"{label} kopiert ✓") + QTimer.singleShot(2000, lambda: self._copy_btn.setText("PW+OTP kopieren")) + + def _open_manager(self): + dlg = ProfileManagerDialog(self, self.profiles, self.master_password) + dlg.exec() + # reload from file in case changes were saved + try: + data = PROFILES_PATH.read_bytes() + self.profiles = decrypt_profiles(data, self.master_password) + except Exception: + pass + selected = self._combo.currentText() + self._refresh_combo(select_name=selected) + self._update_timer_label() + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main(): + app = QApplication(sys.argv) + app.setApplicationName("VPN Clip") + + master_password = "" + profiles: list = [] + + if PROFILES_PATH.exists(): + # Ask for master password and try to decrypt + while True: + dlg = MasterPasswordDialog() + if dlg.exec() != QDialog.DialogCode.Accepted: + sys.exit(0) + master_password = dlg.password() + try: + profiles = decrypt_profiles(PROFILES_PATH.read_bytes(), master_password) + break + except ValueError as exc: + QMessageBox.critical(None, "Fehler", str(exc)) + else: + # First run: set master password, then create first profile + QMessageBox.information( + None, + "Erster Start", + "Keine Profile gefunden. Bitte lege ein Master-Passwort fest und erstelle ein Profil.", + ) + dlg = MasterPasswordDialog(confirm=True) + if dlg.exec() != QDialog.DialogCode.Accepted: + sys.exit(0) + master_password = dlg.password() + + # Create first profile + pdlg = ProfileDialog() + if pdlg.exec() != QDialog.DialogCode.Accepted or pdlg._deleted: + sys.exit(0) + profiles = [pdlg.result_profile()] + data = encrypt_profiles(profiles, master_password) + PROFILES_PATH.write_bytes(data) + + win = MainWindow(profiles, master_password) + win.resize(320, 160) + win.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/vpn-profiles.enc b/vpn-profiles.enc new file mode 100644 index 0000000..fdc101a Binary files /dev/null and b/vpn-profiles.enc differ