467 lines
15 KiB
Python
Executable File
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()
|