fastvpn/vpn-clip.py

467 lines
15 KiB
Python
Executable File

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