upd vpn clip
This commit is contained in:
parent
d3bf08c523
commit
431147e017
111
README.md
111
README.md
|
|
@ -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.
|
|
@ -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()
|
||||||
Binary file not shown.
Loading…
Reference in New Issue