From 431147e01755c5dca69db414b409370017133262 Mon Sep 17 00:00:00 2001 From: d-chrka Date: Sun, 22 Mar 2026 16:12:00 +0100 Subject: [PATCH] upd vpn clip --- README.md | 111 +++++++ __pycache__/vpn-clip.cpython-314.pyc | Bin 0 -> 27287 bytes vpn-clip.py | 466 +++++++++++++++++++++++++++ vpn-profiles.enc | Bin 0 -> 542 bytes 4 files changed, 577 insertions(+) create mode 100644 __pycache__/vpn-clip.cpython-314.pyc create mode 100755 vpn-clip.py create mode 100644 vpn-profiles.enc 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 0000000000000000000000000000000000000000..2d7c971eb262cdabd9abca45f834c620087fb68d GIT binary patch literal 27287 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 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 0000000000000000000000000000000000000000..fdc101a437d16f1860020896a8d9597e3d5183a3 GIT binary patch literal 542 zcmWl`JJORt003ZXE03WCGvQ6jg$*o$g#g)vq+vqBoA8DJw9M=Q5DHaewta%wIav4WS>E19mF(v2(Y>ckM`&Ys+N3G7Q_V(aj+m&igp7UQl2CdM?u$qLbts zFo!oz@!ZD7gwIZgjzXlv{-x2qWS*pD+ga%;$(|#sw50Kt%D195FsWAvF9l6s%+L}z zVnHLfBSz@Lrspld6qVjDGU$txosPJ`P%cLrD%wE}oVtmfSXL)_5L)ii^#Xg_i|)~0 zJ&V!uTuOnv(>Du3m)>>fX+y0cqM3OufjY0oV9vwHGuKunCAC_bmni5?Aomy}mv09o zhVnw+n5dU4PC{NtAP~Dj$3i|+oxtkkK98rm4weTpf=A6aMjW))CSrmtNi@>4{{RKO Bx{Uw; literal 0 HcmV?d00001