upd vpn clip

This commit is contained in:
d-chrka 2026-03-22 16:12:00 +01:00
parent d3bf08c523
commit 431147e017
4 changed files with 577 additions and 0 deletions

111
README.md
View File

@ -105,3 +105,114 @@ To check: `ip link show tun0` or `sudo cat /tmp/vpn-sophos.log`.
### Account lockout ### Account lockout
Sophos locks the account after several failed AUTH attempts. Wait ~5 minutes Sophos locks the account after several failed AUTH attempts. Wait ~5 minutes
before retrying after multiple failures. 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.

Binary file not shown.

466
vpn-clip.py Executable file
View File

@ -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()

BIN
vpn-profiles.enc Normal file

Binary file not shown.