GUI rework for source-only builds + CLI security fixes + packaging + cross-platform scaffolding

Fixes the "GUI functions don't work on GNU Guix" report and a batch of
adversarial-audit findings across the CLI, decoder, crypto and packaging.

GUI (gui/src/zupt_gui.py)
- Root cause of the broken GUI: every encryption path defaulted to the
  libzuptsdk "SDK v2" modes, which are absent from the source-only build and
  exit 1. Reworked Keys/Compress/Extract around the native modes with a
  build-aware PQ-mode selector: Hybrid (--pq, default), Full PQ (--pq-only),
  and SDK v2 only when the binary reports WITH_SDK support. Capability is
  detected from `version` ("Build:"/"KDF:" lines) with a `help` fallback.
- Extract/Verify gain a PQ private-key input with auto-detect (reads the
  archive via `info` to pick --pq vs --pq-only). Verify could not verify any
  PQ archive before (password field only).
- run_async now holds a LIST of in-flight (thread, worker) refs; DiskTab's two
  buttons previously shared one slot, so a second op GC'd the first QThread
  mid-run. About tab corrected (codec 2.60.4, PBKDF2 default, --pq-only, URL).

CLI (src/zupt_main.c)
- compress -p <archive> <files> swallowed the archive name as the password and
  truncated the first input file (silent data loss, exit 0). Added a
  data-loss guard: refuse to overwrite an existing non-.zupt file as the output
  archive unless -y/--force; plus a self-overwrite guard.
- compress <archive> <src> -p <pw> wrote an UNENCRYPTED archive (exit 0)
  because options after the first positional were treated as files. Now errors
  on a misplaced option (with a `--` escape for real dashed filenames).
- Removed duplicated dead --pq-box/--pq-sdk branches. version/banner/usage now
  state the build's real KDF (PBKDF2 on source-only) and repo URL; examples
  lead with native --pq / --pq-only.

Security (audit findings)
- vv_decoder.c: the two AVX2 fast-path token decoders read a 2-/3-byte match
  offset after only checking 1 byte remained -> heap over-read on a crafted
  archive. Added the `ip + off_bytes > ip_end` guard the tail path already had.
- zupt_crypto.c: wipe ML-KEM/X25519 secret-key buffers when hybrid decrypt
  init fails on key read (matches the pq-only path).
- zupt_format.c: bound attacker-controlled encryption_header_off before the
  (off_t)+7 arithmetic in the info enc_type reader (avoid signed-overflow UB).

Packaging (would fail source-only)
- debian/rules (staged into debian/zupt, installed vendored .so), aur/PKGBUILD,
  nix/flake.nix, homebrew (vendored .so + nonexistent AUDIT.md, stale /zupt
  URLs) now build source-only. opensuse spec %files ships the shell completions
  make install writes (rpmbuild no longer fails on unpackaged files);
  _service + debian/control point at the vaptvupt repo; KDF claim corrected.

Cross-platform GUI packaging (new)
- packaging/portable/: OS-agnostic GUI package (zupt_gui.py + .bat/.command/.sh
  launchers + README) that runs on Windows/macOS/Linux/BSD with Python+PySide6.
- .github/workflows/cross-platform.yml: builds real native binaries on
  windows-latest + macos-latest runners (CLI, PyInstaller GUI, Inno Setup
  installer, .dmg) and the portable zip, attaching them to the release on tag.
- packaging/windows/vaptvupt-gui.iss: Inno Setup installer script.

Validation: make check 16/16 (all distro-safe checks), GUI imports + command
contracts verified against the fixed CLI. test_help_consistency updated to
assert the truthful (build-aware) default KDF.
This commit is contained in:
Cristian Cezar Moisés 2026-07-10 12:45:46 -03:00
commit 43d6306a06
19 changed files with 750 additions and 169 deletions

180
.github/workflows/cross-platform.yml vendored Normal file
View file

@ -0,0 +1,180 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# Cross-platform GUI + CLI binaries, built on REAL Windows and macOS runners.
#
# Why a dedicated workflow: the GUI is a PySide6/PyQt6 app and the CLI is
# portable C11, but self-contained native installers (Windows .exe/.msi,
# macOS .app/.dmg) can only be produced on the target OS. This workflow builds
# them on GitHub's windows-latest and macos-latest runners and attaches them to
# the GitHub release on a `v*` tag. Run it manually with "Run workflow"
# (workflow_dispatch) to smoke-test the build before tagging.
#
# Artifacts produced:
# Windows: vaptvupt.exe (CLI, mingw), vaptvupt-gui.exe (PyInstaller onefile),
# VaptVupt-Setup-<ver>.exe (Inno Setup installer)
# macOS: vaptvupt (CLI, universal where possible), VaptVupt-<ver>.dmg
# All: vaptvupt-gui-<ver>-portable.zip (Python GUI + launchers)
name: cross-platform
on:
push:
tags: ['v*']
workflow_dispatch:
permissions:
contents: write
jobs:
# ─────────────────────────── Windows ───────────────────────────
windows:
runs-on: windows-latest
defaults:
run: { shell: msys2 {0} }
steps:
- uses: actions/checkout@v4
- name: Set up MSYS2 (mingw gcc + make)
uses: msys2/setup-msys2@v2
with:
msystem: UCRT64
update: true
install: >-
mingw-w64-ucrt-x86_64-gcc
make
coreutils
- name: Build CLI (vaptvupt.exe, source-only, C fallback crypto)
run: |
make CC=gcc WITH_SDK=0 -j2
./vaptvupt.exe version || ./vaptvupt version
cp "$(ls vaptvupt.exe vaptvupt 2>/dev/null | head -1)" vaptvupt.exe 2>/dev/null || true
- name: Set up Python
shell: pwsh
run: |
# Use the runner's native Python (not MSYS) for PyInstaller so the
# produced .exe targets the standard Windows Python ABI.
python -m pip install --upgrade pip
python -m pip install PySide6 pyinstaller
- name: Get version
id: ver
shell: pwsh
run: |
$ver = (Select-String -Path include/zupt.h -Pattern '^#define ZUPT_VERSION_STRING "([^"]+)"').Matches.Groups[1].Value
"version=$ver" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
- name: Bundle GUI with PyInstaller (vaptvupt-gui.exe)
shell: pwsh
run: |
# onefile GUI that carries the CLI beside it via --add-binary.
pyinstaller --noconfirm --onefile --windowed `
--name vaptvupt-gui `
--icon gui/assets/zupt-icon.png `
--add-binary "vaptvupt.exe;." `
--add-data "gui/assets/zupt-icon.png;assets" `
gui/src/zupt_gui.py
- name: Build Inno Setup installer
shell: pwsh
run: |
choco install innosetup --no-progress -y
& "$env:ChocolateyInstall\bin\ISCC.exe" `
"/DAppVersion=${{ steps.ver.outputs.version }}" `
packaging/windows/vaptvupt-gui.iss
- name: Collect artifacts
shell: pwsh
run: |
$v = "${{ steps.ver.outputs.version }}"
New-Item -ItemType Directory -Force out | Out-Null
Copy-Item vaptvupt.exe "out/vaptvupt-$v-windows-x86_64.exe"
Copy-Item dist/vaptvupt-gui.exe "out/vaptvupt-gui-$v-windows-x86_64.exe"
if (Test-Path "packaging/windows/Output") {
Copy-Item packaging/windows/Output/*.exe "out/" -ErrorAction SilentlyContinue
}
- uses: actions/upload-artifact@v4
with:
name: windows
path: out/*
- name: Attach to release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: out/*
# ─────────────────────────── macOS ───────────────────────────
macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Build CLI (vaptvupt, clang)
run: |
make CC=clang WITH_SDK=0 -j3
./vaptvupt version
- uses: actions/setup-python@v5
with: { python-version: '3.12' }
- name: Install GUI build deps
run: |
python -m pip install --upgrade pip
python -m pip install PySide6 pyinstaller
brew install create-dmg || true
- name: Get version
id: ver
run: echo "version=$(awk -F'\"' '/^#define ZUPT_VERSION_STRING/{print $2}' include/zupt.h)" >> "$GITHUB_OUTPUT"
- name: Bundle GUI (.app) with PyInstaller
run: |
pyinstaller --noconfirm --windowed \
--name "VaptVupt" \
--add-binary "vaptvupt:." \
--add-data "gui/assets/zupt-icon.png:assets" \
gui/src/zupt_gui.py
- name: Build .dmg
run: |
V="${{ steps.ver.outputs.version }}"
create-dmg --volname "VaptVupt $V" --window-size 500 300 \
--app-drop-link 350 120 --icon "VaptVupt.app" 150 120 \
"VaptVupt-$V.dmg" "dist/VaptVupt.app" || \
{ mkdir -p dmgroot && cp -R dist/VaptVupt.app dmgroot/ && \
hdiutil create -volname "VaptVupt $V" -srcfolder dmgroot -ov -format UDZO "VaptVupt-$V.dmg"; }
- name: Collect artifacts
run: |
V="${{ steps.ver.outputs.version }}"
mkdir -p out
cp vaptvupt "out/vaptvupt-$V-macos"
cp "VaptVupt-$V.dmg" out/
- uses: actions/upload-artifact@v4
with:
name: macos
path: out/*
- name: Attach to release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: out/*
# ─────────────── Portable GUI (works on every OS) ───────────────
portable:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Get version
id: ver
run: echo "version=$(awk -F'\"' '/^#define ZUPT_VERSION_STRING/{print $2}' include/zupt.h)" >> "$GITHUB_OUTPUT"
- name: Assemble portable package
run: |
V="${{ steps.ver.outputs.version }}"
D="vaptvupt-gui-$V-portable"
mkdir -p "$D/assets"
cp gui/src/zupt_gui.py "$D/"
cp gui/assets/zupt-icon.png "$D/assets/"
cp packaging/portable/vaptvupt-gui.bat "$D/"
cp packaging/portable/vaptvupt-gui.command "$D/"
cp packaging/portable/vaptvupt-gui.sh "$D/"
cp packaging/portable/README.txt "$D/"
chmod +x "$D/vaptvupt-gui.command" "$D/vaptvupt-gui.sh"
zip -r "$D.zip" "$D"
- uses: actions/upload-artifact@v4
with:
name: portable
path: vaptvupt-gui-*-portable.zip
- name: Attach to release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: vaptvupt-gui-*-portable.zip

View file

@ -190,6 +190,81 @@ def _get_version():
ZUPT_VER_SHORT, ZUPT_VER_NUMBER, ZUPT_VER_FULL = _get_version()
# ── Detect build capabilities from `version` (and `help` as fallback) ──
#
# The default build is SOURCE-ONLY: the libzuptsdk-backed modes (Argon2id
# KDF, --pq-sdk, --pq-box) are absent and fail with exit 1. Offering them in
# the UI is the #1 reason "functions don't work". We detect what THIS binary
# actually supports and build the encryption UI around it:
# - SDK_AVAILABLE : --pq-sdk / --pq-box / Argon2id compiled in (WITH_SDK=1)
# - PQONLY_AVAILABLE: native --pq-only (full post-quantum, v4.2.0+)
# - DEFAULT_KDF : the password KDF this build actually uses
# The `version` banner carries a machine-readable "Build:" line (v4.2.1+);
# for older binaries we fall back to `help` text and default SDK off (safe:
# the native --pq / --pq-only / password modes work on every build).
def _get_caps():
sdk = False
pqonly = False
default_kdf = "PBKDF2-SHA256"
blob = ZUPT_VER_FULL or ""
for line in blob.splitlines():
low = line.lower()
if low.startswith("build:"):
sdk = ("full" in low) and ("libzuptsdk" in low)
elif low.startswith("kdf:"):
default_kdf = "Argon2id" if "argon2id (default)" in low else "PBKDF2-SHA256"
if "--pq-only" in line:
pqonly = True
if not pqonly:
try:
h = subprocess.run([VAPTVUPT, "help"], capture_output=True, text=True, timeout=5)
txt = (h.stdout or "") + (h.stderr or "")
if "--pq-only" in txt:
pqonly = True
except Exception:
pass
return sdk, pqonly, default_kdf
SDK_AVAILABLE, PQONLY_AVAILABLE, DEFAULT_KDF = _get_caps()
# Post-quantum recipient modes offered in the UI, keyed to CLI flags.
# token -> (label, keygen-flag-list, compress/extract-flag)
# keygen-flags are extra flags added to `keygen` (private) and `keygen --pub`.
def pq_mode_options(include_auto=False):
"""Return [(label, token)] for a PQ-mode dropdown given this build."""
opts = []
if include_auto:
opts.append(("Auto-detect from archive", "auto"))
opts.append(("Hybrid — ML-KEM-768 + X25519 (recommended)", "pq"))
if PQONLY_AVAILABLE:
opts.append(("Full PQ — ML-KEM-768 only", "pqonly"))
if SDK_AVAILABLE:
opts.append(("SDK v2 — HKDF + commitment + HPKE", "sdk"))
return opts
# token -> (extra keygen flags, encrypt/decrypt flag)
_PQ_FLAG = {
"pq": ([], "--pq"),
"pqonly": (["--pq-only"], "--pq-only"),
"sdk": (["--sdk"], "--pq-sdk"),
}
def _detect_archive_pq(archive):
"""Inspect an archive's `info` and return the matching PQ token, or None."""
try:
r = subprocess.run([VAPTVUPT, "info", archive], capture_output=True, text=True, timeout=15)
txt = (r.stdout or "") + (r.stderr or "")
except Exception:
return None
low = txt.lower()
if "ml-kem-768 only" in low or "no classical" in low:
return "pqonly"
if "sdk v2" in low or "hpke" in low:
return "sdk"
if "ml-kem-768" in low or "hybrid" in low or "x25519" in low:
return "pq"
return None
# ── Find icon file ──
def _find_icon():
here = Path(getattr(sys, '_MEIPASS', Path(__file__).parent))
@ -328,14 +403,22 @@ def run_async(parent, cmd, btn, log, progress=None):
if progress: progress.show()
t = QThread(); w = Worker(cmd); w.moveToThread(t)
w.log.connect(log.append)
# Keep a LIST of live (thread, worker) refs on the parent. Tabs with more
# than one action button (Disk: backup + restore) previously shared a
# single _thread/_worker slot, so starting a second op dropped the only
# Python reference to the first still-running QThread — Python GC'd it
# mid-run and aborted the operation. A list holds every in-flight thread.
if not hasattr(parent, "_jobs"):
parent._jobs = []
parent._jobs.append((t, w))
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()
parent._jobs = [(th, wk) for (th, wk) in parent._jobs if th is not t]
w.done.connect(finish)
t.started.connect(w.run); t.start()
parent._thread, parent._worker = t, w
# ── Tabs ──
@ -345,21 +428,31 @@ class KeysTab(QWidget):
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(QLabel("Generate or export post-quantum keys (ML-KEM-768)."))
v.addWidget(Sep())
# Key type governs both generate and export so the two stay consistent.
v.addWidget(H("Key type"))
self.mode = QComboBox()
self._modes = pq_mode_options()
for label, _tok in self._modes:
self.mode.addItem(label)
self.mode.setToolTip("Hybrid (--pq) is recommended. Full PQ (--pq-only) drops the\n"
"classical X25519 layer for PQ-only compliance postures. Both use\n"
"in-tree crypto and work on every build.")
v.addWidget(self.mode)
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(QLabel("Writes a private key and its matching public key."))
v.addWidget(H("Private key output"))
self.gen_priv = PathField("e.g. ~/zupt_private.key", "save", "Key (*.key);;All (*)")
self.gen_priv = PathField("e.g. ~/vaptvupt_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)
v.addWidget(H("Public key output"))
self.gen_pub = PathField("e.g. ~/vaptvupt_public.key", "save", "Key (*.key);;All (*)")
v.addWidget(self.gen_pub)
self.gen_btn = QPushButton("Generate Keypair")
self.gen_btn.clicked.connect(self._generate)
@ -370,14 +463,15 @@ class KeysTab(QWidget):
# 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(QLabel("Extract the public key from an existing private key file "
"(uses the key type selected above)."))
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 (*)")
self.exp_pub = PathField("e.g. ~/vaptvupt_public.key", "save", "Key (*.key);;All (*)")
v.addWidget(self.exp_pub)
self.exp_btn = QPushButton("Export Public Key")
@ -389,27 +483,32 @@ class KeysTab(QWidget):
v.addStretch()
lay = QVBoxLayout(self); lay.setContentsMargins(0,0,0,0); lay.addWidget(scrollable(inner))
def _token(self):
return self._modes[self.mode.currentIndex()][1]
def _default_pub(self, priv):
return (priv.rsplit(".", 1)[0] + "_public.key") if "." in priv else priv + ".pub"
def _generate(self):
p = self.gen_priv.path() or str(Path.home() / "zupt_private.key")
p = self.gen_priv.path() or str(Path.home() / "vaptvupt_private.key")
self.gen_priv.edit.setText(p)
pub = self.gen_pub.path() or self._default_pub(p)
self.gen_pub.edit.setText(pub)
tok = self._token()
kflags, _ = _PQ_FLAG[tok]
self.gen_log.clear(); self.gen_btn.setEnabled(False)
if self.gen_sdk.isChecked():
# SDK keygen creates both files in one step.
if tok == "sdk":
# SDK keygen writes the private key and <priv>.pub 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.")
self.gen_log.append(f"\nPrivate key: {p}\nPublic key: {p}.pub" if code == 0 else "\nFailed.")
else:
code, _, err = run_zupt(["keygen", "-o", p])
code, _, err = run_zupt(["keygen"] + kflags + ["-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])
c2, _, e2 = run_zupt(["keygen", "--pub"] + kflags + ["-o", pub, "-k", p])
self.gen_log.append(e2.strip())
if c2 == 0:
self.gen_log.append(f"\nPrivate key: {p}\nPublic key: {pub}")
self.gen_log.append(f"\nPrivate key: {p}\nPublic key: {pub}" if c2 == 0 else "\nFailed to export public key.")
else:
self.gen_log.append("\nFailed.")
self.gen_btn.setEnabled(True)
@ -419,15 +518,13 @@ class KeysTab(QWidget):
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)
pub = self._default_pub(priv); self.exp_pub.edit.setText(pub)
tok = self._token()
kflags, _ = _PQ_FLAG[tok]
self.exp_log.clear(); self.exp_btn.setEnabled(False)
code, _, err = run_zupt(["keygen", "--pub", "-o", pub, "-k", priv])
code, _, err = run_zupt(["keygen", "--pub"] + kflags + ["-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_log.append(f"\nPublic key: {pub}" if code == 0 else "\nFailed.")
self.exp_btn.setEnabled(True)
@ -454,10 +551,14 @@ class CompressTab(QWidget):
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)
mode_box = QVBoxLayout(); mode_box.addWidget(H("PQ mode"))
self.pqmode = QComboBox()
self._pqmodes = pq_mode_options()
for label, _tok in self._pqmodes:
self.pqmode.addItem(label)
self.pqmode.setToolTip("Applies when a PQ public key is set. Must match the key type\n"
"you generated. Hybrid (--pq) is recommended.")
mode_box.addWidget(self.pqmode); mode_box.addStretch(); enc.addLayout(mode_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)
@ -481,7 +582,8 @@ class CompressTab(QWidget):
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"
tok = self._pqmodes[self.pqmode.currentIndex()][1]
_, flag = _PQ_FLAG[tok]
cmd += [flag, self.pq.path()]
cmd.append(dst); cmd.extend(srcs)
run_async(self, cmd, self.btn, self.log, self.progress)
@ -500,10 +602,14 @@ class ExtractTab(QWidget):
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)
mode_box = QVBoxLayout(); mode_box.addWidget(H("PQ mode"))
self.pqmode = QComboBox()
self._pqmodes = pq_mode_options(include_auto=True)
for label, _tok in self._pqmodes:
self.pqmode.addItem(label)
self.pqmode.setToolTip("Auto-detect reads the archive header (vaptvupt info) to pick the\n"
"right mode. Or choose it explicitly to match your private key.")
mode_box.addWidget(self.pqmode); mode_box.addStretch(); enc.addLayout(mode_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)
@ -518,10 +624,13 @@ class ExtractTab(QWidget):
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"
tok = self._pqmodes[self.pqmode.currentIndex()][1]
if tok == "auto":
# The private-key format must match how the archive was encrypted;
# inspect the header (vaptvupt info) to choose the right flag.
tok = _detect_archive_pq(arc) or "pq"
self.log.append(f"[auto-detect] using {_PQ_FLAG[tok][1]}")
_, flag = _PQ_FLAG[tok]
cmd += [flag, self.pq.path()]
cmd.append(arc)
run_async(self, cmd, self.btn, self.log, self.progress)
@ -536,8 +645,16 @@ class VerifyTab(QWidget):
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)
enc = QHBoxLayout(); enc.setSpacing(16)
pw = QVBoxLayout(); pw.addWidget(H("Password (if encrypted)")); self.vpw = PwField("Leave empty if not encrypted"); pw.addWidget(self.vpw); enc.addLayout(pw)
pq = QVBoxLayout(); pq.addWidget(H("PQ private key")); self.vpq = PathField("For --pq / --pq-only archives", filters="Key (*.key);;All (*)"); pq.addWidget(self.vpq); enc.addLayout(pq)
mode_box = QVBoxLayout(); mode_box.addWidget(H("PQ mode"))
self.vpqmode = QComboBox()
self._vpqmodes = pq_mode_options(include_auto=True)
for label, _tok in self._vpqmodes:
self.vpqmode.addItem(label)
mode_box.addWidget(self.vpqmode); mode_box.addStretch(); enc.addLayout(mode_box)
v.addLayout(enc)
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())
@ -552,7 +669,15 @@ class VerifyTab(QWidget):
if not arc: return
cmd = ["test"]
if self.vpw.text(): cmd += ["-p", self.vpw.text()]
cmd.append(arc); self.vlog.clear()
self.vlog.clear()
if self.vpq.path():
tok = self._vpqmodes[self.vpqmode.currentIndex()][1]
if tok == "auto":
tok = _detect_archive_pq(arc) or "pq"
self.vlog.append(f"[auto-detect] using {_PQ_FLAG[tok][1]}")
_, flag = _PQ_FLAG[tok]
cmd += [flag, self.vpq.path()]
cmd.append(arc)
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.")
@ -621,30 +746,32 @@ class AboutTab(QWidget):
("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;"),
("Post-quantum backup compression with ML-KEM-768: --pq hybrid", "color:#6a8898;font-size:13px;"),
(f"(+ X25519) or --pq-only (pure). {DEFAULT_KDF} password KDF,", "color:#6a8898;font-size:13px;"),
("block deduplication, and full-disk backup.", "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;"),
("AES-256-CTR FIPS 197 Symmetric Cipher (fresh per-block nonce)", "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;"),
("PBKDF2-SHA256 RFC 8018 Password KDF (default, 600k iterations)", "color:#5a7a88;font-size:12px;font-family:monospace;"),
("Argon2id RFC 9106 Password KDF (WITH_SDK=1 builds only)", "color:#5a7a88;font-size:12px;font-family:monospace;"),
("SHA3-512 FIPS 202 PQ key derivation (--pq / --pq-only)", "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;"),
("VaptVupt LZ + ANS 2.60.4 LZ77 + tabled ANS entropy", "color:#5a7a88;font-size:12px;font-family:monospace;"),
("AVX2 / NEON SIMD acceleration; CBMC-verified BCJ filters", "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;"),
(" git.securityops.co/cristiancmoises/vaptvupt", "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;"),

View file

@ -18,7 +18,7 @@ replaces=('zupt')
conflicts=('zupt')
pkgdesc='Pure-C11 post-quantum backup compression utility (AES-256-CTR + HMAC-SHA256 + ML-KEM-768 + X25519)'
arch=('x86_64' 'aarch64')
url='https://git.securityops.co/cristiancmoises/zupt'
url='https://git.securityops.co/cristiancmoises/vaptvupt'
license=('AGPL-3.0-or-later')
depends=('glibc')
makedepends=('gcc')
@ -26,39 +26,31 @@ checkdepends=('python')
# Replace SHA256 placeholder with output of:
# sha256sum /tmp/zupt-2.4.4.tar.gz
source=("${pkgname}-${pkgver}.tar.gz::https://git.securityops.co/cristiancmoises/zupt/releases/download/v${pkgver}/${pkgname}-${pkgver}.tar.gz")
source=("${pkgname}-${pkgver}.tar.gz::https://git.securityops.co/cristiancmoises/vaptvupt/releases/download/v${pkgver}/${pkgname}-${pkgver}.tar.gz")
sha256sums=('SKIP')
build() {
cd "${pkgname}-${pkgver}"
# Strict-warning build that the project's own §6 verification matrix uses.
# Source-only build (WITH_SDK=0) with the project's strict warning set.
CFLAGS="${CFLAGS:--O2 -std=c11} -Wall -Wextra -Wpedantic" \
make -j"$(nproc)"
make WITH_SDK=0 -j"$(nproc)"
}
check() {
cd "${pkgname}-${pkgver}"
# Project regression suite — F-06 HMAC, F-08 top-MAC, F-09 byte sweep, etc.
make test
# Distro-safe regression subset (F-06..F-12, dedup-nonce, NIST/RFC vectors).
make WITH_SDK=0 check
}
package() {
cd "${pkgname}-${pkgver}"
make DESTDIR="${pkgdir}" PREFIX=/usr install
# Source-only build (no vendored libraries); `make install` places the
# binary, the zupt symlink, the man pages and the shell completions.
make DESTDIR="${pkgdir}" PREFIX=/usr WITH_SDK=0 install
# Docs that aren't part of `make install`
install -Dm644 README.md "${pkgdir}/usr/share/doc/${pkgname}/README.md"
install -Dm644 SECURITY.md "${pkgdir}/usr/share/doc/${pkgname}/SECURITY.md"
install -Dm644 CHANGELOG.md "${pkgdir}/usr/share/doc/${pkgname}/CHANGELOG.md"
install -Dm644 AUDIT.md "${pkgdir}/usr/share/doc/${pkgname}/AUDIT.md"
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
# Vendored libzuptsdk shipped alongside the binary because the binary
# is linked with -Wl,-rpath,$ORIGIN/vendor/zuptsdk. For system install
# we move it to /usr/lib/zupt/ and the binary's rpath remains relative.
install -d "${pkgdir}/usr/lib/${pkgname}"
install -Dm755 vendor/zuptsdk/libzuptsdk.so.2.0.0 \
"${pkgdir}/usr/lib/${pkgname}/libzuptsdk.so.2.0.0"
ln -sf libzuptsdk.so.2.0.0 "${pkgdir}/usr/lib/${pkgname}/libzuptsdk.so.2"
ln -sf libzuptsdk.so.2.0.0 "${pkgdir}/usr/lib/${pkgname}/libzuptsdk.so"
install -Dm644 README.md "${pkgdir}/usr/share/doc/${pkgname}/README.md"
install -Dm644 SECURITY.md "${pkgdir}/usr/share/doc/${pkgname}/SECURITY.md"
install -Dm644 CHANGELOG.md "${pkgdir}/usr/share/doc/${pkgname}/CHANGELOG.md"
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}

View file

@ -8,9 +8,9 @@ Build-Depends:
libc6-dev,
python3 (>= 3.8)
Standards-Version: 4.6.2
Homepage: https://git.securityops.co/cristiancmoises/zupt
Vcs-Browser: https://git.securityops.co/cristiancmoises/zupt
Vcs-Git: https://git.securityops.co/cristiancmoises/zupt.git
Homepage: https://git.securityops.co/cristiancmoises/vaptvupt
Vcs-Browser: https://git.securityops.co/cristiancmoises/vaptvupt
Vcs-Git: https://git.securityops.co/cristiancmoises/vaptvupt.git
Rules-Requires-Root: no
Package: vaptvupt
@ -24,7 +24,7 @@ Description: Post-quantum backup compression utility (formerly Zupt)
a pure-C11 backup compression utility featuring:
* Post-quantum hybrid encryption (ML-KEM-768 + X25519, FIPS 203)
* AES-256-CTR + HMAC-SHA256 authenticated encryption (Encrypt-then-MAC)
* Argon2id password-based key derivation (default since 2.4.1)
* PBKDF2-SHA256 password key derivation (Argon2id in WITH_SDK=1 builds)
* Multi-threaded compression with the VaptVupt LZ + ANS codec 2.48.5
* Full-disk backup and restore with sparse-region detection
* End-to-end byte-level tamper detection on encrypted archives

View file

@ -14,20 +14,18 @@ export DEB_LDFLAGS_MAINT_APPEND = -Wl,--as-needed
dh $@
override_dh_auto_build:
$(MAKE) -j$$(nproc)
# Source-only build: no vendored libraries, native crypto only.
$(MAKE) WITH_SDK=0 -j$$(nproc)
override_dh_auto_test:
# Project's own regression suite covers F-06..F-12.
$(MAKE) test
# Distro-safe regression subset (F-06..F-12, dedup-nonce, NIST/RFC vectors).
$(MAKE) WITH_SDK=0 check
override_dh_auto_install:
$(MAKE) DESTDIR=$(CURDIR)/debian/zupt PREFIX=/usr install
# Vendored libzuptsdk goes alongside the binary at a relative rpath.
install -d $(CURDIR)/debian/zupt/usr/lib/zupt
install -m 0755 vendor/zuptsdk/libzuptsdk.so.2.0.0 \
$(CURDIR)/debian/zupt/usr/lib/zupt/libzuptsdk.so.2.0.0
ln -sf libzuptsdk.so.2.0.0 $(CURDIR)/debian/zupt/usr/lib/zupt/libzuptsdk.so.2
ln -sf libzuptsdk.so.2.0.0 $(CURDIR)/debian/zupt/usr/lib/zupt/libzuptsdk.so
# Binary package is `vaptvupt` -> stage into debian/vaptvupt (dh derives the
# staging dir from the Package: name in debian/control). Source-only: nothing
# to install beyond `make install` (no .so).
$(MAKE) DESTDIR=$(CURDIR)/debian/vaptvupt PREFIX=/usr WITH_SDK=0 install
override_dh_auto_clean:
$(MAKE) clean

View file

@ -22,8 +22,8 @@
class Vaptvupt < Formula
desc "Post-quantum backup compression utility (ML-KEM-768 + AES-256-CTR + HMAC-SHA256)"
homepage "https://git.securityops.co/cristiancmoises/zupt"
url "https://git.securityops.co/cristiancmoises/zupt/releases/download/v4.2.1/vaptvupt-4.2.1.tar.gz"
homepage "https://git.securityops.co/cristiancmoises/vaptvupt"
url "https://git.securityops.co/cristiancmoises/vaptvupt/releases/download/v4.2.1/vaptvupt-4.2.1.tar.gz"
version "4.2.1"
sha256 "REPLACE_WITH_SHA256_OF_RELEASE_TARBALL"
license "AGPL-3.0-or-later"
@ -31,30 +31,16 @@ class Vaptvupt < Formula
depends_on "python@3.12" => :test # only for test-suite tamper harness
def install
# macOS build: no Jasmin, C-fallback crypto paths are used.
# The Makefile auto-detects Jasmin availability and falls back cleanly.
# Source-only build (WITH_SDK=0): native crypto only, no vendored libraries.
# macOS uses the C-fallback crypto paths (no Jasmin); the Makefile detects
# this and falls back cleanly.
ENV["CFLAGS"] = "#{ENV.cflags} -O2 -std=c11 -Wall -Wextra"
system "make", "-j#{ENV.make_jobs}"
system "make", "DESTDIR=#{prefix}", "PREFIX=", "install"
system "make", "WITH_SDK=0", "-j#{ENV.make_jobs}"
system "make", "DESTDIR=#{prefix}", "PREFIX=", "WITH_SDK=0", "install"
# Vendored libzuptsdk goes into lib/zupt/ with @loader_path rpath.
# Note: Linux ships .so.2.0.0; macOS .dylib equivalent must be built
# separately by the vendored makefile. For the initial Homebrew
# submission this assumes the upstream tarball includes a .dylib build;
# if not, build it here.
lib_zupt = lib/"zupt"
lib_zupt.mkpath
if File.exist?("vendor/zuptsdk/libzuptsdk.dylib")
cp "vendor/zuptsdk/libzuptsdk.dylib", lib_zupt
elsif File.exist?("vendor/zuptsdk/libzuptsdk.so.2.0.0")
# Fallback: link Linux-style .so on macOS (works for direct loads but
# not for dlopen-on-Darwin scenarios). Upstream is tracking this.
cp "vendor/zuptsdk/libzuptsdk.so.2.0.0", lib_zupt
end
# Docs
doc.install "README.md", "SECURITY.md", "CHANGELOG.md", "AUDIT.md"
# Docs (no vendored .so/.dylib in the source-only build).
doc.install "README.md", "SECURITY.md", "CHANGELOG.md"
end
test do

View file

@ -52,48 +52,38 @@
# hardening flags. Don't override -O2 from stdenv.
NIX_CFLAGS_COMPILE = "-Wall -Wextra -Wpedantic -std=c11";
# `make` builds the binary using vendored libzuptsdk via rpath.
# Source-only build (WITH_SDK=0): native crypto, no vendored libraries.
buildPhase = ''
runHook preBuild
make -j$NIX_BUILD_CORES
make WITH_SDK=0 -j$NIX_BUILD_CORES
runHook postBuild
'';
# Run the full upstream regression suite. Disable per-package by
# setting doCheck = false; on by default.
# Distro-safe regression subset. Disable with doCheck = false;.
doCheck = true;
checkPhase = ''
runHook preCheck
make test
make WITH_SDK=0 check
runHook postCheck
'';
installPhase = ''
runHook preInstall
make DESTDIR=$out PREFIX= install
# Move libzuptsdk into $out/lib/zupt/. The binary's rpath is
# $ORIGIN/../lib/zupt after autopatchelf rewrites it during
# the fixup phase.
mkdir -p $out/lib/zupt
install -m 0755 vendor/zuptsdk/libzuptsdk.so.2.0.0 \
$out/lib/zupt/libzuptsdk.so.2.0.0
ln -sf libzuptsdk.so.2.0.0 $out/lib/zupt/libzuptsdk.so.2
ln -sf libzuptsdk.so.2.0.0 $out/lib/zupt/libzuptsdk.so
make DESTDIR=$out PREFIX= WITH_SDK=0 install
# Docs
mkdir -p $out/share/doc/zupt
cp README.md SECURITY.md CHANGELOG.md AUDIT.md $out/share/doc/zupt/
mkdir -p $out/share/doc/vaptvupt
cp README.md SECURITY.md CHANGELOG.md $out/share/doc/vaptvupt/
runHook postInstall
'';
meta = with pkgs.lib; {
description = "Post-quantum backup compression utility (ML-KEM-768 + AES-256-CTR + HMAC-SHA256 + Argon2id)";
homepage = "https://git.securityops.co/cristiancmoises/zupt";
description = "Post-quantum backup compression utility (ML-KEM-768 + X25519 + AES-256-CTR + HMAC-SHA256)";
homepage = "https://git.securityops.co/cristiancmoises/vaptvupt";
license = with licenses; [ agpl3Plus gpl3Plus ];
maintainers = [ ];
platforms = [ "x86_64-linux" "aarch64-linux" ];
mainProgram = "zupt";
mainProgram = "vaptvupt";
};
};
in {

View file

@ -1,6 +1,6 @@
<services>
<service name="tar_scm" mode="manual">
<param name="url">https://github.com/cristiancmoises/zupt</param>
<param name="url">https://github.com/cristiancmoises/vaptvupt</param>
<param name="scm">git</param>
<param name="revision">v4.2.1</param>
<param name="versionformat">@PARENT_TAG@</param>

View file

@ -97,6 +97,11 @@ chmod +x tests/*.sh
%doc README.md SECURITY.md CHANGELOG.md
%{_bindir}/vaptvupt
%{_bindir}/zupt
%{_datadir}/bash-completion/completions/vaptvupt
%{_datadir}/bash-completion/completions/zupt
%{_datadir}/zsh/site-functions/_vaptvupt
%{_datadir}/zsh/site-functions/_zupt
%{_datadir}/fish/vendor_completions.d/vaptvupt.fish
%{_mandir}/man1/vaptvupt.1%{?ext_man}
%{_mandir}/man1/zupt.1%{?ext_man}

View file

@ -0,0 +1,57 @@
VaptVupt GUI — portable cross-platform package
==============================================
The VaptVupt GUI is a single Python file (zupt_gui.py) built on Qt for Python
(PySide6, or PyQt6 as a fallback). It runs on Windows, macOS, Linux and the
BSDs — anywhere Python 3 and a Qt binding are installed. This portable package
contains the GUI plus a launcher for each platform; it drives the `vaptvupt`
command-line tool under the hood.
Contents
--------
zupt_gui.py The GUI (PySide6 / PyQt6).
vaptvupt-gui.bat Windows launcher.
vaptvupt-gui.command macOS launcher (double-clickable in Finder).
vaptvupt-gui.sh Linux / BSD launcher.
assets/zupt-icon.png Application icon.
Requirements
------------
1. Python 3.8 or newer.
Windows: https://python.org (tick "Add python.exe to PATH")
macOS: python.org, or `brew install python`
Linux: your distro's python3 package
FreeBSD: pkg install python311
OpenBSD: pkg_add python%3
2. A Qt binding:
pip (any OS): python3 -m pip install PySide6
Debian/Ubuntu: sudo apt install python3-pyqt6
Fedora/RHEL: sudo dnf install python3-pyqt6
FreeBSD: pkg install py311-pyside6
OpenBSD: pkg_add py3-pyside6
3. The vaptvupt CLI, either:
* placed next to the launcher (vaptvupt.exe on Windows, vaptvupt
elsewhere) — the launcher auto-detects it via VAPTVUPT_BIN, or
* installed on PATH (deb/rpm/AppImage/Homebrew/pkg — see the project
release page).
Running
-------
Windows: double-click vaptvupt-gui.bat
macOS: double-click vaptvupt-gui.command
(first run: right-click > Open to bypass Gatekeeper for an
unsigned script, or `xattr -dr com.apple.quarantine .`)
Linux/BSD: ./vaptvupt-gui.sh
Troubleshooting
---------------
* "requires PySide6 or PyQt6" -> install a Qt binding (requirement 2).
* "vaptvupt not found" -> put the CLI next to the launcher or on PATH.
* Set VAPTVUPT_DEBUG=1 to print the binary-discovery log to stderr.
Fully self-contained native installers (Windows .exe/.msi, macOS .dmg) that
bundle Python + Qt + the CLI are produced by the project's CI on real Windows
and macOS runners — see the release page. This portable package is the
dependency-light option that works identically on every platform.
License: AGPL-3.0-or-later. Project: https://git.securityops.co/cristiancmoises/vaptvupt

View file

@ -0,0 +1,31 @@
@echo off
rem SPDX-License-Identifier: AGPL-3.0-or-later
rem VaptVupt GUI launcher for Windows (portable package).
rem
rem Requirements on the target machine:
rem * Python 3.8+ (https://python.org — tick "Add python.exe to PATH")
rem * PySide6 or PyQt6: py -m pip install PySide6
rem * The vaptvupt CLI: vaptvupt.exe next to this file, or on PATH.
rem
rem If vaptvupt.exe sits beside this launcher we pin it via VAPTVUPT_BIN so the
rem GUI drives the bundled CLI rather than any other copy on PATH.
setlocal
set "HERE=%~dp0"
if exist "%HERE%vaptvupt.exe" set "VAPTVUPT_BIN=%HERE%vaptvupt.exe"
rem Prefer the py launcher, fall back to python on PATH.
where py >nul 2>nul
if %ERRORLEVEL%==0 (
py -3 "%HERE%zupt_gui.py" %*
) else (
python "%HERE%zupt_gui.py" %*
)
set "RC=%ERRORLEVEL%"
if not "%RC%"=="0" (
echo.
echo vaptvupt-gui exited with code %RC%.
echo If you saw an import error, install the Qt binding: py -m pip install PySide6
echo If the CLI was not found, put vaptvupt.exe next to this launcher or on PATH.
pause
)
endlocal

View file

@ -0,0 +1,17 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# VaptVupt GUI launcher for macOS (portable package).
# Double-clickable in Finder (.command). Requirements on the target Mac:
# * Python 3.8+ (python.org, Homebrew `brew install python`, or Xcode CLT)
# * PySide6 or PyQt6: python3 -m pip install PySide6
# * The vaptvupt CLI: `vaptvupt` next to this file, or on PATH
# (Homebrew: `brew install cristiancmoises/tap/vaptvupt`).
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
[ -x "$HERE/vaptvupt" ] && export VAPTVUPT_BIN="$HERE/vaptvupt"
PY="$(command -v python3 || command -v python || true)"
if [ -z "$PY" ]; then
osascript -e 'display alert "VaptVupt GUI" message "Python 3 not found. Install it from python.org or `brew install python`, then run: python3 -m pip install PySide6"' 2>/dev/null
echo "Python 3 not found." >&2; exit 1
fi
exec "$PY" "$HERE/zupt_gui.py" "$@"

View file

@ -0,0 +1,21 @@
#!/bin/sh
# SPDX-License-Identifier: AGPL-3.0-or-later
# VaptVupt GUI launcher for Linux and the BSDs (portable package).
# Requirements on the target system:
# * Python 3.8+
# * PySide6 or PyQt6
# Debian/Ubuntu: sudo apt install python3-pyqt6
# Fedora/RHEL: sudo dnf install python3-pyqt6
# FreeBSD: pkg install py311-pyside6 (or py311-qt6-pyqt)
# OpenBSD: pkg_add py3-pyside6
# any OS via pip: python3 -m pip install PySide6
# * The vaptvupt CLI: `vaptvupt` next to this file, or on PATH.
HERE="$(cd "$(dirname "$0")" && pwd)"
[ -x "$HERE/vaptvupt" ] && export VAPTVUPT_BIN="$HERE/vaptvupt"
PY="$(command -v python3 || command -v python || true)"
if [ -z "$PY" ]; then
echo "vaptvupt-gui: Python 3 not found on PATH." >&2
exit 1
fi
exec "$PY" "$HERE/zupt_gui.py" "$@"

View file

@ -0,0 +1,73 @@
; SPDX-License-Identifier: AGPL-3.0-or-later
; Inno Setup script for the VaptVupt GUI Windows installer.
;
; Compiled by the cross-platform CI (.github/workflows/cross-platform.yml) with:
; ISCC.exe /DAppVersion=<version> packaging/windows/vaptvupt-gui.iss
; after PyInstaller has produced dist\vaptvupt-gui.exe (a onefile bundle that
; already contains Python, PySide6 and vaptvupt.exe). Requires Inno Setup 6+.
;
; To build locally on Windows: install Inno Setup, then run the same ISCC line
; from the repo root (with dist\vaptvupt-gui.exe present).
#ifndef AppVersion
#define AppVersion "0.0.0"
#endif
[Setup]
AppName=VaptVupt
AppVersion={#AppVersion}
AppPublisher=Cristian Cezar Moises
AppPublisherURL=https://git.securityops.co/cristiancmoises/vaptvupt
DefaultDirName={autopf}\VaptVupt
DefaultGroupName=VaptVupt
UninstallDisplayIcon={app}\vaptvupt-gui.exe
OutputDir=packaging\windows\Output
OutputBaseFilename=VaptVupt-Setup-{#AppVersion}
Compression=lzma2
SolidCompression=yes
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
WizardStyle=modern
LicenseFile=LICENSE
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Files]
; PyInstaller onefile bundle (Python + PySide6 + the GUI + vaptvupt.exe).
Source: "dist\vaptvupt-gui.exe"; DestDir: "{app}"; Flags: ignoreversion
; Ship the raw CLI too so it can be added to PATH and used from a terminal.
Source: "vaptvupt.exe"; DestDir: "{app}"; Flags: ignoreversion skipifsourcedoesntexist
Source: "README.md"; DestDir: "{app}"; Flags: ignoreversion isreadme
Source: "CHANGELOG.md"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
Name: "{group}\VaptVupt"; Filename: "{app}\vaptvupt-gui.exe"
Name: "{group}\Uninstall VaptVupt"; Filename: "{uninstallexe}"
Name: "{autodesktop}\VaptVupt"; Filename: "{app}\vaptvupt-gui.exe"; Tasks: desktopicon
[Tasks]
Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional icons:"
Name: "addtopath"; Description: "Add the vaptvupt CLI to PATH (current user)"; GroupDescription: "Command line:"
[Registry]
; Optionally add the install dir to the user PATH (for the vaptvupt.exe CLI).
Root: HKCU; Subkey: "Environment"; ValueType: expandsz; ValueName: "Path"; \
ValueData: "{olddata};{app}"; Tasks: addtopath; Check: NeedsAddPath('{app}')
[Run]
Filename: "{app}\vaptvupt-gui.exe"; Description: "Launch VaptVupt"; \
Flags: nowait postinstall skipifsilent
[Code]
function NeedsAddPath(Param: string): Boolean;
var
OrigPath: string;
begin
if not RegQueryStringValue(HKEY_CURRENT_USER, 'Environment', 'Path', OrigPath) then
begin
Result := True;
exit;
end;
Result := Pos(';' + ExpandConstant(Param) + ';', ';' + OrigPath + ';') = 0;
end;

View file

@ -202,6 +202,12 @@ decode_block_tokens_impl(
if (VV_UNLIKELY(ip >= ip_end)) break;
/* Bound the 2-/3-byte offset read against the compressed-block end.
* Without this a crafted block whose last literal advances ip to
* ip_end-1 (or ip_end-2 for 3-byte offsets) makes vv_read16 / the
* 3-byte load read past the heap buffer. The general/tail decode path
* already carries this guard; the AVX2 fast paths were missing it. */
if (VV_UNLIKELY(ip + off_bytes > ip_end)) return VV_ERR_CORRUPT;
uint32_t offset;
if (off_bytes == 2) {
offset = vv_read16(ip);
@ -285,6 +291,12 @@ decode_block_tokens_impl(
if (VV_UNLIKELY(ip >= ip_end)) break;
/* Bound the 2-/3-byte offset read against the compressed-block end.
* Without this a crafted block whose last literal advances ip to
* ip_end-1 (or ip_end-2 for 3-byte offsets) makes vv_read16 / the
* 3-byte load read past the heap buffer. The general/tail decode path
* already carries this guard; the AVX2 fast paths were missing it. */
if (VV_UNLIKELY(ip + off_bytes > ip_end)) return VV_ERR_CORRUPT;
uint32_t offset;
if (off_bytes == 2) {
offset = vv_read16(ip);

View file

@ -817,7 +817,14 @@ int zupt_hybrid_decrypt_init(zupt_keyring_t *kr, const char *privkeyfile,
const uint8_t *nonce = enc_hdr + 1 + 1088 + 32;
uint8_t ml_pk[1184], x_pk[32], ml_sk[2400], x_sk[32];
if (read_privkey(privkeyfile, ml_pk, x_pk, ml_sk, x_sk) != 0) return -1;
if (read_privkey(privkeyfile, ml_pk, x_pk, ml_sk, x_sk) != 0) {
/* Wipe any partially-read secret-key material on error, matching the
* pq-only decrypt path (a bad/truncated key file must not leave secret
* bytes on the stack). */
zupt_secure_wipe(ml_sk, sizeof(ml_sk));
zupt_secure_wipe(x_sk, sizeof(x_sk));
return -1;
}
/* ML-KEM-768 decapsulation */
uint8_t ml_ss[32];

View file

@ -2911,7 +2911,11 @@ zupt_error_t zupt_archive_info(const char *path) {
* write_enc_header: 7-byte prefix (magic0,magic1,block_type,codec u16,
* flags u16) + varint(len) + varint(len) + u64 xxh64 + enc_hdr[0]=enc_type. */
uint8_t enc_type = 0;
/* encryption_header_off is attacker-controlled; bound it inside the file
* before the (off_t)+7 arithmetic so the signed addition cannot overflow
* (UB) and the seek stays in-range. All subsequent reads are EOF-checked. */
if ((hdr.global_flags & ZUPT_FLAG_ENCRYPTED) && hdr.encryption_header_off != 0 &&
hdr.encryption_header_off < file_size && (file_size - hdr.encryption_header_off) > 7 &&
fseeko(f, (off_t)hdr.encryption_header_off + 7, SEEK_SET) == 0) {
uint64_t l1 = 0, l2 = 0;
if (zupt_read_varint(f, &l1) > 0 && zupt_read_varint(f, &l2) > 0 &&

View file

@ -12,6 +12,12 @@
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <sys/stat.h> /* stat()/S_ISREG for the compress output-overwrite guard */
/* MSVC's <sys/stat.h> defines _S_IFREG/S_IFREG but not the S_ISREG macro. */
#ifndef S_ISREG
# define S_ISREG(m) (((m) & S_IFMT) == S_IFREG)
#endif
#ifdef _WIN32
#include <conio.h>
@ -23,7 +29,12 @@ static void banner(void) {
fprintf(stderr,
"%s %s - %s\n"
"Format v%d.%d | Codec: VaptVupt + Zupt-LZ | Checksum: XXH64\n"
"Encryption: AES-256-CTR + HMAC-SHA256 | KDF: Argon2id (default) / PBKDF2 (--kdf pbkdf2)\n\n",
"Encryption: AES-256-CTR + HMAC-SHA256 | KDF: "
#ifdef ZUPT_WITH_SDK
"Argon2id (default) / PBKDF2 (--kdf pbkdf2)\n\n",
#else
"PBKDF2-SHA256 (Argon2id needs a WITH_SDK=1 build)\n\n",
#endif
ZUPT_PRODUCT_NAME, ZUPT_VERSION_STRING, ZUPT_PRODUCT_TAGLINE,
ZUPT_FORMAT_MAJOR, ZUPT_FORMAT_MINOR);
}
@ -67,19 +78,25 @@ static void usage(void) {
" -f, --fast Use fast LZ codec (less compression)\n"
" --vv, --vaptvupt Use VaptVupt codec (LZ + ANS entropy, default)\n"
" --lzhp Use Zupt-LZHP codec (LZ77+Huffman, no SIMD needed)\n"
" -p, --password <PW> Encrypt with AES-256 (prompted if empty)\n"
" --kdf <argon2id|pbkdf2> KDF for password mode. Default: argon2id (v2.4.1+).\n"
" -p, --password <PW> Encrypt with AES-256 (bare -p prompts). Options must\n"
" precede <output.zupt>; a value ending in .zupt is\n"
" taken as the password, so put -p before the archive.\n"
#ifdef ZUPT_WITH_SDK
" --kdf <argon2id|pbkdf2> KDF for password mode. Default: argon2id.\n"
" Use 'pbkdf2' for v2.4.0-and-older reader compatibility.\n"
#else
" --kdf <pbkdf2> KDF for password mode. Default (and only, this build):\n"
" PBKDF2-SHA256 600k. Argon2id needs a WITH_SDK=1 build.\n"
#endif
" -c, --comment <TEXT> Embed a free-form archive comment (v2.4.3+).\n"
" --comment-file <FILE> Read comment from file (max 4096 bytes).\n"
" --pq <pubkey> Post-quantum HYBRID encryption (ML-KEM-768 + X25519) [recommended]\n"
" --pq-only <pubkey> FULL post-quantum encryption (ML-KEM-768 only, no classical layer)\n"
" --pq-sdk <pubkey> Post-quantum encryption via libzuptsdk (WITH_SDK=1 builds only)\n"
" --pq-box <pubkey> Post-quantum sealed box via libpqvaptvupt (HKDF combiner)\n"
" (HKDF combiner + key commitment + HPKE binding\n"
" + Argon2id; recommended for new archives)\n"
" --pq-box <pubkey> Post-quantum sealed box via libpqvaptvupt (WITH_SDK=1 builds only)\n"
" --dedup, -D Block-level deduplication\n"
" --solid Solid mode (single stream)\n"
" -y, --force Overwrite an existing non-.zupt file as the output archive\n"
" -v, --verbose Verbose per-file output\n"
" -t, --threads <N> Thread count (0=auto, 1=single, 2-64=explicit)\n"
"\n");
@ -112,35 +129,44 @@ static void usage(void) {
/* ── Section 4: examples ── */
fprintf(stderr,
"Examples:\n"
" # Legacy PQ workflow\n"
" vaptvupt keygen -o mykey.key # Generate keypair\n"
" # Post-quantum HYBRID workflow (--pq, recommended)\n"
" vaptvupt keygen -o mykey.key # Generate hybrid private key\n"
" vaptvupt keygen --pub -o pub.key -k mykey.key # Export public key\n"
" vaptvupt compress --pq pub.key backup.zupt ~/Documents/ # Encrypt\n"
" vaptvupt extract --pq mykey.key backup.zupt -o ~/restored/ # Decrypt\n"
"\n"
" # SDK v2 PQ workflow (recommended for new archives)\n"
" vaptvupt keygen --sdk -o mykey.priv # Writes mykey.priv + .pub\n"
" vaptvupt compress --pq-sdk mykey.priv.pub backup.zupt files/ # Encrypt (HKDF+commit+HPKE)\n"
" vaptvupt extract --pq-sdk mykey.priv backup.zupt # Decrypt\n"
" # Full (pure) post-quantum workflow (--pq-only, ML-KEM-768 only)\n"
" vaptvupt keygen --pq-only -o pqkey # Generate pq-only private key\n"
" vaptvupt keygen --pub --pq-only -o pqkey.pub -k pqkey # Export public key\n"
" vaptvupt compress --pq-only pqkey.pub backup.zupt files/ # Encrypt (no classical layer)\n"
" vaptvupt extract --pq-only pqkey backup.zupt -o out/ # Decrypt\n"
"\n"
" # Conventional / password\n"
" # Conventional / password (PBKDF2-SHA256)\n"
" vaptvupt compress backup.zupt ~/Documents/ # No encryption\n"
" vaptvupt compress -l 9 -p mysecret secure.zupt data/ # Password + max compression\n"
" vaptvupt list secure.zupt -p mysecret # List with password\n"
" vaptvupt extract -o restored/ -p mysecret secure.zupt # Extract with password\n"
" vaptvupt bench ~/Documents/ # Benchmark\n"
"\n"
" # SDK v2 / sealed-box modes require an upstream 'make WITH_SDK=1' build:\n"
" # keygen --sdk / --box, compress/extract --pq-sdk / --pq-box\n"
"\n");
/* ── Section 5: footer ── */
fprintf(stderr,
"Default codec: VaptVupt LZ + ANS " ZUPT_CODEC_RELEASE " (AVX2/NEON SIMD)\n"
"Encryption: AES-256-CTR + HMAC-SHA256 (Encrypt-then-MAC)\n"
"KDF: Argon2id (default, v2.4.1+); PBKDF2-SHA256 600k iter via --kdf pbkdf2\n"
#ifdef ZUPT_WITH_SDK
"KDF: Argon2id (default); PBKDF2-SHA256 600k iter via --kdf pbkdf2\n"
#else
"KDF: PBKDF2-SHA256 600k iter (default; Argon2id needs WITH_SDK=1)\n"
#endif
"Post-quantum: --pq (hybrid ML-KEM-768 + X25519, recommended); --pq-only (ML-KEM-768 only)\n"
"Format: v1.6 (since v2.3.1); archives byte-compatible with v2.3.1+\n"
"\n"
"License: AGPL-3.0-or-later (VaptVupt) + GPL-3.0-or-later (VaptVupt codec)\n"
" Dual-licensed: commercial license available: sac@securityops.co\n"
"Project: https://git.securityops.co/cristiancmoises/zupt\n"
"Project: https://git.securityops.co/cristiancmoises/vaptvupt\n"
);
}
@ -191,11 +217,18 @@ int main(int argc, char **argv) {
"Format: v%d.%d | Archive extension: .zupt (unchanged)\n"
"Codec: VaptVupt " ZUPT_CODEC_RELEASE " (0x%04X) — LZ + ANS, optimal parser + large-window extreme\n"
"Encryption: AES-256-CTR + HMAC-SHA256\n"
#ifdef ZUPT_WITH_SDK
"KDF: Argon2id (default) / PBKDF2-SHA256 %d iter (--kdf pbkdf2)\n"
"Post-quantum: ML-KEM-768 + X25519 hybrid (FIPS 203 + RFC 7748)\n"
"Post-quantum: --pq hybrid (ML-KEM-768 + X25519), --pq-only (ML-KEM-768), --pq-sdk/--pq-box (libzuptsdk)\n"
"Build: full (libzuptsdk: Argon2id, --pq-sdk, --pq-box available)\n"
#else
"KDF: PBKDF2-SHA256 %d iter (default; Argon2id needs WITH_SDK=1)\n"
"Post-quantum: --pq hybrid (ML-KEM-768 + X25519), --pq-only (ML-KEM-768 only) — FIPS 203 + RFC 7748\n"
"Build: source-only (native crypto; --pq-sdk/--pq-box/Argon2id need WITH_SDK=1)\n"
#endif
"License: AGPL-3.0-or-later (VaptVupt) + GPL-3.0-or-later (codec)\n"
" Dual-licensed: commercial license available\n"
"Project: https://git.securityops.co/cristiancmoises/zupt\n"
"Project: https://git.securityops.co/cristiancmoises/vaptvupt\n"
"Commercial: sac@securityops.co\n",
ZUPT_VERSION_STRING, ZUPT_FORMAT_MAJOR, ZUPT_FORMAT_MINOR,
ZUPT_CODEC_VAPTVUPT, ZUPT_KDF_ITERATIONS);
@ -219,6 +252,7 @@ int main(int argc, char **argv) {
if (streq(cmd,"compress")||streq(cmd,"c")) {
zupt_options_t opts; zupt_default_options(&opts);
int ai = 2;
int force = 0; /* -y/--force: allow overwriting a non-.zupt output */
while (ai<argc && isopt(argv[ai])) {
if ((streq(argv[ai],"-l")||streq(argv[ai],"--level"))&&ai+1<argc) {
opts.level=atoi(argv[++ai]); if(opts.level<1)opts.level=1; if(opts.level>9)opts.level=9;
@ -260,18 +294,6 @@ int main(int argc, char **argv) {
} else if (streq(argv[ai],"--pq-sdk")&&ai+1<argc) {
opts.pq_mode=1; opts.sdk_mode=1; opts.encrypt=1;
strncpy(opts.keyfile, argv[++ai], sizeof(opts.keyfile)-1);
} else if (streq(argv[ai],"--pq-box")&&ai+1<argc) {
opts.pq_mode=1; opts.box_mode=1; opts.encrypt=1;
strncpy(opts.keyfile, argv[++ai], sizeof(opts.keyfile)-1);
} else if (streq(argv[ai],"--pq-sdk")&&ai+1<argc) {
opts.pq_mode=1; opts.sdk_mode=1; opts.encrypt=1;
strncpy(opts.keyfile, argv[++ai], sizeof(opts.keyfile)-1);
} else if (streq(argv[ai],"--pq-box")&&ai+1<argc) {
opts.pq_mode=1; opts.box_mode=1; opts.encrypt=1;
strncpy(opts.keyfile, argv[++ai], sizeof(opts.keyfile)-1);
} else if (streq(argv[ai],"--pq-sdk")&&ai+1<argc) {
opts.pq_mode=1; opts.sdk_mode=1; opts.encrypt=1;
strncpy(opts.keyfile, argv[++ai], sizeof(opts.keyfile)-1);
} else if (streq(argv[ai],"--pq-only")&&ai+1<argc) {
opts.pqonly_mode=1; opts.encrypt=1;
strncpy(opts.keyfile, argv[++ai], sizeof(opts.keyfile)-1);
@ -314,6 +336,8 @@ int main(int argc, char **argv) {
fprintf(stderr, "Error: --kdf must be 'argon2id' or 'pbkdf2', got '%s'\n", argv[ai]);
return 1;
}
} else if (streq(argv[ai],"-y")||streq(argv[ai],"--force")) {
force = 1;
} else {
fprintf(stderr,"Error: Unknown option '%s'\n",argv[ai]); return 1;
}
@ -324,10 +348,59 @@ int main(int argc, char **argv) {
}
const char *output = argv[ai++];
/* Collect files (expand directories recursively) */
/* Reject a misplaced option among the file positionals. Without this,
* `compress out.zupt dir -p secret` silently treats "-p"/"secret" as
* (skipped) input files and writes an UNENCRYPTED archive with exit 0
* the user believes it is encrypted. Options must precede the
* output archive (use `--` before a real filename that starts with '-'). */
int seen_dashdash = 0;
for (int i=ai; i<argc; i++) {
if (!seen_dashdash && streq(argv[i],"--")) { seen_dashdash = 1; continue; }
if (!seen_dashdash && isopt(argv[i])) {
fprintf(stderr,
"Error: option '%s' appears after the output archive.\n"
" In compress, all options (including -p/--pq) must come BEFORE\n"
" the output archive name. Example:\n"
" vaptvupt compress -p PASSWORD %s %s ...\n",
argv[i], output, (i>ai ? argv[ai] : "<files>"));
return 1;
}
}
/* Data-loss guard. `compress -p out.zupt a.txt b.txt` makes -p swallow
* "out.zupt" as the PASSWORD, shifts positionals so the output archive
* becomes "a.txt", and truncates a.txt (a user data file) with archive
* bytes silently, exit 0. Refuse to overwrite an existing regular file
* that is not a .zupt archive unless -y/--force is given. Archives the
* tool writes end in .zupt, so this never blocks normal use. */
{
struct stat ost;
size_t olen = strlen(output);
int is_zupt = (olen >= 5 && strcmp(output + olen - 5, ".zupt") == 0);
if (!force && !is_zupt && stat(output, &ost) == 0 && S_ISREG(ost.st_mode)) {
fprintf(stderr,
"Error: refusing to overwrite existing file '%s' as the output archive\n"
" (it does not end in .zupt). If you meant to set a password, use\n"
" '-p<password>' or put '-p PASSWORD' BEFORE the archive name.\n"
" Pass -y/--force to overwrite '%s' anyway.\n",
output, output);
return 1;
}
}
/* Skip a leading `--` separator before the file list. */
if (ai < argc && streq(argv[ai], "--")) ai++;
/* Collect files (expand directories recursively). Guard against the
* output archive also being one of the inputs (self-overwrite). */
zupt_filelist_t fl; zupt_filelist_init(&fl);
for (int i=ai; i<argc; i++)
for (int i=ai; i<argc; i++) {
if (streq(argv[i], output)) {
fprintf(stderr, "Error: input '%s' is the same as the output archive.\n", argv[i]);
zupt_filelist_free(&fl); return 1;
}
zupt_collect_files(&fl, argv[i], argv[i]);
}
if (fl.count == 0) {
fprintf(stderr, "Error: No files found.\n");

View file

@ -109,11 +109,19 @@ else
fi
# ─── KDF consistency ───
# Argon2id is the default since v2.4.1; the help must say so.
if echo "$HELP" | grep -qE "Argon2id.*default"; then
P "help correctly identifies Argon2id as the default KDF"
# The help must state the ACTUAL default KDF for this build: PBKDF2-SHA256 on
# the source-only build (WITH_SDK=0), Argon2id only when built with WITH_SDK=1.
# A build that advertises Argon2id-by-default but derives PBKDF2 keys overstates
# its GPU/ASIC resistance (regression from v4.2.1).
if echo "$HELP" | grep -qiE "argon2id.*WITH_SDK=1"; then
P "help correctly scopes Argon2id to WITH_SDK=1 (source-only build)"
elif echo "$HELP" | grep -qE "PBKDF2.*[Dd]efault|[Dd]efault.*PBKDF2"; then
P "help correctly identifies PBKDF2-SHA256 as the default KDF"
elif echo "$HELP" | grep -qE "Argon2id.*[Dd]efault"; then
# A WITH_SDK=1 build legitimately defaults to Argon2id.
P "help identifies Argon2id as the default KDF (WITH_SDK=1 build)"
else
F "help doesn't identify Argon2id as the default KDF"
F "help does not state the default password KDF"
fi
# ─── Format consistency ───