#!/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()