zupt/gui/src/zupt_gui.py
Cristian Cezar Moisés 544a2cd647
Some checks failed
CI / build-and-test (clang) (push) Has been cancelled
CI / build-and-test (gcc) (push) Has been cancelled
CI / strict-warnings (clang, -Wall -Wextra -Wpedantic -Wshadow -Wcast-align -Wstrict-prototypes -Wmissing-prototypes -Wnull-dereference -O2 -std=c11 -Werror) (push) Has been cancelled
CI / strict-warnings (gcc, -Wall -Wextra -Wpedantic -Wshadow -Wcast-align -Wstrict-prototypes -Wmissing-prototypes -Wnull-dereference -Wformat-security -Wlogical-op -Wjump-misses-init -Wdouble-promotion -O2 -std=c11 -Werror) (push) Has been cancelled
CI / sanitizers (push) Has been cancelled
CI / pie-hardening (push) Has been cancelled
CI / cross-aarch64 (push) Has been cancelled
CI / dist-reproducibility (push) Has been cancelled
CI / packaging-syntax (push) Has been cancelled
CI / release (push) Has been cancelled
v4.0.0: codec 2.60.4 security release, --pq-box sealed-box mode, F-16 fix
Major release. Highlights:

- Codec: vendored VaptVupt codec moves to canonical 2.60.4 security
  release. Fixes a high-severity OOB heap write in the AVX2 decode fast
  path (reachable on a valid stream sized to exactly content_size, both
  tail variants). Brings CBMC-formally-verified BCJ filters with
  automatic ELF/PE/Mach-O detection. Compressed output stays
  byte-identical (ratio gate Δ 0.00%); wire format unchanged at v1.6.
- New --pq-box sealed-box recipient mode (vendored libpqvaptvupt 0.6.0):
  ML-KEM-768 + X25519 combined via HKDF-SHA256 with domain separation,
  AES-256-CTR + HMAC-SHA256 EtM. Legacy --pq and --pq-sdk stay readable.
- F-16: discloses and fixes a pre-existing data-loss defect in the
  <= 3.8.0 in-tree BCJ encoder. Full back-compat matrix decodes
  byte-exact under 4.0.0; every readable pre-4.0 archive remains readable.

Repository hygiene:
- Sync full 4.0.0 source tree (codec, crypto, SDK, GUI, packaging, tests).
- Remove internal scratch files (PROMPT.md, FORMAL_AUDIT_PROMPT.md)
  and superseded version-specific docs (INTEGRATION_PROTOCOL_2.60.4.md,
  docs/FINDINGS-2.x.md) and a stray test binary.
- Refresh README download/install section to real 4.0.0 release assets;
  bump version badge to 4.0.0.
- Add .gitignore for build outputs (keeps vendored prebuilt libraries).
2026-06-10 18:48:58 -03:00

754 lines
37 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
"""VaptVupt GUI — Cross-Platform Post-Quantum Backup.
Renamed from "Zupt" in v3.0.0 due to INPI Brasil trademark.
The .zupt file extension is preserved.
Tries PySide6 first (preferred), falls back to PyQt6 if PySide6 is
not installed. PyQt6 is the default available package on Debian/Ubuntu
without requiring pip; PySide6 ships with broader signal/slot semantics
but the API surface used here is portable between the two.
"""
import sys, os, re, subprocess, shutil
from pathlib import Path
# Try PySide6, fall back to PyQt6. The two have nearly-identical APIs;
# the only adjustment needed is Signal/pyqtSignal naming, handled below.
try:
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QLineEdit, QComboBox, QFileDialog,
QTextEdit, QProgressBar, QTabWidget, QFrame, QCheckBox,
QSpinBox, QMessageBox, QStatusBar, QScrollArea
)
from PySide6.QtCore import Qt, Signal, QObject, QThread
from PySide6.QtGui import QPalette, QColor, QIcon, QPixmap
QT_BINDING = "PySide6"
except ImportError:
try:
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QLineEdit, QComboBox, QFileDialog,
QTextEdit, QProgressBar, QTabWidget, QFrame, QCheckBox,
QSpinBox, QMessageBox, QStatusBar, QScrollArea
)
from PyQt6.QtCore import Qt, pyqtSignal as Signal, QObject, QThread
from PyQt6.QtGui import QPalette, QColor, QIcon, QPixmap
QT_BINDING = "PyQt6"
except ImportError:
sys.stderr.write(
"ERROR: vaptvupt-gui requires PySide6 or PyQt6. Install one of:\n"
" Debian/Ubuntu: sudo apt install python3-pyqt6\n"
" Fedora/RHEL: sudo dnf install python3-pyqt6\n"
" pip (any OS): pip install PySide6\n"
)
sys.exit(1)
# ── Find vaptvupt binary ──
#
# v3.0.0 rename: the binary is now `vaptvupt`; older installations
# (1.x/2.x) ship `zupt`. We try the new name first, fall back to the
# old name, and on every candidate verify it's actually executable
# (not just present). After picking a candidate, we run a quick
# `version` liveness check — this catches the case where the binary
# exists but can't load its shared library (the original bug report:
# "GUI doesn't find zupt; copying to /usr/local/bin fixes it").
#
# Diagnostic output goes to stderr so users can `vaptvupt-gui 2>log`
# to see exactly which path was tried and why each failed.
_DISCOVERY_LOG = []
def _discovery_log(msg):
_DISCOVERY_LOG.append(msg)
# Echo to stderr if VAPTVUPT_DEBUG or ZUPT_DEBUG is set
if os.environ.get("VAPTVUPT_DEBUG") or os.environ.get("ZUPT_DEBUG"):
sys.stderr.write(f" [discovery] {msg}\n")
def _is_runnable(path):
"""A path is runnable if it's a file, executable, and exits 0 on `version`."""
p = str(path)
if not os.path.isfile(p):
return False, "not a regular file"
if not os.access(p, os.X_OK):
return False, "not executable (chmod +x needed?)"
# Liveness check — catches missing shared libraries, broken rpath,
# ABI mismatch, etc. 3-second cap so we never hang the GUI startup.
try:
r = subprocess.run([p, "version"], capture_output=True, timeout=3)
if r.returncode != 0:
err = r.stderr.decode("utf-8", errors="replace").strip()
return False, f"exit {r.returncode}: {err.splitlines()[0] if err else 'no stderr'}"
return True, "OK"
except FileNotFoundError:
return False, "FileNotFoundError on exec"
except subprocess.TimeoutExpired:
return False, "timeout (3s) on `version` — binary hung"
except OSError as e:
return False, f"OSError: {e}"
def _find_vaptvupt():
# 1. Explicit env override
for env in ("VAPTVUPT_BIN", "ZUPT_BIN"):
p = os.environ.get(env)
if p:
ok, reason = _is_runnable(p)
_discovery_log(f"env {env}={p}: {reason}")
if ok:
return p
# 2. Local project tree (running from a source checkout)
# Try BOTH names (vaptvupt is v3.0.0+, zupt is legacy).
here = Path(getattr(sys, "_MEIPASS", Path(__file__).parent))
for parent in (here.parent.parent, here.parent, here):
for name in ("vaptvupt", "zupt", "vaptvupt.exe", "zupt.exe"):
c = parent / name
ok, reason = _is_runnable(c)
_discovery_log(f"local {c}: {reason}")
if ok:
return str(c.resolve())
# 3. System PATH — try new name first, then legacy
for name in ("vaptvupt", "zupt"):
found = shutil.which(name)
if found:
ok, reason = _is_runnable(found)
_discovery_log(f"PATH which({name})={found}: {reason}")
if ok:
return found
else:
_discovery_log(f"PATH which({name}): not found")
# 4. Hard-coded common install paths — catches the "GUI launched
# from a desktop session with a minimal PATH that omits /usr/bin"
# scenario reported against v2.4.8.
common = [
# New name (v3.0.0+)
"/usr/local/bin/vaptvupt", "/usr/bin/vaptvupt",
"/opt/vaptvupt/bin/vaptvupt", "/opt/homebrew/bin/vaptvupt",
# Legacy name (1.x/2.x)
"/usr/local/bin/zupt", "/usr/bin/zupt",
"/opt/zupt/bin/zupt", "/opt/homebrew/bin/zupt",
# Termux (Android) install path
"/data/data/com.termux/files/usr/bin/vaptvupt",
"/data/data/com.termux/files/usr/bin/zupt",
# Flatpak sandbox runtime path
"/app/bin/vaptvupt", "/app/bin/zupt",
]
for path in common:
ok, reason = _is_runnable(path)
_discovery_log(f"common {path}: {reason}")
if ok:
return path
# 5. Last resort — return "vaptvupt" and let exec fail loudly later.
# A caller-visible error is better than silently returning a path
# that doesn't work.
_discovery_log("FAILED: no runnable vaptvupt/zupt binary found")
return "vaptvupt"
# Backward-compat: code elsewhere in this file still uses `ZUPT`.
VAPTVUPT = _find_vaptvupt()
ZUPT = VAPTVUPT # legacy alias used throughout the rest of zupt_gui.py
# ── Query version ONCE at import (cached) ──
#
# The CLI's `version` first line is the brand banner. Examples:
# v2.4.x: "zupt 2.4.8"
# v3.0.x: "vaptvupt 3.0.0 (formerly zupt; renamed in v3.0.0 — INPI Brasil trademark)"
#
# We extract three things from that line:
# - VER_SHORT: the full first line (used as a fallback display)
# - VER_NUMBER: just the version number "3.0.0" or "2.4.8" (for hero text)
# - VER_FULL: the entire stdout (used in the about panel)
#
# Earlier code did `ZUPT_VER_SHORT.replace("zupt ", "")` to peel the
# product name. That breaks on v3.0.x because the same string "zupt "
# also appears inside the parenthetical "formerly zupt; renamed".
# We now use a strict regex anchored at the start of the line.
_VERSION_RE = re.compile(r'^(?:vaptvupt|zupt)\s+(\d+\.\d+\.\d+(?:[._A-Za-z0-9-]*)?)')
def _get_version():
short = "vaptvupt (not found)"
number = "?"
full = ""
try:
r = subprocess.run([VAPTVUPT, "version"], capture_output=True, text=True, timeout=5)
if r.returncode == 0:
full = r.stdout.strip()
lines = full.split("\n")
short = lines[0]
m = _VERSION_RE.match(short)
if m:
number = m.group(1)
except Exception:
pass
return short, number, full
ZUPT_VER_SHORT, ZUPT_VER_NUMBER, ZUPT_VER_FULL = _get_version()
# ── Find icon file ──
def _find_icon():
here = Path(getattr(sys, '_MEIPASS', Path(__file__).parent))
for p in [here/"assets/zupt.ico", here/"assets/zupt-icon.png", here/"assets/zupt.png",
here.parent/"assets/zupt.ico", here.parent/"assets/zupt-icon.png",
Path("/usr/share/icons/hicolor/256x256/apps/zupt-gui.png"),
Path("/usr/share/zupt-gui/assets/zupt-icon.png")]:
if p.is_file(): return str(p)
return None
ICON_PATH = _find_icon()
STYLE = """
* { font-family: "Segoe UI", "Cantarell", "Noto Sans", sans-serif; }
QMainWindow { background: #0a0a0a; }
QTabWidget::pane { background: #0a0a0a; border: none; border-top: 1px solid #1a2a30; }
QTabBar { background: #050a0e; }
QTabBar::tab {
background: #050a0e; color: #5a7a88; padding: 11px 22px;
border: none; border-bottom: 2px solid transparent;
font-weight: 600; font-size: 12px;
}
QTabBar::tab:hover { color: #a0c8d8; background: #0a1018; }
QTabBar::tab:selected { color: #00dde0; border-bottom-color: #00dde0; background: #0c1218; }
QLabel { color: #90acb8; }
QLineEdit, QComboBox, QSpinBox {
background: #0e1820; color: #c0dce8; border: 1px solid #1a2a30;
border-radius: 6px; padding: 9px 12px;
font-family: "JetBrains Mono", "Cascadia Code", "SF Mono", monospace; font-size: 13px;
selection-background-color: #004858;
}
QLineEdit:focus, QComboBox:focus, QSpinBox:focus { border-color: #006878; }
QComboBox::drop-down { border: none; padding-right: 8px; }
QComboBox QAbstractItemView { background: #0e1820; color: #c0dce8; border: 1px solid #1a2a30; selection-background-color: #004858; }
QPushButton {
background: #0a2028; color: #00dde0; border: 1px solid #1a3a40;
border-radius: 6px; padding: 10px 24px; font-weight: 600; font-size: 12px;
}
QPushButton:hover { background: #0e2830; border-color: #00dde0; }
QPushButton:pressed { background: #062020; }
QPushButton:disabled { color: #304048; border-color: #151e22; }
QPushButton#green { color: #00c868; border-color: #0a3020; background: #061a10; }
QPushButton#green:hover { border-color: #00c868; background: #0a2818; }
QPushButton#amber { color: #d0a020; border-color: #2a2010; background: #181200; }
QPushButton#amber:hover { border-color: #d0a020; background: #201800; }
QPushButton#small { padding: 8px 14px; font-size: 11px; }
QTextEdit {
background: #060c10; color: #608898; border: 1px solid #1a2a30;
border-radius: 6px; padding: 10px;
font-family: "JetBrains Mono", "Cascadia Code", "SF Mono", monospace; font-size: 11px;
}
QProgressBar { background: #0e1820; border: none; border-radius: 3px; max-height: 6px; }
QProgressBar::chunk { background: qlineargradient(x1:0,y1:0,x2:1,y2:0,stop:0 #00dde0,stop:1 #008090); border-radius: 3px; }
QCheckBox { color: #6a8898; spacing: 6px; font-size: 13px; }
QCheckBox::indicator { width: 16px; height: 16px; border-radius: 3px; border: 1px solid #1a3a40; background: #0e1820; }
QCheckBox::indicator:checked { background: #004858; border-color: #00dde0; }
QScrollArea { border: none; background: transparent; }
QStatusBar { background: #050a0e; color: #3a5868; font-size: 11px; border-top: 1px solid #1a2a30; }
QFrame#sep { background: #1a2a30; max-height: 1px; }
"""
def run_zupt(args, timeout=30):
try:
r = subprocess.run([ZUPT]+list(args), capture_output=True, text=True, timeout=timeout)
return r.returncode, r.stdout, r.stderr
except FileNotFoundError: return -1, "", f"vaptvupt not found: {VAPTVUPT}\n\nDiscovery log:\n" + "\n".join(_DISCOVERY_LOG[-10:])
except subprocess.TimeoutExpired: return -1, "", "Timed out"
class Worker(QObject):
done = Signal(int, str, str)
log = Signal(str)
def __init__(self, args): super().__init__(); self.args = args
def run(self):
self.log.emit(f"$ {Path(VAPTVUPT).name} {' '.join(self.args)}")
try:
proc = subprocess.Popen([ZUPT]+self.args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
err_lines = []
for line in proc.stderr:
line = line.rstrip('\n')
if line: err_lines.append(line); self.log.emit(line)
stdout, _ = proc.communicate(timeout=7200)
self.done.emit(proc.returncode, stdout or "", "\n".join(err_lines))
except FileNotFoundError: self.done.emit(-1, "", f"vaptvupt not found: {VAPTVUPT}")
except subprocess.TimeoutExpired: proc.kill(); self.done.emit(-1, "", "Timed out")
# ── Widgets ──
def H(text):
lbl = QLabel(text.upper())
lbl.setStyleSheet("font-size:10px;font-weight:700;color:#3a5868;letter-spacing:1.5px;")
return lbl
def Sep():
f = QFrame(); f.setObjectName("sep"); f.setFixedHeight(1); return f
def Log(h=150):
t = QTextEdit(); t.setReadOnly(True); t.setMaximumHeight(h); return t
def PwField(ph="Optional"):
pw = QLineEdit(); pw.setEchoMode(QLineEdit.EchoMode.Password); pw.setPlaceholderText(ph); return pw
class PathField(QWidget):
def __init__(self, ph="", mode="open", filters="All Files (*)"):
super().__init__()
self.mode, self.filters = mode, filters
self.setAcceptDrops(True)
lay = QHBoxLayout(self); lay.setContentsMargins(0,0,0,0); lay.setSpacing(6)
self.edit = QLineEdit(); self.edit.setPlaceholderText(ph)
lay.addWidget(self.edit, 1)
btn = QPushButton("Browse"); btn.setObjectName("small"); btn.setFixedWidth(70)
btn.clicked.connect(self._pick); lay.addWidget(btn)
def _pick(self):
if self.mode == "save": p, _ = QFileDialog.getSaveFileName(self, "Save", "", self.filters)
elif self.mode == "dir": p = QFileDialog.getExistingDirectory(self, "Directory")
elif self.mode == "multi":
ps, _ = QFileDialog.getOpenFileNames(self, "Select", "", self.filters)
p = "|".join(ps) if ps else ""
else: p, _ = QFileDialog.getOpenFileName(self, "Open", "", self.filters)
if p: self.edit.setText(p)
def path(self): return self.edit.text().strip()
def paths(self):
t = self.path(); return t.split("|") if "|" in t else [t] if t else []
def dragEnterEvent(self, e):
if e.mimeData().hasUrls(): e.acceptProposedAction()
def dropEvent(self, e):
ps = [u.toLocalFile() for u in e.mimeData().urls() if u.toLocalFile()]
if ps: self.edit.setText("|".join(ps) if self.mode == "multi" else ps[0])
def scrollable(w):
sa = QScrollArea(); sa.setWidgetResizable(True); sa.setWidget(w); sa.setFrameShape(QFrame.Shape.NoFrame); return sa
def run_async(parent, cmd, btn, log, progress=None):
log.clear(); btn.setEnabled(False)
if progress: progress.show()
t = QThread(); w = Worker(cmd); w.moveToThread(t)
w.log.connect(log.append)
def finish(code, out, err):
btn.setEnabled(True)
if progress: progress.hide()
log.append("\nDone." if code == 0 else f"\nFailed (exit {code}).")
t.quit()
w.done.connect(finish)
t.started.connect(w.run); t.start()
parent._thread, parent._worker = t, w
# ── Tabs ──
class KeysTab(QWidget):
def __init__(self):
super().__init__()
inner = QWidget()
v = QVBoxLayout(inner); v.setContentsMargins(24,24,24,24); v.setSpacing(10)
v.addWidget(QLabel("Generate or export ML-KEM-768 + X25519 hybrid keys."))
v.addWidget(Sep())
# Section 1: Generate new keypair
v.addWidget(H("Generate new keypair"))
v.addWidget(QLabel("Creates both private and public key files."))
v.addWidget(H("Private key output"))
self.gen_priv = PathField("e.g. ~/zupt_private.key", "save", "Key (*.key);;All (*)")
v.addWidget(self.gen_priv)
self.gen_sdk = QCheckBox("SDK v2 format (HKDF combiner + commitment + HPKE — recommended)")
self.gen_sdk.setChecked(True)
self.gen_sdk.setToolTip("Generates a libzuptsdk-format keypair. Use --pq-sdk in CLI or 'SDK v2' checkbox in compress to use these keys. Disable for legacy --pq compatibility.")
v.addWidget(self.gen_sdk)
self.gen_btn = QPushButton("Generate Keypair")
self.gen_btn.clicked.connect(self._generate)
v.addWidget(self.gen_btn)
self.gen_log = Log(120); v.addWidget(self.gen_log)
v.addWidget(Sep())
# Section 2: Export public key from existing private key
v.addWidget(H("Export public key from private key"))
v.addWidget(QLabel("Extract the public key from an existing private key file."))
v.addWidget(H("Existing private key"))
self.exp_priv = PathField("Select private key", "open", "Key (*.key);;All (*)")
v.addWidget(self.exp_priv)
v.addWidget(H("Public key output"))
self.exp_pub = PathField("e.g. ~/zupt_public.key", "save", "Key (*.key);;All (*)")
v.addWidget(self.exp_pub)
self.exp_btn = QPushButton("Export Public Key")
self.exp_btn.setObjectName("green")
self.exp_btn.clicked.connect(self._export)
v.addWidget(self.exp_btn)
self.exp_log = Log(100); v.addWidget(self.exp_log)
v.addStretch()
lay = QVBoxLayout(self); lay.setContentsMargins(0,0,0,0); lay.addWidget(scrollable(inner))
def _generate(self):
p = self.gen_priv.path() or str(Path.home() / "zupt_private.key")
self.gen_priv.edit.setText(p)
self.gen_log.clear(); self.gen_btn.setEnabled(False)
if self.gen_sdk.isChecked():
# SDK keygen creates both files in one step.
code, _, err = run_zupt(["keygen", "--sdk", "-o", p])
self.gen_log.append(err.strip())
if code == 0:
self.gen_log.append(f"\nPrivate key: {p}\nPublic key: {p}.pub")
else:
self.gen_log.append("\nFailed.")
else:
code, _, err = run_zupt(["keygen", "-o", p])
self.gen_log.append(err.strip())
if code == 0:
pub = p.rsplit(".", 1)[0] + "_public.key" if "." in p else p + ".pub"
c2, _, e2 = run_zupt(["keygen", "--pub", "-o", pub, "-k", p])
self.gen_log.append(e2.strip())
if c2 == 0:
self.gen_log.append(f"\nPrivate key: {p}\nPublic key: {pub}")
else:
self.gen_log.append("\nFailed.")
self.gen_btn.setEnabled(True)
def _export(self):
priv = self.exp_priv.path()
pub = self.exp_pub.path()
if not priv: QMessageBox.warning(self, "VaptVupt", "Select the private key file."); return
if not pub:
pub = priv.rsplit(".", 1)[0] + "_public.key" if "." in priv else priv + ".pub"
self.exp_pub.edit.setText(pub)
self.exp_log.clear(); self.exp_btn.setEnabled(False)
code, _, err = run_zupt(["keygen", "--pub", "-o", pub, "-k", priv])
self.exp_log.append(err.strip())
if code == 0:
self.exp_log.append(f"\nPublic key: {pub}")
else:
self.exp_log.append("\nFailed.")
self.exp_btn.setEnabled(True)
class CompressTab(QWidget):
def __init__(self, initial=None):
super().__init__()
self._thread = self._worker = None
inner = QWidget()
v = QVBoxLayout(inner); v.setContentsMargins(24,24,24,24); v.setSpacing(10)
v.addWidget(QLabel("Compress files into an encrypted .zupt archive."))
v.addWidget(Sep())
v.addWidget(H("Source files / directory"))
self.src = PathField("Drop files here or browse", "multi"); v.addWidget(self.src)
v.addWidget(H("Output archive"))
self.dst = PathField("e.g. backup.zupt", "save", "VaptVupt archive (*.zupt);;All (*)"); v.addWidget(self.dst)
row = QHBoxLayout(); row.setSpacing(16)
for label, widget in [("Codec", self._mk_codec()), ("Level", self._mk_level())]:
c = QVBoxLayout(); c.addWidget(H(label)); c.addWidget(widget); row.addLayout(c)
c = QVBoxLayout(); c.addWidget(H("Options"))
self.dedup = QCheckBox("Dedup"); self.solid = QCheckBox("Solid")
oh = QHBoxLayout(); oh.addWidget(self.dedup); oh.addWidget(self.solid); oh.addStretch()
c.addLayout(oh); row.addLayout(c)
v.addLayout(row); v.addWidget(Sep())
enc = QHBoxLayout(); enc.setSpacing(16)
pw = QVBoxLayout(); pw.addWidget(H("Password")); self.pw = PwField("AES-256"); pw.addWidget(self.pw); enc.addLayout(pw)
pq = QVBoxLayout(); pq.addWidget(H("PQ public key")); self.pq = PathField("Optional .key", filters="Key (*.key *.pub);;All (*)"); pq.addWidget(self.pq); enc.addLayout(pq)
sdk_box = QVBoxLayout(); sdk_box.addWidget(H("Mode"))
self.sdk = QCheckBox("Use SDK v2 (HKDF + commitment + HPKE)"); self.sdk.setChecked(True)
self.sdk.setToolTip("v2.2+ uses libzuptsdk: HKDF-SHA3 combiner, key commitment, HPKE binding, Argon2id. Disable for legacy --pq compatibility.")
sdk_box.addWidget(self.sdk); sdk_box.addStretch(); enc.addLayout(sdk_box)
v.addLayout(enc)
self.btn = QPushButton("Compress"); self.btn.clicked.connect(self._run); v.addWidget(self.btn)
self.progress = QProgressBar(); self.progress.setRange(0,0); self.progress.hide(); v.addWidget(self.progress)
self.log = Log(140); v.addWidget(self.log); v.addStretch()
lay = QVBoxLayout(self); lay.setContentsMargins(0,0,0,0); lay.addWidget(scrollable(inner))
if initial: self.src.edit.setText("|".join(initial))
def _mk_codec(self):
self.codec = QComboBox(); self.codec.addItems(["AUTO","VaptVupt","LZHP","Store"]); return self.codec
def _mk_level(self):
self.level = QSpinBox(); self.level.setRange(1,9); self.level.setValue(7); return self.level
def _run(self):
srcs = self.src.paths()
if not srcs or not srcs[0]: QMessageBox.warning(self, "VaptVupt", "Select files."); return
dst = self.dst.path() or srcs[0] + ".zupt"; self.dst.edit.setText(dst)
cmd = ["compress", "-l", str(self.level.value())]
cm = {"AUTO": None, "VaptVupt": "--vv", "LZHP": "--lzhp", "Store": "-s"}
if cm.get(self.codec.currentText()): cmd.append(cm[self.codec.currentText()])
if self.dedup.isChecked(): cmd.append("--dedup")
if self.solid.isChecked(): cmd.append("--solid")
if self.pw.text(): cmd += ["-p", self.pw.text()]
if self.pq.path():
flag = "--pq-sdk" if self.sdk.isChecked() else "--pq"
cmd += [flag, self.pq.path()]
cmd.append(dst); cmd.extend(srcs)
run_async(self, cmd, self.btn, self.log, self.progress)
class ExtractTab(QWidget):
def __init__(self, initial=None):
super().__init__()
self._thread = self._worker = None
inner = QWidget()
v = QVBoxLayout(inner); v.setContentsMargins(24,24,24,24); v.setSpacing(10)
v.addWidget(QLabel("Extract and decrypt a .zupt archive."))
v.addWidget(Sep())
v.addWidget(H("Archive")); self.arc = PathField("Drop .zupt here", filters="VaptVupt archive (*.zupt);;All (*)"); v.addWidget(self.arc)
v.addWidget(H("Output directory")); self.out = PathField("Same as archive", "dir"); v.addWidget(self.out)
enc = QHBoxLayout(); enc.setSpacing(16)
pw = QVBoxLayout(); pw.addWidget(H("Password")); self.pw = PwField(); pw.addWidget(self.pw); enc.addLayout(pw)
pq = QVBoxLayout(); pq.addWidget(H("PQ private key")); self.pq = PathField("Optional .key", filters="Key (*.key);;All (*)"); pq.addWidget(self.pq); enc.addLayout(pq)
sdk_box = QVBoxLayout(); sdk_box.addWidget(H("Mode"))
self.sdk = QCheckBox("Auto-detect (SDK or legacy)"); self.sdk.setChecked(True)
self.sdk.setToolTip("Tries --pq-sdk first, falls back to --pq for legacy archives.")
sdk_box.addWidget(self.sdk); sdk_box.addStretch(); enc.addLayout(sdk_box)
v.addLayout(enc)
self.btn = QPushButton("Extract"); self.btn.setObjectName("green"); self.btn.clicked.connect(self._run); v.addWidget(self.btn)
self.progress = QProgressBar(); self.progress.setRange(0,0); self.progress.hide(); v.addWidget(self.progress)
self.log = Log(140); v.addWidget(self.log); v.addStretch()
lay = QVBoxLayout(self); lay.setContentsMargins(0,0,0,0); lay.addWidget(scrollable(inner))
if initial: self.arc.edit.setText(initial)
def _run(self):
arc = self.arc.path()
if not arc: QMessageBox.warning(self, "VaptVupt", "Select an archive."); return
cmd = ["extract"]
if self.out.path(): cmd += ["-o", self.out.path()]
if self.pw.text(): cmd += ["-p", self.pw.text()]
if self.pq.path():
# Auto-detect: zupt's extract auto-discovers enc type from header,
# so passing --pq-sdk works for both SDK and legacy keyfiles when
# the archive is SDK-encoded; --pq is needed for legacy archives.
flag = "--pq-sdk" if self.sdk.isChecked() else "--pq"
cmd += [flag, self.pq.path()]
cmd.append(arc)
run_async(self, cmd, self.btn, self.log, self.progress)
class VerifyTab(QWidget):
def __init__(self):
super().__init__()
inner = QWidget()
v = QVBoxLayout(inner); v.setContentsMargins(24,24,24,24); v.setSpacing(10)
v.addWidget(QLabel("Verify checksums or inspect archive metadata."))
v.addWidget(Sep())
v.addWidget(H("Verify integrity"))
self.varc = PathField("Archive to verify", filters="VaptVupt archive (*.zupt);;All (*)"); v.addWidget(self.varc)
v.addWidget(H("Password (if encrypted)"))
self.vpw = PwField("Leave empty if not encrypted"); v.addWidget(self.vpw)
self.vbtn = QPushButton("Verify"); self.vbtn.setObjectName("amber"); self.vbtn.clicked.connect(self._verify); v.addWidget(self.vbtn)
self.vlog = Log(120); v.addWidget(self.vlog)
v.addWidget(Sep())
v.addWidget(H("Archive info (no password needed)"))
self.iarc = PathField("Archive to inspect", filters="VaptVupt archive (*.zupt);;All (*)"); v.addWidget(self.iarc)
self.ibtn = QPushButton("Show Info"); self.ibtn.clicked.connect(self._info); v.addWidget(self.ibtn)
self.ilog = Log(140); v.addWidget(self.ilog); v.addStretch()
lay = QVBoxLayout(self); lay.setContentsMargins(0,0,0,0); lay.addWidget(scrollable(inner))
def _verify(self):
arc = self.varc.path()
if not arc: return
cmd = ["test"]
if self.vpw.text(): cmd += ["-p", self.vpw.text()]
cmd.append(arc); self.vlog.clear()
code, out, err = run_zupt(cmd, timeout=600)
self.vlog.append((err + "\n" + out).strip())
self.vlog.append("\nAll checksums passed." if code == 0 else "\nVerification failed.")
def _info(self):
arc = self.iarc.path()
if not arc: return
self.ilog.clear()
code, out, err = run_zupt(["info", arc])
self.ilog.append(out.strip() if out.strip() else err.strip())
class DiskTab(QWidget):
def __init__(self):
super().__init__()
self._thread = self._worker = None
inner = QWidget()
v = QVBoxLayout(inner); v.setContentsMargins(24,24,24,24); v.setSpacing(10)
v.addWidget(QLabel("Full-disk or partition backup and restore."))
v.addWidget(Sep())
v.addWidget(H("Backup — source device or image"))
self.bsrc = PathField("/dev/sdX or disk.img"); v.addWidget(self.bsrc)
v.addWidget(H("Backup — output archive"))
self.bout = PathField("backup.zupt", "save", "VaptVupt archive (*.zupt);;All (*)"); v.addWidget(self.bout)
bopt = QHBoxLayout(); bopt.setSpacing(16)
oc = QVBoxLayout(); oc.addWidget(H("Options")); self.bdedup = QCheckBox("Block deduplication"); oc.addWidget(self.bdedup); bopt.addLayout(oc)
pc = QVBoxLayout(); pc.addWidget(H("Password")); self.bpw = PwField("Optional — AES-256"); pc.addWidget(self.bpw); bopt.addLayout(pc)
v.addLayout(bopt)
self.bbtn = QPushButton("Start Backup"); self.bbtn.clicked.connect(self._backup); v.addWidget(self.bbtn)
self.blog = Log(100); v.addWidget(self.blog)
v.addWidget(Sep())
v.addWidget(H("Restore — archive"))
self.rarc = PathField("backup.zupt", filters="VaptVupt archive (*.zupt);;All (*)"); v.addWidget(self.rarc)
v.addWidget(H("Restore — target device or file"))
self.rtgt = PathField("/dev/sdX or output.img", "save"); v.addWidget(self.rtgt)
v.addWidget(H("Restore — password"))
self.rpw = PwField("If archive is encrypted"); v.addWidget(self.rpw)
self.rbtn = QPushButton("Start Restore"); self.rbtn.setObjectName("green"); self.rbtn.clicked.connect(self._restore); v.addWidget(self.rbtn)
self.rlog = Log(100); v.addWidget(self.rlog); v.addStretch()
lay = QVBoxLayout(self); lay.setContentsMargins(0,0,0,0); lay.addWidget(scrollable(inner))
def _backup(self):
s, o = self.bsrc.path(), self.bout.path()
if not s or not o: QMessageBox.warning(self, "VaptVupt", "Set source and output."); return
cmd = ["disk", "backup"]
if self.bdedup.isChecked(): cmd.append("--dedup")
if self.bpw.text(): cmd += ["-p", self.bpw.text()]
cmd += [o, s]; run_async(self, cmd, self.bbtn, self.blog)
def _restore(self):
a, t = self.rarc.path(), self.rtgt.path()
if not a or not t: QMessageBox.warning(self, "VaptVupt", "Set archive and target."); return
SB = QMessageBox.StandardButton
if QMessageBox.warning(self, "Confirm", f"OVERWRITE {t}?", SB.Yes|SB.Cancel) != SB.Yes: return
cmd = ["disk", "restore"]
if self.rpw.text(): cmd += ["-p", self.rpw.text()]
cmd += [a, t]; run_async(self, cmd, self.rbtn, self.rlog)
class AboutTab(QWidget):
def __init__(self):
super().__init__()
inner = QWidget()
v = QVBoxLayout(inner); v.setContentsMargins(24,24,24,24); v.setSpacing(4)
for text, style in [
("VAPTVUPT", "color:#00dde0;font-size:10px;font-weight:700;letter-spacing:2px;font-family:monospace;"),
(ZUPT_VER_NUMBER, "color:white;font-size:28px;font-weight:800;font-family:monospace;"),
("", ""),
("Post-quantum backup compression with ML-KEM-768 + X25519", "color:#6a8898;font-size:13px;"),
("hybrid encryption, Argon2id KDF, and block deduplication.", "color:#6a8898;font-size:13px;"),
("Renamed from Zupt in v3.0.0 (INPI Brasil trademark); .zupt", "color:#6a8898;font-size:13px;"),
("archive extension and v1.6 wire format are unchanged.", "color:#6a8898;font-size:13px;"),
("", ""),
("CRYPTOGRAPHIC STACK", "color:#00dde0;font-size:10px;font-weight:700;letter-spacing:2px;font-family:monospace;"),
("ML-KEM-768 FIPS 203 Post-Quantum KEM", "color:#5a7a88;font-size:12px;font-family:monospace;"),
("X25519 RFC 7748 Elliptic Curve DH (hybrid w/ ML-KEM)", "color:#5a7a88;font-size:12px;font-family:monospace;"),
("AES-256-CTR FIPS 197 Symmetric Cipher", "color:#5a7a88;font-size:12px;font-family:monospace;"),
("HMAC-SHA256 RFC 2104 Authentication (Encrypt-then-MAC)", "color:#5a7a88;font-size:12px;font-family:monospace;"),
("Argon2id RFC 9106 Password KDF (default since 2.4.1)", "color:#5a7a88;font-size:12px;font-family:monospace;"),
("PBKDF2 RFC 8018 Password KDF (legacy; --kdf pbkdf2)", "color:#5a7a88;font-size:12px;font-family:monospace;"),
("HKDF RFC 5869 Key Derivation Function", "color:#5a7a88;font-size:12px;font-family:monospace;"),
("SHA3/SHAKE FIPS 202 Hash / XOF", "color:#5a7a88;font-size:12px;font-family:monospace;"),
("XXH64 (non-crypto) Per-block checksum (inside AEAD)", "color:#5a7a88;font-size:12px;font-family:monospace;"),
("", ""),
("COMPRESSION CODEC", "color:#00dde0;font-size:10px;font-weight:700;letter-spacing:2px;font-family:monospace;"),
("VaptVupt LZ + ANS 2.48.5 LZ77 + tabled ANS entropy", "color:#5a7a88;font-size:12px;font-family:monospace;"),
("AVX2 / NEON SIMD acceleration; 1.27x zstd-3 decode aggregate", "color:#5a7a88;font-size:12px;font-family:monospace;"),
("", ""),
("CREDITS", "color:#00dde0;font-size:10px;font-weight:700;letter-spacing:2px;font-family:monospace;"),
("VaptVupt application Cristian Cezar Moisés", "color:#5a7a88;font-size:12px;font-family:monospace;"),
(" License: AGPL-3.0-or-later (commercial license available)", "color:#5a7a88;font-size:12px;font-family:monospace;"),
(" git.securityops.co/cristiancmoises/zupt", "color:#3a5868;font-size:11px;font-family:monospace;"),
("", ""),
("VaptVupt LZ + ANS codec Cristian Cezar Moisés", "color:#5a7a88;font-size:12px;font-family:monospace;"),
(" License: GPL-3.0-or-later (commercial license available)", "color:#5a7a88;font-size:12px;font-family:monospace;"),
(" git.securityops.co/cristiancmoises/vaptvupt", "color:#3a5868;font-size:11px;font-family:monospace;"),
("", ""),
("WEBSITE & CONTACT", "color:#00dde0;font-size:10px;font-weight:700;letter-spacing:2px;font-family:monospace;"),
("https://zupt.securityops.co", "color:#5a7a88;font-size:12px;font-family:monospace;"),
("sac@securityops.co (commercial licensing)", "color:#5a7a88;font-size:12px;font-family:monospace;"),
("zupt@riseup.net (general / bugs)", "color:#5a7a88;font-size:12px;font-family:monospace;"),
("", ""),
(ZUPT_VER_SHORT, "color:#3a5868;font-size:11px;font-family:monospace;"),
]:
lbl = QLabel(text)
if text == "": lbl.setFixedHeight(10)
elif style: lbl.setStyleSheet(style)
v.addWidget(lbl)
v.addStretch()
lay = QVBoxLayout(self); lay.setContentsMargins(0,0,0,0); lay.addWidget(scrollable(inner))
# ── Main ──
class ZuptWindow(QMainWindow):
def __init__(self, compress_files=None, extract_file=None):
super().__init__()
self.setWindowTitle(f"VaptVupt {ZUPT_VER_NUMBER}")
self.setMinimumSize(720, 500)
self.resize(880, 640)
self.setAcceptDrops(True)
# Window icon
if ICON_PATH:
self.setWindowIcon(QIcon(ICON_PATH))
central = QWidget(); self.setCentralWidget(central)
layout = QVBoxLayout(central); layout.setContentsMargins(0,0,0,0); layout.setSpacing(0)
# Header
hdr = QFrame(); hdr.setStyleSheet("background:#050a0e;border-bottom:1px solid #1a2a30;")
hl = QHBoxLayout(hdr); hl.setContentsMargins(20,10,20,10)
title = QLabel("VAPTVUPT"); title.setStyleSheet("color:white;font-size:15px;font-weight:800;letter-spacing:3px;")
hl.addWidget(title)
sub = QLabel("Post-Quantum Backup"); sub.setStyleSheet("color:#3a5868;font-size:10px;font-weight:600;letter-spacing:1px;margin-left:8px;")
hl.addWidget(sub); hl.addStretch()
vl = QLabel(f"v{ZUPT_VER_NUMBER}"); vl.setStyleSheet("color:#3a5868;font-size:10px;font-family:monospace;background:#0a1018;padding:3px 10px;border-radius:4px;border:1px solid #1a2a30;")
hl.addWidget(vl)
layout.addWidget(hdr)
# Tabs
self.tabs = QTabWidget()
self.compress_tab = CompressTab(initial=compress_files)
self.extract_tab = ExtractTab(initial=extract_file)
self.tabs.addTab(KeysTab(), "Keys")
self.tabs.addTab(self.compress_tab, "Compress")
self.tabs.addTab(self.extract_tab, "Extract")
self.tabs.addTab(VerifyTab(), "Verify")
self.tabs.addTab(DiskTab(), "Disk")
self.tabs.addTab(AboutTab(), "About")
if compress_files: self.tabs.setCurrentIndex(1)
elif extract_file: self.tabs.setCurrentIndex(2)
layout.addWidget(self.tabs)
sb = QStatusBar()
sb.showMessage(f"VaptVupt {ZUPT_VER_NUMBER} | {VAPTVUPT}")
self.setStatusBar(sb)
def dragEnterEvent(self, e):
if e.mimeData().hasUrls(): e.acceptProposedAction()
def dropEvent(self, e):
ps = [u.toLocalFile() for u in e.mimeData().urls() if u.toLocalFile()]
if not ps: return
if ps[0].endswith(".zupt"):
self.extract_tab.arc.edit.setText(ps[0]); self.tabs.setCurrentIndex(2)
else:
self.compress_tab.src.edit.setText("|".join(ps)); self.tabs.setCurrentIndex(1)
def main():
compress_files = extract_file = None
args = sys.argv[1:]
if args:
if args[0] == "--compress" and len(args) > 1: compress_files = args[1:]
elif args[0] == "--extract" and len(args) > 1: extract_file = args[1]
elif args[0].endswith(".zupt"): extract_file = args[0]
else: compress_files = args
app = QApplication(sys.argv)
app.setApplicationName("VaptVupt")
if ICON_PATH: app.setWindowIcon(QIcon(ICON_PATH))
app.setStyle("Fusion")
app.setStyleSheet(STYLE)
pal = QPalette()
# PyQt6 requires scoped enums (QPalette.ColorRole.Window); PySide6 supports
# both forms but we use the scoped form for cross-binding compatibility.
CR = QPalette.ColorRole
for role, c in [(CR.Window,"#0a0a0a"),(CR.WindowText,"#90acb8"),
(CR.Base,"#0e1820"),(CR.Text,"#c0dce8"),
(CR.Button,"#0a2028"),(CR.ButtonText,"#00dde0"),
(CR.Highlight,"#004858"),(CR.HighlightedText,"#00dde0")]:
pal.setColor(role, QColor(c))
app.setPalette(pal)
win = ZuptWindow(compress_files=compress_files, extract_file=extract_file)
win.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()