v4.0.0: codec 2.60.4 security release, --pq-box sealed-box mode, F-16 fix
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

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).
This commit is contained in:
Cristian Cezar Moisés 2026-06-10 18:48:58 -03:00
commit 544a2cd647
98 changed files with 15615 additions and 1397 deletions

View file

@ -1,104 +1,251 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2026 Cristian Cezar Moisés
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# Zupt CI matrix.
#
# Mirrors the project's local-verification protocol from PROMPT.md §6:
# 1. Plain GCC build
# 2. Plain Clang build
# 3. Strict GCC (full warning set)
# 4. Strict Clang (full warning set)
# 5. ASAN + UBSAN
# 6. Full regression suite (12 suites: audit, dedup, path-traversal,
# argument-order, block-swap, F-08, F-09 byte sweep, F-10, F-11,
# F-12, packaging syntax, dist reproducibility)
# 7. License header audit
# 8. `make dist` reproducibility (two runs, sha256 must match)
# 9. aarch64 cross-test via QEMU emulation
# 10. Automatic release on git tag push
name: CI
on:
push:
branches: [main, develop]
tags: ['v*']
pull_request:
branches: [main]
branches: [main, develop]
jobs:
# ─── Plain build + test, exactly as a user would do it ───
build-and-test:
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
cc: [gcc, clang]
steps:
- uses: actions/checkout@v4
- name: Install build dependencies
- name: Install build deps
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
libargon2-dev libargon2-1 \
libssl-dev libssl3 \
python3
- name: Build zupt
run: make
- name: Run quick tests
sudo apt-get install -y build-essential clang dpkg-dev python3
- name: Build (${{ matrix.cc }})
run: make CC=${{ matrix.cc }} -j$(nproc)
- name: zupt version
run: ./zupt version
- name: Full regression suite
run: make test
- name: License header audit
run: make audit-licenses
- name: Run audit suite
run: bash tests/test_audit.sh
# ─── Strict warning matrix — what the project's §6 protocol uses ───
strict-warnings:
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
include:
- cc: gcc
cflags: "-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"
- cc: clang
cflags: "-Wall -Wextra -Wpedantic -Wshadow -Wcast-align -Wstrict-prototypes -Wmissing-prototypes -Wnull-dereference -O2 -std=c11 -Werror"
steps:
- uses: actions/checkout@v4
- name: Install build deps
run: sudo apt-get update && sudo apt-get install -y build-essential clang
- name: Strict ${{ matrix.cc }} build (warnings → errors)
run: make CC=${{ matrix.cc }} CFLAGS="${{ matrix.cflags }}" -j$(nproc)
- name: Run dedup property tests
run: bash tests/test_dedup_props.sh
asan-build:
# ─── ASAN + UBSAN — catches memory bugs the warning matrix can't ───
sanitizers:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y build-essential libargon2-dev libssl-dev
- name: Build zupt (release)
run: make
- name: Build zupt (ASAN/UBSAN)
- name: Install build deps
run: sudo apt-get update && sudo apt-get install -y build-essential python3
- name: Build with ASAN + UBSAN
run: make test-asan
- name: Run all suites under ASAN/UBSAN
- name: PQ-SDK byte-exact roundtrip under ASAN
env:
ASAN_OPTIONS: detect_leaks=0:abort_on_error=1
run: make test-asan-run
UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1
run: |
./zupt_asan keygen --sdk -o /tmp/k.priv
./zupt_asan compress --pq-sdk /tmp/k.priv.pub /tmp/a.zupt include/
mkdir -p /tmp/extracted
./zupt_asan extract --pq-sdk /tmp/k.priv -o /tmp/extracted /tmp/a.zupt
diff -qr include /tmp/extracted/include
fuzz-format:
# ─── PIE hardening build — verifies no runtime breakage from -fPIE ───
pie-hardening:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Install dependencies
- name: Install build deps
run: sudo apt-get update && sudo apt-get install -y build-essential
- name: Build with PIE + hardening
run: |
make CFLAGS="-O2 -std=c11 -fPIE -fstack-protector-strong -D_FORTIFY_SOURCE=2 -Wformat -Wformat-security" \
LDFLAGS="-pie -Wl,-z,relro -Wl,-z,now -Wl,-z,noexecstack" \
-j$(nproc)
- name: Verify binary is PIE
run: |
file ./zupt | grep -E "ELF .*executable.*pie|ELF .*shared object" || \
{ file ./zupt; echo "binary is not PIE"; exit 1; }
- name: Smoke test
run: |
echo "test" > /tmp/in.txt
./zupt c -p secret /tmp/a.zupt /tmp/in.txt
mkdir /tmp/out
(cd /tmp/out && ./../../home/runner/work/zupt/zupt/zupt x -p secret /tmp/a.zupt) || \
{ cd /tmp/out && "$GITHUB_WORKSPACE/zupt" x -p secret /tmp/a.zupt; }
diff -q /tmp/in.txt /tmp/out/in.txt
# ─── aarch64 cross-build via QEMU emulation ───
cross-aarch64:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- name: Build + test inside aarch64 container
run: |
docker run --rm --platform linux/arm64 \
-v "$PWD":/src -w /src \
ubuntu:24.04 \
bash -c '
apt-get update -qq
apt-get install -y -qq build-essential python3
make -j$(nproc)
./zupt version
make test
'
# ─── make dist reproducibility ───
dist-reproducibility:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Install build deps
run: sudo apt-get update && sudo apt-get install -y build-essential python3
- name: First dist build
run: make dist
- name: Capture sha256 (run 1)
id: sha1
run: |
VER=$(grep '^#define ZUPT_VERSION_STRING' include/zupt.h | awk -F'"' '{print $2}')
SHA=$(sha256sum /tmp/zupt-$VER.tar.gz | awk '{print $1}')
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "ver=$VER" >> "$GITHUB_OUTPUT"
echo "Run 1: $SHA"
- name: Second dist build (must produce identical sha256)
run: make dist
- name: Verify reproducibility
run: |
VER="${{ steps.sha1.outputs.ver }}"
SHA2=$(sha256sum /tmp/zupt-$VER.tar.gz | awk '{print $1}')
if [ "$SHA2" != "${{ steps.sha1.outputs.sha }}" ]; then
echo "::error::make dist is NOT reproducible"
echo " run 1: ${{ steps.sha1.outputs.sha }}"
echo " run 2: $SHA2"
exit 1
fi
echo "Reproducible ✓ ($SHA2)"
- name: Upload reproducible source tarball
uses: actions/upload-artifact@v4
with:
name: zupt-source-tarball
path: /tmp/zupt-*.tar.gz
# ─── Packaging-recipe syntax (cross-distro) ───
packaging-syntax:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Install validators
run: |
sudo apt-get update
sudo apt-get install -y build-essential libargon2-dev libssl-dev
sudo apt-get install -y build-essential dpkg-dev ruby rpm
- name: Build (for include/zupt.h to exist; not strictly needed for syntax test)
run: make -j$(nproc)
- name: Run packaging syntax test
run: bash tests/test_packaging_syntax.sh
- name: Build zupt + ASAN binary
# ─── Automatic GitHub release on git tag push ───
release:
if: startsWith(github.ref, 'refs/tags/v')
needs: [build-and-test, strict-warnings, sanitizers, dist-reproducibility, packaging-syntax]
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Install build deps
run: sudo apt-get update && sudo apt-get install -y build-essential python3
- name: Build reproducible source tarball
run: make dist
- name: Get version
id: ver
run: |
VER=$(grep '^#define ZUPT_VERSION_STRING' include/zupt.h | awk -F'"' '{print $2}')
echo "version=$VER" >> "$GITHUB_OUTPUT"
- name: Verify tag matches version
run: |
TAG="${GITHUB_REF#refs/tags/}"
EXPECTED="v${{ steps.ver.outputs.version }}"
if [ "$TAG" != "$EXPECTED" ]; then
echo "::error::tag $TAG doesn't match include/zupt.h $EXPECTED"
exit 1
fi
- name: Compute sha256
id: sha
run: |
VER="${{ steps.ver.outputs.version }}"
SHA=$(sha256sum /tmp/zupt-$VER.tar.gz | awk '{print $1}')
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "$SHA zupt-$VER.tar.gz" > /tmp/zupt-$VER.tar.gz.sha256
- name: Create GitHub release
uses: softprops/action-gh-release@v2
with:
files: |
/tmp/zupt-${{ steps.ver.outputs.version }}.tar.gz
/tmp/zupt-${{ steps.ver.outputs.version }}.tar.gz.sha256
body: |
## Zupt v${{ steps.ver.outputs.version }}
Reproducible source tarball.
```
sha256: ${{ steps.sha.outputs.sha }}
```
See CHANGELOG.md for release notes.
### Verifying the tarball
```sh
sha256sum -c zupt-${{ steps.ver.outputs.version }}.tar.gz.sha256
```
### Building
```sh
tar xzf zupt-${{ steps.ver.outputs.version }}.tar.gz
cd zupt-${{ steps.ver.outputs.version }}
make
make test-asan
- name: Build fuzz harness
run: make fuzz-format
- name: Run 1000 fuzz iterations under ASAN/UBSAN
env:
ASAN_OPTIONS: detect_leaks=0:abort_on_error=1
run: make fuzz-format-run
package-deb:
runs-on: ubuntu-24.04
needs: [build-and-test]
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y build-essential libargon2-dev libssl-dev dpkg-dev
- name: Build zupt
run: make
- name: Build .deb
run: bash packaging/build-deb.sh
- name: Build GUI .deb
run: bash packaging/build-gui-deb.sh
- name: Verify deb installs
run: |
sudo dpkg -i /tmp/zupt_*.deb
zupt version
which zupt
ls /usr/include/zuptsdk*.h
make test
sudo make install
```

51
.gitignore vendored Normal file
View file

@ -0,0 +1,51 @@
# Build outputs
/vaptvupt
/zupt
*.o
*.a
*.so
*.so.*
*.dylib
# ...but keep vendored prebuilt libraries (shipped, no in-tree source)
!vendor/**/*.so
!vendor/**/*.so.*
*.exe
*.obj
*.lib
# Coverage / profiling
*.gcda
*.gcno
*.gcov
*.profraw
# CMake / out-of-tree build dirs
/build/
/cmake-build-*/
CMakeCache.txt
CMakeFiles/
# Distribution tarballs and packages (published as release assets, not committed)
/*.tar.gz
/*.tar.xz
/*.zip
/*.deb
/*.rpm
/*.AppImage
*.AppDir/
SHA256SUMS.txt
# Test/scratch binaries
/test_*
!/tests/test_*.c
!/tests/test_*.sh
# Editor / OS noise
*.swp
*~
.DS_Store
.idea/
.vscode/
# AI assistant scratch
.claude/

1095
AUDIT.md

File diff suppressed because it is too large Load diff

113
BENCHMARKS.md Normal file
View file

@ -0,0 +1,113 @@
# VaptVupt Benchmarks
All numbers here are **measured**, not aspirational. Each table states the
machine, the build, and the method. Where VaptVupt loses to a competitor,
the table shows it.
> **Superseded data note.** The v3.8.0 edition of this file was measured
> on a different machine (Xeon 2.80 GHz, no SHA-NI) against a different
> generation of the synthetic fixtures. Absolute numbers below are not
> comparable to that edition; the codec-stability question is settled by
> the same-input gate table (§1), not by cross-edition comparison.
## Test environment
| Property | Value |
|----------|-------|
| CPU | Intel Xeon @ 2.10 GHz |
| Cores used | 1 (single-threaded measurement) |
| Hardware accel present | AES-NI, **SHA-NI**, AVX2 (codec) |
| Build | VaptVupt 4.0.0, `make` defaults, `-O2`, Jasmin AES-NI path active |
| Codec | VaptVupt **2.60.4** (LZ + ANS, canonical BCJ) |
| Method | best of 3 runs, wall clock |
## 1. Codec ratio gate: 2.53.3-era vs 2.60.4, identical inputs
Upstream 2.60.4 claims compressed output byte-identical to prior
releases. Verified here by compressing the **same fixture bytes** with
the shipped 3.8.0 binary and the 4.0.0 binary (L9, plain):
| Fixture | 3.8.0 archive | 4.0.0 archive | Δ |
|---------|--------------:|--------------:|---|
| text | 1 990 322 B | 1 990 322 B | **0.00 %** |
| source | 1 698 907 B | 1 698 907 B | **0.00 %** |
| redundant | 3 344 B | 3 344 B | **0.00 %** |
| binary | 3 119 605 B | 3 356 213 B | +7.58 % — **not comparable**: the 3.8.0 stream is the F-16 *corrupt* output (undecodable by any version); 4.0.0 emits the canonical BCJ stream that actually decodes |
Gate **holds** everywhere a valid stream exists on both sides.
## 2. Compression ratio + throughput (plain, level 9, this box's fixtures)
| Fixture | In (MB) | Ratio | Encode (MB/s) | Decode (MB/s) |
|---------|--------:|------:|--------------:|--------------:|
| text | 10.0 | 5.27 | 2 | 314 |
| binary | 7.5 | 2.34 | 2 | 209 |
| source | 10.0 | 6.17 | 1 | 304 |
| redundant | 10.0 | 3135.69 | 299 | 692 |
| random | 5.0 | 1.00 | 24 | 570 |
Decode 209692 MB/s; L9 encode remains 12 MB/s on compressible data
(optimal parser) — use lower levels when encode speed matters.
## 3. SHA-256: scalar vs SHA-NI (measured, same box)
The v3.2.0 SHA-NI path could only be **estimated** (38×) because the
old measurement box lacked the instruction set. Measured now, 256 MiB
single buffer, runtime dispatch vs forced scalar:
| Path | Throughput |
|------|-----------:|
| scalar C | 204 MB/s |
| SHA-NI | **1184 MB/s** |
| **speedup** | **5.8×** |
(Independently consistent with libpqvaptvupt 0.6.0's own measurement of
5.9× on its SHA-256.) The estimate label is hereby retired.
## 4. Encryption overhead (store mode isolates crypto from the codec)
Per-MB ≈ (t₄₀MB t₁MB) ÷ 39; KDF ≈ t₁MB per-MB.
| Mode | Per-MB crypto | One-time KDF |
|------|--------------:|-------------:|
| plain (no encryption) | 1.94 ms (515 MB/s) | ≈3 ms |
| password — Argon2id (default) | 3.42 ms (**293 MB/s**) | ≈839 ms |
| password — PBKDF2 | 3.58 ms (280 MB/s) | ≈550 ms |
| **pq-box** (`--pq-box`, v4.0.0) | same as plain + MAC path | seal ≈3 ms / open ≈3 ms |
Readings:
- Encrypted per-block throughput is **~2× the 3.8.0-era figure on a
slower clock** (293 MB/s at 2.10 GHz vs 146 MB/s at 2.80 GHz) — the
HMAC-SHA256 Encrypt-then-MAC second pass now runs on SHA-NI.
- The Argon2id one-time cost (~0.8 s) is memory-hardness working as
designed, not a target for optimization.
- PBKDF2's KDF also benefits from SHA-NI (~550 ms here vs ~1.56 s on the
old non-SHA-NI box) — but Argon2id remains the default for its
memory-hardness, not its speed.
- `--pq-box` adds ~3 ms one-time seal/open for the 32-byte session key
(ML-KEM-768 + X25519 + HKDF); per-block cost is the standard AES-NI +
SHA-NI path.
## 5. What the product is
The codec is competitive on decode, not the reason to use VaptVupt
(zstd-19 wins pure ratio). The reason is the combination: post-quantum
hybrid recipient encryption (three modes, newest = HKDF-domain-separated
sealed box), Argon2id by default with a self-describing KDF header,
per-block Encrypt-then-MAC with every security-critical comparison
routed through one audited measured-constant-time primitive, canonical
CBMC-verified BCJ filters, and 16 NIST/RFC known-answer vectors in CI.
## Reproducing
```sh
make
./vaptvupt c -l 9 /tmp/a.zupt fixtures/text.dat # ratio/speed
./vaptvupt c -s -p PW /tmp/p.zupt big.dat # crypto overhead
./vaptvupt keygen --box -o k && \
./vaptvupt c -s --pq-box k.pub /tmp/b.zupt big.dat # pq-box
make test-vectors && ./test_vectors # NIST/RFC vectors
```
Absolute numbers vary by machine; the shape (KDF-dominated password
cost, SHA-NI ≈6× on SHA-256, ~3 ms pq-box envelope) is stable.

File diff suppressed because it is too large Load diff

268
DISTRIBUTION.md Normal file
View file

@ -0,0 +1,268 @@
# Distributing Zupt
This document describes the upstream packaging recipes shipped under
`packaging/` and the path from "local source tree" to "package
installable on every major Linux distribution and macOS."
Recipes are upstream-maintained but distro-submission-ready. Real
submission to AUR / Debian / Fedora / Homebrew / NixOS is operational
work outside this repository.
## Producing a reproducible source tarball
Every packaging recipe expects an upstream tarball `zupt-VERSION.tar.gz`
produced by the project's `make dist` target. The tarball is
**byte-reproducible**:
```sh
make dist
# → /tmp/zupt-2.4.4.tar.gz
# → sha256: 407d20ef03e5bf857195b99e04843ef3b07357416a4115add1e8aaa2007a769f
# → bytes: 813113
```
Re-running `make dist` on the same source tree produces an identical
sha256 (verified by `tests/test_dist_reproducible.sh`, wired into
`make test`). This lets distros pin a stable hash in their recipes.
The reproducibility properties:
- Files sorted by name (deterministic order across filesystems)
- mtime fixed to `SOURCE_DATE_EPOCH` (default `1747699200`; override
via env)
- uid/gid pinned to root (0/0) via `--owner=0 --group=0 --numeric-owner`
- gzip wrapped with `-9n` (no embedded timestamp or filename)
- Source-only — no `.o`, no built binaries, no `.git/` tree
- Includes the vendored `libzuptsdk.so.2.0.0` real file plus its two
symlinks (`libzuptsdk.so`, `libzuptsdk.so.2`)
To force a specific epoch (for distro release-day pinning):
```sh
SOURCE_DATE_EPOCH=1727740800 make dist # 2024-10-01 UTC
```
## Recipes shipped
| Distro / Platform | Path | Format |
|-------------------|---------------------------------|----------------|
| Arch Linux | `packaging/aur/PKGBUILD` | AUR PKGBUILD |
| Debian / Ubuntu | `packaging/debian/` | Source package (`3.0 (quilt)`) |
| Fedora / RHEL | `packaging/rpm/zupt.spec` | RPM .spec |
| macOS | `packaging/homebrew/zupt.rb` | Homebrew formula |
| NixOS / Nix flake | `packaging/nix/flake.nix` | Nix flake |
All recipes:
- Install the binary to `$PREFIX/bin/zupt` (default `/usr/bin/zupt`)
- Install the vendored `libzuptsdk.so*` triple to `$PREFIX/lib/zupt/`
(the binary uses relative `rpath` so users don't need `LD_LIBRARY_PATH`)
- Install manpage to `$PREFIX/share/man/man1/zupt.1.gz`
- Install docs (README, SECURITY, CHANGELOG, AUDIT) to
`$PREFIX/share/doc/zupt/`
- Run the full upstream regression suite (`make test`) during build
when the distro's package guidelines allow check-phase execution
## Arch Linux (AUR)
Maintainer flow:
```sh
# 1. Produce the upstream tarball
make dist
# → /tmp/zupt-2.4.4.tar.gz
# 2. Upload to a stable URL (e.g. git.securityops.co releases)
# 3. Update packaging/aur/PKGBUILD:
# - Set pkgver=2.4.4
# - Set sha256sums=("$(sha256sum /tmp/zupt-2.4.4.tar.gz | awk '{print $1}')")
# 4. Generate .SRCINFO
cd packaging/aur && makepkg --printsrcinfo > .SRCINFO
# 5. Test locally
makepkg -s
# 6. Push to AUR
git clone ssh://aur@aur.archlinux.org/zupt.git aur-zupt
cp packaging/aur/PKGBUILD packaging/aur/.SRCINFO aur-zupt/
cd aur-zupt && git add -A && git commit -m "v2.4.4" && git push
```
User install:
```sh
yay -S zupt # or paru, pikaur, etc.
```
## Shell completions (v2.4.7+)
`make install` automatically installs Bash, zsh, and fish completion
files alongside the binary and manpage:
| Shell | Path |
|---|---|
| Bash | `$PREFIX/share/bash-completion/completions/zupt` |
| zsh | `$PREFIX/share/zsh/site-functions/_zupt` |
| fish | `$PREFIX/share/fish/vendor_completions.d/zupt.fish` |
The source files live under `completions/` in the project tree.
Distros that prefer a different install location should override
the relevant paths in their `make install` invocation; the
underlying recipe is straightforward.
For per-user installation without root:
```sh
# Bash
cp completions/zupt.bash ~/.local/share/bash-completion/completions/zupt
# zsh (somewhere in $fpath; add the directory to ~/.zshrc if needed)
cp completions/_zupt ~/.zsh/completion/_zupt
# fish
cp completions/zupt.fish ~/.config/fish/completions/zupt.fish
```
Completions cover every CLI flag the binary actually parses
(`--kdf`, `--comment`, `--comment-file`, `--pq-sdk`, `--dedup`,
etc.) and are validated on every CI run via
`tests/test_completions_manpage.sh`.
## Debian / Ubuntu
The `packaging/debian/` tree is a Debian source-package layout.
Maintainer flow:
```sh
# 1. Produce the upstream tarball with the standard Debian
# orig.tar.gz naming convention:
make dist
cp /tmp/zupt-2.4.4.tar.gz /tmp/zupt_2.4.4.orig.tar.gz
# 2. Unpack and overlay the debian/ tree:
cd /tmp && tar xzf zupt_2.4.4.orig.tar.gz && cd zupt-2.4.4
cp -a /path/to/zupt/packaging/debian ./debian
# 3. Build the source package:
dpkg-buildpackage -S -us -uc # source-only
dpkg-buildpackage -b -us -uc # binary
# 4. Lint:
lintian zupt_2.4.4-1_*.deb
# 5. Submit via the standard Debian mentors process:
# https://mentors.debian.net/intro-maintainers/
```
User install (after the package lands in Debian unstable / Ubuntu):
```sh
sudo apt install zupt
```
## Fedora / RHEL / CentOS
```sh
# 1. Produce the tarball
make dist
cp /tmp/zupt-2.4.4.tar.gz ~/rpmbuild/SOURCES/
# 2. Drop the .spec into the SPECS directory:
cp packaging/rpm/zupt.spec ~/rpmbuild/SPECS/
# 3. Build source + binary RPMs:
cd ~/rpmbuild && rpmbuild -ba SPECS/zupt.spec
# 4. Lint:
rpmlint RPMS/x86_64/zupt-2.4.4-1.fc*.rpm
# 5. Submit via the Fedora new-package review process:
# https://docs.fedoraproject.org/en-US/package-maintainers/Package_Review_Process/
# EPEL automatically inherits Fedora packages.
```
User install (after the package lands in Fedora / EPEL):
```sh
sudo dnf install zupt # Fedora
sudo dnf install epel-release zupt # RHEL/CentOS via EPEL
```
## macOS (Homebrew)
```sh
# 1. Produce the tarball and upload to a stable release URL.
# 2. Update packaging/homebrew/zupt.rb:
# - Set url to the release URL
# - Set sha256 to the upstream tarball sha256
# 3. Test locally:
brew install --build-from-source ./packaging/homebrew/zupt.rb
brew test zupt
brew audit --strict --online zupt
# 4. Submit to homebrew-core (preferred, requires popularity threshold):
# https://docs.brew.sh/Adding-Software-to-Homebrew
#
# OR host in your own tap:
# https://docs.brew.sh/How-to-Create-and-Maintain-a-Tap
```
User install (after submission lands):
```sh
brew install zupt
# OR from a custom tap:
brew install cristiancmoises/tap/zupt
```
## NixOS / Nix flake
```sh
# 1. Build directly from the flake (no central submission needed):
nix build github:cristiancmoises/zupt#zupt
nix run github:cristiancmoises/zupt#zupt -- version
# 2. To consume from another flake:
# inputs.zupt.url = "github:cristiancmoises/zupt?ref=v2.4.4";
# packages.x86_64-linux.default = inputs.zupt.packages.x86_64-linux.zupt;
# 3. To submit to nixpkgs (https://github.com/NixOS/nixpkgs):
# - Adapt packaging/nix/flake.nix's `zupt` derivation into a
# pkgs/by-name/zu/zupt/package.nix using fetchurl and a hash.
# - Follow the nixpkgs contribution guide:
# https://github.com/NixOS/nixpkgs/blob/master/CONTRIBUTING.md
```
## Submitting upstream — checklist
Before pushing any recipe to a distro repository:
- [ ] `make dist` produces a reproducible tarball (verified by
`tests/test_dist_reproducible.sh` on every `make test`)
- [ ] The tarball is uploaded to a stable, immutable URL
- [ ] The recipe's checksum field is updated to match
`sha256sum /tmp/zupt-VERSION.tar.gz`
- [ ] The recipe builds and tests pass in a clean chroot/container
- [ ] The CHANGELOG mentions distro-relevant changes since the last release
- [ ] The license metadata is correct (AGPL-3.0-or-later for Zupt core;
GPL-3.0-or-later for the vendored VaptVupt codec)
## Security posture for downstream
Every packaging recipe runs `make test` during build (`check()` for AUR,
`override_dh_auto_test` for Debian, `%check` for RPM, `checkPhase` for
Nix, `test` block for Homebrew). The suite includes:
- **F-06**: 2 000 HMAC tamper trials, 0 silent accepts required
- **F-08**: top-MAC header/footer integrity-trailer regression
- **F-09**: 1 827-position exhaustive byte sweep on PQ-SDK archive,
0 silent accepts required
- **F-10..F-12**: KDF default, auth-fail message, encrypted comments
- **dist reproducibility**: `make dist` byte-identical across two runs
A build that doesn't pass `make test` will fail at distro check time —
the recipes don't paper over regressions.

View file

@ -1,182 +0,0 @@
# Zupt + libzuptsdk — Formal Cryptographic & Security Audit Prompt v2.2.3
## Auditor profile
You are operating as a **Principal Cryptographic Engineer with 15+ years of
experience in production cryptographic systems**. Concrete background:
- Implementation review of TLS stacks, IPsec, post-quantum cryptography
(NIST PQC competition tracking from Round 1 onward), HSM firmware
- Familiarity with attacks: Lucky 13, Bleichenbacher, EFAIL, Logjam, Heartbleed,
Spectre/Meltdown side channels, Kyber-768 fault attacks (Hermelink et al. 2023),
ChaCha20 nonce-misuse, GCM forbidden-attacks
- Experience with formal methods (Jasmin, F*, ProVerif), constant-time
verification, and adversarial testing methodology
- Direct exposure to NIST FIPS 140-3, Common Criteria EAL evaluations,
ICP-Brasil DOC-ICP-01.01 audits
You operate as if the codebase will be deployed to:
- Government archives with 30+ year retention (LGPD Art. 46, IN ITI 35/2026)
- Financial institutions under Brazilian Central Bank Resolução 4.658/2018
- Healthcare systems under HIPAA / LGPD-Saúde
- Defense systems requiring NSA Suite B / CNSA 2.0 alignment
The user is the sole maintainer running this in production. **Mistakes ship to
real users. There is no margin for hand-waving.**
## Audit methodology — DOUBLE-VALIDATION
Every property is checked via **two independent paths** that must agree. If
they disagree, that disagreement is itself a finding.
### Path A: Manual review
Read each file line-by-line. For every function, document:
1. Preconditions (what must be true before entry)
2. Postconditions (what must be true after exit)
3. Invariants (what stays true throughout)
4. Trust boundary (what input is attacker-controlled)
5. Failure modes (what happens on malloc fail, EINTR, partial read, NULL)
### Path B: Adversarial test
Construct a test that would catch the vulnerability if Path A missed it.
Run under ASAN+UBSAN+MSAN where applicable. Mutation-fuzz where possible.
If both pass: invariant holds.
If either fails: bug found, fix it, regression-test it.
## Threat model
The adversary is assumed to:
1. Control input archives (mutation, truncation, oversized fields, OOB offsets)
2. Control input files (filenames with `..`, symlinks, FIFO, /dev/zero, large)
3. Control environment (PATH, LD_LIBRARY_PATH, TMPDIR, locale, signals)
4. Have local execution at lower privilege (TOCTOU, /tmp races, /proc reads)
5. Observe timing and cache access patterns (if process is local)
6. Eventually possess a quantum computer (harvest-now, decrypt-later)
The adversary is assumed NOT to:
- Have root on the target system (root-equivalent compromises are out of scope)
- Have physical access (cold-boot, voltage glitching out of scope unless flagged)
- Bypass TLS/transport (Zupt is at-rest crypto, not transport)
## Cryptographic primitives — FIPS / RFC compliance check
For each primitive, verify:
| Primitive | Standard | Verify |
|---|---|---|
| AES-256-CTR | FIPS 197 + SP 800-38A | key/IV size, counter init, no IV reuse |
| AES-256-SIV | RFC 5297 | nonce-misuse resistance, AD coverage |
| XChaCha20-Poly1305 | RFC 8439 + draft-irtf-cfrg-xchacha | 192-bit nonce, AD coverage |
| HMAC-SHA256 | RFC 2104 + FIPS 198 | key separation from enc, full message coverage |
| SHA3 / SHAKE | FIPS 202 | rate/capacity, no domain confusion |
| ML-KEM-768 | FIPS 203 | parameter set, key sanitization, decap fault resistance |
| X25519 | RFC 7748 | scalar clamping, all-zero output rejection |
| Ed25519 | RFC 8032 | nonce derivation, Mal-formed signature rejection |
| HKDF-SHA3 | RFC 5869 | salt vs IKM separation, info domain separation |
| HPKE | RFC 9180 | suite ID, mode binding, encap context |
| Argon2id | RFC 9106 | m≥64MiB, t≥3, p≥1, salt≥16B |
## Formal portability matrix
Code must compile and pass tests on:
| OS | Arch | Compiler | Status |
|---|---|---|---|
| Linux | x86_64 | GCC 11+ | primary |
| Linux | x86_64 | Clang 14+ | required |
| Linux | aarch64 | GCC 11+ | required (Termux + servers) |
| Linux | armhf | GCC 11+ | should |
| Linux | riscv64 | GCC 13+ | nice-to-have |
| macOS | x86_64 | Clang 14+ | required |
| macOS | aarch64 | Clang 14+ | required (Apple Silicon) |
| FreeBSD | x86_64 | Clang | should |
| OpenBSD | x86_64 | Clang | should |
| NetBSD | x86_64 | GCC | nice |
| Windows | x86_64 | MSVC 2022 | should |
| Windows | x86_64 | MinGW-w64 | required |
Verify portability via:
- `_WIN32` / `__APPLE__` / `__linux__` / `__FreeBSD__` / `__OpenBSD__` ifdef coverage
- POSIX vs Win32 file APIs (fseeko/_fseeki64, mkdir/_mkdir)
- Endianness (use le32/le64 helpers, never raw struct casts)
- Alignment (no `*(uint64_t*)ptr` on potentially-unaligned ptr)
- Threading (pthreads vs Windows threads)
- Path separators (/ vs \, max length)
## Concrete checklist (must complete or document why not)
### A. Memory safety
- [ ] Every malloc has a NULL check
- [ ] Every realloc handles failure without invalidating original
- [ ] Every free is paired with a single allocation
- [ ] No use-after-free across function boundaries
- [ ] No double-free on error paths
- [ ] Stack buffers sized correctly (no `sprintf` without bounds)
- [ ] Heap buffers bounded against attacker input
- [ ] All `memcpy`/`memmove` source+dest+len are bounded
### B. Integer safety
- [ ] No size_t overflow in `a * b` where both are user-controlled
- [ ] No signed overflow in pointer arithmetic
- [ ] No truncation in narrowing conversions (uint64→size_t on 32-bit)
- [ ] Loop counters can't underflow to large values
### C. Cryptographic safety
- [ ] No nonce reuse possible under any execution path
- [ ] No key reuse across primitives (KDF separation enforced)
- [ ] Constant-time for all secret-dependent operations
- [ ] No early-return after partial MAC verification
- [ ] Memory containing keys is wiped (`secure_zero` not `memset`)
- [ ] No fallback to weaker primitive on error
### D. Format parser hardening
- [ ] All length fields validated against file size before allocation
- [ ] All offsets validated as in-bounds before seek
- [ ] All references validated as backward (no forward jumps)
- [ ] Recursion depth bounded
- [ ] Truncation, oversized fields, malformed magic all rejected
### E. Filesystem safety
- [ ] Path traversal blocked (`..`, absolute paths in archive entries)
- [ ] Symlink following blocked or explicit
- [ ] FIFO/socket/device files handled or rejected
- [ ] No TOCTOU between stat and open
- [ ] Output files created with safe modes (0600 for keys)
### F. Concurrency safety
- [ ] Shared state behind mutex
- [ ] No double-checked locking without atomics
- [ ] Thread cancellation safe
- [ ] No data race on signal handlers
### G. Compiler/linker hardening (per-platform)
- [ ] `-fstack-protector-strong` (GCC/Clang)
- [ ] `-D_FORTIFY_SOURCE=2`
- [ ] `-fPIE -pie` for executables
- [ ] `-Wl,-z,relro,-z,now`
- [ ] `/GS /DYNAMICBASE /NXCOMPAT` (MSVC)
- [ ] No executable stack
- [ ] CFI / shadow stack where available
## Deliverables
For each session:
1. Numbered list of bugs found, with file:line and severity (info/low/med/high/crit)
2. For each bug: failing test → fix → passing regression test
3. Updated CHANGELOG entry (per-bug, not aggregated)
4. Updated SECURITY.md threat model section
5. Updated AUDIT.md with cumulative test surface
6. Final test run with all suites green under ASAN+UBSAN
7. Cross-platform smoke test (at minimum: GCC + Clang + `-Wpedantic` clean)
End with:
- Source tarball (.tar.gz)
- Binary tarball (Linux x86_64)
- .deb (CLI + GUI)
- .rpm (CLI + GUI, or SRPM-equivalent)
- AppImage (CLI + GUI, or AppDir tarball)
- SHA-256 sums
Do not stop until every checklist item is done or explicitly deferred with a
written reason. Version stays at 2.2.2 — this is post-release hardening.

12
LICENSE
View file

@ -3,12 +3,14 @@
Copyright (C) 2026 Cristian Cezar Moisés <zupt@riseup.net>
Zupt is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
VaptVupt (formerly Zupt; renamed in v3.0.0 due to a prior INPI Brasil
trademark registration of "Zupt" for unrelated software) is free
software: you can redistribute it and/or modify it under the terms of
the GNU Affero General Public License as published by the Free
Software Foundation, either version 3 of the License, or (at your
option) any later version.
Zupt is distributed in the hope that it will be useful, but
VaptVupt is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Affero General Public License for more details.

246
Makefile
View file

@ -24,7 +24,11 @@
# FreeBSD, OpenBSD (with system make compatibility shims).
CC ?= cc
CFLAGS ?= -Wall -Wextra -O2 -std=c11
# v3.0.2: -Woverlength-strings catches usage()-style string literals
# that violate the C99 4095-char single-string limit. F-13 was hit
# in v3.0.1 when usage() drifted past the limit; the warning now
# fails the build under -Werror downstream.
CFLAGS ?= -Wall -Wextra -Woverlength-strings -O2 -std=c11
CFLAGS += -Iinclude -Isrc
LDFLAGS ?=
LDLIBS ?= -lm
@ -53,8 +57,8 @@ endif
# --- Zupt core sources ---
ZUPT_SOURCES = src/zupt_main.c src/zupt_format.c src/zupt_lz.c src/zupt_lzh.c \
src/zupt_xxh.c src/zupt_sha256.c src/zupt_aes256.c src/zupt_crypto.c \
src/zupt_crypto_sdk.c \
src/zupt_xxh.c src/zupt_sha256.c src/zupt_sha256_shani.c src/zupt_aes256.c src/zupt_crypto.c \
src/zupt_crypto_sdk.c src/zupt_crypto_pqbox.c \
src/zupt_predict.c src/zupt_parallel.c src/zupt_keccak.c \
src/zupt_x25519.c src/zupt_mlkem.c src/zupt_cpuid.c src/zupt_mlock.c \
src/zupt_filetype.c src/zupt_disk.c src/zupt_dedup.c
@ -63,11 +67,19 @@ ZUPT_SOURCES = src/zupt_main.c src/zupt_format.c src/zupt_lz.c src/zupt_lzh.c \
ZUPTSDK_DIR ?= vendor/zuptsdk
ZUPTSDK_ABS := $(abspath $(ZUPTSDK_DIR))
CFLAGS += -I$(ZUPTSDK_DIR)/include
PQVV_DIR ?= vendor/pqvaptvupt
CFLAGS += -I$(PQVV_DIR)/include
LDFLAGS += -L$(ZUPTSDK_DIR) -Wl,-rpath,$(ZUPTSDK_ABS) -Wl,-rpath,'$$ORIGIN/$(ZUPTSDK_DIR)'
LDLIBS += -lzuptsdk
PQVV_ABS := $(abspath $(PQVV_DIR))
LDFLAGS += -L$(PQVV_DIR) -Wl,-rpath,$(PQVV_ABS) -Wl,-rpath,'$$ORIGIN/$(PQVV_DIR)'
# Installed layout: vendored libs live in $(PREFIX)/lib/$(TARGET)/ — give the
# binary a matching relative rpath so `make install` is self-contained.
LDFLAGS += -Wl,-rpath,'$$ORIGIN/../lib/vaptvupt'
LDLIBS += -lpqvaptvupt
# --- VAPTVUPT: VaptVupt codec sources (Apache-2.0, integrated under MIT) ---
VV_SOURCES = src/vv_encoder.c src/vv_decoder.c src/vv_ans.c \
# --- VAPTVUPT: VaptVupt codec sources (GPL-3.0-or-later; tool is AGPL-3.0-or-later) ---
VV_SOURCES = src/vv_encoder.c src/vv_decoder.c src/vv_ans.c src/vv_bcj.c \
src/vv_huffman.c src/vv_simd.c src/vv_xxh64.c src/vaptvupt_api.c
SOURCES = $(ZUPT_SOURCES) $(VV_SOURCES)
@ -79,8 +91,9 @@ HEADERS = include/zupt.h include/zupt_keccak.h include/zupt_mlkem.h \
include/vv_platform.h \
src/zupt_thread.h src/zupt_parallel.h
TARGET = zupt
MANPAGE = doc/zupt.1
TARGET = vaptvupt
LEGACY_LINK = zupt
MANPAGE = doc/vaptvupt.1
MANPAGE_GZ = $(TARGET).1.gz
# ═══════════════════════════════════════════════════════════════════
@ -97,8 +110,10 @@ ARCH := $(shell uname -m)
# --- AVX2: enable SIMD for VaptVupt on x86_64 ---
ifeq ($(ARCH),x86_64)
VV_SIMD_FLAGS = -mavx2
SHANI_FLAGS = -msha -mssse3 -msse4.1
else
VV_SIMD_FLAGS =
SHANI_FLAGS =
endif
# --- Jasmin: enable only on x86_64 with pre-compiled .s files ---
@ -123,7 +138,14 @@ endif
# --- Object files ---
# VV SIMD files need -mavx2 on x86_64 (no-op on other arches)
VV_SIMD_OBJS = src/vv_encoder.o src/vv_decoder.o src/vv_simd.o
VV_PLAIN_OBJS = src/vv_ans.o src/vv_huffman.o src/vv_xxh64.o src/vaptvupt_api.o
# Vendored codec sources follow the UPSTREAM warning policy (kept byte-exact
# to canonical releases for clean future drop-ins). Two benign clang-only
# categories are silenced here instead of patching upstream files:
# vv_decoder.c: unused helper retained upstream; vv_ans.c: stats variable.
VV_WPOLICY = -Wno-unused-function -Wno-unused-but-set-variable
$(VV_SOURCES:.c=.o): CFLAGS += $(VV_WPOLICY)
VV_PLAIN_OBJS = src/vv_ans.o src/vv_huffman.o src/vv_xxh64.o src/vv_bcj.o src/vaptvupt_api.o
ZUPT_OBJS = $(patsubst %.c,%.o,$(ZUPT_SOURCES))
ALL_OBJS = $(ZUPT_OBJS) $(VV_SIMD_OBJS) $(VV_PLAIN_OBJS)
@ -165,7 +187,7 @@ endif
# BUILD RULES
# ═══════════════════════════════════════════════════════════════════
.PHONY: all clean install uninstall test test-all test-asan test-asan-run test-vectors test-vv fuzz-build fuzz-format fuzz-format-run help audit-licenses
.PHONY: all clean install uninstall test test-all test-asan test-asan-run test-vectors test-vv fuzz-build fuzz-format fuzz-format-run help audit-licenses dist check
all: $(TARGET)
@ -210,8 +232,10 @@ audit-licenses:
fi
# Jasmin pre-compiled assembly (x86_64 only)
# Jasmin emits GNU-as syntax (macros with C-style trailing comments);
# clang's integrated assembler rejects it, so assemble with as(1) directly.
jasmin/%.o: jasmin/%.s
$(Q)$(CC) $(CFLAGS) -c -o $@ $<
$(Q)as -o $@ $<
# VaptVupt SIMD files: compile with AVX2 on x86_64
$(VV_SIMD_OBJS): src/%.o: src/%.c $(HEADERS)
@ -221,16 +245,27 @@ $(VV_SIMD_OBJS): src/%.o: src/%.c $(HEADERS)
$(VV_PLAIN_OBJS): src/%.o: src/%.c $(HEADERS)
$(Q)$(CC) $(CFLAGS) -c -o $@ $<
# Zupt core files
$(ZUPT_OBJS): src/%.o: src/%.c $(HEADERS)
# Zupt core files (the SHA-NI object has its own rule below with -msha)
ZUPT_OBJS_GENERIC = $(filter-out src/zupt_sha256_shani.o,$(ZUPT_OBJS))
$(ZUPT_OBJS_GENERIC): src/%.o: src/%.c $(HEADERS)
$(Q)$(CC) $(CFLAGS) -c -o $@ $<
# SHA-NI path needs -msha -mssse3 -msse4.1 on x86_64.
# On non-x86_64, SHANI_FLAGS is empty and the file is a no-op TU.
src/zupt_sha256_shani.o: src/zupt_sha256_shani.c $(HEADERS)
$(Q)$(CC) $(CFLAGS) $(SHANI_FLAGS) -c -o $@ $<
# Final link step. Order matters: CFLAGS before LDFLAGS, then objects,
# then LDLIBS — keeps GCC/Clang happy when LDFLAGS contains -pie or
# similar position-sensitive flags.
$(TARGET): $(ALL_OBJS) $(JAZZ_O)
$(Q)$(CC) $(CFLAGS) $(LDFLAGS) $(ALL_OBJS) $(JAZZ_O) -o $(TARGET) $(LDLIBS)
@echo "Build complete: ./$(TARGET) [$(ARCH)]"
@# v3.0.0: in-tree legacy symlink. Existing tests, scripts and IDE
@# launchers reference `./zupt`; we keep that working without
@# modifying 27 test files. The install rule emits the same symlink
@# at $(BINDIR)/zupt for runtime users.
$(Q)ln -sf $(TARGET) $(LEGACY_LINK)
@echo "Build complete: ./$(TARGET) [$(ARCH)] (legacy: ./$(LEGACY_LINK) -> $(TARGET))"
# ═══════════════════════════════════════════════════════════════════
# INSTALL / UNINSTALL
@ -239,21 +274,112 @@ $(TARGET): $(ALL_OBJS) $(JAZZ_O)
install: $(TARGET)
$(Q)mkdir -p $(DESTDIR)$(BINDIR)
$(Q)install -m 755 $(TARGET) $(DESTDIR)$(BINDIR)/$(TARGET)
# v3.0.0 (INPI Brasil rename): legacy `zupt` symlink so existing
# scripts and shell history keep working. Distros may strip this
# after one major version cycle.
$(Q)ln -sf $(TARGET) $(DESTDIR)$(BINDIR)/$(LEGACY_LINK)
$(Q)if [ -f "$(MANPAGE)" ]; then \
mkdir -p $(DESTDIR)$(MAN1DIR); \
$(GZIP) $(GZIPFLAGS) -c "$(MANPAGE)" > "$(DESTDIR)$(MAN1DIR)/$(MANPAGE_GZ)"; \
chmod 0644 "$(DESTDIR)$(MAN1DIR)/$(MANPAGE_GZ)"; \
echo "Installed: $(DESTDIR)$(MAN1DIR)/$(MANPAGE_GZ)"; \
ln -sf "$(MANPAGE_GZ)" "$(DESTDIR)$(MAN1DIR)/$(LEGACY_LINK).1.gz"; \
echo "Installed: $(DESTDIR)$(MAN1DIR)/$(MANPAGE_GZ) (+ $(LEGACY_LINK).1.gz symlink)"; \
else \
echo "Warning: man page not found: $(MANPAGE)"; \
fi
@echo "Installed: $(DESTDIR)$(BINDIR)/$(TARGET)"
# Shell completions (v2.4.7+). Honour distro path conventions where
# possible; downstream packagers can override DESTDIR + the specific
# dirs as needed.
$(Q)if [ -f completions/vaptvupt.bash ]; then \
mkdir -p "$(DESTDIR)$(PREFIX)/share/bash-completion/completions"; \
install -m 0644 completions/vaptvupt.bash \
"$(DESTDIR)$(PREFIX)/share/bash-completion/completions/$(TARGET)"; \
ln -sf "$(TARGET)" "$(DESTDIR)$(PREFIX)/share/bash-completion/completions/$(LEGACY_LINK)"; \
echo "Installed: $(DESTDIR)$(PREFIX)/share/bash-completion/completions/$(TARGET) (+ $(LEGACY_LINK) symlink)"; \
fi
$(Q)if [ -f completions/_vaptvupt ]; then \
mkdir -p "$(DESTDIR)$(PREFIX)/share/zsh/site-functions"; \
install -m 0644 completions/_vaptvupt \
"$(DESTDIR)$(PREFIX)/share/zsh/site-functions/_$(TARGET)"; \
ln -sf "_$(TARGET)" "$(DESTDIR)$(PREFIX)/share/zsh/site-functions/_$(LEGACY_LINK)"; \
echo "Installed: $(DESTDIR)$(PREFIX)/share/zsh/site-functions/_$(TARGET) (+ _$(LEGACY_LINK) symlink)"; \
fi
$(Q)if [ -f completions/vaptvupt.fish ]; then \
mkdir -p "$(DESTDIR)$(PREFIX)/share/fish/vendor_completions.d"; \
install -m 0644 completions/vaptvupt.fish \
"$(DESTDIR)$(PREFIX)/share/fish/vendor_completions.d/$(TARGET).fish"; \
echo "Installed: $(DESTDIR)$(PREFIX)/share/fish/vendor_completions.d/$(TARGET).fish"; \
fi
# Vendored runtime libraries (NEEDED by the binary): libzuptsdk
# (password KDF + --pq-sdk) and libpqvaptvupt (--pq-box, v4.0.0+).
$(Q)mkdir -p $(DESTDIR)$(PREFIX)/lib/vaptvupt
$(Q)install -m 755 vendor/zuptsdk/libzuptsdk.so.2.0.0 $(DESTDIR)$(PREFIX)/lib/vaptvupt/libzuptsdk.so.2.0.0
$(Q)ln -sf libzuptsdk.so.2.0.0 $(DESTDIR)$(PREFIX)/lib/vaptvupt/libzuptsdk.so.2
$(Q)ln -sf libzuptsdk.so.2.0.0 $(DESTDIR)$(PREFIX)/lib/vaptvupt/libzuptsdk.so
$(Q)install -m 755 vendor/pqvaptvupt/libpqvaptvupt.so.0.6.0 $(DESTDIR)$(PREFIX)/lib/vaptvupt/libpqvaptvupt.so.0.6.0
$(Q)ln -sf libpqvaptvupt.so.0.6.0 $(DESTDIR)$(PREFIX)/lib/vaptvupt/libpqvaptvupt.so.0
$(Q)ln -sf libpqvaptvupt.so.0.6.0 $(DESTDIR)$(PREFIX)/lib/vaptvupt/libpqvaptvupt.so
@echo "Installed: $(DESTDIR)$(BINDIR)/$(TARGET) (legacy: $(DESTDIR)$(BINDIR)/$(LEGACY_LINK) -> $(TARGET))"
uninstall:
$(Q)rm -f $(DESTDIR)$(BINDIR)/$(TARGET)
$(Q)rm -f $(DESTDIR)$(MAN1DIR)/$(MANPAGE_GZ)
$(Q)rm -rf $(DESTDIR)$(PREFIX)/lib/vaptvupt
$(Q)rm -f $(DESTDIR)$(BINDIR)/$(TARGET) $(DESTDIR)$(BINDIR)/$(LEGACY_LINK)
$(Q)rm -f $(DESTDIR)$(MAN1DIR)/$(MANPAGE_GZ) $(DESTDIR)$(MAN1DIR)/$(LEGACY_LINK).1.gz
$(Q)rm -f $(DESTDIR)$(PREFIX)/share/bash-completion/completions/$(TARGET) \
$(DESTDIR)$(PREFIX)/share/bash-completion/completions/$(LEGACY_LINK)
$(Q)rm -f $(DESTDIR)$(PREFIX)/share/zsh/site-functions/_zupt
$(Q)rm -f $(DESTDIR)$(PREFIX)/share/fish/vendor_completions.d/zupt.fish
# ═══════════════════════════════════════════════════════════════════
# DIST — reproducible source tarball for distro packaging
# ═══════════════════════════════════════════════════════════════════
#
# `make dist` produces zupt-VERSION.tar.gz that is BYTE-IDENTICAL given
# the same input source tree. Properties:
#
# - Files sorted by name (stable order regardless of filesystem layout)
# - mtime fixed to SOURCE_DATE_EPOCH (or to the version-string-derived
# epoch when SOURCE_DATE_EPOCH is unset)
# - uid/gid fixed to root (0/0) via --owner / --group
# - gzip wrapped with --no-name (no embedded timestamp/filename)
# - No binaries, no .o, no .so. Source only.
#
# Used by AUR / Debian / Homebrew / RPM upstream packaging.
# Output: /tmp/zupt-VERSION.tar.gz so it doesn't pollute the source tree.
DIST_VERSION = $(shell grep '^\#define ZUPT_VERSION_STRING' include/zupt.h | awk -F'"' '{print $$2}')
DIST_NAME = $(TARGET)-$(DIST_VERSION)
DIST_DIR = /tmp/$(DIST_NAME).distbuild
DIST_TARBALL = /tmp/$(DIST_NAME).tar.gz
SOURCE_DATE_EPOCH ?= 1747699200 # 2025-05-20 UTC — stable epoch for this release line
dist: clean
$(Q)rm -rf $(DIST_DIR) $(DIST_TARBALL)
$(Q)mkdir -p $(DIST_DIR)/$(DIST_NAME)
$(Q)git ls-files 2>/dev/null > $(DIST_DIR)/filelist.txt || \
find . \( -type f -o -type l \) \! -path './.git/*' \! -path './*.o' \! -name '*.o' \! -name '$(TARGET)' \
\! -name '$(LEGACY_LINK)' \
\! -name 'zupt_asan' \! -name 'test_vectors' \! -name 'test_vaptvupt' \
\! -name 'fuzz_decompress' \! -name 'fuzz_vv_decompress' \
\! -path './.distbuild*' 2>/dev/null | sed 's|^\./||' | sort > $(DIST_DIR)/filelist.txt
$(Q)tar -cf - --files-from=$(DIST_DIR)/filelist.txt | tar -xf - -C $(DIST_DIR)/$(DIST_NAME)
$(Q)find $(DIST_DIR)/$(DIST_NAME) -exec touch -d "@$(SOURCE_DATE_EPOCH)" {} +
$(Q)tar --sort=name \
--owner=0 --group=0 --numeric-owner \
--mtime="@$(SOURCE_DATE_EPOCH)" \
-C $(DIST_DIR) -cf - $(DIST_NAME) \
| gzip -9n > $(DIST_TARBALL)
$(Q)rm -rf $(DIST_DIR)
@echo ""
@echo " Reproducible source tarball:"
@echo " $(DIST_TARBALL)"
@echo " sha256: `sha256sum $(DIST_TARBALL) | awk '{print $$1}'`"
@echo " bytes: `wc -c < $(DIST_TARBALL)`"
@echo " Reproducibility: re-run 'make dist' on the same tree, sha256 MUST match."
# ═══════════════════════════════════════════════════════════════════
# CLEAN
@ -275,6 +401,24 @@ test: $(TARGET)
$(Q)bash tests/test_path_traversal.sh
$(Q)bash tests/test_arg_order.sh
$(Q)bash tests/test_block_swap.sh
$(Q)bash tests/test_f08_topmac.sh
$(Q)bash tests/test_f09_preface.sh
$(Q)bash tests/test_f10_kdf_default.sh
$(Q)bash tests/test_f11_authfail_message.sh
$(Q)bash tests/test_f12_comment.sh
$(Q)bash tests/test_gui_branding.sh
$(Q)bash tests/test_help_consistency.sh
$(Q)bash tests/test_static_analysis.sh
$(Q)bash tests/test_vv_decode_slack.sh
$(Q)bash tests/test_sha256_shani.sh
$(Q)bash tests/test_hmac_incremental.sh
$(Q)bash tests/test_kdf_transparency.sh
$(Q)bash tests/test_ct_timing.sh
$(Q)bash tests/test_codec_exact_size.sh
$(Q)bash tests/test_pqbox.sh
$(Q)bash tests/test_packaging_syntax.sh
$(Q)bash tests/test_completions_manpage.sh
$(Q)bash tests/test_dist_reproducible.sh
test-all: $(TARGET) test-vectors test-vv
@echo "==============================================="
@ -289,13 +433,71 @@ test-all: $(TARGET) test-vectors test-vv
@./test_vaptvupt 2>&1 | tail -2
@echo "==============================================="
# ═══════════════════════════════════════════════════════════════════
# CHECK — distro-friendly safe subset
# ═══════════════════════════════════════════════════════════════════
#
# Targeted at downstream packagers (openSUSE OBS, Debian, Fedora) who
# need a `%check` / `override_dh_auto_test` target that:
#
# - Runs in a few minutes (not the full byte-sweep arc)
# - Doesn't call `make clean` mid-stream (rules out test_dist_reproducible.sh)
# - Doesn't depend on tools that may be absent in the build chroot
# (no python3 PyYAML, no ruby, no dpkg-parsechangelog)
# - Doesn't depend on multi-threading that's flaky under emulation
# (skips test_threaded.sh and test_pq.sh's MT subset)
# - Covers the security-critical regressions: F-06 HMAC, F-08 AIT,
# F-09 byte-level integrity, F-10 KDF default, F-11 auth-fail
# wording, F-12 comments
# - Verifies cryptographic primitives against NIST/RFC vectors
#
# This is the recommended target for OBS %check sections.
check: $(TARGET) test-vectors
$(Q)sh tests/run_quick.sh
$(Q)bash tests/test_audit.sh
$(Q)bash tests/test_path_traversal.sh
$(Q)bash tests/test_arg_order.sh
$(Q)bash tests/test_block_swap.sh
$(Q)bash tests/test_f08_topmac.sh
$(Q)bash tests/test_f10_kdf_default.sh
$(Q)bash tests/test_f11_authfail_message.sh
$(Q)bash tests/test_f12_comment.sh
$(Q)bash tests/test_gui_branding.sh
$(Q)bash tests/test_help_consistency.sh
$(Q)bash tests/test_static_analysis.sh
$(Q)bash tests/test_vv_decode_slack.sh
$(Q)bash tests/test_sha256_shani.sh
$(Q)bash tests/test_hmac_incremental.sh
$(Q)bash tests/test_kdf_transparency.sh
$(Q)bash tests/test_ct_timing.sh
$(Q)bash tests/test_codec_exact_size.sh
$(Q)bash tests/test_pqbox.sh
$(Q)./test_vectors
@echo ""
@echo " ═════════════════════════════════════════"
@echo " All distro-safe checks passed."
@echo " ═════════════════════════════════════════"
test-vectors: tests/test_vectors.c $(HEADERS)
$(Q)$(CC) -O2 -std=c11 -Iinclude -Isrc $(LDFLAGS) tests/test_vectors.c \
src/zupt_sha256.c src/zupt_crypto.c src/zupt_aes256.c src/zupt_xxh.c \
$(Q)$(CC) -O2 -std=c11 -Iinclude -Isrc $(SHANI_FLAGS) $(LDFLAGS) tests/test_vectors.c \
src/zupt_sha256.c src/zupt_sha256_shani.c src/zupt_crypto.c src/zupt_aes256.c src/zupt_xxh.c \
src/zupt_keccak.c src/zupt_x25519.c src/zupt_mlkem.c src/zupt_cpuid.c \
src/zupt_mlock.c \
-o test_vectors $(LDLIBS)
# F-06 regression — HMAC accept-on-disjoint-bits (Zupt 2.2.5).
# Inherits $(CFLAGS) so ZUPT_USE_JASMIN is defined on x86_64 (exercising
# the original buggy path). Links the same crypto modules as test-vectors
# plus the Jasmin .o files when available.
test-f06: tests/test_f06_hmac.c $(HEADERS) $(JAZZ_O)
$(Q)$(CC) $(CFLAGS) $(SHANI_FLAGS) $(LDFLAGS) tests/test_f06_hmac.c \
src/zupt_sha256.c src/zupt_sha256_shani.c src/zupt_crypto.c src/zupt_aes256.c src/zupt_xxh.c \
src/zupt_keccak.c src/zupt_cpuid.c src/zupt_mlock.c \
src/zupt_x25519.c src/zupt_mlkem.c $(JAZZ_O) \
-o test_f06 $(LDLIBS)
$(Q)./test_f06
# VAPTVUPT: VaptVupt codec unit tests
test-vv: tests/test_vaptvupt.c $(HEADERS)
$(Q)$(CC) $(CFLAGS) $(VV_SIMD_FLAGS) $(LDFLAGS) tests/test_vaptvupt.c \
@ -307,8 +509,8 @@ test-vv: tests/test_vaptvupt.c $(HEADERS)
test-asan: $(SOURCES) $(HEADERS) $(JAZZ_O)
$(Q)$(CC) -Wall -Wextra -std=c11 -Iinclude -Isrc -I$(ZUPTSDK_DIR)/include \
-fsanitize=address,undefined -g -O1 \
$(VV_SIMD_FLAGS) -L$(ZUPTSDK_DIR) -Wl,-rpath,$(ZUPTSDK_ABS) \
$(SOURCES) $(JAZZ_O) -o zupt_asan -lzuptsdk $(LDLIBS)
$(VV_SIMD_FLAGS) $(SHANI_FLAGS) -I$(PQVV_DIR)/include -L$(ZUPTSDK_DIR) -Wl,-rpath,$(ZUPTSDK_ABS) \
$(SOURCES) $(JAZZ_O) -o zupt_asan -lzuptsdk -L$(PQVV_DIR) -Wl,-rpath,$(PQVV_ABS) $(LDLIBS)
@echo "ASAN build: ./zupt_asan"
# Build the format-parser fuzz harness. Runs against ./zupt_asan to catch
@ -371,7 +573,7 @@ fuzz-build:
@echo " afl-fuzz -i corpus_vv -o findings_vv -- ./fuzz_vv_decompress"
help:
@echo "Zupt v2.0.0 build targets:"
@echo "Zupt v$(shell grep '^#define ZUPT_VERSION_STRING' include/zupt.h | awk -F'\"' '{print $$2}') build targets:"
@echo " make Build zupt binary"
@echo " make V=1 Build with verbose output"
@echo " make test Quick test"

190
README.md
View file

@ -1,28 +1,43 @@
<!-- Logo: rehost on git.securityops.co/cristiancmoises/zupt or zupt.securityops.co; old GitHub user-attachments URL no longer in use -->
<!-- <img width="493" height="173" alt="logo" src="https://zupt.securityops.co/assets/logo.png"/> -->
# Zupt
# VaptVupt
**Compress everything. Trust nothing. Encrypt always.**
![Build](https://img.shields.io/badge/build-passing-brightgreen)
![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue)
![Version](https://img.shields.io/badge/version-2.2.3-brightgreen)
![Version](https://img.shields.io/badge/version-4.0.0-brightgreen)
![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows-lightgrey)
Backup compression with hardware-adaptive codec selection, AES-256 authenticated encryption, post-quantum key encapsulation, and full-disk backup. Pure C11, zero dependencies, ~13,000 lines. Builds and runs on x86_64, aarch64, armhf, ppc64le, s390x, and riscv64.
> **Renamed from "Zupt" in v3.0.0** because of a prior INPI Brasil
> trademark registration on the name "Zupt" for unrelated software.
> The `.zupt` archive extension and `ZUPT` header magic bytes are
> unchanged — v2.x and v3.0.0 archives are bidirectionally compatible.
> The `zupt` command is preserved as a symlink to `vaptvupt` for one
> major version cycle.
Backup compression with hardware-adaptive codec selection, AES-256
authenticated encryption, post-quantum key encapsulation, and
full-disk backup. Pure C11, zero dependencies, ~13,000 lines. Builds
and runs on x86_64, aarch64, armhf, ppc64le, s390x, and riscv64.
---
## Why Zupt
## Why VaptVupt
- **Hardware-adaptive codec** — auto-detects AVX2/NEON at runtime and selects the best codec: VaptVupt (LZ77 + tANS + SIMD decode) on capable hardware, Zupt-LZHP on everything else. Override with `--vv` or `--lzhp`.
- **Post-quantum encryption**`--pq` mode uses ML-KEM-768 + X25519 hybrid KEM (same approach as Signal and iMessage). Protects against "harvest now, decrypt later" quantum attacks.
- **AES-NI hardware acceleration** — AES-256-CTR via Jasmin-verified assembly with 4-block interleaved pipeline. Safe AVX detection with OSXSAVE/XCR0 validation — no SIGILL on any CPU. Falls back to C table-based AES on unsupported hardware.
- **SHA-NI hardware acceleration** — HMAC-SHA256 (the Encrypt-then-MAC second pass) and PBKDF2 use an Intel SHA-NI compression path (`SHA256RNDS2`/`MSG1`/`MSG2`) when the CPU supports it (Intel Goldmont+/Ice Lake+, AMD Zen+), selected at runtime via CPUID. **measured 5.8×** over the scalar path (204 → 1184 MB/s, 256 MiB, Xeon 2.10 GHz) *and* constant-time by construction. Bit-identical output; scalar C fallback elsewhere (incl. aarch64). `vaptvupt version` prints the live acceleration set for your CPU.
- **Incremental HMAC** — the per-block MAC streams its segments through an incremental HMAC-SHA256 (key prefix folded once per keyring) instead of copying each block's ciphertext into a temporary buffer. Removes a per-block heap allocation and full-payload copy on both encrypt and decrypt, with a byte-for-byte identical MAC (RFC 2104).
- **Multi-threaded** — Compression and decompression both parallelized. `-t 0` auto-detects cores.
- **Full-disk backup**`zupt disk backup` clones entire disks or partitions in one command. Sparse block detection skips zero regions, real-time progress bar, all encryption modes supported. Restore with byte-for-byte verification via per-block XXH64 checksums.
- **Encrypted backups in one command**`zupt compress -p changeme backup.zupt ~/data/` — AES-256 + HMAC-SHA256, file names hidden.
- **Per-block integrity** — XXH64 checksum + HMAC-SHA256 per block. Wrong password rejected instantly.
- **Self-describing KDF** — password archives record their key-derivation profile in the (authenticated) header, so an archive always carries the parameters needed to open it years later. Unknown profiles are refused fail-closed rather than mis-derived. Argon2id is the default; PBKDF2 (600K iter) via `--kdf pbkdf2`.
- **Measured constant-time comparisons** — every security-critical comparison (HMAC tag, archive-integrity trailer, and the ML-KEM-768 decapsulation implicit-rejection check) routes through a single audited primitive (`zupt_ct_memeq`, branch-free, volatile accumulator, length-independent) verified by a dudect-style Welch t-test in CI, not just annotated. Its data-dependent timing signal measures ~1% of a leaky-`memcmp` control in the same environment; a reintroduced early-return or inline loop fails the test (timing + source-routing guard).
- **Sealed-box PQ recipients (`--pq-box`, v4.0.0)** — third post-quantum mode via vendored libpqvaptvupt: ML-KEM-768 + X25519 combined through HKDF-SHA256 with domain separation ("pqvv-seal-v1"), AES-256-CTR + HMAC-SHA256 EtM in the box, magic-tagged keypair files that reject key-type confusion. Legacy `--pq` and `--pq-sdk` remain readable.
- **Formally verified crypto** — 5 Jasmin assembly functions with constant-time proofs. 19 ACSL-annotated functions for Frama-C memory safety analysis.
- **Multi-architecture** — builds on x86_64, aarch64, armhf, ppc64le, s390x, riscv64. Jasmin CT crypto on x86_64, C fallback everywhere else. Any archive decompresses on any architecture.
- **Zero dependencies** — ML-KEM, X25519, Keccak, SHA-256, AES-256, HMAC, PBKDF2, VaptVupt codec — all pure C11. Builds with `gcc` or `cl` alone.
@ -38,47 +53,56 @@ curl -fsSL https://short.securityops.co/zupt | bash
### Build & Install
```
git clone https://git.securityops.co/cristiancmoises/zupt.git && \
cd zupt && \
git clone https://git.securityops.co/cristiancmoises/vaptvupt.git && \
cd vaptvupt && \
make && \
sudo make install
```
### Pre-built packages
All assets are published on the [v4.0.0 release page](https://git.securityops.co/cristiancmoises/vaptvupt/releases/tag/v4.0.0)
and verifiable against the published `SHA256SUMS.txt`.
**Command-line tool (`vaptvupt` 4.0.0):**
| Format | File | Distros |
|---|---|---|
| Debian/Ubuntu | `zupt_2.2.3_amd64.deb` | Debian 11+, Ubuntu 22.04+, Mint 21+ |
| RPM | `zupt-2.2.3-1.x86_64.rpm` | Fedora 38+, RHEL 9+, openSUSE, AlmaLinux, Rocky, and other RPM-based distributions |
| AppImage | `zupt-2.2.3-x86_64.AppImage` | Any glibc 2.28+ (single-file, no install) |
| AppDir tarball | `zupt-2.2.3-x86_64.AppDir.tar.gz` | Any glibc 2.28+ (extract & run) |
| Generic tarball | `zupt-2.2.3-linux-x86_64.tar.gz` | Any Linux x86_64 (binary + man page) |
| Source | `zupt-2.2.3-source.tar.gz` | Build from source |
| Debian/Ubuntu | `vaptvupt_4.0.0_amd64.deb` | Debian 11+, Ubuntu 22.04+, Mint 21+ |
| RPM | `vaptvupt-4.0.0-1.x86_64.rpm` | Fedora 38+, RHEL 9+, openSUSE, AlmaLinux, Rocky, and other RPM-based distributions |
| AppDir tarball | `vaptvupt-4.0.0-x86_64.AppDir.tar.gz` | Any glibc 2.28+ (extract & run, no FUSE) |
| Source tarball | `vaptvupt-4.0.0.tar.gz` | Build from source on any platform |
| openSUSE OBS | `vaptvupt-4.0.0-opensuse-obs.tar.gz` | Open Build Service source bundle |
**Graphical front-end (`vaptvupt-gui` 1.3.0):**
| Format | File | Distros |
|---|---|---|
| Debian/Ubuntu | `vaptvupt-gui_1.3.0_all.deb` | Debian 11+, Ubuntu 22.04+, Mint 21+ |
| RPM | `vaptvupt-gui-1.3.0-1.noarch.rpm` | RPM-based distributions |
| AppImage | `VaptVupt-GUI-1.3.0-x86_64.AppImage` | Any glibc 2.28+ (single-file, no install) |
| AppDir tarball | `VaptVupt-GUI-1.3.0-x86_64.AppDir.tar.gz` | Any glibc 2.28+ (extract & run) |
```bash
# Verify downloads first
sha256sum -c SHA256SUMS.txt
# Debian / Ubuntu / Mint
sudo dpkg -i zupt_2.2.3_amd64.deb
sudo dpkg -i vaptvupt_4.0.0_amd64.deb
sudo apt-get install -f # resolve any missing deps
# Fedora / RHEL / openSUSE / AlmaLinux / Rocky and other RPM-based distros
sudo rpm -i zupt-2.2.3-1.x86_64.rpm
sudo rpm -i vaptvupt-4.0.0-1.x86_64.rpm
# or
sudo dnf install ./zupt-2.2.3-1.x86_64.rpm
# AppImage (single executable, runs anywhere)
chmod +x zupt-2.2.3-x86_64.AppImage
./zupt-2.2.3-x86_64.AppImage --help
# Optionally place in PATH:
sudo install -m 755 zupt-2.2.3-x86_64.AppImage /usr/local/bin/zupt
sudo dnf install ./vaptvupt-4.0.0-1.x86_64.rpm
# AppDir tarball (no install, no FUSE required)
tar xzf zupt-2.2.3-x86_64.AppDir.tar.gz
./zupt-2.2.3-x86_64.AppDir/AppRun --help
tar xzf vaptvupt-4.0.0-x86_64.AppDir.tar.gz
./vaptvupt-4.0.0-x86_64.AppDir/AppRun --help
# Generic tarball (binary + man page, install manually)
tar xzf zupt-2.2.3-linux-x86_64.tar.gz
sudo install -m 755 zupt-2.2.3-linux-x86_64/zupt /usr/local/bin/zupt
sudo install -m 644 zupt-2.2.3-linux-x86_64/zupt.1.gz /usr/local/share/man/man1/
# GUI AppImage (single executable, runs anywhere)
chmod +x VaptVupt-GUI-1.3.0-x86_64.AppImage
./VaptVupt-GUI-1.3.0-x86_64.AppImage
```
### Building from SRPM (Fedora / RHEL / RPM-based distributions)
@ -137,6 +161,11 @@ zupt keygen --sdk -o mykey.priv # writes mykey.priv and mykey.priv.pub
zupt compress --pq-sdk mykey.priv.pub backup.zupt ~/Documents/
zupt extract --pq-sdk mykey.priv -o ~/restored/ backup.zupt
# pq-box sealed-box workflow (v4.0.0; HKDF-SHA256 domain-separated combiner)
zupt keygen --box -o box.key # writes box.key + box.key.pub
zupt compress --pq-box box.key.pub backup.zupt ~/Documents/
zupt extract --pq-box box.key -o ~/restored/ backup.zupt
# Legacy --pq mode (XOR+SHA3-512 combiner) — kept for back-compat with
# archives created by Zupt 2.02.1. Do NOT use for new archives.
zupt keygen -o mykey.key
@ -185,7 +214,14 @@ Override with `--vv` (force VaptVupt) or `--lzhp` (force Zupt-LZHP) when you kno
VaptVupt is Zupt's high-performance compression codec. It combines LZ77 dictionary matching with tANS (table-based Asymmetric Numeral Systems) entropy coding and SIMD-accelerated decompression.
**This release embeds VaptVupt 2.48.2** — the version cut explicitly as the integration target for Zupt 2.2.3. See `CHANGELOG.md` for the full list of changes.
**This release embeds VaptVupt 2.60.4** (security release: fixes an OOB
heap write in the AVX2 decode fast path; adds canonical CBMC-verified
BCJ filters with auto-detection). The codec API is byte-identical
to the 2.48.x line; the 2.48.5 → 2.60.4 upgrades add the optimal parser
(measured: text 1.95%, binary 1.31%, source 4.72% smaller on our
fixtures), large-window extreme mode, faster decode (now roughly on par
with zstd-19, up from 1.52× slower), and six upstream corrupt-input
decoder memory-safety fixes. See `CHANGELOG.md` for the full list.
### Architecture
@ -209,19 +245,95 @@ Format: v1 frame (default) and v2 frame (T-tag, min_match=3) for binary data
The Zupt wrapper enables VaptVupt's `format_v2` flag (47% better real-binary ratio) automatically for Balanced and Extreme modes. Ultra-Fast stays on the v1 frame because the `format_v2 + ULTRA_FAST` combination is not yet covered by VaptVupt's upstream test matrix.
### Benchmark Results (this release)
### Benchmark Results (v3.8.0, codec 2.60.4)
Measured on the build host with a 4 MB mixed corpus (text records.csv, random.bin). Each codec run once, wall-clock via `time(NULL)` boundaries. Reproduce with `zupt bench <file>`.
> Full, reproducible measured benchmarks — compression ratio/speed
> across levels, crypto overhead (KDF vs per-block), and a head-to-head
> ratio comparison against zstd — are in **[`BENCHMARKS.md`](BENCHMARKS.md)**,
> with the test machine and method stated for every table.
| Codec / Level | text 4MB → ratio | random 4MB → ratio | Notes |
|---|---|---|---|
| **VaptVupt L1** (UltraFast) | 4.31:1 | ~1.00:1 | Fastest |
| **VaptVupt L3** (Balanced + format_v2) | **15.83:1** | ~1.00:1 | Default sweet spot |
| **VaptVupt L5** (Balanced + format_v2) | 15.40:1 | ~1.00:1 | |
| **VaptVupt L9** (Extreme + format_v2) | 15.23:1 | ~1.00:1 | Max ratio |
| gzip -9 | 8.70:1 | ~1.00:1 | Baseline |
> **F-16 (data loss, fixed in 4.0.0):** archives created by **≤ 3.8.0** at
> `-l 8`/`-l 9` whose inputs included x86/ELF/PE executables may be
> **undecodable by any version** (defect at write time in the old
> divergent BCJ encoder). Re-create such archives with 4.0.0 and verify
> extraction before deleting source data. Details in CHANGELOG/AUDIT.
On the standard Silesia + fixture suite measured by the upstream VaptVupt project, v2.48.x **beats zstd-3 by 1.07% in aggregate ratio** (was +1.2% behind in v2.47.x), with decode throughput at **1.27× zstd-3** in aggregate and **3.7× zstd-19 / 1.5× lz4-9** on AEAD-shaped (random) data with `--fast`.
**Measured against gzip-9, zstd-3, zstd-19** on a 4-fixture suite
(text 10 MB, binary-struct 7.5 MB, source code 10 MB, random 5 MB).
Decode timed across 3 runs, minimum reported; wall-clock including the
`.zupt` envelope (HMAC etc.). Host: Intel Xeon @ 2.1 GHz, single vCPU,
codec built at the distribution's default optimisation level (AVX2).
**Reproduce with `vaptvupt bench <file>`** to compare VaptVupt levels,
or the comparative harness in the source tree.
| Fixture | Tool | Ratio | Dec MB/s |
|---------------|-----------|---------:|---------:|
| text 10 MB | vv-9 | 25.6% | 278 |
| text 10 MB | gzip-9 | 22.6% | 156 |
| text 10 MB | zstd-3 | 24.2% | 556 |
| text 10 MB | zstd-19 | **17.6%**| 435 |
| binary 7.5 MB | vv-9 | 46.1% | 300 |
| binary 7.5 MB | gzip-9 | 46.8% | 123 |
| binary 7.5 MB | zstd-3 | 44.8% | 577 |
| binary 7.5 MB | zstd-19 | **41.1%**| 417 |
| source 10 MB | vv-9 | 4.5% | 714 |
| source 10 MB | gzip-9 | 4.0% | 238 |
| source 10 MB | zstd-3 | 5.6% | 1000 |
| source 10 MB | zstd-19 | **2.7%** | 769 |
| random 5 MB | vv-9 | 100.0% | 625 |
| random 5 MB | zstd-3 | 100.0% | 681 |
Honest reading (these are measured numbers, not aspirations):
- **On ratio, zstd-19 wins every fixture.** VaptVupt L9 lands between
zstd-3 and zstd-19 on text and binary, beats zstd-3 on source (4.5%
vs 5.6%), and loses to zstd-19 everywhere. If smallest-file is the
only goal, use `xz -9` or `zstd -19`.
- **Decode is now competitive**, not a weakness: 278714 MB/s, in the
same band as zstd-19 (and within ~1.3× of zstd-3). The 2.60.4 codec's
decode-speed work (Sprint 53/58) closed most of the gap that existed
at 2.48.5. The earlier "1.27× zstd-3 decode" headline (inherited from
upstream docs) is **not claimed here** — it did not reproduce in our
own single-vCPU measurement.
- **Encode throughput remains the weakness.** The optimal parser and
depth-24 hash-chain walk that win ratio cost encode speed; balanced
mode is ~6× slower than fast and ~14× slower than zstd-1. For
encode-latency-bound workloads use `vaptvupt compress -l 1`/`-l 2`.
- **On a degenerate single-pattern input**, large-window extreme (L9)
is slightly *worse* than L5/L7 — a known tradeoff of optimizing for
real long-range matches. Doesn't affect realistic corpora.
- **On random / already-compressed data**, all codecs hit the
incompressibility wall; the comparison degenerates to
framing-overhead measurement.
### Security Test Results (v3.0.0 release)
Every release re-runs the full security regression matrix. These are
the v3.0.0 numbers:
| Test | Coverage | Result |
|---------------------------------|--------------------------------------------------------------------------|-------------------|
| F-06 HMAC tamper fuzz | 2000 trials, single-bit flip in HMAC tag | **0 silent accepts** / 2000 honest roundtrips OK |
| F-08 archive-integrity trailer | Header/footer tamper detection | **5/5 pass** |
| F-09 byte-level integrity sweep | 1827 positions on a PQ-SDK archive, every byte flipped exhaustively | **0/1827 silent accepts** ✓ |
| F-10 KDF default | Argon2id is the default; PBKDF2 available via `--kdf pbkdf2` | **10/10 pass** |
| F-11 auth-fail wording | Wrong-password vs tampered-archive messages are indistinguishable | **12/12 pass** |
| F-12 encrypted comments | Comment block bound to per-block AAD; tamper rejected at extract | **11/11 pass** |
| F-15 KDF transparency | Argon2id header self-describes its profile; back-compat + fail-closed | **5/5 pass** |
| Constant-time comparisons | dudect Welch t-test (MAC tag + ML-KEM decaps); ~1% of leaky-memcmp + source-routing guard | **2/2 pass** |
| Codec exact-size decode (OOB) | 80 exact-`content_size` cases incl. BCJ payloads, ASan (codec 2.60.4 fix class) | **80/80 pass** |
| pq-box mode | roundtrips L1/L9/BCJ; wrong-key/key-confusion/tamper/cross-mode rejection | **13/13 pass** |
| NIST/RFC test vectors | SHA-256, SHA-3, SHAKE-128, ML-KEM-768, AES-256-CTR (SP 800-38A), HMAC-SHA256, X25519, XXH64 | **16/16 pass** |
| Path-traversal | Absolute paths and `..` components refused | **5/5 pass** |
| Block-swap | Re-ordered block detection | **6/6 pass** |
| Dedup property | Deduplication never produces wrong output | **12/12 pass** |
| Audit suite | Curated smoke tests | **10/10 pass** |
| Argument-order | CLI flag ordering doesn't change semantics | **8/8 pass** |
| Distro-safe `make check` | Aggregate of the above (no flaky threading, no `make clean` mid-stream) | **91/91 pass** |
Reproduce: `make check` (≈2 minutes, 91 assertions across 10 suites,
all green on x86_64 + aarch64). `make test` runs the full 15-suite
arc including dist reproducibility and packaging-syntax.
### Why VaptVupt?
@ -389,7 +501,7 @@ Key protection: mlock() prevents swap, buffer canaries detect overflow
Timing: Always-decrypt mitigation (no timing oracle on MAC failure)
AES dispatch: AVX+AES-NI check with OSXSAVE/XCR0 (no SIGILL on any CPU)
Path safety: Zip Slip / symlink defenses (zupt_path_is_safe + O_NOFOLLOW)
Verification: 5 Jasmin CT proofs, 19 ACSL contracts, 13 NIST/RFC test vectors
Verification: 5 Jasmin CT proofs, 19 ACSL contracts, 16 NIST/RFC test vectors
```
**Audit history:** Three internal audit sprints conducted on the 2.2.x line.

View file

@ -25,12 +25,40 @@
| **v2.1.3** | **✅** | **Disk restore rewritten — shared block I/O, fixes checksum mismatch on encrypted/PQ archives** |
| **v2.1.3** | **✅** | **LZHP prediction encoding fix, shared write_enc_header, SOLID flag removed from disk, 78 tests** |
| **v2.1.4** | **✅** | **CodeQL: 4 security fixes — TOCTOU races (fstat on fd), X25519 scalar wipe (volatile), 78 tests** |
| **v2.1.5** | **✅ Current** | **Block-level deduplication (--dedup), XXH64 fingerprint index, DEDUP_REF block type, 81 tests** |
| **v2.1.5** | **✅** | **Block-level deduplication (--dedup), XXH64 fingerprint index, DEDUP_REF block type, 81 tests** |
| v2.2.4 | ✅ | Five-finding audit pass: help-format, flaky audit (F-02a), `-Wshadow`, missing-prototype on ML-KEM selftest (now wired as vector 14/14), three `const` cppcheck hints; F-02b (index MAC) opened as deferred |
| v2.2.5 | ✅ | F-06 (high): HMAC verifier silently accepted ~6.35% of single-bit MAC tampers on the Jasmin (x86_64) path. F-07: structural check that block at `index_offset` claims `INDEX` type. F-02b reclassified as resolved (the index IS MAC'd; the verifier was buggy). F-08 (cosmetic-metadata coverage) opened, deferred to v2.3.0. |
| v2.3.0 | ✅ | F-08 closed: archive-integrity-trailer (32B AIT after footer; HMAC-SHA256 over `hdr ‖ ft[0..23]` in encrypted modes, XXH64 best-effort in plaintext). Format v1.4 → v1.5. Backward-compat read path with downgrade warning on legacy v1.4 archives. Exhaustive byte sweep: 86 → 18 undetected positions (all per-block-header trivia, deferred to v2.3.1 as F-09). New `tests/test_f08_topmac.sh` regression. |
| v2.3.1 | ✅ | F-09 closed: per-block frame preface bound into MAC via extended-AAD primitives + strict structural validation of the encryption-header block. Format v1.5 → v1.6. Reaches 100% byte-level tamper detection on encrypted archives — exhaustive sweep on 1827-byte v1.6 PQ-SDK archive: 0/1827 silent accepts. v2.3.0 archives extract unchanged; v2.3.0 cleanly rejects v2.3.1 archives (no silent corruption). |
| v2.4.0 | ✅ | Methodology release. `PROMPT.md` → v2: NEW §3.5 exhaustive byte-sweep mandate after format changes, sprint protocol gains a step, §11 outage table grows four rows for F-06..F-09. Makefile help banner now auto-derived from `include/zupt.h` (closes a recurring banner-drift bug). No source/binary changes; archives byte-identical to v2.3.1. |
| v2.4.1 | ✅ | F-10: password-mode KDF default flipped from PBKDF2-SHA256 to Argon2id (libzuptsdk). PBKDF2 remains available via `--kdf pbkdf2` for v2.4.0-and-older reader compatibility. No format change; v2.4.0 already supports reading Argon2id archives via existing enc_type dispatch. F-11 (auth-fail vs integrity-fail error message UX) opened, deferred. |
| v2.4.2 | ✅ | F-11 closed: wrong-password and tampered-archive error messages collapsed into one uniform `Authentication failed (wrong key, wrong password, or tampered archive)` line. Detailed top-MAC wording moves behind `--verbose`. Plaintext tamper keeps detailed XXH64 wording (no key, no oracle concern). Eliminates a verbal probe-oracle that was leaking which failure cause hit first. No format change. |
| v2.4.3 | ✅ | F-12 closed: encrypted archive comments. Implements the previously-reserved `comment_offset` header field via new block type `ZUPT_BLOCK_COMMENT = 0x05`. Comments are UTF-8, up to 4096 bytes, encrypted using the same per-block AEAD pipeline as data blocks (including F-09 preface AAD). `hdr.comment_offset` is in the AIT-signed region, so pointer tampering → auth-fail. CLI flags `-c` / `--comment` and `--comment-file`. `zupt info` reports presence without decrypting; `zupt x` displays comment after extract. v2.4.2 readers extract v2.4.3 archives byte-exact (they ignore `comment_offset`). Format still v1.6. Exhaustive byte sweep on 1878-byte archive with comment: 0/1878 silent accepts. |
| v2.4.4 | ✅ | Distribution packaging + reproducible source tarball. New `make dist` produces byte-identical `zupt-VERSION.tar.gz`; regression test `tests/test_dist_reproducible.sh` asserts two consecutive runs produce identical sha256. Upstream packaging recipes added at `packaging/aur/PKGBUILD`, `packaging/debian/{control,rules,changelog,copyright,source/format}`, and `packaging/homebrew/zupt.rb`. No source-code changes, no format changes. |
| v2.4.5 | ✅ | Packaging arc completion. New `packaging/rpm/zupt.spec` (Fedora/RHEL/CentOS) and `packaging/nix/flake.nix` (NixOS, x86_64 + aarch64). New `DISTRIBUTION.md` covers all 5 packaging methods with concrete submission flows. New `tests/test_packaging_syntax.sh` (18 assertions, wired into `make test`) enforces cross-recipe version consistency and basic syntax validity. No source-code changes. |
| v2.4.6 | ✅ | CI + threat model. Rewrote `.github/workflows/ci.yml` from 4 jobs to 8 (matrix builds, strict warnings, ASAN, PIE, aarch64, dist-reproducibility, packaging-syntax, tag-triggered release). New `THREAT_MODEL.md` (12 KB) documents what Zupt protects against and — explicitly per userPreferences — what it does NOT. Packaging-syntax test expanded 18 → 22. No source-code changes. |
| v2.4.7 | ✅ | Manpage refresh + shell completions. |
| v2.4.8 | ✅ | Distro-safe `make check` target + binary packages. |
| v3.0.0 | ✅ | MAJOR: Zupt → VaptVupt rename, VV codec 2.48.5, GUI binary-discovery fix. |
| v3.0.1 | ✅ | GUI license + version-parsing cleanup. |
| v3.0.2 | ✅ | F-13 closed (usage() string-literal length) + help-text drift cleanup. |
| v3.0.3 | ✅ | Static-analysis cleanup (cppcheck + -Wconversion). |
| v3.1.0 | ✅ | VaptVupt codec 2.48.5 → 2.53.3 + F-14 decode over-copy fix. |
| v3.2.0 | ✅ | SHA-256 hardware acceleration (Intel SHA-NI). |
| v3.3.0 | ✅ | Incremental HMAC-SHA256 (per-block MAC malloc + copy eliminated). |
| v3.4.0 | ✅ | F-15: Argon2id KDF parameter transparency (self-describing header). |
| v3.5.0 | ✅ | Measured constant-time MAC comparison (dudect-style). |
| v3.6.0 | ✅ | NIST SP 800-38A AES-256-CTR vectors + ML-KEM self-test fixes. |
| v3.7.0 | ✅ | ML-KEM decaps comparison routed through the audited CT primitive. |
| **v4.0.0** | **✅ Current** | **Stack integration release. Codec → canonical VaptVupt 2.60.4 (security: fixes high-severity OOB heap write in AVX2 decode on exact-`content_size` buffers; brings CBMC-verified BCJ with auto-detection; ratio gate verified byte-identical on identical inputs). F-16 disclosed and fixed: ≤3.8.0's divergent pre-release BCJ encoder wrote undecodable archives on executable content at L8/L9 — affected archives must be re-created with 4.0.0. New `--pq-box` recipient mode (ZUPT_ENC_PQ_BOX_V1, 0x05) via vendored libpqvaptvupt 0.6.0: ML-KEM-768 + X25519 through HKDF-SHA256 domain-separated combiner, magic-tagged keyfiles, 13/13 adversarial suite, ASan/UBSan clean. SHA-NI finally measured on capable silicon: 5.8× (204→1184 MB/s) — the v3.2.0 [ESTIMATED] is retired. Clang restored to the strict matrix (as(1) for Jasmin output). Wire v1.6 unchanged; 8-mode back-compat matrix byte-exact. 26 suites, test_vectors 16/0.** |
| v3.8.0 | ✅ Shipped | **Consolidated measured benchmarks (documentation-only; no source/crypto/wire change, v1.6 identical to 3.7.0). New BENCHMARKS.md publishes a complete reproducible benchmark set with the test machine + method stated for every table: compression ratio + encode/decode throughput at L9 across 5 fixtures; encode-speed-vs-level trade-off (L1 ≈88 MB/s at 2.55×, L9 ≈1 MB/s at 3.90×); encryption overhead separating the one-time KDF (Argon2id ≈741 ms, PBKDF2 ≈1562 ms) from per-block crypto (≈147 MB/s) and plain throughput (≈944 MB/s); and a head-to-head ratio comparison vs zstd-3/zstd-19 that plainly shows where VaptVupt loses. Previously the only documented benchmarks were codec-ratio numbers dated v3.1.0; the crypto-path data measured across 3.2.03.7.0 was never consolidated. SHA-NI speedup explicitly marked [ESTIMATED] (test box has no SHA-NI). README benchmark section re-dated and linked to BENCHMARKS.md. 24/24 suites green, test_vectors 16/0, F-09 0/1827.** |
## Planned
| Version | Status | Description |
|---------|--------|-------------|
| v2.3.0 | ✅ shipped | (see "released" table) |
| v2.3.1 | ✅ shipped | (see "released" table — F-09 closed) |
| v2.1 | 📋 Planned | Homebrew, AUR, Debian, RPM, Nix packages |
| v2.2 | 📋 Planned | Coverity Scan, clang-tidy security checkers, Frama-C Eva analysis |
| v2.3 | 📋 Planned | Silesia corpus benchmarks, performance tuning, NEON ARM64 decode path |

View file

@ -113,10 +113,14 @@ These functions are compiled from Jasmin source to x86-64 assembly. The Jasmin c
| Asset | Protection |
|-------|-----------|
| File contents | AES-256-CTR encryption |
| File names, sizes, structure | Encrypted in central index block |
| Archive integrity | Per-block XXH64 + HMAC-SHA256 |
| File names, sizes, structure | Encrypted in central index block, HMAC-protected |
| Archive integrity (payloads + index) | Per-block HMAC-SHA256 (v2.2.5+ verifier — see CHANGELOG F-06) |
| Archive integrity (header + footer metadata) | **v1.5 archives**: 32-byte archive-integrity-trailer HMAC-SHA256 over `hdr ‖ ft[0..23]`. **v1.4 archives**: not covered, downgrade warning on extract (see F-08 / CHANGELOG 2.3.0). |
| Against stolen backups | AES-256 requires key/password to read |
| Against tampering | HMAC detects any modification |
| Against tampering of file contents, names, sizes, offsets | HMAC detects any modification (overwhelming probability after F-06 fix) |
| Against tampering of header+footer metadata | **v1.5: top-MAC detects tamper.** v1.4: not detected (legacy; re-archive with v2.3.0+ to upgrade). |
| Against tampering of per-block frame preface bytes (codec_id, block_flags, varints, plaintext-XXH64) | **v1.6 (F-09)**: per-block MAC binds the canonical preface AAD; encryption-header block validated structurally. v1.5 and older: partial detection only (parser/decoder rejects malformed values; explicit MAC coverage was v1.6 work). |
| Against tampering of archive comment (when present) | v2.4.3 (F-12): comment block goes through the same per-block AEAD pipeline as data (AES-256-CTR + HMAC-SHA256 + preface AAD); `hdr.comment_offset` pointer is in the AIT-signed region. Both payload and pointer are MAC-covered end-to-end. |
| Against quantum adversary | `--pq` mode: ML-KEM-768 (NIST Level 3) |
### What Zupt Does NOT Protect Against
@ -127,7 +131,7 @@ These functions are compiled from Jasmin source to x86-64 assembly. The Jasmin c
| Cache-timing side channels (C AES) | Table-based S-box lookups | Build with Jasmin AES-NI when available |
| Memory forensics during operation | Keys on stack during compress/extract | `zupt_secure_wipe()` on completion; `mlock()` planned |
| Deniability | Archive header identifies format | `.zupt` magic bytes visible; ENCRYPTED flag in header |
| Weak passwords | PBKDF2 adds ~20 bits of work factor | Use `--pq` mode for critical data |
| Weak passwords | Argon2id (default, v2.4.1+) is memory-hard and adds ~2530 bits of work factor vs ~20 for PBKDF2. PBKDF2-SHA256 with 600k iterations available via `--kdf pbkdf2` for legacy reader compatibility. | Use `--pq` or `--pq-sdk` mode for critical data — keys are random, not derived from a password. |
| Traffic analysis | Archive size reveals data volume | Outside Zupt's scope |
| File permission/ownership | Not stored in archive | Documented in README.md (Architecture & platform support) |

305
THREAT_MODEL.md Normal file
View file

@ -0,0 +1,305 @@
# Zupt threat model
Plain-English description of what Zupt protects against, what it
doesn't, and what assumptions you're making when you use it.
This document is for users and downstream packagers. Read it before
trusting Zupt with anything you can't afford to lose.
---
## TL;DR
Zupt is designed for **at-rest backup encryption** by someone who
controls the machine doing the encryption and the machine doing the
extraction. It is **not** a network protocol, a multi-party scheme, or
a substitute for full-disk encryption.
| Use case | Zupt is appropriate? |
|---|---|
| Backing up files to an untrusted cloud (S3, Backblaze, Google Drive) | **Yes** |
| Backing up a disk image to external media you might lose | **Yes** |
| Long-term archival of personal/business data | **Yes** |
| Sharing an encrypted archive with someone you trust to handle the key | **Yes, with care** (see "Key distribution" below) |
| Real-time encrypted communication | **No** (use Signal, age, or TLS) |
| Multi-party access (n-of-m) | **No** (no threshold scheme) |
| Hiding the existence of an archive (steganography) | **No** (archive header has fixed magic bytes) |
| Protecting against a hostile machine you're encrypting on | **No** (a compromised host can read plaintext before encryption) |
---
## What Zupt protects against
### 1. Confidentiality of archive contents (encrypted mode)
An attacker with read access to the archive bytes cannot recover
plaintext file contents, file names, file sizes, file modes, or
embedded comments **without the key/password**, assuming:
- The chosen mode is one of the encrypted modes (`-p`, `--pq`, `--pq-sdk`, or `--pq-box`)
- The password is strong enough to resist offline brute-force
(Argon2id default with m=64 MB, t=3, p=4 makes this very expensive
but not infinite — see "Password strength" below)
- The key file (for `--pq-sdk` / `--pq-box`) was not compromised at generation time
### 2. Integrity of every byte of an encrypted archive
If any single bit of the on-disk archive bytes is flipped, the
extraction **must fail** with an authentication error. This has been
verified by the v1.6 exhaustive byte sweep:
- 0 silent-accept positions out of 1827 (encrypted, no comment)
- 0 silent-accept positions out of 1878 (encrypted, with comment)
Coverage layers:
- **Per-block HMAC-SHA256** with frame-preface AAD (F-09): every data
block carries an HMAC over its ciphertext and over the canonical
29-byte preface (block_type, codec_id, block_flags, sizes, plaintext-XXH64)
- **Archive Integrity Trailer (F-08)**: HMAC-SHA256 over the
64-byte header and 24 bytes of footer, appended after the footer
- **Strict structural validation of the encryption-header block (F-09)**:
codec must be `STORE`, flags must be 0, csz must equal usz, the
plaintext XXH64 must match
### 3. Tamper detection on plaintext archives (best-effort)
Plaintext archives (no `-p`, no `--pq*`) are protected by XXH64
plaintext checksums per block plus structural validation. This is
**not cryptographic integrity** — a determined attacker with
write access can produce a tampered plaintext archive that passes
the checksum (XXH64 is not collision-resistant). It does catch
accidental corruption and naive tampering.
Use an encrypted mode if you need cryptographic integrity.
### 4. Authentication failure indistinguishability (F-11)
The default error message for "wrong password", "wrong PQ key",
and "actual header tamper" is the same single line:
> `Error: Authentication failed (wrong key, wrong password, or tampered archive).`
This prevents an attacker who can issue extraction attempts from
learning which check failed first via the stderr output. Timing is
also constant (HMAC is always run, branchless return).
The detailed cause is available via `--verbose` for debugging on
machines under the user's own control.
### 5. Post-quantum forward secrecy (in `--pq-sdk` mode)
`--pq-sdk` uses ML-KEM-768 (FIPS 203) hybridized with X25519 via an
HKDF combiner. Archives encrypted today cannot be decrypted by a
future quantum adversary holding only the ciphertext, **assuming**:
- ML-KEM-768 retains its claimed security level (NIST Category 3,
192-bit classical / 96-bit quantum strength)
- X25519 hybridization protects against an unforeseen ML-KEM break
- The recipient's private key is not later compromised
### 6. Side-channel resistance for cryptographic primitives
The hot crypto paths (AES-256-CTR, HMAC-SHA256 comparison, X25519
field operations, ML-KEM polynomial arithmetic) are implemented in
Jasmin and proved constant-time at the assembly level on x86_64.
Non-Jasmin platforms (aarch64, fallback x86_64) use careful C
implementations that avoid secret-dependent branches and memory
accesses where feasible — but **without formal proof**.
---
## What Zupt does NOT protect against
This list is **exhaustive of the major omissions** — if you have a
concern that doesn't appear here, please file an issue.
### 1. Compromised endpoints
Zupt cannot protect against:
- Malware on the machine doing the encryption (it sees plaintext
before any crypto is applied)
- Malware on the machine doing the extraction (it sees plaintext
after decryption)
- A hardware keylogger capturing the password
- A compromised user account that can read your files or
`~/.zupt-key` directly
- Cold-boot attacks on running machines
If you don't trust the machine, Zupt cannot help.
### 2. Key compromise
If the password or `~/.zupt-key` is leaked:
- All archives encrypted with that key are decryptable
- Zupt has **no forward secrecy across archives** — each archive
is encrypted under a single static key derived from the password
or stored in the key file
- There is no key-rotation feature; rotate by re-encrypting
archives under a new password/key and securely deleting the old
password/key
For high-value, long-term archives, treat the key file as you
would a master password: store it offline, encrypt it under
another layer (e.g. on an encrypted USB), and rotate periodically.
### 3. Password strength
Argon2id with m=64 MB, t=3, p=4 makes a single guess cost roughly
~200 ms on commodity hardware. That's **not enough** to protect a
short, common password against a determined attacker with GPU
clusters or cloud compute.
| Password type | Approximate brute-force resistance with Argon2id |
|---|---|
| 6-char common word | Hours to days |
| 10-char mixed alphanumeric | Years on a single GPU; days on a cluster |
| 6-word diceware passphrase | Centuries to millennia even with cloud-scale resources |
| Random 16-char with full alphabet | Infeasible without quantum breakthrough |
For critical data, use `--pq-sdk` mode with a random key file
generated by `zupt keygen --sdk` — the key is 64 bytes of CSPRNG
output, not derived from human-typed text.
### 4. Metadata leakage from archive structure
Even with encryption, an attacker who can see the archive bytes
can infer:
- **Approximate file count** (from `total_blocks` in the footer)
- **Total archive size** (file size on disk)
- **Whether the archive is encrypted at all** (`ZUPT_FLAG_ENCRYPTED`
in the global flags is visible)
- **Whether the archive is solid or per-file mode** (visible flag)
- **Whether post-quantum mode is in use** (visible flag)
- **Approximate file size distribution** (block sizes are visible
even when block payloads are encrypted)
- **Archive creation time** (a 64-bit timestamp in the header)
- **A random 16-byte UUID per archive** (no information leak, but
globally identifies the archive across copies)
If metadata privacy matters, layer Zupt under another tool that
hides bulk metadata (e.g., put the `.zupt` file inside a fixed-size
encrypted container).
### 5. Network attacks
Zupt is not a network protocol. There is no:
- Forward-secure session establishment (use TLS or Noise)
- Mutual authentication of remote parties (use signed messages or
TLS client certs)
- Replay protection across sessions (archives can be replayed by
an attacker who can write to the destination)
- Network-layer encryption (use TLS to transport `.zupt` files)
### 6. Multi-party schemes
There is **no threshold cryptography, no n-of-m sharing, no
multi-party computation, no proxy re-encryption**. Each archive
has exactly one decryption credential (one password OR one
recipient key). To give two people access to the same archive,
they must share the password or the key file.
### 7. Plausible deniability / hidden volumes
Zupt archives have a fixed 6-byte magic `\x90\x5a\x55\x50\x54\x01`
at offset 0. Anyone scanning the bytes can see it's a Zupt
archive. Zupt has **no hidden-volume or duress-password feature**.
### 8. Side channels we don't claim to address
- Power analysis (relevant for embedded targets, not commodity desktops)
- Electromagnetic emanation
- Acoustic side channels
- Network timing of upload patterns
- Filesystem-level metadata (mtime/atime of the `.zupt` file)
### 9. Trusted setup of post-quantum primitives
The ML-KEM-768 implementation lives in `libzuptsdk` and was not
independently audited at the time of writing. We use NIST KAT
vectors for correctness verification but have not formally proven
constant-time properties for every PQ code path.
For maximum assurance, treat `--pq-sdk` as the post-quantum
**hedge** — it does not replace the X25519 layer; both must be
broken for an attacker to recover plaintext.
### 10. Format extension attacks
The format is versioned (v1.6). Older readers may accept newer
archives in unexpected ways. We try to maintain forward
compatibility (v2.4.5 readers correctly handle v1.6 archives
including encrypted comments and the Argon2id KDF path), but a
careful attacker who can produce malformed-but-just-valid
archives may find parser-state issues that don't rise to the
level of a CVE. The fuzzing harness (`make fuzz-format`) is the
primary mitigation; report bugs.
### 11. Compression-side-channel attacks (CRIME / BREACH style)
Zupt compresses **before** encryption. If an attacker can:
- Influence part of the plaintext (e.g. inject a known prefix)
- Observe the resulting archive size precisely
then they can use the compression ratio to learn information about
the rest of the plaintext — this is the classic CRIME/BREACH attack
against TLS compression.
Zupt is designed for offline backup, where attacker-controlled
plaintext injection is rare. **If your threat model includes
attacker-chosen plaintext mixed with secret plaintext in the same
archive**, use `--no-compress` (codec 0 = STORE) to disable the
LZ codec and eliminate this side channel.
---
## Cryptographic assumptions
Zupt's security rests on the following standard assumptions:
| Assumption | What breaks if it fails |
|---|---|
| AES-256-CTR is a secure stream cipher | All encrypted archives become readable |
| HMAC-SHA256 is a secure PRF / MAC | Tamper detection fails; integrity can be forged |
| Argon2id is a secure password KDF | Password-mode archives become brute-forceable faster |
| ML-KEM-768 retains NIST Category 3 security | `--pq-sdk` mode reduces to the X25519 layer |
| X25519 retains 128-bit security (no quantum) | `--pq-sdk` mode reduces to the ML-KEM layer; legacy `--pq` mode broken |
| HKDF-SHA256 is a secure key-derivation construction | Combined PQ + classical keys may be predictable |
| SHA3 / SHAKE retain pre-image and collision resistance | Auxiliary protocol bindings may be forged |
If you don't trust one of these primitives, Zupt cannot protect
you. We rely on the same primitives the broader cryptographic
community has standardized.
---
## Reporting security issues
Email `sac@securityops.co` with the subject `Zupt security report`.
PGP key available on request.
We will:
- Acknowledge receipt within 7 days
- Investigate and publish a CVE / advisory if warranted
- Credit you in the CHANGELOG if you wish
Please don't open public issues for security reports until we've
coordinated disclosure. For non-security bugs (parser edge cases,
documentation typos, performance issues), open a public issue
normally.
---
## Document version
- **v1.0** (sprint 2.4.6): initial threat model. Covers archive
format v1.6.
- Document is part of the source tree (`THREAT_MODEL.md`) and
versioned with the project; this section will be updated as
the format evolves.

136
completions/_vaptvupt Normal file
View file

@ -0,0 +1,136 @@
#compdef vaptvupt zupt
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# Install:
# sudo install -m 644 completions/_zupt /usr/share/zsh/site-functions/_zupt
# or for a single user (anywhere in $fpath):
# cp completions/_zupt ~/.zsh/completion/_zupt
# # then in ~/.zshrc:
# # fpath=(~/.zsh/completion $fpath)
# # autoload -U compinit && compinit
_zupt_levels() {
_values 'compression level' \
'1[fastest, smallest window]' \
'2[fast]' \
'3[balanced (low)]' \
'4[balanced]' \
'5[balanced (high)]' \
'6[high compression]' \
'7[default; high]' \
'8[maximum, 1MB window]' \
'9[maximum, deep search]'
}
_zupt_kdf() {
_values 'KDF' \
'argon2id[memory-hard, default since v2.4.1]' \
'pbkdf2[legacy 600k-iter PBKDF2-SHA256]'
}
_zupt_threads() {
_values 'threads' '0[auto]' '1' '2' '4' '8' '16' '32' '64'
}
_zupt_compress_opts() {
_arguments \
'(-l --level)'{-l,--level}'[compression level]:level:_zupt_levels' \
'(-b --block)'{-b,--block}'[block size in bytes]:size:' \
'(-s --store)'{-s,--store}'[store without compression]' \
'(-f --fast)'{-f,--fast}'[use fast LZ codec]' \
'(--vv --vaptvupt)'{--vv,--vaptvupt}'[use VaptVupt codec]' \
'--lzhp[use Zupt-LZHP codec (LZ77+Huffman, no SIMD)]' \
'(-p --password)'{-p,--password}'[encrypt with password]:password:' \
'--kdf[password KDF]:kdf:_zupt_kdf' \
'(-c --comment)'{-c,--comment}'[embed archive comment]:text:' \
'--comment-file[read comment from file]:file:_files' \
'--pq[legacy PQ encryption]:pubkey:_files' \
'--pq-sdk[PQ encryption via libzuptsdk]:pubkey:_files' \
'(-D --dedup)'{-D,--dedup}'[block-level deduplication]' \
'--solid[solid mode: single stream]' \
'(-v --verbose)'{-v,--verbose}'[verbose output]' \
'(-q --quiet)'{-q,--quiet}'[suppress non-error output]' \
'(-t --threads)'{-t,--threads}'[thread count]:threads:_zupt_threads' \
'*:files:_files'
}
_zupt_extract_opts() {
_arguments \
'(-o --output)'{-o,--output}'[output directory]:directory:_directories' \
'(-p --password)'{-p,--password}'[decryption password]:password:' \
'--pq[legacy PQ decryption]:privkey:_files' \
'--pq-sdk[PQ decryption via libzuptsdk]:privkey:_files' \
'(-v --verbose)'{-v,--verbose}'[verbose output]' \
'(-t --threads)'{-t,--threads}'[thread count]:threads:_zupt_threads' \
'*:archive:_files -g "*.zupt"'
}
_zupt() {
local context curcontext="$curcontext" state line
local -a subcommands
subcommands=(
'compress:create an archive'
'c:create an archive (alias)'
'extract:extract an archive'
'x:extract an archive (alias)'
'list:list archive entries'
'l:list archive entries (alias)'
'test:verify archive integrity'
't:verify archive integrity (alias)'
'info:archive metadata (no key needed)'
'bench:benchmark levels 1-9'
'disk:full-disk backup/restore'
'keygen:generate a key file'
'version:print version info'
'help:print help'
)
_arguments -C \
'(-): :->command' \
'(-)*:: :->args'
case $state in
command)
_describe -t commands 'zupt subcommand' subcommands
;;
args)
case $line[1] in
compress|c)
_zupt_compress_opts
;;
extract|x)
_zupt_extract_opts
;;
list|l|test|t)
_arguments \
'(-p --password)'{-p,--password}'[password]:password:' \
'--pq[legacy PQ privkey]:privkey:_files' \
'--pq-sdk[PQ privkey]:privkey:_files' \
'(-v --verbose)'{-v,--verbose}'[verbose]' \
'*:archive:_files -g "*.zupt"'
;;
info)
_arguments '*:archive:_files -g "*.zupt"'
;;
disk)
_values 'disk action' 'backup' 'restore'
;;
keygen)
_arguments \
'--sdk[generate SDK v2 keypair]' \
'--pq-sdk[same as --sdk]' \
'-o[output keyfile]:file:_files' \
'--pub[export public key from -k]' \
'-k[source private key for --pub]:file:_files'
;;
bench)
_arguments '*:files:_files'
;;
esac
;;
esac
}
_zupt "$@"

160
completions/vaptvupt.bash Normal file
View file

@ -0,0 +1,160 @@
# bash completion for vaptvupt (with `zupt` legacy alias)
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# Install (system-wide):
# sudo install -m 644 completions/vaptvupt.bash /usr/share/bash-completion/completions/vaptvupt
# sudo ln -sf vaptvupt /usr/share/bash-completion/completions/zupt
# or for a single user:
# cp completions/vaptvupt.bash ~/.local/share/bash-completion/completions/vaptvupt
#
# Reload your shell or `source` the file to pick up changes.
_vaptvupt() {
local cur prev words cword
_init_completion -n = 2>/dev/null || {
# _init_completion missing on this host; fall back to manual setup.
local IFS=$' \t\n'
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
cword=$COMP_CWORD
words=("${COMP_WORDS[@]}")
}
local subcommands="compress c extract x list l test t info bench disk keygen version help"
local global_opts="-v --verbose -q --quiet -t --threads -h --help"
# First positional → subcommand
if [ "$cword" -eq 1 ]; then
COMPREPLY=( $(compgen -W "$subcommands" -- "$cur") )
return 0
fi
local subcmd="${words[1]}"
case "$prev" in
-p|--password)
# Don't complete passwords from filesystem
COMPREPLY=()
return 0
;;
-l|--level)
COMPREPLY=( $(compgen -W "1 2 3 4 5 6 7 8 9" -- "$cur") )
return 0
;;
--kdf)
COMPREPLY=( $(compgen -W "argon2id pbkdf2" -- "$cur") )
return 0
;;
-t|--threads)
COMPREPLY=( $(compgen -W "0 1 2 4 8 16 32" -- "$cur") )
return 0
;;
-b|--block)
COMPREPLY=( $(compgen -W "65536 131072 262144 524288 1048576" -- "$cur") )
return 0
;;
-o|--output)
_filedir -d
return 0
;;
--pq|--pq-sdk)
# Key files (no extension constraint)
_filedir
return 0
;;
--comment-file)
_filedir
return 0
;;
-c|--comment)
# Free-form text; no useful completion
COMPREPLY=()
return 0
;;
-k)
_filedir
return 0
;;
esac
case "$subcmd" in
compress|c)
if [[ "$cur" == -* ]]; then
COMPREPLY=( $(compgen -W "
-l --level -b --block -s --store -f --fast
--vv --vaptvupt --lzhp
-p --password --kdf
-c --comment --comment-file
--pq --pq-sdk
--dedup -D --solid
-v --verbose -q --quiet -t --threads
$global_opts
" -- "$cur") )
else
_filedir
fi
;;
extract|x)
if [[ "$cur" == -* ]]; then
COMPREPLY=( $(compgen -W "
-o --output -p --password
--pq --pq-sdk
-v --verbose -t --threads
$global_opts
" -- "$cur") )
else
_filedir 'zupt'
fi
;;
list|l|test|t)
if [[ "$cur" == -* ]]; then
COMPREPLY=( $(compgen -W "
-p --password --pq --pq-sdk
-v --verbose
$global_opts
" -- "$cur") )
else
_filedir 'zupt'
fi
;;
info)
_filedir 'zupt'
;;
disk)
if [ "$cword" -eq 2 ]; then
COMPREPLY=( $(compgen -W "backup restore" -- "$cur") )
elif [[ "$cur" == -* ]]; then
COMPREPLY=( $(compgen -W "
-p --password --pq --pq-sdk
--kdf -c --comment --comment-file
-v --verbose
" -- "$cur") )
else
_filedir
fi
;;
keygen)
if [[ "$cur" == -* ]]; then
COMPREPLY=( $(compgen -W "--sdk --pq-sdk -o --pub -k" -- "$cur") )
else
_filedir
fi
;;
bench)
_filedir
;;
version|help)
COMPREPLY=()
;;
*)
_filedir
;;
esac
return 0
}
complete -F _vaptvupt vaptvupt
# v3.0.0: legacy `zupt` name retained as an alias.
complete -F _vaptvupt zupt

112
completions/vaptvupt.fish Normal file
View file

@ -0,0 +1,112 @@
# Fish completions for zupt
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# Install:
# sudo install -m 644 completions/zupt.fish /usr/share/fish/vendor_completions.d/
# or for a single user:
# cp completions/zupt.fish ~/.config/fish/completions/
# ─── Subcommands ───
complete -c vaptvupt -f -n '__fish_use_subcommand' -a 'compress c' -d 'Create an archive'
complete -c zupt -f -n '__fish_use_subcommand' -a 'compress c' -d 'Create an archive'
complete -c vaptvupt -f -n '__fish_use_subcommand' -a 'extract x' -d 'Extract an archive'
complete -c zupt -f -n '__fish_use_subcommand' -a 'extract x' -d 'Extract an archive'
complete -c vaptvupt -f -n '__fish_use_subcommand' -a 'list l' -d 'List archive entries'
complete -c zupt -f -n '__fish_use_subcommand' -a 'list l' -d 'List archive entries'
complete -c vaptvupt -f -n '__fish_use_subcommand' -a 'test t' -d 'Verify archive integrity'
complete -c zupt -f -n '__fish_use_subcommand' -a 'test t' -d 'Verify archive integrity'
complete -c vaptvupt -f -n '__fish_use_subcommand' -a 'info' -d 'Archive metadata (no key needed)'
complete -c zupt -f -n '__fish_use_subcommand' -a 'info' -d 'Archive metadata (no key needed)'
complete -c vaptvupt -f -n '__fish_use_subcommand' -a 'bench' -d 'Benchmark compression levels'
complete -c zupt -f -n '__fish_use_subcommand' -a 'bench' -d 'Benchmark compression levels'
complete -c vaptvupt -f -n '__fish_use_subcommand' -a 'disk' -d 'Full-disk backup/restore'
complete -c zupt -f -n '__fish_use_subcommand' -a 'disk' -d 'Full-disk backup/restore'
complete -c vaptvupt -f -n '__fish_use_subcommand' -a 'keygen' -d 'Generate a key file'
complete -c zupt -f -n '__fish_use_subcommand' -a 'keygen' -d 'Generate a key file'
complete -c vaptvupt -f -n '__fish_use_subcommand' -a 'version' -d 'Print version info'
complete -c zupt -f -n '__fish_use_subcommand' -a 'version' -d 'Print version info'
complete -c vaptvupt -f -n '__fish_use_subcommand' -a 'help' -d 'Print help'
complete -c zupt -f -n '__fish_use_subcommand' -a 'help' -d 'Print help'
# Helper predicates
function __fish_zupt_using_subcommand
set -l cmd (commandline -opc)
if test (count $cmd) -gt 1
contains -- $cmd[2] $argv
return $status
end
return 1
end
# ─── Compress options ───
set -l compress_cmds compress c
complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -s l -l level -d 'Compression level (1-9, default 7)' -x -a '1 2 3 4 5 6 7 8 9'
complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -s l -l level -d 'Compression level (1-9, default 7)' -x -a '1 2 3 4 5 6 7 8 9'
complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -s b -l block -d 'Block size in bytes' -x
complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -s b -l block -d 'Block size in bytes' -x
complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -s s -l store -d 'Store without compression'
complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -s s -l store -d 'Store without compression'
complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -s f -l fast -d 'Use fast LZ codec'
complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -s f -l fast -d 'Use fast LZ codec'
complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -l vv -l vaptvupt -d 'Use VaptVupt codec'
complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -l vv -l vaptvupt -d 'Use VaptVupt codec'
complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -l lzhp -d 'Use Zupt-LZHP codec (no SIMD needed)'
complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -l lzhp -d 'Use Zupt-LZHP codec (no SIMD needed)'
complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -s p -l password -d 'Encrypt with password (prompted if empty)' -x
complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -s p -l password -d 'Encrypt with password (prompted if empty)' -x
complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -l kdf -d 'Password KDF' -x -a 'argon2id pbkdf2'
complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -l kdf -d 'Password KDF' -x -a 'argon2id pbkdf2'
complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -s c -l comment -d 'Embed archive comment (UTF-8, ≤4096 B)' -x
complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -s c -l comment -d 'Embed archive comment (UTF-8, ≤4096 B)' -x
complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -l comment-file -d 'Read comment from file' -r
complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -l comment-file -d 'Read comment from file' -r
complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -l pq -d 'Legacy PQ public key' -r
complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -l pq -d 'Legacy PQ public key' -r
complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -l pq-sdk -d 'PQ public key (libzuptsdk)' -r
complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -l pq-sdk -d 'PQ public key (libzuptsdk)' -r
complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -s D -l dedup -d 'Block-level deduplication'
complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -s D -l dedup -d 'Block-level deduplication'
complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -l solid -d 'Solid mode (single stream)'
complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -l solid -d 'Solid mode (single stream)'
complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -s v -l verbose -d 'Verbose output'
complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -s v -l verbose -d 'Verbose output'
complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -s q -l quiet -d 'Suppress non-error output'
complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -s q -l quiet -d 'Suppress non-error output'
complete -c vaptvupt -n "__fish_zupt_using_subcommand $compress_cmds" -s t -l threads -d 'Thread count (0=auto)' -x
complete -c zupt -n "__fish_zupt_using_subcommand $compress_cmds" -s t -l threads -d 'Thread count (0=auto)' -x
# ─── Extract / List / Test options ───
set -l rw_cmds extract x list l test t
complete -c vaptvupt -n "__fish_zupt_using_subcommand $rw_cmds" -s o -l output -d 'Output directory' -x -a '(__fish_complete_directories)'
complete -c zupt -n "__fish_zupt_using_subcommand $rw_cmds" -s o -l output -d 'Output directory' -x -a '(__fish_complete_directories)'
complete -c vaptvupt -n "__fish_zupt_using_subcommand $rw_cmds" -s p -l password -d 'Decryption password' -x
complete -c zupt -n "__fish_zupt_using_subcommand $rw_cmds" -s p -l password -d 'Decryption password' -x
complete -c vaptvupt -n "__fish_zupt_using_subcommand $rw_cmds" -l pq -d 'Legacy PQ private key' -r
complete -c zupt -n "__fish_zupt_using_subcommand $rw_cmds" -l pq -d 'Legacy PQ private key' -r
complete -c vaptvupt -n "__fish_zupt_using_subcommand $rw_cmds" -l pq-sdk -d 'PQ private key (libzuptsdk)' -r
complete -c zupt -n "__fish_zupt_using_subcommand $rw_cmds" -l pq-sdk -d 'PQ private key (libzuptsdk)' -r
complete -c vaptvupt -n "__fish_zupt_using_subcommand $rw_cmds" -s v -l verbose -d 'Verbose output (surfaces top-MAC/SDK details on failure)'
complete -c zupt -n "__fish_zupt_using_subcommand $rw_cmds" -s v -l verbose -d 'Verbose output (surfaces top-MAC/SDK details on failure)'
complete -c vaptvupt -n "__fish_zupt_using_subcommand $rw_cmds" -s t -l threads -d 'Thread count' -x
complete -c zupt -n "__fish_zupt_using_subcommand $rw_cmds" -s t -l threads -d 'Thread count' -x
# ─── Disk subcommand ───
complete -c vaptvupt -f -n "__fish_zupt_using_subcommand disk; and not __fish_seen_subcommand_from backup restore" \
complete -c zupt -f -n "__fish_zupt_using_subcommand disk; and not __fish_seen_subcommand_from backup restore" \
-a 'backup' -d 'Read a block device into an archive'
complete -c vaptvupt -f -n "__fish_zupt_using_subcommand disk; and not __fish_seen_subcommand_from backup restore" \
complete -c zupt -f -n "__fish_zupt_using_subcommand disk; and not __fish_seen_subcommand_from backup restore" \
-a 'restore' -d 'Write an archive to a block device'
# ─── Keygen options ───
complete -c vaptvupt -n '__fish_zupt_using_subcommand keygen' -l sdk -d 'Generate SDK v2 keypair'
complete -c zupt -n '__fish_zupt_using_subcommand keygen' -l sdk -d 'Generate SDK v2 keypair'
complete -c vaptvupt -n '__fish_zupt_using_subcommand keygen' -l pq-sdk -d 'Same as --sdk'
complete -c zupt -n '__fish_zupt_using_subcommand keygen' -l pq-sdk -d 'Same as --sdk'
complete -c vaptvupt -n '__fish_zupt_using_subcommand keygen' -s o -d 'Output keyfile path' -r
complete -c zupt -n '__fish_zupt_using_subcommand keygen' -s o -d 'Output keyfile path' -r
complete -c vaptvupt -n '__fish_zupt_using_subcommand keygen' -l pub -d 'Export public key from -k'
complete -c zupt -n '__fish_zupt_using_subcommand keygen' -l pub -d 'Export public key from -k'
complete -c vaptvupt -n '__fish_zupt_using_subcommand keygen' -s k -d 'Source private keyfile' -r
complete -c zupt -n '__fish_zupt_using_subcommand keygen' -s k -d 'Source private keyfile' -r

616
doc/vaptvupt.1 Normal file
View file

@ -0,0 +1,616 @@
.\" Manpage for vaptvupt (formerly zupt; INPI Brasil trademark rename in v3.0.0)
.\" SPDX-License-Identifier: AGPL-3.0-or-later
.\" Copyright (c) 2025-2026 Cristian Cezar Moisés
.TH VAPTVUPT 1 "May 2026" "vaptvupt 4.0.0" "User Commands"
.SH NAME
vaptvupt \- post-quantum backup compression utility (formerly zupt)
.SH SYNOPSIS
.B vaptvupt compress
.RI [ options ]
.I out.zupt
.I files...
.br
.B vaptvupt extract
.RI [ options ]
.I archive.zupt
.br
.B vaptvupt list
.RI [ options ]
.I archive.zupt
.br
.B vaptvupt test
.RI [ options ]
.I archive.zupt
.br
.B vaptvupt info
.I archive.zupt
.br
.B vaptvupt bench
.I files/dirs...
.br
.B vaptvupt disk backup
.RI [ options ]
.I out.zupt
.I device
.br
.B vaptvupt disk restore
.RI [ options ]
.I archive.zupt
.I device
.br
.B vaptvupt keygen
.RI [ options ]
.br
.B vaptvupt version
.br
.B vaptvupt help
.PP
The legacy command name
.B zupt
is preserved as an alias for backward compatibility; both invocations
behave identically.
.SH DESCRIPTION
.B vaptvupt
compresses and encrypts files, directories, and whole block devices
into self-contained, authenticated archives with the
.B .zupt
extension. It targets long-lived backup storage where:
.RS
.IP \(bu 2
the archive is written once and restored under time pressure many years later;
.IP \(bu 2
the encryption envelope must remain secure against a future cryptographically-relevant quantum computer (ML-KEM-768);
.IP \(bu 2
every byte of the archive — header, footer, per-block metadata, comments — is authenticated, and a single bit-flip is rejected at restore time.
.RE
.PP
The compression layer is the
.B VaptVupt LZ + ANS
codec (version 2.53.3), which prioritises decode speed and ratio over
encode speed. Aggregate decode throughput on this build is 1.27\(mu
zstd\-3; encode throughput is 0.2\(mu\(en0.5\(mu zstd\-3 depending on
content. See
.B PERFORMANCE
below.
.PP
The on-disk format is v1.6 and has been wire-compatible since release
v2.3.1. The product was renamed from
.B Zupt
to
.B VaptVupt
in v3.0.0 because of a prior INPI Brasil trademark registration of the
name "Zupt" for unrelated software. The
.B .zupt
file extension and the
.B ZUPT
header magic bytes are unchanged: archives produced by any v2.x release
extract cleanly under v3.0.0 and vice versa.
.SH COMMANDS
.TP
.B compress
Create an archive. Default codec is VaptVupt (level 7). Compression
is multi-threaded; one worker per detected CPU by default.
.TP
.B extract
Decompress an archive into the current directory (or
.BR -o " " \fIdir\fR ).
Refuses to write outside the destination directory (path-traversal
defence). Files are written with their original permissions and
mtime preserved.
.TP
.B list
Print archive metadata: per-file path, size, mtime, mode, compressed
size, codec. With
.B --verbose
also prints per-block sizes and HMAC tags (first 8 bytes).
.TP
.B test
Decompress all blocks in memory and verify HMAC tags + archive
integrity trailer. Does not write any files. Use to validate an
archive without restoring it. Exit code is non-zero on any failure.
.TP
.B info
Print archive header metadata without requiring the decryption key.
Reports: format version, codec, encryption type (none / PBKDF2 /
Argon2id / ML-KEM-768+X25519), KDF iteration count, file count,
creation timestamp, archive UUID, AIT presence. Safe to run on an
untrusted archive.
.TP
.B bench
Compare compression levels 1\(en9 on the supplied files; reports
ratio and encode/decode throughput per level. Useful when picking
the right
.B -l
for a given workload.
.TP
.B disk backup
Read a block device and write a sparse-aware archive. Detects
all-zero regions and records them as runs rather than compressing
them.
.TP
.B disk restore
Inverse of
.BR "disk backup" .
Writes the archive's contents back to a block device. Verifies
target device size before writing; refuses if the target is smaller
than the archived size. With
.B --sync
issues
.BR fsync (2)
after each block.
.TP
.B keygen
Generate a key file for keyfile-mode encryption. With
.B --sdk
generates an ML-KEM-768 + X25519 hybrid keypair suitable for
.B --pq-sdk
mode. With
.B --box
generates a libpqvaptvupt sealed-box keypair (writes \fIfile\fR and
\fIfile\fR.pub) for
.B --pq-box
mode. With
.B --pub
extracts the public key from an existing private key.
.SH GLOBAL OPTIONS
.TP
.BR -v ", " --verbose
Print per-file and per-block details during compress/extract/list/test.
.TP
.BR -q ", " --quiet
Suppress non-error output.
.TP
.BR -j " " \fIN\fR ", " --jobs " " \fIN\fR
Worker thread count for parallel compression. Default: number of
online CPUs.
.SH COMPRESS OPTIONS
.TP
.BR -l " " \fI1..9\fR ", " --level " " \fI1..9\fR
Compression level. 1\(en2 = ultra-fast (~80 MB/s encode on typical
hardware, lower ratio). 3\(en7 = balanced (default 7). 8\(en9 =
extreme (optimal parsing, ~5\(en10\(mu slower encode, best ratio).
.TP
.B --codec \fIid\fR
Force a specific codec by id. Accepted values:
.BR store " (0x0000), "
.BR zupt-lz " (0x0008), "
.BR zupt-lzh " (0x0009), "
.BR zupt-lzhp " (0x000A), "
.BR vaptvupt " (0x0010 — default), "
.BR auto " (0xFFFF — pick at runtime)."
.TP
.BR -p " " \fIpassword\fR
Enable password-based encryption (Argon2id KDF by default since v2.4.1).
Reading the password from a flag exposes it in
.BR ps (1)
output; prefer
.B --pass-file
or interactive prompt.
.TP
.B --pass-file \fIpath\fR
Read password from the first line of the file. The file's permission
bits should be 0600.
.TP
.B --pass-fd \fIN\fR
Read password from file descriptor N.
.TP
.B --kdf \fIalgo\fR
Choose key-derivation function for password mode:
.BR argon2id " (default since v2.4.1; memory-hard) or "
.BR pbkdf2 " (SHA-256, 600 000 iter; needed for compatibility with v2.4.0 and earlier readers)."
.TP
.B --keyfile \fIpath\fR
Use a 32-byte raw key file (generated with
.BR "vaptvupt keygen" ).
.TP
.B --pq-box \fIpub\fR
Enable post-quantum sealed-box encryption via the vendored libpqvaptvupt
(v4.0.0+, envelope type 0x05). ML-KEM-768 + X25519 shared secrets are
combined through HKDF-SHA256 with a domain-separating info string
("pqvv-seal-v1"); the box carries AES-256-CTR + HMAC-SHA256
Encrypt-then-MAC. The
.I pub
argument is the recipient's public-key file from
.B keygen --box
(magic-tagged; public and secret key files are not interchangeable).
On extraction, pass the secret key:
.B --pq-box
\fIpriv\fR.
.TP
.B --pq-sdk \fIpub\fR
Enable post-quantum hybrid encryption. Uses ML-KEM-768 + X25519 with
HKDF combiner, HPKE binding, and key commitment. The
.I pub
argument is the recipient's public-key file generated by
.BR "vaptvupt keygen --sdk" .
.TP
.B -c \fItext\fR ", " --comment " " \fItext\fR
Embed an encrypted UTF-8 comment in the archive (up to 4096 bytes).
The comment is bound to the archive's frame-preface AAD; tampering
is detected at extract time.
.TP
.B --comment-file \fIpath\fR
Read the comment from a file rather than the command line.
.TP
.B -b \fIsize\fR ", " --block-size " " \fIsize\fR
Compression block size. Default 4 MiB. Smaller blocks improve
random-access decode but lose some ratio.
.SH EXTRACT, LIST, TEST OPTIONS
.TP
.BR -o " " \fIdir\fR ", " --output " " \fIdir\fR
Extract into
.IR dir
(created if it doesn't exist). Default: current directory.
.TP
.B --no-mtime
Do not restore archived modification times; use current time instead.
.TP
.B --strip-components \fIN\fR
Strip
.I N
leading path components from each entry, like
.BR tar 's
flag of the same name.
.SH POST-QUANTUM ENCRYPTION
.B vaptvupt
implements a hybrid KEM as specified in FIPS 203 (ML-KEM) combined
with X25519 (RFC 7748). The session key is derived as:
.RS
.nf
ss_pq = ML-KEM-768.decaps(sk_pq, ct_pq)
ss_ec = X25519(sk_ec, pk_ec_peer)
session = HKDF-SHA256(ss_pq || ss_ec,
info = "vaptvupt-pq-sdk-v1",
salt = archive_uuid)
.fi
.RE
The hybrid combiner means the session key is at least as strong as
the strongest of {ML-KEM-768, X25519}: an attacker must break both
to recover the key.
.PP
.B Key commitment:
the ciphertext is bound to the exact public key it was encrypted to
via an HPKE-style derivation. An attacker cannot present a different
public key that decrypts to the same plaintext (this defeats the
"partitioning" attack class).
.PP
.B Implementation notes:
the ML-KEM-768 implementation is vendored from a clean reference and
verified against the FIPS 203 KAT vectors. The X25519 implementation
uses 4\(mu64-bit field arithmetic with Jasmin-verified constant-time
field operations on x86_64. On other architectures the same routines
run in pure C, also constant-time by construction.
.SH SECURITY
.SS Threat model
What
.B vaptvupt
.B protects against:
.RS
.IP \(bu 2
Confidentiality of archived data at rest (AES-256-CTR with HMAC-SHA256 EtM, or AEAD via libzuptsdk on the
.B --pq-sdk
path).
.IP \(bu 2
End-to-end byte-level tamper detection on encrypted archives. The F-09 byte-sweep regression (1827 positions on a representative archive, 2000 trials, every run) shows zero silent-accept positions.
.IP \(bu 2
Wrong-password and tampered-archive indistinguishability at the user-visible message layer (F-11). The default error wording is identical for both cases; only
.B --verbose
prints the distinguishing detail. This closes the "verbal probe-oracle" attack class where the error string leaked which check failed first.
.IP \(bu 2
Post-quantum forward secrecy on archives encrypted with
.B --pq-sdk
(assuming ML-KEM-768 holds against future quantum attack).
.IP \(bu 2
Archive-header and footer authentication via a 32-byte HMAC-SHA256 trailer (F-08). Tampering with the file count, comment offset, or timestamp is detected at archive open time.
.IP \(bu 2
Path-traversal at extract time. Entries with absolute paths or
.B ..
components are refused or stripped.
.RE
What it does
.B NOT
protect against:
.RS
.IP \(bu 2
Compromise of the endpoint that creates or restores the archive. If the host is compromised, the password, key file, or plaintext is accessible.
.IP \(bu 2
Compromise of the key file or password. Key custody is the user's responsibility.
.IP \(bu 2
A weak password. Argon2id with default parameters needs ~256 MiB and ~1 s to derive a key on commodity hardware; a 4-character password is still trivially crackable.
.IP \(bu 2
Metadata leakage. File names, sizes, and modification times are encrypted, but the archive's total size and the count of compressed blocks are visible to an observer.
.IP \(bu 2
Side channels on the compression layer (CRIME/BREACH-style). If the same archive contains both attacker-controlled and secret data and the attacker can observe the compressed size, length-based oracles may be possible.
.IP \(bu 2
Denial-of-service via malformed input on the decoder. The decoder rejects malformed input cleanly (no crashes in the fuzz harness), but a very large compressed input can still consume CPU and memory proportional to its size.
.RE
.SS Cryptographic primitives
.TS
tab(|);
l l.
SHA-256 | FIPS 180-4
SHA-3 / SHAKE | FIPS 202
ML-KEM-768 | FIPS 203
AES-256-CTR | NIST SP 800-38A
HMAC-SHA256 | RFC 2104 / FIPS 198-1
X25519 | RFC 7748
HKDF-SHA256 | RFC 5869
PBKDF2-SHA256 | RFC 8018
Argon2id | RFC 9106
XXH64 | non-cryptographic; used only inside the AEAD envelope
.TE
.SS Constant-time guarantees
All secret-dependent comparisons and table lookups in the cryptographic
core are constant-time. On x86_64 the hot paths (HMAC equality compare,
ML-KEM Fujisaki-Okamoto implicit rejection) are implemented in Jasmin
and assembled with
.BR jasminc (1).
On other architectures the same routines run in portable C; the
constant-time property is preserved by source-level construction.
.SH FILES
.TP
.I ~/.config/vaptvupt/
Per-user configuration directory (reserved; not used in v3.0.0).
.TP
.I /etc/vaptvupt/
System-wide configuration directory (reserved; not used in v3.0.0).
.TP
.I /usr/share/bash-completion/completions/vaptvupt
Bash completion (and the symlinked legacy
.IR /usr/share/bash-completion/completions/zupt ).
.TP
.I /usr/share/zsh/site-functions/_vaptvupt
zsh completion.
.TP
.I /usr/share/fish/vendor_completions.d/vaptvupt.fish
fish completion.
.SH ENVIRONMENT
.TP
.B VAPTVUPT_BIN
Override the binary path used by the GUI front-end. Legacy
.B ZUPT_BIN
is also honoured.
.TP
.B VAPTVUPT_DEBUG
If set to any non-empty value, the GUI front-end prints its binary-discovery log to stderr.
.SH EXIT STATUS
.TP
.B 0
Success.
.TP
.B 1
General error (bad arguments, file not found, etc.).
.TP
.B 2
Authentication failed. Wrong password, wrong key file, or the archive has been tampered with. Use
.B --verbose
to see the distinguishing detail (subject to F-11's threat model: detailed messages may leak which failure cause fired first).
.TP
.B 3
Archive-format error (wrong magic bytes, unsupported format version, corrupted header).
.TP
.B 4
I/O error (disk full, permission denied, network failure).
.TP
.B 5
Compressed-data integrity error (per-block HMAC mismatch detected mid-stream).
.SH PERFORMANCE
Numbers below are from the v3.0.0 release benchmark (May 2026), run
on an Intel Xeon @ 2.8 GHz with the codec compiled with the
distribution's default optimisation level.
.TS
tab(|);
l l l l l.
\fBFixture\fR | \fBTool\fR | \fBRatio\fR | \fBEnc MB/s\fR | \fBDec MB/s\fR
text 8 MB | vv-9 | 34.6% | 5.4 | 219
text 8 MB | gzip-9 | 30.9% | 6.2 | 137
text 8 MB | zstd-3 | 31.6% | 137 | 427
text 8 MB | zstd-19 | 25.5% | 1.6 | 384
source 670 KB | vv-9 | 25.1% | 11.4 | 128
source 670 KB | gzip-9 | 23.2% | 10.0 | 107
source 670 KB | zstd-3 | 24.1% | 91 | 160
binary 2.4 MB | vv-9 | 44.7% | 7.7 | 153
binary 2.4 MB | gzip-9 | 52.0% | 12.6 | 109
binary 2.4 MB | zstd-3 | 77.3% | 164 | 382
binary 2.4 MB | zstd-19 | 48.0% | 5.8 | 229
random 5 MB | vv-9 | 100.0% | 11.1 | 477
random 5 MB | zstd-3 | 100.0% | 397 | 681
.TE
.PP
Honest reading of these numbers:
.RS
.IP \(bu 2
On binary-structured data (game saves, mmap'd structures, struct arrays),
.B vaptvupt
beats zstd-3 by a wide margin on ratio (44.7% vs 77.3%) at the cost of being ~20\(mu slower to encode. For write-once / restore-often workloads this is the right trade.
.IP \(bu 2
On text and source, zstd-19 beats
.B vaptvupt
on ratio. The fundamental codec difference is that zstd's reference encoder has had years of compiler-engineering attention that
.B vaptvupt
has not.
.IP \(bu 2
Encode throughput is
.BR vaptvupt 's
weak axis. If encode latency matters more than ratio, use
.B -l 1
or
.BR -l 2 .
.IP \(bu 2
On random / already-compressed data, all codecs hit the incompressibility wall; comparing encode/decode throughput in that regime is mostly measuring memcpy speed plus framing overhead.
.RE
.SH EXAMPLES
.PP
Compress with default settings (Argon2id password, VaptVupt level 7, multi-threaded):
.RS
.nf
$ vaptvupt compress -p secret backup.zupt ~/Documents
.fi
.RE
Compress with post-quantum hybrid encryption to a published public key:
.RS
.nf
$ vaptvupt keygen --sdk -o ~/.config/vaptvupt-mykey
$ vaptvupt keygen --sdk --pub -o mykey.pub -k ~/.config/vaptvupt-mykey
$ vaptvupt compress --pq-sdk mykey.pub backup.zupt ~/Documents
.fi
.RE
Backup a block device, sparse-aware:
.RS
.nf
$ sudo vaptvupt disk backup -p secret system.zupt /dev/nvme0n1p2
.fi
.RE
Verify an archive without restoring:
.RS
.nf
$ vaptvupt test -p secret backup.zupt
.fi
.RE
Print archive metadata without supplying a password:
.RS
.nf
$ vaptvupt info backup.zupt
.fi
.RE
Compare compression levels:
.RS
.nf
$ vaptvupt bench ~/Downloads/big-dataset.bin
.fi
.RE
Run the GUI from a desktop session where /usr/bin isn't on PATH (the bug fixed in v3.0.0):
.RS
.nf
$ VAPTVUPT_DEBUG=1 vaptvupt-gui 2> /tmp/discovery.log
.fi
.RE
.SH STANDARDS
ISO C11; POSIX.1-2017 for I/O and threading. The cryptographic
primitives implement the specifications listed in
.BR SECURITY
above. The on-disk archive format is documented in
.B FORMAT.md
in the source distribution.
.SH AUTHORS
Cristian Cezar Moisés <zupt@riseup.net> — primary author and maintainer.
.SH BUGS
Report bugs at https://git.securityops.co/cristiancmoises/zupt/issues
or by email to <zupt@riseup.net>.
.SH LICENSE
AGPL-3.0-or-later for the application; GPL-3.0-or-later for the
embedded VaptVupt codec. Dual-licensed: a commercial licence is
available from <sac@securityops.co>.
.SH SEE ALSO
.BR vaptvupt-gui (1),
.BR zstd (1),
.BR gzip (1),
.BR xz (1),
.BR tar (1),
.BR cryptsetup (8),
.BR jasminc (1).
.PP
Project home: https://git.securityops.co/cristiancmoises/zupt
.br
Threat model: see
.B THREAT_MODEL.md
in the source distribution.
.br
Archive format spec: see
.B FORMAT.md
in the source distribution.

View file

@ -1,368 +0,0 @@
.TH ZUPT 1 "2026-05-01" "Zupt 2.2.3" "User Commands"
.SH NAME
zupt \- backup-oriented compression utility with hybrid post-quantum encryption
.SH SYNOPSIS
.B zupt compress
.RI [ OPTIONS ]
.I output.zupt files/dirs...
.br
.B zupt extract
.RI [ OPTIONS ]
.I archive.zupt
.br
.B zupt list
.RI [ OPTIONS ]
.I archive.zupt
.br
.B zupt test
.RI [ OPTIONS ]
.I archive.zupt
.br
.B zupt info
.I archive.zupt
.br
.B zupt bench
.RI [ --compare ]
.I files/dirs...
.br
.B zupt disk
.B backup\fR | \fBrestore
.RI [ OPTIONS ]
.br
.B zupt keygen
.RI [ -o
.IR file ]
.RI [ --pub ]
.RI [ --sdk ]
.RI [ -k
.IR privkey ]
.br
.B zupt version
.br
.B zupt help
.SH DESCRIPTION
.B zupt
is a backup-oriented compression utility with multi-threaded compression,
integrity verification, password-based encryption, and hybrid post-quantum
public-key encryption (ML-KEM-768 + X25519). Two PQ encryption modes are
supported: a legacy combiner kept for backward compatibility, and a
state-of-the-art mode backed by libzuptsdk (HKDF-SHA3 hybrid combiner with
domain separation, key commitment, HPKE binding RFC 9180, anti-fault
decapsulation, and Argon2id RFC 9106 password derivation).
.SH COMMANDS
.TP
.B compress, c
Create a compressed archive from one or more files or directories.
.TP
.B extract, x
Extract files from an archive.
.TP
.B list, l
List archive contents without extracting.
.TP
.B test, t
Verify archive integrity (decompresses without writing files).
.TP
.B info
Show archive metadata; works without password and without keys.
.TP
.B bench
Compare compression levels 1\(en9 on the given input.
.TP
.B disk backup\fR / \fBrestore
Full-disk backup/restore with sparse-region detection, progress
reporting, and proper sync discipline (\fBO_SYNC\fR + \fBfsync\fR + \fBsync\fR).
.TP
.B keygen
Generate or export hybrid PQ keypair. With
.B --sdk
flag, generates a libzuptsdk v2 keypair (private key file plus
.IR file .pub
public key file). Without
.BR --sdk ,
generates a legacy keypair compatible with
.BR --pq .
.SH GLOBAL OPTIONS
.TP
.BR -v ", " --verbose
Verbose per-file output.
.TP
.BR -q ", " --quiet
Suppress non-error output.
.TP
.BR -t ", " --threads " " \fIN\fR
Thread count: 0 = auto, 1 = single, 2\(en64 = explicit.
.SH COMPRESS OPTIONS
.TP
.BR -l ", " --level " " \fI1-9\fR
Compression level. Default 7. 1\(en2 fast/small window;
3\(en5 balanced; 6\(en7 high; 8\(en9 maximum (1MB window, deep search).
.TP
.BR -b ", " --block " " \fISIZE\fR
Block size in bytes. Default 128KB.
.TP
.BR -s ", " --store
Store without compression.
.TP
.BR -f ", " --fast
Use the fast LZ codec (less compression, higher throughput).
.TP
.BR --vv ", " --vaptvupt
Use the VaptVupt codec (LZ77 + tANS entropy, SIMD decode).
.TP
.B --lzhp
Use Zupt-LZHP (LZ77 + Huffman, no SIMD required).
.TP
.BR -p ", " --password " " \fIPW\fR
Encrypt with AES-256. If
.I PW
is empty, prompt the user.
.TP
.BR --pq " " \fIPUBKEY\fR
Encrypt using legacy PQ combiner (XOR + SHA3-512). Kept for
compatibility. New archives should prefer
.BR --pq-sdk .
.TP
.BR --pq-sdk " " \fIPUBKEY\fR
Encrypt using libzuptsdk v2 (recommended): HKDF-SHA3 hybrid combiner
with domain separation, 32-byte key commitment, HPKE binding (RFC 9180),
anti-fault decapsulation, AEAD via XChaCha20-Poly1305, password mode
via Argon2id (RFC 9106). The
.I PUBKEY
file is the
.IR file .pub
produced by
.BR "zupt keygen --sdk" .
.TP
.BR -D ", " --dedup
Block-level deduplication. Identical blocks across files are stored once.
.TP
.B --solid
Solid mode: concatenate files into a single stream before compression.
.SH EXTRACT / LIST / TEST OPTIONS
.TP
.BR -o ", " --output " " \fIDIR\fR
Output directory (extract only). Default: current directory.
.TP
.BR -p ", " --password " " \fIPW\fR
Decryption password.
.TP
.BR --pq " " \fIPRIVKEY\fR
Decrypt a legacy PQ archive.
.TP
.BR --pq-sdk " " \fIPRIVKEY\fR
Decrypt an SDK v2 PQ archive.
.SH KEYGEN OPTIONS
.TP
.BR -o " " \fIFILE\fR
Output keyfile path (required).
.TP
.B --pub
Export public key from an existing private key (used with
.BR -k ).
.TP
.BR -k " " \fIPRIVKEY\fR
Source private keyfile when exporting public key.
.TP
.BR --sdk ", " --pq-sdk
Generate an SDK v2 keypair. Writes
.I FILE
(private key) and
.IR FILE .pub
(public key) in one step. Use these keys with
.BR --pq-sdk .
.SH EXAMPLES
.TP
Compress without encryption:
.B
zupt c backup.zupt ~/Documents/
.TP
Compress with password:
.B
zupt c -l 9 -p 'my-pass' secure.zupt data/
.TP
Generate legacy keypair, encrypt, decrypt:
.nf
zupt keygen -o mykey.key
zupt keygen --pub -o pub.key -k mykey.key
zupt c --pq pub.key backup.zupt ~/Documents/
zupt x --pq mykey.key backup.zupt -o ~/restored/
.fi
.TP
Generate SDK v2 keypair, encrypt, decrypt (recommended):
.nf
zupt keygen --sdk -o mykey.priv
# creates mykey.priv (private) and mykey.priv.pub (public)
zupt c --pq-sdk mykey.priv.pub backup.zupt ~/Documents/
zupt x --pq-sdk mykey.priv backup.zupt
.fi
.TP
Full-disk backup with PQ encryption:
.nf
zupt keygen --sdk -o disk.priv
zupt disk backup --pq-sdk disk.priv.pub /dev/sda backup.img.zupt
.fi
.SH FILES
.TP
.I /usr/bin/zupt
The zupt binary.
.TP
.I /usr/lib/x86_64-linux-gnu/libzuptsdk.so.2
The libzuptsdk shared library (Linux x86_64).
.TP
.I /usr/include/zuptsdk.h
libzuptsdk public C API.
.TP
.I /usr/share/doc/zupt/
Documentation, changelog, audit reports.
.SH ENVIRONMENT
.TP
.B ZUPT_THREADS
Default thread count when
.B -t
is not specified.
.TP
.B ZUPT_TMPDIR
Temporary directory for intermediate files (default:
.IR /tmp ).
.SH EXIT STATUS
.TP
.B 0
Success.
.TP
.B 1
General error (invalid arguments, file not found, etc.).
.TP
.B 2
Authentication failure (wrong password, wrong key, tampered ciphertext).
.TP
.B 3
I/O error.
.TP
.B 4
Archive format error (corrupt, unsupported version, malformed header).
.SH SECURITY
.B zupt 2.2+
recommends
.B --pq-sdk
for new archives. The legacy
.B --pq
mode uses an XOR+SHA3-512 hybrid combiner that has been superseded
by HKDF-SHA3 with domain separation in the SDK path. Both modes
remain supported for archive interoperability.
For password-encrypted archives, prefer the SDK path: it uses
Argon2id (RFC 9106) with OWASP-compliant minimum parameters
(64 MiB memory, 3 iterations, 1 thread), versus PBKDF2-SHA256 in
the legacy path.
.B Path traversal protection.
zupt 2.2.3+ rejects archive entries containing
.IR ".." ,
absolute paths
.RI ( /foo
or
.IR C:\\foo ),
or embedded NUL bytes. On POSIX systems, output files are opened with
.B O_NOFOLLOW
so that pre-existing symlinks at the extraction target are not followed
(defense against TOCTOU attacks where an attacker plants a symlink in the
output directory before extraction). On Windows, this defense relies on
directory ACLs.
.B Operational guidance for untrusted archives.
Always extract into an empty dedicated directory, audit symlinks before
extraction, and never run extraction as root.
.SH BUGS
Report at
.UR https://git.securityops.co/cristiancmoises/zupt/issues
.UE
or by email to
.MT zupt@riseup.net
.ME .
.SH AUTHOR
Cristian Cezar Moisés
.MT zupt@riseup.net
.ME
.SH SEE ALSO
.BR zupt-gui (1),
.BR tar (1),
.BR gzip (1).
.SH STANDARDS
Zupt implements algorithms from FIPS 197 (AES), FIPS 202 (Keccak/SHA-3),
FIPS 203 (ML-KEM), and follows RFC 5297 (AES-SIV), RFC 5869 (HKDF),
RFC 7748 (X25519), RFC 8439 (ChaCha20-Poly1305), RFC 9106 (Argon2),
and RFC 9180 (HPKE). Cryptographic primitive selection is aligned
with Brazilian Instrução Normativa ITI nº 35/2026, which incorporated
ML-KEM-768/1024 into the ICP-Brasil framework.
.SH LICENSE
.PP
Zupt itself (CLI, GUI, libzuptsdk, Jasmin source) is licensed under the
.B GNU Affero General Public License version 3 or later
(AGPL-3.0-or-later). The bundled VaptVupt LZ codec
.RB ( src/vv_*.c ", " src/vaptvupt_api.c )
is licensed under the
.B GNU General Public License version 3 or later
(GPL-3.0-or-later). VaptVupt is GPL not AGPL so that, with sufficient
maturity, it can be considered for upstreaming into the Linux or BSD
kernels.
.PP
Commercial licenses (relief from copyleft terms) are available for both
components. Contact
.MT sac@securityops.co
.ME .
.PP
See
.B /usr/share/doc/zupt/LICENSE
and
.B /usr/share/doc/zupt/THIRD-PARTY-NOTICES.md
for the full text and complete attribution.
.SH PROJECT
.PP
Home page:
.UR https://git.securityops.co/cristiancmoises/zupt
.UE
.PP
Related projects (all by Cristian Cezar Moisés, hosted on
git.securityops.co):
.IP \(bu 2
.B zupt-android
\(em
.UR https://git.securityops.co/cristiancmoises/zupt-android
.UE
.IP \(bu 2
.B zupt-web
\(em
.UR https://git.securityops.co/cristiancmoises/zupt-web
.UE
.IP \(bu 2
.B libzuptsdk
\(em
.UR https://git.securityops.co/cristiancmoises/libzuptsdk
.UE
.IP \(bu 2
.B vaptvupt
(standalone GPL codec) \(em
.UR https://git.securityops.co/cristiancmoises/vaptvupt
.UE

1
doc/zupt.1 Symbolic link
View file

@ -0,0 +1 @@
vaptvupt.1

View file

@ -1,21 +1,43 @@
MIT License
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (c) 2026 Cristian Cezar Moisés
Copyright (C) 2026 Cristian Cezar Moisés <zupt@riseup.net>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
VaptVupt GUI (formerly Zupt GUI; parent application renamed in v3.0.0
due to a prior INPI Brasil trademark registration of "Zupt") is free
software: you can redistribute it and/or modify it under the terms of
the GNU Affero General Public License as published by the Free
Software Foundation, either version 3 of the License, or (at your
option) any later version.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
VaptVupt GUI is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Affero General Public License for more details.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
You should have received a copy of the GNU Affero General Public
License along with this program. If not, see:
https://www.gnu.org/licenses/agpl-3.0.txt
https://www.gnu.org/licenses/agpl-3.0.html
─────────────────────────────────────────────────────────────────────
PRIOR LICENSE NOTE
Earlier copies of this file may have stated "MIT License" — that was
a packaging mistake inherited from a template. The GUI source code's
SPDX-License-Identifier header has always been AGPL-3.0-or-later;
the file-level license here is corrected to match. There is no
historical MIT-licensed release of VaptVupt GUI; do not assume MIT
grant from any prior tarball that contained this file.
─────────────────────────────────────────────────────────────────────
COMMERCIAL LICENSING
The VaptVupt GUI may be commercially relicensed by the author. If
you require relief from copyleft terms (proprietary derivatives,
closed-source bundling, etc.), contact:
sac@securityops.co

View file

@ -1,14 +1,17 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
"""Zupt GUI — Cross-Platform Post-Quantum Backup.
"""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, subprocess, shutil
import sys, os, re, subprocess, shutil
from pathlib import Path
# Try PySide6, fall back to PyQt6. The two have nearly-identical APIs;
@ -36,48 +39,156 @@ except ImportError:
QT_BINDING = "PyQt6"
except ImportError:
sys.stderr.write(
"ERROR: zupt-gui requires PySide6 or PyQt6. Install one of:\n"
"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 zupt binary ──
def _find_zupt():
if os.environ.get("ZUPT_BIN") and os.path.isfile(os.environ["ZUPT_BIN"]):
return os.environ["ZUPT_BIN"]
# Check local project tree FIRST (handles running from zupt-2.1.6/gui/)
here = Path(getattr(sys, '_MEIPASS', Path(__file__).parent))
for c in [here.parent.parent/"zupt", # zupt-2.1.6/gui/src -> zupt-2.1.6/zupt
here.parent/"zupt", # zupt-2.1.6/gui -> zupt-2.1.6/zupt (shouldn't happen but safe)
here/"zupt", # same dir as script
here.parent.parent/"zupt.exe",
here.parent/"zupt.exe",
here/"zupt.exe"]:
if c.is_file() and os.access(str(c), os.X_OK):
return str(c.resolve())
# Then system PATH
found = shutil.which("zupt")
if found: return found
# Fallback common paths
for c in [Path("/usr/local/bin/zupt"), Path("/usr/bin/zupt")]:
if c.is_file(): return str(c)
return "zupt"
# ── 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.
ZUPT = _find_zupt()
_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) ──
def _get_version():
try:
r = subprocess.run([ZUPT, "version"], capture_output=True, text=True, timeout=5)
if r.returncode == 0:
lines = r.stdout.strip().split("\n")
return lines[0], r.stdout.strip()
except Exception: pass
return "zupt (not found)", ""
#
# 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.
ZUPT_VER_SHORT, ZUPT_VER_FULL = _get_version()
_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():
@ -144,7 +255,7 @@ 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"zupt not found: {ZUPT}"
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):
@ -152,7 +263,7 @@ class Worker(QObject):
log = Signal(str)
def __init__(self, args): super().__init__(); self.args = args
def run(self):
self.log.emit(f"$ zupt {' '.join(self.args)}")
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 = []
@ -161,7 +272,7 @@ class Worker(QObject):
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"zupt not found: {ZUPT}")
except FileNotFoundError: self.done.emit(-1, "", f"vaptvupt not found: {VAPTVUPT}")
except subprocess.TimeoutExpired: proc.kill(); self.done.emit(-1, "", "Timed out")
# ── Widgets ──
@ -306,7 +417,7 @@ class KeysTab(QWidget):
def _export(self):
priv = self.exp_priv.path()
pub = self.exp_pub.path()
if not priv: QMessageBox.warning(self, "Zupt", "Select the private key file."); return
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)
@ -331,7 +442,7 @@ class CompressTab(QWidget):
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", "Zupt (*.zupt);;All (*)"); v.addWidget(self.dst)
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)
@ -361,7 +472,7 @@ class CompressTab(QWidget):
def _run(self):
srcs = self.src.paths()
if not srcs or not srcs[0]: QMessageBox.warning(self, "Zupt", "Select files."); return
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"}
@ -384,7 +495,7 @@ class ExtractTab(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="Zupt (*.zupt);;All (*)"); v.addWidget(self.arc)
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)
@ -402,7 +513,7 @@ class ExtractTab(QWidget):
def _run(self):
arc = self.arc.path()
if not arc: QMessageBox.warning(self, "Zupt", "Select an archive."); return
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()]
@ -424,14 +535,14 @@ class VerifyTab(QWidget):
v.addWidget(QLabel("Verify checksums or inspect archive metadata."))
v.addWidget(Sep())
v.addWidget(H("Verify integrity"))
self.varc = PathField("Archive to verify", filters="Zupt (*.zupt);;All (*)"); v.addWidget(self.varc)
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="Zupt (*.zupt);;All (*)"); v.addWidget(self.iarc)
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))
@ -465,7 +576,7 @@ class DiskTab(QWidget):
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", "Zupt (*.zupt);;All (*)"); v.addWidget(self.bout)
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)
@ -474,7 +585,7 @@ class DiskTab(QWidget):
self.blog = Log(100); v.addWidget(self.blog)
v.addWidget(Sep())
v.addWidget(H("Restore — archive"))
self.rarc = PathField("backup.zupt", filters="Zupt (*.zupt);;All (*)"); v.addWidget(self.rarc)
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"))
@ -485,7 +596,7 @@ class DiskTab(QWidget):
def _backup(self):
s, o = self.bsrc.path(), self.bout.path()
if not s or not o: QMessageBox.warning(self, "Zupt", "Set source and output."); return
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()]
@ -493,7 +604,7 @@ class DiskTab(QWidget):
def _restore(self):
a, t = self.rarc.path(), self.rtgt.path()
if not a or not t: QMessageBox.warning(self, "Zupt", "Set archive and target."); return
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"]
@ -506,33 +617,43 @@ class AboutTab(QWidget):
super().__init__()
inner = QWidget()
v = QVBoxLayout(inner); v.setContentsMargins(24,24,24,24); v.setSpacing(4)
# Extract version number from cached string
ver_num = ZUPT_VER_SHORT.replace("zupt ", "").strip() if "zupt " in ZUPT_VER_SHORT else ZUPT_VER_SHORT
for text, style in [
("ZUPT", "color:#00dde0;font-size:10px;font-weight:700;letter-spacing:2px;font-family:monospace;"),
(ver_num, "color:white;font-size:28px;font-weight:800;font-family:monospace;"),
("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, hardware-adaptive codecs, and block dedup.", "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", "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", "color:#5a7a88;font-size:12px;font-family:monospace;"),
("PBKDF2 RFC 8018 Key Derivation", "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;"),
("zupt Cristian Cezar Moises MIT", "color:#5a7a88;font-size:12px;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;"),
("", ""),
("zupt Cristian Cezar Moises AGPL-3.0+", "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", "color:#00dde0;font-size:10px;font-weight:700;letter-spacing:2px;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;"),
("zupt@riseup.net", "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;"),
]:
@ -549,7 +670,7 @@ class AboutTab(QWidget):
class ZuptWindow(QMainWindow):
def __init__(self, compress_files=None, extract_file=None):
super().__init__()
self.setWindowTitle(f"Zupt — {ZUPT_VER_SHORT}")
self.setWindowTitle(f"VaptVupt {ZUPT_VER_NUMBER}")
self.setMinimumSize(720, 500)
self.resize(880, 640)
self.setAcceptDrops(True)
@ -564,12 +685,11 @@ class ZuptWindow(QMainWindow):
# Header
hdr = QFrame(); hdr.setStyleSheet("background:#050a0e;border-bottom:1px solid #1a2a30;")
hl = QHBoxLayout(hdr); hl.setContentsMargins(20,10,20,10)
title = QLabel("ZUPT"); title.setStyleSheet("color:white;font-size:15px;font-weight:800;letter-spacing:3px;")
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()
ver_num = ZUPT_VER_SHORT.replace("zupt ", "v").strip()
vl = QLabel(ver_num); vl.setStyleSheet("color:#3a5868;font-size:10px;font-family:monospace;background:#0a1018;padding:3px 10px;border-radius:4px;border:1px solid #1a2a30;")
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)
@ -588,7 +708,7 @@ class ZuptWindow(QMainWindow):
layout.addWidget(self.tabs)
sb = QStatusBar()
sb.showMessage(f"{ZUPT_VER_SHORT} | {ZUPT}")
sb.showMessage(f"VaptVupt {ZUPT_VER_NUMBER} | {VAPTVUPT}")
self.setStatusBar(sb)
def dragEnterEvent(self, e):
@ -612,7 +732,7 @@ def main():
else: compress_files = args
app = QApplication(sys.argv)
app.setApplicationName("Zupt")
app.setApplicationName("VaptVupt")
if ICON_PATH: app.setWindowIcon(QIcon(ICON_PATH))
app.setStyle("Fusion")
app.setStyleSheet(STYLE)

View file

@ -121,7 +121,9 @@ static inline uint32_t vv_bh_pack(vv_block_type_t t, int last, uint32_t sz) {
typedef struct {
uint32_t magic; /* VV_MAGIC */
uint8_t version; /* Format version (1) */
uint8_t flags; /* bit0: has_checksum, bit1: has_dict */
uint8_t flags; /* bit0: has_checksum, bit1: has_dict,
* bit2: x86 BCJ filter applied,
* bit3: ARM64 BCJ filter applied */
uint8_t mode_hint; /* Compression mode used (informational) */
uint8_t window_log; /* Window size = 1 << window_log */
uint64_t content_size; /* Uncompressed size (0 = unknown) */
@ -195,6 +197,63 @@ typedef struct {
* output must be readable by v2.46.5 or
* older decoders. Default 0 (lit_fmt=4
* enabled, requires v2.47+ decoder). */
int filter_x86; /* 1 = apply the reversible x86 BCJ branch
* filter before compression (header flag
* bit2). Improves x86/x86-64 machine-code
* ratio (~+37% measured); the decoder
* inverts it automatically. Requires a
* v2.53.4+ decoder. Opt-in; default 0. */
int filter_arm64; /* 1 = apply the reversible AArch64 (ARM64) BCJ
* branch filter (BL + ADRP) before
* compression (header flag bit3). Improves
* AArch64 machine-code ratio (~+25%
* measured); the decoder inverts it
* automatically. Requires a v2.54.0+
* decoder. Opt-in; default 0. Mutually
* exclusive with filter_x86 (a file is one
* architecture). */
int filter_auto; /* 1 = sniff the input for an ELF/PE/Mach-O
* header and automatically select the x86
* or ARM64 BCJ filter (or none) to match.
* Has no effect if filter_x86 or
* filter_arm64 is already set, or if no
* executable header is recognised in
* which case output is unchanged. Opt-in;
* default 0. */
uint32_t depth_override;/* 0 = use the mode's default match-finder chain
* depth (fast=4, balanced=24, extreme=256).
* Non-zero overrides it, clamped to
* [1, 4096], trading encode speed for ratio
* along a smooth monotonic curve (measured:
* on dickens, fast depth 18 spans
* 1.785@79 MB/s to 2.067@55 MB/s). Affects
* only the chosen matches, so output stays a
* valid stream any decoder reads; default
* output (0) is byte-identical to prior
* releases. Opt-in; default 0. */
uint32_t accel; /* 0 = off (default; byte-identical). >0 enables
* lz4-style position-skip acceleration: after
* a run of f consecutive no-match positions
* the parser advances by 1 + ((f*accel)>>6)
* instead of 1, skipping hash/insert work on
* unmatchable input. Massively speeds up
* encode on incompressible / already-
* compressed data (measured ~8-9x on
* random/gzip input) for a small ratio cost
* on compressible data (~-0.2% on dickens),
* which is why it is opt-in. Clamped to
* [0, 64]; higher = more aggressive skipping.
* Primarily useful with -m fast. Output stays
* decodable by any decoder. */
int no_rep; /* 1 = disable rep-match probing in the greedy/
* lazy parser. Measured net-positive on ratio
* in fast mode (which has no entropy stage, so
* rep offsets are not cheaper to code) and
* ~10% faster; on binary it can cost a little
* ratio, so it is opt-in. Default 0 keeps rep
* enabled and output byte-identical. Affects
* fast/balanced (the greedy/lazy parser);
* designed for -m fast. */
} vv_options_t;
static inline void vv_default_options(vv_options_t *o) {
@ -204,6 +263,12 @@ static inline void vv_default_options(vv_options_t *o) {
o->verbose = 0;
o->format_v2 = 0;
o->compat_v246_5_decoder = 0;
o->filter_x86 = 0;
o->filter_arm64 = 0;
o->filter_auto = 0;
o->depth_override = 0;
o->accel = 0;
o->no_rep = 0;
}
/* ═══════════════════════════════════════════════════════════════
@ -228,7 +293,7 @@ int64_t vv_decompress(const uint8_t *src, size_t src_len,
* Use when the caller has its own
* integrity protection (e.g. AES-GCM
* wrapping the compressed data, as in
* Zupt backups). On RAW/random-data
* application backups). On RAW/random-data
* inputs where XXH64 dominates decode
* time, this flag delivers a ~2× speedup.
*
@ -250,8 +315,8 @@ size_t vv_compress_bound(size_t src_len);
* MULTI-THREADED COMPRESSION
*
* Compresses large inputs in parallel by splitting into independent
* frames (each a valid .vv frame on its own concatenated output
* is a valid .vv file that vv_decompress handles natively as a
* frames (each a valid VaptVupt frame on its own concatenated output
* is a valid .zupt file that vv_decompress handles natively as a
* multi-frame stream).
*
* Requires the library to be built with VV_ENABLE_THREADS (and

View file

@ -4,7 +4,7 @@
* VaptVupt tANS Entropy Codec (v2: sparse header + 4-way interleaved)
*
* Standalone: define VV_ANS_STANDALONE to use without VaptVupt.
* ZUPT-COMPAT: this header has zero VaptVupt dependencies when standalone.
* EMBED-COMPAT: this header has zero VaptVupt dependencies when standalone.
*
* v0.6 changes:
* - Adaptive sparse/dense header (Item 1): 3× smaller on typical data
@ -28,7 +28,7 @@ extern "C" {
#define VVA_HDR_SINGLE 0x01 /* Single symbol: 0-bit encoding */
#define VVA_HDR_SPARSE 0x02 /* ≤32 active symbols: (sym,freq) pairs */
#define VVA_HDR_DENSE 0x03 /* >32 active symbols: max_sym + freq array */
/* ZUPT-COMPAT: v0.5 legacy format detected by first byte being 0x00-0xFF
/* EMBED-COMPAT: v0.5 legacy format detected by first byte being 0x00-0xFF
* without matching any HDR_* code fall back to old read path. */
#define VVA_HDR_LEGACY 0x00 /* v0.5 format: [max_sym] [2B×(max_sym+1)] */
@ -86,7 +86,7 @@ vva_error_t vva_decode_ctx(const uint8_t *src, size_t src_len,
size_t num_literals, size_t *src_consumed);
/* ═══ Sequence coding (tag 'S', v0.8+) ═══
* ZUPT-COMPAT: available when VV_ANS_STANDALONE is defined.
* EMBED-COMPAT: available when VV_ANS_STANDALONE is defined.
*
* Encodes an LZ token stream using 3 ANS tables: literals, match-length
* codes (36 symbols), and offset codes (24 symbols). Replaces raw varint

57
include/vv_bcj.h Normal file
View file

@ -0,0 +1,57 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
* VaptVupt BCJ branch filters (see src/vv_bcj.c).
*
* Reversible, architecture-specific branch converters that improve the
* compression of machine code by turning relative call targets into an
* absolute form. Each is an exact bijection on arbitrary input, so a file
* filtered with the wrong architecture (or no machine code at all) still
* round-trips byte-for-byte.
*/
#ifndef VV_BCJ_H
#define VV_BCJ_H
#include <stddef.h>
#include <stdint.h>
/*
* x86 / x86-64 BCJ. Converts near CALL (0xE8) and JMP (0xE9) relative
* displacements to/from absolute. encoding != 0 = forward (compress-side),
* 0 = inverse (decode-side). `ip` is the stream offset of byte 0 (use 0 for
* whole-buffer transforms). Returns the prefix length that may have been
* modified. vv_bcj_x86(b,n,0,0) undoes vv_bcj_x86(b,n,0,1).
*/
size_t vv_bcj_x86(uint8_t *data, size_t size, uint32_t ip, int encoding);
/*
* AArch64 (ARM64) BL + ADRP filter. Converts BL (call) 26-bit relative word
* offsets and ADRP (PC-relative page address) 21-bit page offsets to/from an
* absolute form, each modulo its immediate width. Same calling convention as
* vv_bcj_x86. Only BL (opcode 100101) and ADRP (1xx10000) are touched;
* opcode and register bits are preserved, so the transform is an exact
* bijection on arbitrary input. vv_bcj_arm64(b,n,0,0) undoes
* vv_bcj_arm64(b,n,0,1).
*/
size_t vv_bcj_arm64(uint8_t *data, size_t size, uint32_t ip, int encoding);
/* Which branch filter best fits a buffer, by sniffing an executable header. */
typedef enum {
VV_FILTER_NONE = 0,
VV_FILTER_X86 = 1,
VV_FILTER_ARM64 = 2
} vv_filter_kind_t;
/*
* Inspect the first bytes of `data` for an ELF, PE (MZ/PE), or Mach-O header
* and return the BCJ filter that matches its machine type:
* - x86 / x86-64 (and 32-bit x86) -> VV_FILTER_X86
* - AArch64 (ARM64) -> VV_FILTER_ARM64
* - anything else, or no recognised header-> VV_FILTER_NONE
* Fully bounds-checked: safe on truncated or arbitrary input. Detection
* errors are never correctness bugs a missed match just means no filter,
* and a spurious match still round-trips (the filters are bijections), it
* merely may not improve the ratio.
*/
vv_filter_kind_t vv_bcj_detect(const uint8_t *data, size_t size);
#endif /* VV_BCJ_H */

View file

@ -4,7 +4,7 @@
* VaptVupt Canonical Huffman Codec
*
* Standalone header: can be used independently with VV_HUFFMAN_STANDALONE.
* Designed for embedding in Zupt or any other LZ codec.
* Designed for embedding in a host application or any other LZ codec.
*
* API:
* vvh_encode() compress raw literals into Huffman bitstream

View file

@ -30,9 +30,47 @@
#define zupt_mkdir(p) mkdir(p, 0755)
#endif
#define ZUPT_VERSION_STRING "2.2.3"
/* ─── Product identity ─────────────────────────────────────────────
*
* v3.0.0 (INPI Brasil trademark rename):
* - Product name is now "VaptVupt" (was "Zupt"). The earlier name
* conflicted with a software trademark already registered at INPI
* Brasil under "Zupt".
* - File extension stays `.zupt` for archive-format continuity:
* v1.0v2.4.x archives remain readable, the magic bytes
* `\x5A\x55\x50\x54\x1A\x00` ("ZUPT" + sub-version) are unchanged.
* - C identifier prefix stays `zupt_` / `ZUPT_` for ABI continuity
* with libzuptsdk and existing callers. Only user-visible strings
* (binary name, banner, help text, package names) change.
* - The binary is now `vaptvupt`. Distro packages may ship a
* compatibility symlink `zupt -> vaptvupt` for one major version.
*/
#define ZUPT_PRODUCT_NAME "VaptVupt"
#define ZUPT_PRODUCT_NAME_LC "vaptvupt" /* lowercase: binary name */
#define ZUPT_PRODUCT_EXTENSION ".zupt" /* on-disk archive extension (kept stable) */
#define ZUPT_PRODUCT_TAGLINE "Post-quantum backup compression"
#define ZUPT_VERSION_STRING "4.0.0"
/* Vendored codec release (upstream tag) — single source for display strings.
* The codec's own VV_VERSION_* is its internal API version, not the release. */
#define ZUPT_CODEC_RELEASE "2.60.4"
#define ZUPT_FORMAT_MAJOR 1
#define ZUPT_FORMAT_MINOR 4
#define ZUPT_FORMAT_MINOR 6
/* F-08 of v2.3.0: archive-integrity trailer.
*
* v1.5 archives append a 32-byte trailing field AFTER the 32-byte footer.
* Encrypted modes store HMAC-SHA256(mac_key, hdr[0..63] || footer[0..23]).
* Plaintext modes store XXH64(...) in the first 8 bytes, zeros in the rest.
*
* The MAC input excludes footer[24..31] (the "ZEND" magic and footer_version)
* to keep the field stable across format-version transitions. Both bytes are
* structurally validated by read_footer().
*
* Read path falls back to v1.4 layout (no trailer) when the footer magic is
* found at EOF-32 instead of EOF-64. */
#define ZUPT_AIT_SIZE 32
#define ZUPT_AIT_MAC_INPUT_LEN (sizeof(zupt_archive_header_t) + 24)
#define ZUPT_MAGIC_0 0x5A
#define ZUPT_MAGIC_1 0x55
@ -58,21 +96,58 @@
#define ZUPT_FLAG_FORMAT_STABLE (1u << 4) /* v1.0: format frozen */
#define ZUPT_FLAG_DEDUP (1u << 7) /* Block-level deduplication enabled */
#define ZUPT_FLAG_AAD_SEQ (1u << 8) /* MAC binds block_seq as AAD (anti-reorder) */
#define ZUPT_FLAG_AAD_PREFACE (1u << 9) /* v1.6: MAC also binds per-block frame preface (F-09) */
/* Encryption types (stored in encryption header block) */
#define ZUPT_ENC_PBKDF2 0x01 /* Password-based: PBKDF2 → AES-256-CTR + HMAC */
#define ZUPT_ENC_PQ_HYBRID 0x02 /* ML-KEM-768 + X25519 hybrid KEM (legacy XOR+SHA3) */
#define ZUPT_ENC_PQ_SDK_V2 0x03 /* libzuptsdk v2 header: HKDF combiner + commitment + HPKE binding */
#define ZUPT_ENC_PW_ARGON2 0x04 /* Password-based via libzuptsdk: Argon2id + XChaCha20-Poly1305 */
#define ZUPT_ENC_PQ_BOX_V1 0x05 /* libpqvaptvupt sealed box: HKDF-SHA256 domain-separated combiner */
/* Argon2id KDF profile descriptor (v3.4.0).
*
* The 0x04 Argon2id enc-header historically recorded only [type|salt|
* nonce] (33 bytes) and said nothing about the KDF cost parameters,
* unlike the PBKDF2 header which records its iteration count. That made
* an 0x04 archive non-self-describing: if the underlying Argon2id cost
* preset ever changed, old archives could become undecryptable with no
* way for a reader to know which cost produced them.
*
* v3.4.0 appends ONE descriptor byte at offset 33 naming the KDF profile
* that produced the archive. Readers that understand the byte can select
* the matching derivation; the legacy reader (which checks enc_hdr_len
* >= 33 and reads fixed offsets) simply ignores the trailing byte, so
* existing 33-byte archives and new 34-byte archives both decrypt. The
* descriptor is covered by the archive-integrity trailer (F-08), so it
* cannot be stripped or forged without failing authentication.
*
* Profile 0 (implicit, absent byte) == the historical libzuptsdk
* "MODERATE" Argon2id preset reached via zuptsdk_easy_derive_key.
* Profile 1 is the same derivation with the descriptor made explicit so
* future profiles (should the cost change) get distinct IDs. */
#define ZUPT_ARGON2_PROFILE_LEGACY 0x00 /* implicit: pre-3.4.0, no descriptor byte */
#define ZUPT_ARGON2_PROFILE_MODERATE 0x01 /* explicit: libzuptsdk MODERATE preset */
#define ZUPT_ARGON2_HDR_LEN_V1 33 /* [type|salt16|nonce16] */
#define ZUPT_ARGON2_HDR_LEN_V2 34 /* + [profile1] */
/* Block types */
#define ZUPT_BLOCK_DATA 0x00
#define ZUPT_BLOCK_INDEX 0x02
#define ZUPT_BLOCK_ENC_HEADER 0x03
#define ZUPT_BLOCK_DEDUP_REF 0x04 /* Dedup reference: payload = 8B offset of original block */
#define ZUPT_BLOCK_COMMENT 0x05 /* v2.4.3: free-form UTF-8 comment, encrypted same as data blocks */
#define ZUPT_MAX_COMMENT_LEN 4096 /* Maximum comment payload size (bytes). */
/* Block flags */
#define ZUPT_BFLAG_ENCRYPTED (1u << 0)
/* No per-block flag for F-09 — the v1.6 preface-AAD policy is anchored at
* archive level via ZUPT_FLAG_AAD_PREFACE in global_flags. That flag is
* itself MAC-protected by the v1.5 archive-integrity-trailer (F-08), so an
* attacker can't clear it to downgrade. A per-block flag here would be
* unauthenticated until the per-block MAC was checked, creating a chicken-
* and-egg gap. */
/* Codec IDs */
#define ZUPT_CODEC_STORE 0x0000
@ -82,6 +157,20 @@
#define ZUPT_CODEC_VAPTVUPT 0x0010 /* VAPTVUPT: VaptVupt LZ + ANS entropy codec */
#define ZUPT_CODEC_AUTO 0xFFFF /* Auto-detect: VaptVupt if AVX2, else LZHP */
/* SIMD decode over-copy guard (bytes).
*
* The VaptVupt codec's AVX2 decode hot path over-writes up to 32 bytes
* past the logical output end (vaptvupt.h: "Copy exactly n bytes, may
* over-read/write by up to 32 bytes. Caller must ensure sufficient slack
* in destination."). Every decode output buffer is over-allocated by
* this many bytes and the padded capacity is passed to the codec so the
* over-copy lands in owned memory. The reported uncompressed size is
* unchanged; the slack is never part of the output. 64 > 32 leaves
* margin for any future SIMD store-width increase (AVX-512 = 64 B).
* Used by both the single-threaded (zupt_format.c) and parallel
* (zupt_parallel.c) decode paths. */
#define ZUPT_VV_DECODE_SLACK 64
/* Crypto */
#define ZUPT_SALT_SIZE 32
#define ZUPT_NONCE_SIZE 16
@ -147,6 +236,7 @@ typedef struct {
uint32_t iterations;
int active;
uint64_t canary_tail; /* Must equal ZUPT_CANARY */
int use_preface_aad; /* F-09 of v2.3.1: appended after canary so existing field layout is preserved */
} zupt_keyring_t;
/* Check keyring canaries — abort on buffer overflow */
@ -174,9 +264,13 @@ typedef struct {
int verbose, encrypt, quiet, solid, threads;
int pq_mode; /* 1 = post-quantum hybrid KEM mode */
int sdk_mode; /* 1 = use libzuptsdk-backed v3 crypto (HKDF combiner + commitment + HPKE) */
int box_mode; /* 1 = libpqvaptvupt sealed-box mode (ZUPT_ENC_PQ_BOX_V1) */
int dedup; /* 1 = block-level deduplication enabled */
int kdf_legacy_pbkdf2; /* v2.4.1: 1 = force PBKDF2-SHA256 enc-header (compat with v2.4.0 and older readers). Default 0 = Argon2id. */
char password[256];
char keyfile[ZUPT_MAX_PATH]; /* Path to .zupt-key file */
char comment[ZUPT_MAX_COMMENT_LEN]; /* v2.4.3: free-form archive comment, encrypted on write if -e */
int has_comment; /* v2.4.3: 1 = a comment was supplied (write side) or read from archive (read side) */
zupt_keyring_t keyring;
} zupt_options_t;
@ -263,6 +357,12 @@ void zupt_sha256_init(zupt_sha256_ctx *c);
void zupt_sha256_update(zupt_sha256_ctx *c, const uint8_t *d, size_t n);
void zupt_sha256_final(zupt_sha256_ctx *c, uint8_t h[32]);
void zupt_sha256(const uint8_t *d, size_t n, uint8_t h[32]);
/* SHA-NI hardware compression function (x86_64; src/zupt_sha256_shani.c).
* Processes `blocks` full 64-byte blocks, updating state[8] in place.
* Internal: called by zupt_sha256_update() only when zupt_cpu.has_shani. */
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86)
void zupt_sha256_transform_shani(uint32_t state[8], const uint8_t *data, size_t blocks);
#endif
/* ─── AES-256 ─── */
typedef struct { uint32_t rk[60]; } zupt_aes256_ctx;
@ -271,11 +371,50 @@ void zupt_aes256_encrypt_block(const zupt_aes256_ctx *c, const uint8_t in[16], u
/* ─── Crypto ops ─── */
void zupt_hmac_sha256(const uint8_t *key, size_t klen, const uint8_t *data, size_t dlen, uint8_t mac[32]);
/* Incremental HMAC-SHA256 (RFC 2104).
*
* For repeated MACs under the SAME key (the per-block Encrypt-then-MAC
* hot path), this folds the ipad/opad key-prefix compression ONCE in
* _init and lets the caller stream the message via _update avoiding
* both the per-call key-pad recompute and any concat/copy buffer for
* the message segments. Bit-identical output to the one-shot
* zupt_hmac_sha256 (which is itself implemented on top of this). */
typedef struct {
zupt_sha256_ctx inner; /* SHA-256 state seeded with the ipad block */
zupt_sha256_ctx outer; /* SHA-256 state seeded with the opad block */
} zupt_hmac_ctx;
void zupt_hmac_sha256_init(zupt_hmac_ctx *c, const uint8_t *key, size_t klen);
void zupt_hmac_sha256_update(zupt_hmac_ctx *c, const uint8_t *data, size_t dlen);
void zupt_hmac_sha256_final(zupt_hmac_ctx *c, uint8_t mac[32]);
/* Constant-time buffer equality. Returns 1 if equal, 0 otherwise, in
* time dependent only on n (not contents / mismatch position). The single
* audited MAC-tag comparison primitive; timing-verified by the
* dudect-style test in tests/test_ct_timing.c. CT-REQUIRED. */
int zupt_ct_memeq(const void *a, const void *b, size_t n);
void zupt_pbkdf2_sha256(const uint8_t *pw, size_t pwlen, const uint8_t *salt, size_t slen, uint32_t iter, uint8_t *out, size_t olen);
void zupt_aes256_ctr(const uint8_t key[32], const uint8_t nonce[16], const uint8_t *in, uint8_t *out, size_t len);
void zupt_derive_keys(zupt_keyring_t *kr, const char *pw, const uint8_t salt[32], const uint8_t nonce[16], uint32_t iter);
uint8_t *zupt_encrypt_buffer(const zupt_keyring_t *kr, const uint8_t *plain, size_t plen, uint64_t seq, size_t *olen);
uint8_t *zupt_decrypt_buffer(const zupt_keyring_t *kr, const uint8_t *pkg, size_t pkglen, uint64_t seq, size_t *olen);
/* F-09 of v2.3.1: extended-AAD variants. The MAC input becomes
* aad_extra || nonce || ciphertext || aad_seq, which lets the caller bind
* the per-block frame preface (block_type, codec_id, block_flags, sizes,
* checksum) into the per-block HMAC without changing the on-disk payload
* layout. v1.6 archives use these; older archives keep using the original
* functions. */
uint8_t *zupt_encrypt_buffer_aad(const zupt_keyring_t *kr,
const uint8_t *plain, size_t plen,
uint64_t seq,
const uint8_t *aad_extra, size_t aad_extra_len,
size_t *olen);
uint8_t *zupt_decrypt_buffer_aad(const zupt_keyring_t *kr,
const uint8_t *pkg, size_t pkglen,
uint64_t seq,
const uint8_t *aad_extra, size_t aad_extra_len,
size_t *olen);
void zupt_random_bytes(uint8_t *buf, size_t len);
/* ─── Memory locking for key material ─── */
@ -335,6 +474,13 @@ int zupt_sdk_hybrid_encrypt_init(zupt_keyring_t *kr, const char *pubkeyfile,
uint8_t *enc_hdr, size_t *enc_hdr_len);
int zupt_sdk_hybrid_decrypt_init(zupt_keyring_t *kr, const char *privkeyfile,
const uint8_t *enc_hdr, size_t enc_hdr_len);
/* pq-box mode (ZUPT_ENC_PQ_BOX_V1, vendored libpqvaptvupt) */
int zupt_pqbox_keygen(const char *privkeyfile, const char *pubkeyfile);
int zupt_pqbox_encrypt_init(zupt_keyring_t *kr, const char *pubkeyfile,
uint8_t *enc_hdr, size_t *enc_hdr_len);
int zupt_pqbox_decrypt_init(zupt_keyring_t *kr, const char *privkeyfile,
const uint8_t *payload, size_t payload_len);
int zupt_sdk_password_encrypt_init(zupt_keyring_t *kr, const char *password,
uint8_t *enc_hdr, size_t *enc_hdr_len);
int zupt_sdk_password_decrypt_init(zupt_keyring_t *kr, const char *password,

View file

@ -15,14 +15,16 @@ typedef struct {
int has_pclmul; /* CPUID.01H:ECX[1] — CLMUL (carry-less multiply) */
int has_avx2; /* CPUID.07H:EBX[5] — AVX2 (256-bit SIMD) */
int has_sse41; /* CPUID.01H:ECX[19] — SSE4.1 */
int has_shani; /* CPUID.07H:EBX[29] — SHA-NI (SHA-1/SHA-256 ext) */
} zupt_cpu_features_t;
/*@ assigns f->has_aesni, f->has_avx, f->has_pclmul, f->has_avx2, f->has_sse41;
/*@ assigns f->has_aesni, f->has_avx, f->has_pclmul, f->has_avx2, f->has_sse41, f->has_shani;
@ ensures f->has_aesni == 0 || f->has_aesni == 1;
@ ensures f->has_avx == 0 || f->has_avx == 1;
@ ensures f->has_pclmul == 0 || f->has_pclmul == 1;
@ ensures f->has_avx2 == 0 || f->has_avx2 == 1;
@ ensures f->has_sse41 == 0 || f->has_sse41 == 1;
@ ensures f->has_shani == 0 || f->has_shani == 1;
*/
void zupt_detect_cpu(zupt_cpu_features_t *f);

View file

@ -62,4 +62,9 @@ int zupt_mlkem768_decaps(uint8_t ss[MLKEM_SSBYTES],
const uint8_t ct[MLKEM_CIPHERTEXTBYTES],
const uint8_t sk[MLKEM_SECRETKEYBYTES]);
/* Self-test: NTT/iNTT roundtrip + CBD-sampler range invariants.
* Returns 1 on pass, 0 on fail. Called from test_vectors.c case 14
* (F-04, Zupt 2.2.4). */
int zupt_mlkem768_selftest(void);
#endif

64
packaging/aur/PKGBUILD Normal file
View file

@ -0,0 +1,64 @@
# Maintainer: Cristian Cezar Moisés <sac@securityops.co>
#
# AUR submission instructions:
# 1. Run `make dist` upstream to produce zupt-VERSION.tar.gz.
# 2. Upload that tarball somewhere stable (GitHub release / git.securityops.co).
# 3. Update `source=()` URL and `sha256sums=()` below.
# 4. Run `makepkg --printsrcinfo > .SRCINFO` in this directory.
# 5. Commit and push to ssh://aur@aur.archlinux.org/zupt.git
#
# Test locally: `makepkg -s` in this directory after dropping a copy of the
# zupt-VERSION.tar.gz alongside the PKGBUILD.
pkgname=vaptvupt
pkgver=4.0.0
pkgrel=1
provides=('zupt')
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'
license=('AGPL-3.0-or-later')
depends=('glibc')
makedepends=('gcc')
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")
sha256sums=('SKIP')
build() {
cd "${pkgname}-${pkgver}"
# Strict-warning build that the project's own §6 verification matrix uses.
CFLAGS="${CFLAGS:--O2 -std=c11} -Wall -Wextra -Wpedantic" \
make -j"$(nproc)"
}
check() {
cd "${pkgname}-${pkgver}"
# Project regression suite — F-06 HMAC, F-08 top-MAC, F-09 byte sweep, etc.
make test
}
package() {
cd "${pkgname}-${pkgver}"
make DESTDIR="${pkgdir}" PREFIX=/usr 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"
}

View file

@ -1,49 +1,57 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
# Build zupt as AppImage (portable single-file binary).
# Build vaptvupt CLI as AppImage (portable single-file binary).
# Includes a legacy `zupt` symlink so AppDir users can invoke either name.
set -e
cd "$(dirname "$0")/.."
VERSION="${VERSION:-2.2.3}"
VERSION="${VERSION:-3.0.0}"
ARCH="${ARCH:-x86_64}"
NAME="zupt-$VERSION-$ARCH"
PKGNAME="vaptvupt"
LEGACY="zupt"
NAME="$PKGNAME-$VERSION-$ARCH"
OUT="/tmp/${NAME}.AppDir"
rm -rf "$OUT"
mkdir -p "$OUT/usr/bin" "$OUT/usr/lib" "$OUT/usr/share/applications" "$OUT/usr/share/icons/hicolor/256x256/apps"
install -m 755 zupt "$OUT/usr/bin/"
install -m 755 $PKGNAME "$OUT/usr/bin/$PKGNAME"
ln -sf $PKGNAME "$OUT/usr/bin/$LEGACY"
install -m 644 vendor/zuptsdk/libzuptsdk.so.2.0.0 "$OUT/usr/lib/"
ln -sf libzuptsdk.so.2.0.0 "$OUT/usr/lib/libzuptsdk.so.2"
ln -sf libzuptsdk.so.2 "$OUT/usr/lib/libzuptsdk.so"
install -m 644 vendor/pqvaptvupt/libpqvaptvupt.so.0.6.0 "$OUT/usr/lib/"
ln -sf libpqvaptvupt.so.0.6.0 "$OUT/usr/lib/libpqvaptvupt.so.0"
ln -sf libpqvaptvupt.so.0 "$OUT/usr/lib/libpqvaptvupt.so"
cat > "$OUT/AppRun" <<'APPRUN'
cat > "$OUT/AppRun" <<APPRUN
#!/bin/bash
HERE="$(dirname "$(readlink -f "${0}")")"
export LD_LIBRARY_PATH="$HERE/usr/lib:$LD_LIBRARY_PATH"
export PATH="$HERE/usr/bin:$PATH"
exec "$HERE/usr/bin/zupt" "$@"
HERE="\$(dirname "\$(readlink -f "\${0}")")"
export LD_LIBRARY_PATH="\$HERE/usr/lib:\$LD_LIBRARY_PATH"
export PATH="\$HERE/usr/bin:\$PATH"
exec "\$HERE/usr/bin/$PKGNAME" "\$@"
APPRUN
chmod +x "$OUT/AppRun"
cat > "$OUT/zupt.desktop" <<'DESK'
cat > "$OUT/$PKGNAME.desktop" <<DESK
[Desktop Entry]
Name=Zupt
Comment=Post-quantum backup compression utility
Exec=zupt
Name=VaptVupt
Comment=Post-quantum backup compression utility (formerly Zupt)
Exec=$PKGNAME
Terminal=true
Type=Application
Categories=Utility;Archiving;
Icon=zupt
Categories=Utility;Archiving;Security;
Icon=$PKGNAME
DESK
cp "$OUT/zupt.desktop" "$OUT/usr/share/applications/"
cp "$OUT/$PKGNAME.desktop" "$OUT/usr/share/applications/"
printf '\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\xfc\xcf\xc0\x00\x00\x00\x05\x00\x01\xa5\xf6E@\x00\x00\x00\x00IEND\xaeB`\x82' > "$OUT/zupt.png"
cp "$OUT/zupt.png" "$OUT/usr/share/icons/hicolor/256x256/apps/zupt.png"
# 1x1 PNG placeholder — replace with a real icon when the brand asset exists
printf '\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\xfc\xcf\xc0\x00\x00\x00\x05\x00\x01\xa5\xf6E@\x00\x00\x00\x00IEND\xaeB`\x82' > "$OUT/$PKGNAME.png"
cp "$OUT/$PKGNAME.png" "$OUT/usr/share/icons/hicolor/256x256/apps/$PKGNAME.png"
if command -v appimagetool >/dev/null 2>&1; then
ARCH=$ARCH appimagetool "$OUT" "/tmp/${NAME}.AppImage"
ARCH=$ARCH appimagetool "$OUT" "/tmp/${NAME}.AppImage" 2>&1 | tail -5
echo "Built: /tmp/${NAME}.AppImage"
fi
@ -53,4 +61,4 @@ fi
cd /tmp
tar -czf "${NAME}.AppDir.tar.gz" "$(basename "$OUT")"
echo "Built: /tmp/${NAME}.AppDir.tar.gz"
echo "Users can run: tar xzf ${NAME}.AppDir.tar.gz && ./${NAME}.AppDir/AppRun"
echo "Users can run: tar xzf ${NAME}.AppDir.tar.gz && ./${NAME}.AppDir/AppRun version"

View file

@ -1,16 +1,27 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
# Build self-contained zupt CLI .deb package.
# Bundles libzuptsdk.so.2 under /usr/lib/zupt/ so users do NOT need to
# separately install the libzuptsdk package.
#
# Build self-contained vaptvupt CLI .deb package.
#
# v3.0.0 rename: the binary is now `vaptvupt`; we install it at
# /usr/bin/vaptvupt and create /usr/bin/zupt → /usr/bin/vaptvupt as
# a legacy symlink for one major version cycle. The package name
# is `vaptvupt` with Provides/Replaces/Conflicts on `zupt` so
# `apt install zupt` still resolves cleanly.
#
# Bundles libzuptsdk.so.2 under /usr/lib/vaptvupt/ so users do NOT
# need to separately install the libzuptsdk package.
set -e
cd "$(dirname "$0")/.."
VERSION="${VERSION:-2.2.3}"
VERSION="${VERSION:-3.0.0}"
ARCH="${ARCH:-amd64}"
PKGNAME="vaptvupt"
LEGACY="zupt"
PKG="zupt_${VERSION}_${ARCH}"
PKG="${PKGNAME}_${VERSION}_${ARCH}"
ROOT="/tmp/$PKG"
# Vendored libzuptsdk path (relative to project root)
@ -19,141 +30,105 @@ if [ ! -f "$SDK_LIB" ]; then
echo "ERROR: $SDK_LIB not found. Vendor the libzuptsdk shared object first." >&2
exit 1
fi
PQVV_LIB="vendor/pqvaptvupt/libpqvaptvupt.so.0.6.0"
if [ ! -f "$PQVV_LIB" ]; then
echo "ERROR: $PQVV_LIB not found. Vendor the libpqvaptvupt shared object first." >&2
exit 1
fi
# Rebuild zupt fresh, then patch the rpath to point at /usr/lib/zupt
echo "[deb] Building zupt"
echo "[deb] Building vaptvupt"
make clean >/dev/null 2>&1 || true
make -j"$(nproc)" >/dev/null
echo "[deb] Patching rpath -> /usr/lib/zupt:/usr/lib64/zupt"
patchelf --set-rpath '/usr/lib/zupt:/usr/lib64/zupt' zupt
echo "[deb] Patching rpath -> /usr/lib/$PKGNAME:/usr/lib64/$PKGNAME"
patchelf --set-rpath "/usr/lib/$PKGNAME:/usr/lib64/$PKGNAME" $PKGNAME
# Verify rpath was applied
if ! readelf -d zupt | grep -q "RUNPATH.*\[/usr/lib/zupt:/usr/lib64/zupt\]"; then
echo "ERROR: built zupt does not have correct RUNPATH" >&2
readelf -d zupt | grep -E "RPATH|RUNPATH"
if ! readelf -d $PKGNAME | grep -q "RUNPATH.*\[/usr/lib/$PKGNAME:/usr/lib64/$PKGNAME\]"; then
echo "ERROR: built $PKGNAME does not have correct RUNPATH" >&2
readelf -d $PKGNAME | grep -E "RPATH|RUNPATH"
exit 1
fi
rm -rf "$ROOT"
mkdir -p "$ROOT/DEBIAN" \
"$ROOT/usr/bin" \
"$ROOT/usr/lib/zupt" \
"$ROOT/usr/share/doc/zupt" \
"$ROOT/usr/share/man/man1"
"$ROOT/usr/lib/$PKGNAME" \
"$ROOT/usr/share/doc/$PKGNAME" \
"$ROOT/usr/share/man/man1" \
"$ROOT/usr/share/bash-completion/completions" \
"$ROOT/usr/share/zsh/site-functions" \
"$ROOT/usr/share/fish/vendor_completions.d"
# Binary
install -m 755 zupt "$ROOT/usr/bin/zupt"
# Binary + legacy symlink
install -m 755 $PKGNAME "$ROOT/usr/bin/$PKGNAME"
ln -sf $PKGNAME "$ROOT/usr/bin/$LEGACY"
# Bundled libzuptsdk
install -m 755 "$SDK_LIB" "$ROOT/usr/lib/zupt/libzuptsdk.so.2.0.0"
ln -sf libzuptsdk.so.2.0.0 "$ROOT/usr/lib/zupt/libzuptsdk.so.2"
ln -sf libzuptsdk.so.2.0.0 "$ROOT/usr/lib/zupt/libzuptsdk.so"
install -m 755 "$SDK_LIB" "$ROOT/usr/lib/$PKGNAME/libzuptsdk.so.2.0.0"
ln -sf libzuptsdk.so.2.0.0 "$ROOT/usr/lib/$PKGNAME/libzuptsdk.so.2"
ln -sf libzuptsdk.so.2.0.0 "$ROOT/usr/lib/$PKGNAME/libzuptsdk.so"
install -m 755 "$PQVV_LIB" "$ROOT/usr/lib/$PKGNAME/libpqvaptvupt.so.0.6.0"
ln -sf libpqvaptvupt.so.0.6.0 "$ROOT/usr/lib/$PKGNAME/libpqvaptvupt.so.0"
ln -sf libpqvaptvupt.so.0.6.0 "$ROOT/usr/lib/$PKGNAME/libpqvaptvupt.so"
# Manpage (gzip-compressed); install + legacy alias
if [ -f doc/vaptvupt.1 ]; then
gzip -9n -c doc/vaptvupt.1 > "$ROOT/usr/share/man/man1/$PKGNAME.1.gz"
ln -sf $PKGNAME.1.gz "$ROOT/usr/share/man/man1/$LEGACY.1.gz"
fi
# Shell completions
if [ -f completions/vaptvupt.bash ]; then
install -m 0644 completions/vaptvupt.bash "$ROOT/usr/share/bash-completion/completions/$PKGNAME"
ln -sf $PKGNAME "$ROOT/usr/share/bash-completion/completions/$LEGACY"
fi
if [ -f completions/_vaptvupt ]; then
install -m 0644 completions/_vaptvupt "$ROOT/usr/share/zsh/site-functions/_$PKGNAME"
ln -sf _$PKGNAME "$ROOT/usr/share/zsh/site-functions/_$LEGACY"
fi
if [ -f completions/vaptvupt.fish ]; then
install -m 0644 completions/vaptvupt.fish "$ROOT/usr/share/fish/vendor_completions.d/$PKGNAME.fish"
fi
# Docs
install -m 644 README.md CHANGELOG.md SECURITY.md AUDIT.md "$ROOT/usr/share/doc/zupt/"
gzip -9n -c CHANGELOG.md > "$ROOT/usr/share/doc/zupt/changelog.gz"
install -m 0644 README.md "$ROOT/usr/share/doc/$PKGNAME/README.md"
install -m 0644 LICENSE "$ROOT/usr/share/doc/$PKGNAME/copyright"
[ -f SECURITY.md ] && install -m 0644 SECURITY.md "$ROOT/usr/share/doc/$PKGNAME/SECURITY.md"
[ -f CHANGELOG.md ] && install -m 0644 CHANGELOG.md "$ROOT/usr/share/doc/$PKGNAME/CHANGELOG.md"
[ -f THREAT_MODEL.md ] && install -m 0644 THREAT_MODEL.md "$ROOT/usr/share/doc/$PKGNAME/THREAT_MODEL.md"
# Man page
if [ -f doc/zupt.1 ]; then
install -m 644 doc/zupt.1 "$ROOT/usr/share/man/man1/zupt.1"
else
cat > "$ROOT/usr/share/man/man1/zupt.1" <<MAN
.TH ZUPT 1 "May 2026" "zupt $VERSION" "User Commands"
.SH NAME
zupt \\- post-quantum backup compression utility
.SH SYNOPSIS
.B zupt
[\\fIcommand\\fR] [\\fIoptions\\fR] \\fIarchive\\fR [\\fIfiles...\\fR]
.SH SEE ALSO
Run \\fBzupt help\\fR for the full options reference.
MAN
fi
gzip -9n "$ROOT/usr/share/man/man1/zupt.1"
# Copyright
cat > "$ROOT/usr/share/doc/zupt/copyright" <<'COPYRIGHT'
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: zupt
Upstream-Contact: Cristian Cezar Moisés <zupt@riseup.net>
Source: https://git.securityops.co/cristiancmoises/zupt
Files: *
Copyright: 2025-2026 Cristian Cezar Moisés
License: AGPL-3.0+
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
.
On Debian systems, the complete text of the GNU Affero General Public
License version 3 can be found in /usr/share/common-licenses/AGPL-3.
Files: src/vv_*.c src/vaptvupt_api.c include/vv_*.h include/vaptvupt*.h
Copyright: 2025-2026 Cristian Cezar Moisés
License: GPL-3.0+
The VaptVupt LZ codec is licensed under the GNU General Public License
version 3 or later (NOT AGPL like the rest of the project). VaptVupt
is GPL so that, with sufficient maturity, it can be considered for
upstreaming into the Linux or BSD kernels.
.
On Debian systems, the complete text of the GNU General Public
License version 3 can be found in /usr/share/common-licenses/GPL-3.
Files: usr/lib/zupt/libzuptsdk.so.*
Copyright: 2025-2026 Cristian Cezar Moisés
License: AGPL-3.0+
Bundled libzuptsdk shared object is part of the upstream libzuptsdk
project, AGPL-3.0+. Source: https://git.securityops.co/cristiancmoises/libzuptsdk
Comment:
Commercial licenses (relief from AGPL/GPL copyleft terms) are
available for both components. Contact sac@securityops.co for
commercial inquiries.
COPYRIGHT
# Control
INSTALLED_SIZE=$(du -sk "$ROOT" | cut -f1)
# DEBIAN/control
INSTALLED_KB=$(du -sk "$ROOT/usr" | awk '{print $1}')
cat > "$ROOT/DEBIAN/control" <<EOF
Package: zupt
Package: $PKGNAME
Version: $VERSION
Section: utils
Priority: optional
Architecture: $ARCH
Depends: libc6 (>= 2.28), libargon2-1, libssl3
Maintainer: Cristian Cezar Moisés <zupt@riseup.net>
Installed-Size: $INSTALLED_SIZE
Provides: $LEGACY (= $VERSION)
Replaces: $LEGACY (<< 3.0.0)
Conflicts: $LEGACY (<< 3.0.0)
Depends: libargon2-1, libssl3 | libssl3t64
Installed-Size: $INSTALLED_KB
Maintainer: Cristian Cezar Moisés <sac@securityops.co>
Homepage: https://git.securityops.co/cristiancmoises/zupt
Description: Post-quantum backup compression utility
Zupt is a backup-oriented compression utility with hybrid post-quantum
encryption (ML-KEM-768 + X25519). It provides AES-256-CTR + HMAC-SHA256
authenticated encryption, multi-threaded compression, full-disk
backup/restore, block-level deduplication, and embeds the VaptVupt
2.48.2 codec for high-throughput LZ77 + tANS compression with AVX2
and NEON SIMD acceleration. The libzuptsdk shared library is bundled
under /usr/lib/zupt -- no separate package required.
Description: Post-quantum backup compression utility (formerly zupt)
VaptVupt (renamed from Zupt in v3.0.0 due to a prior INPI Brasil
trademark on the name) is a pure-C11 backup compression utility
featuring post-quantum hybrid encryption (ML-KEM-768 + X25519,
FIPS 203), AES-256-CTR + HMAC-SHA256 authenticated encryption,
Argon2id KDF (PBKDF2-SHA256 via --kdf pbkdf2), multi-threaded
compression with the VaptVupt LZ + ANS codec 2.48.5, full-disk
backup with sparse-region detection, and end-to-end byte-level
tamper detection on encrypted archives (F-09: 0/1827 silent
accepts).
.
The .zupt archive extension is unchanged; v2.x and v3.0.0
archives are bidirectionally compatible. The legacy /usr/bin/zupt
symlink is preserved for one major version cycle.
EOF
# Postinst / Postrm: nothing needed; libzuptsdk is found via RPATH
cat > "$ROOT/DEBIAN/postinst" <<'POSTINST'
#!/bin/sh
set -e
exit 0
POSTINST
chmod 755 "$ROOT/DEBIAN/postinst"
cat > "$ROOT/DEBIAN/postrm" <<'POSTRM'
#!/bin/sh
set -e
exit 0
POSTRM
chmod 755 "$ROOT/DEBIAN/postrm"
# Build
dpkg-deb -Zxz --build --root-owner-group "$ROOT" "/tmp/$PKG.deb"
echo ""
echo "Built: /tmp/$PKG.deb ($(du -h /tmp/$PKG.deb | cut -f1))"
dpkg-deb --info "/tmp/$PKG.deb" | head -20
echo ""
echo "Contents:"
dpkg-deb --contents "/tmp/$PKG.deb" | head -20
DEB_OUT="/tmp/${PKGNAME}_${VERSION}_${ARCH}.deb"
dpkg-deb --build --root-owner-group "$ROOT" "$DEB_OUT" >/dev/null
echo "Built: $DEB_OUT ($(du -h "$DEB_OUT" | cut -f1))"
dpkg-deb -I "$DEB_OUT" | sed -n '1,20p'

188
packaging/build-dmg.sh Executable file
View file

@ -0,0 +1,188 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# Build a macOS .dmg installer for the Zupt CLI.
#
# This script MUST be run on macOS — `hdiutil` is required and only
# ships with macOS. There is no portable way to produce a .dmg from
# Linux that Apple's installer will mount cleanly (libdmg-hfsplus and
# dmg2img exist but produce read-only images that some macOS versions
# reject).
#
# On macOS:
# xcode-select --install # one-time, for clang
# make # build the zupt binary
# VERSION=2.4.7 bash packaging/build-dmg.sh
#
# Produces: /tmp/Zupt-VERSION.dmg with:
# - zupt binary (universal2 if built with -arch x86_64 -arch arm64)
# - libzuptsdk dylib alongside the binary at @loader_path
# - install.command (drag-to-install script)
# - README.md, LICENSE
# - Optional: code-signed and notarized if APPLE_DEV_ID env is set
#
# For Homebrew installation, prefer packaging/homebrew/zupt.rb instead.
# The .dmg is for users who don't want to install Homebrew.
set -e
cd "$(dirname "$0")/.."
VERSION="${VERSION:-2.4.7}"
ARCH="${ARCH:-$(uname -m)}" # x86_64 or arm64
NAME="Zupt-${VERSION}-${ARCH}"
STAGE="/tmp/${NAME}.app/Contents"
# ── Platform check ──
if [ "$(uname)" != "Darwin" ]; then
cat >&2 <<EOF
ERROR: build-dmg.sh must be run on macOS.
The .dmg format requires Apple's hdiutil. On Linux:
- Use the .deb (packaging/build-deb.sh) for Debian/Ubuntu/Mint
- Use the .rpm (packaging/build-rpm.sh) for Fedora/RHEL/openSUSE
- Use the AppImage (packaging/build-appimage.sh) for universal Linux
- Use the Homebrew formula on macOS (packaging/homebrew/zupt.rb)
If you need a macOS .pkg without macOS hardware, GitHub Actions has
macos-14 runners that can produce signed .dmg/.pkg artefacts. See
.github/workflows/ci.yml for the matrix template.
EOF
exit 1
fi
# ── Build zupt (universal binary if possible) ──
echo "[dmg] Building zupt"
make clean
if xcrun --sdk macosx clang -dM -E - </dev/null | grep -q __aarch64__; then
# arm64 host → can cross-build for x86_64 via -arch flag
CFLAGS="-O2 -std=c11 -arch arm64 -arch x86_64" \
LDFLAGS="-arch arm64 -arch x86_64" \
make -j"$(sysctl -n hw.ncpu)" || make -j"$(sysctl -n hw.ncpu)"
else
make -j"$(sysctl -n hw.ncpu)"
fi
# ── Stage the .app bundle ──
echo "[dmg] Staging .app bundle"
rm -rf "/tmp/${NAME}.app"
mkdir -p "$STAGE/MacOS" "$STAGE/Resources" "$STAGE/Frameworks"
install -m 755 zupt "$STAGE/MacOS/zupt"
# Vendored libzuptsdk — on macOS it'd be .dylib, but if the vendored
# build is Linux-style .so, ship that and warn. A proper macOS build
# would produce libzuptsdk.2.0.0.dylib.
if [ -f vendor/zuptsdk/libzuptsdk.2.0.0.dylib ]; then
install -m 755 vendor/zuptsdk/libzuptsdk.2.0.0.dylib "$STAGE/Frameworks/"
install_name_tool -id "@loader_path/../Frameworks/libzuptsdk.2.0.0.dylib" \
"$STAGE/Frameworks/libzuptsdk.2.0.0.dylib"
install_name_tool -change "vendor/zuptsdk/libzuptsdk.so.2" \
"@loader_path/../Frameworks/libzuptsdk.2.0.0.dylib" \
"$STAGE/MacOS/zupt"
elif [ -f vendor/zuptsdk/libzuptsdk.so.2.0.0 ]; then
cat >&2 <<EOF
WARNING: vendor/zuptsdk ships .so (Linux), not .dylib (macOS).
The .dmg will include the Linux library which won't load on macOS.
Build libzuptsdk natively on macOS first, or modify the Makefile
to produce .dylib output on Darwin.
EOF
install -m 755 vendor/zuptsdk/libzuptsdk.so.2.0.0 "$STAGE/Frameworks/"
fi
# Info.plist (minimal — zupt is a CLI, so the .app is mostly a wrapper)
cat > "$STAGE/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>co.securityops.zupt</string>
<key>CFBundleName</key>
<string>Zupt</string>
<key>CFBundleDisplayName</key>
<string>Zupt</string>
<key>CFBundleVersion</key>
<string>${VERSION}</string>
<key>CFBundleShortVersionString</key>
<string>${VERSION}</string>
<key>CFBundleExecutable</key>
<string>zupt</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
</dict>
</plist>
PLIST
cp README.md "$STAGE/Resources/" 2>/dev/null || true
cp LICENSE "$STAGE/Resources/" 2>/dev/null || true
# ── Drag-to-install command file ──
cat > "/tmp/${NAME}-install.command" <<'INSTALL'
#!/bin/bash
# Drag-installer for Zupt CLI. Copies the binary to /usr/local/bin
# (or the user's ~/bin if /usr/local isn't writable).
set -e
DIR="$(cd "$(dirname "$0")" && pwd)"
APP="$DIR/Zupt.app"
TARGET="/usr/local/bin"
if [ ! -w "$TARGET" ]; then
TARGET="$HOME/bin"
mkdir -p "$TARGET"
echo "Installing to $TARGET (add to PATH if missing)"
fi
cp "$APP/Contents/MacOS/zupt" "$TARGET/zupt"
chmod 755 "$TARGET/zupt"
# Bundle the dylib alongside under a stable path
LIBDIR="/usr/local/lib/zupt"
[ -w /usr/local/lib ] || LIBDIR="$HOME/lib/zupt"
mkdir -p "$LIBDIR"
if [ -d "$APP/Contents/Frameworks" ]; then
cp -P "$APP/Contents/Frameworks"/* "$LIBDIR/" 2>/dev/null || true
fi
echo "Installed: $TARGET/zupt"
"$TARGET/zupt" version
INSTALL
chmod 755 "/tmp/${NAME}-install.command"
# ── Optional: code sign ──
if [ -n "${APPLE_DEV_ID:-}" ]; then
echo "[dmg] Code-signing with Developer ID: $APPLE_DEV_ID"
codesign --force --options runtime --sign "$APPLE_DEV_ID" \
--entitlements packaging/macos/entitlements.plist \
"$STAGE/MacOS/zupt" 2>&1 || echo " (no entitlements file — proceeding unsigned for hardening)"
codesign --force --sign "$APPLE_DEV_ID" "/tmp/${NAME}.app" || true
fi
# ── Build .dmg ──
echo "[dmg] Building disk image"
DMG="/tmp/${NAME}.dmg"
rm -f "$DMG"
# Stage a directory tree that becomes the .dmg root
DMGSRC="/tmp/${NAME}-dmgsrc"
rm -rf "$DMGSRC"
mkdir -p "$DMGSRC"
cp -R "/tmp/${NAME}.app" "$DMGSRC/Zupt.app"
cp "/tmp/${NAME}-install.command" "$DMGSRC/Install Zupt.command"
[ -f README.md ] && cp README.md "$DMGSRC/"
[ -f LICENSE ] && cp LICENSE "$DMGSRC/"
hdiutil create -fs HFS+ -srcfolder "$DMGSRC" -volname "Zupt ${VERSION}" \
-format UDZO -ov "$DMG"
# ── Optional: notarize ──
if [ -n "${APPLE_DEV_ID:-}" ] && [ -n "${APPLE_NOTARIZE_KEY:-}" ]; then
echo "[dmg] Submitting for notarization"
xcrun notarytool submit "$DMG" --apple-id "$APPLE_DEV_ID" \
--password "$APPLE_NOTARIZE_KEY" --wait
xcrun stapler staple "$DMG"
fi
echo ""
echo "Built: $DMG ($(du -h "$DMG" | cut -f1))"
echo "Users mount and drag 'Zupt.app' or double-click 'Install Zupt.command'."

View file

@ -13,44 +13,44 @@
set -e
cd "$(dirname "$0")/.."
VERSION="${VERSION:-1.1.1}"
APPDIR="/tmp/zupt-gui.AppDir"
VERSION="${VERSION:-1.2.0}"
APPDIR="/tmp/vaptvupt-gui.AppDir"
rm -rf "$APPDIR"
mkdir -p "$APPDIR/usr/bin" \
"$APPDIR/usr/lib/zupt-gui" \
"$APPDIR/usr/lib/vaptvupt-gui" \
"$APPDIR/usr/share/applications" \
"$APPDIR/usr/share/icons/hicolor/256x256/apps"
# Python source
install -m 644 gui/src/zupt_gui.py "$APPDIR/usr/lib/zupt-gui/"
install -m 644 gui/src/zupt_gui.py "$APPDIR/usr/lib/vaptvupt-gui/"
# Wrapper
cat > "$APPDIR/usr/bin/zupt-gui" <<'WRAP'
cat > "$APPDIR/usr/bin/vaptvupt-gui" <<'WRAP'
#!/bin/sh
exec python3 "$(dirname "$0")/../lib/zupt-gui/zupt_gui.py" "$@"
exec python3 "$(dirname "$0")/../lib/vaptvupt-gui/zupt_gui.py" "$@"
WRAP
chmod 755 "$APPDIR/usr/bin/zupt-gui"
chmod 755 "$APPDIR/usr/bin/vaptvupt-gui"
# Desktop file
cat > "$APPDIR/zupt-gui.desktop" <<'DESKTOP'
cat > "$APPDIR/vaptvupt-gui.desktop" <<'DESKTOP'
[Desktop Entry]
Type=Application
Name=Zupt GUI
Name=VaptVupt GUI
GenericName=Backup and Compression Utility
Comment=Post-quantum backup with HKDF combiner, key commitment, HPKE binding
Exec=zupt-gui %f
Icon=zupt-gui
Exec=vaptvupt-gui %f
Icon=vaptvupt-gui
Terminal=false
Categories=Utility;Archiving;Compression;Security;
StartupNotify=true
DESKTOP
cp "$APPDIR/zupt-gui.desktop" "$APPDIR/usr/share/applications/"
cp "$APPDIR/vaptvupt-gui.desktop" "$APPDIR/usr/share/applications/"
# Icon
if [ -f gui/assets/zupt-icon.png ]; then
cp gui/assets/zupt-icon.png "$APPDIR/zupt-gui.png"
cp gui/assets/zupt-icon.png "$APPDIR/usr/share/icons/hicolor/256x256/apps/zupt-gui.png"
cp gui/assets/zupt-icon.png "$APPDIR/vaptvupt-gui.png"
cp gui/assets/zupt-icon.png "$APPDIR/usr/share/icons/hicolor/256x256/apps/vaptvupt-gui.png"
else
python3 -c "
import struct, zlib
@ -60,7 +60,7 @@ def png(w, h, color):
return b'\\x89PNG\\r\\n\\x1a\\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 2, 0, 0, 0)) + chunk(b'IDAT', zlib.compress(raw)) + chunk(b'IEND', b'')
open('$APPDIR/zupt-gui.png','wb').write(png(256, 256, (88, 92, 215)))
"
cp "$APPDIR/zupt-gui.png" "$APPDIR/usr/share/icons/hicolor/256x256/apps/zupt-gui.png"
cp "$APPDIR/vaptvupt-gui.png" "$APPDIR/usr/share/icons/hicolor/256x256/apps/vaptvupt-gui.png"
fi
# AppRun — sets PATH so zupt-gui finds the bundled wrapper, falls
@ -73,7 +73,7 @@ export PATH="$HERE/usr/bin:$PATH"
# Pre-flight check: is python3 available? Is a Qt6 binding installed?
if ! command -v python3 >/dev/null 2>&1; then
cat >&2 <<EOF
zupt-gui: python3 is not installed.
vaptvupt-gui: python3 is not installed.
Install: sudo apt install python3 (Debian/Ubuntu)
sudo dnf install python3 (Fedora/RHEL)
EOF
@ -83,7 +83,7 @@ fi
if ! python3 -c 'import PyQt6.QtWidgets' 2>/dev/null \
&& ! python3 -c 'import PySide6.QtWidgets' 2>/dev/null; then
cat >&2 <<EOF
zupt-gui: needs a Qt6 Python binding (PyQt6 or PySide6).
vaptvupt-gui: needs a Qt6 Python binding (PyQt6 or PySide6).
Install one of:
Debian/Ubuntu: sudo apt install python3-pyqt6
Fedora/RHEL: sudo dnf install python3-pyqt6
@ -92,30 +92,30 @@ EOF
exit 1
fi
if ! command -v zupt >/dev/null 2>&1; then
if ! command -v vaptvupt >/dev/null 2>&1 && ! command -v zupt >/dev/null 2>&1; then
cat >&2 <<EOF
zupt-gui: warning — the 'zupt' CLI was not found in PATH.
Install the zupt package or place the binary in PATH.
vaptvupt-gui: warning — neither 'vaptvupt' nor legacy 'zupt' CLI was found in PATH.
Install the vaptvupt package or place the binary in PATH.
The GUI will start but compress/extract operations will fail.
EOF
fi
exec "$HERE/usr/bin/zupt-gui" "$@"
exec "$HERE/usr/bin/vaptvupt-gui" "$@"
APPRUN
chmod 755 "$APPDIR/AppRun"
# Build AppImage
if command -v appimagetool >/dev/null 2>&1; then
ARCH=x86_64 appimagetool "$APPDIR" "/tmp/Zupt-GUI-$VERSION-x86_64.AppImage" 2>&1 | tail -5
echo "Built: /tmp/Zupt-GUI-$VERSION-x86_64.AppImage"
ARCH=x86_64 appimagetool "$APPDIR" "/tmp/VaptVupt-GUI-$VERSION-x86_64.AppImage" 2>&1 | tail -5
echo "Built: /tmp/VaptVupt-GUI-$VERSION-x86_64.AppImage"
else
cd /tmp
rm -f "Zupt-GUI-$VERSION-x86_64.AppDir.tar.gz"
tar -czf "Zupt-GUI-$VERSION-x86_64.AppDir.tar.gz" zupt-gui.AppDir
rm -f "VaptVupt-GUI-$VERSION-x86_64.AppDir.tar.gz"
tar -czf "VaptVupt-GUI-$VERSION-x86_64.AppDir.tar.gz" vaptvupt-gui.AppDir
cd - >/dev/null
echo "appimagetool unavailable; portable AppDir tarball at:"
echo " /tmp/Zupt-GUI-$VERSION-x86_64.AppDir.tar.gz"
echo "Run via: tar -xzf ... && ./zupt-gui.AppDir/AppRun"
echo " /tmp/VaptVupt-GUI-$VERSION-x86_64.AppDir.tar.gz"
echo "Run via: tar -xzf ... && ./vaptvupt-gui.AppDir/AppRun"
echo "Convert to AppImage on a host with appimagetool:"
echo " ARCH=x86_64 appimagetool zupt-gui.AppDir Zupt-GUI-$VERSION-x86_64.AppImage"
echo " ARCH=x86_64 appimagetool vaptvupt-gui.AppDir VaptVupt-GUI-$VERSION-x86_64.AppImage"
fi

View file

@ -5,39 +5,41 @@
set -e
cd "$(dirname "$0")/.."
VERSION="${VERSION:-1.1.1}"
VERSION="${VERSION:-1.2.0}"
ARCH="all"
PKG="zupt-gui_${VERSION}_${ARCH}"
PKG="vaptvupt-gui_${VERSION}_${ARCH}"
ROOT="/tmp/$PKG"
rm -rf "$ROOT"
mkdir -p "$ROOT/DEBIAN" \
"$ROOT/usr/bin" \
"$ROOT/usr/lib/zupt-gui" \
"$ROOT/usr/lib/vaptvupt-gui" \
"$ROOT/usr/share/applications" \
"$ROOT/usr/share/icons/hicolor/256x256/apps" \
"$ROOT/usr/share/man/man1" \
"$ROOT/usr/share/doc/zupt-gui"
"$ROOT/usr/share/doc/vaptvupt-gui"
# Source files
install -m 644 gui/src/zupt_gui.py "$ROOT/usr/lib/zupt-gui/"
install -m 644 gui/src/zupt_gui.py "$ROOT/usr/lib/vaptvupt-gui/"
# Wrapper script in /usr/bin
cat > "$ROOT/usr/bin/zupt-gui" <<'WRAP'
cat > "$ROOT/usr/bin/vaptvupt-gui" <<'WRAP'
#!/bin/sh
exec python3 /usr/lib/zupt-gui/zupt_gui.py "$@"
exec python3 /usr/lib/vaptvupt-gui/zupt_gui.py "$@"
WRAP
chmod 755 "$ROOT/usr/bin/zupt-gui"
chmod 755 "$ROOT/usr/bin/vaptvupt-gui"
# v3.0.0: legacy zupt-gui symlink
ln -sf vaptvupt-gui "$ROOT/usr/bin/zupt-gui"
# Desktop entry
cat > "$ROOT/usr/share/applications/zupt-gui.desktop" <<'DESKTOP'
cat > "$ROOT/usr/share/applications/vaptvupt-gui.desktop" <<'DESKTOP'
[Desktop Entry]
Type=Application
Name=Zupt GUI
Name=VaptVupt GUI
GenericName=Backup and Compression Utility
Comment=Post-quantum backup with HKDF combiner, key commitment, HPKE binding
Exec=zupt-gui %f
Icon=zupt-gui
Exec=vaptvupt-gui %f
Icon=vaptvupt-gui
Terminal=false
Categories=Utility;Archiving;Compression;Security;
StartupNotify=true
@ -46,14 +48,14 @@ Keywords=archive;compression;encryption;post-quantum;backup;
DESKTOP
# Man page
if [ -f doc/zupt-gui.1 ]; then
install -m 644 doc/zupt-gui.1 "$ROOT/usr/share/man/man1/zupt-gui.1"
gzip -9n "$ROOT/usr/share/man/man1/zupt-gui.1"
if [ -f doc/vaptvupt-gui.1 ]; then
install -m 644 doc/vaptvupt-gui.1 "$ROOT/usr/share/man/man1/vaptvupt-gui.1"
gzip -9n "$ROOT/usr/share/man/man1/vaptvupt-gui.1"
fi
# Icon
if [ -f gui/assets/zupt-icon.png ]; then
cp gui/assets/zupt-icon.png "$ROOT/usr/share/icons/hicolor/256x256/apps/zupt-gui.png"
cp gui/assets/zupt-icon.png "$ROOT/usr/share/icons/hicolor/256x256/apps/vaptvupt-gui.png"
else
python3 -c "
import struct, zlib
@ -66,12 +68,12 @@ open('$ROOT/usr/share/icons/hicolor/256x256/apps/zupt-gui.png','wb').write(png(2
fi
# Docs
install -m 644 gui/README.md "$ROOT/usr/share/doc/zupt-gui/" 2>/dev/null || true
gzip -9n -c CHANGELOG.md > "$ROOT/usr/share/doc/zupt-gui/changelog.gz"
install -m 644 gui/README.md "$ROOT/usr/share/doc/vaptvupt-gui/" 2>/dev/null || true
gzip -9n -c CHANGELOG.md > "$ROOT/usr/share/doc/vaptvupt-gui/changelog.gz"
cat > "$ROOT/usr/share/doc/zupt-gui/copyright" <<'COPYRIGHT'
cat > "$ROOT/usr/share/doc/vaptvupt-gui/copyright" <<'COPYRIGHT'
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: zupt-gui
Upstream-Name: vaptvupt-gui
Upstream-Contact: Cristian Cezar Moisés <zupt@riseup.net>
Source: https://git.securityops.co/cristiancmoises/zupt
@ -90,17 +92,20 @@ COPYRIGHT
# Control
INSTALLED_SIZE=$(du -sk "$ROOT" | cut -f1)
cat > "$ROOT/DEBIAN/control" <<EOF
Package: zupt-gui
Package: vaptvupt-gui
Version: $VERSION
Section: utils
Priority: optional
Architecture: $ARCH
Depends: python3 (>= 3.9), python3-pyqt6 | python3-pyside6, zupt (>= 2.2.3)
Depends: python3 (>= 3.9), python3-pyqt6 | python3-pyside6, vaptvupt (>= 3.0.0) | zupt (>= 2.2.3)
Provides: zupt-gui (= ${VERSION})
Replaces: zupt-gui (<< 1.2.0)
Conflicts: zupt-gui (<< 1.2.0)
Maintainer: Cristian Cezar Moisés <zupt@riseup.net>
Installed-Size: $INSTALLED_SIZE
Homepage: https://git.securityops.co/cristiancmoises/zupt
Description: Graphical interface for the Zupt post-quantum backup utility
PySide6/PyQt6 frontend for Zupt. Supports compression, extraction, key
Description: Graphical interface for VaptVupt post-quantum backup utility
PySide6/PyQt6 frontend for VaptVupt (formerly zupt-gui in 1.x). Supports compression, extraction, key
management, and full disk backup/restore. Exposes both legacy --pq
and new --pq-sdk (libzuptsdk: HKDF combiner, key commitment, HPKE
binding, Argon2id) encryption modes.
@ -125,7 +130,7 @@ if ! python3 -c 'import PyQt6.QtWidgets' 2>/dev/null \
cat << 'MSG'
──────────────────────────────────────────────────────────────────────
zupt-gui installed, but no Qt6 Python binding is available.
vaptvupt-gui installed, but no Qt6 Python binding is available.
Install one of the following to enable the GUI:
@ -134,20 +139,20 @@ Install one of the following to enable the GUI:
Arch/Manjaro: sudo pacman -S python-pyqt6
pip (any distro): pip install --user PySide6
After installing the binding, launch with: zupt-gui
After installing the binding, launch with: vaptvupt-gui
──────────────────────────────────────────────────────────────────────
MSG
fi
# Same friendly warning if zupt CLI not installed.
if ! command -v zupt >/dev/null 2>&1; then
if ! command -v vaptvupt >/dev/null 2>&1 && ! command -v zupt >/dev/null 2>&1; then
cat << 'MSG'
──────────────────────────────────────────────────────────────────────
zupt-gui needs the 'zupt' CLI to function. Install it:
vaptvupt-gui needs the 'vaptvupt' CLI to function. Install it:
Debian/Ubuntu/Mint: sudo dpkg -i zupt_2.2.3_amd64.deb
Debian/Ubuntu/Mint: sudo dpkg -i vaptvupt_3.0.0_amd64.deb
(followed by: sudo apt --fix-broken install)
──────────────────────────────────────────────────────────────────────

View file

@ -5,37 +5,40 @@
set -e
cd "$(dirname "$0")/.."
VERSION="${VERSION:-1.1.1}"
RPMROOT="/tmp/rpmbuild-zupt-gui"
VERSION="${VERSION:-1.2.0}"
RPMROOT="/tmp/rpmbuild-vaptvupt-gui"
rm -rf "$RPMROOT"
mkdir -p "$RPMROOT"/{BUILD,RPMS,SOURCES,SPECS,SRPMS}
TMP="/tmp/zupt-gui-$VERSION"
TMP="/tmp/vaptvupt-gui-$VERSION"
rm -rf "$TMP" && mkdir -p "$TMP/src" "$TMP/doc" "$TMP/assets"
cp gui/src/zupt_gui.py "$TMP/src/"
cp doc/zupt-gui.1 "$TMP/doc/" 2>/dev/null || true
cp doc/vaptvupt-gui.1 "$TMP/doc/" 2>/dev/null || true
cp gui/README.md "$TMP/" 2>/dev/null || true
cp LICENSE "$TMP/" 2>/dev/null || true
[ -f gui/assets/zupt-icon.png ] && cp gui/assets/zupt-icon.png "$TMP/assets/"
tar -czf "$RPMROOT/SOURCES/zupt-gui-$VERSION.tar.gz" -C /tmp "zupt-gui-$VERSION"
tar -czf "$RPMROOT/SOURCES/vaptvupt-gui-$VERSION.tar.gz" -C /tmp "vaptvupt-gui-$VERSION"
cat > "$RPMROOT/SPECS/zupt-gui.spec" <<EOF
Name: zupt-gui
cat > "$RPMROOT/SPECS/vaptvupt-gui.spec" <<EOF
Name: vaptvupt-gui
Version: $VERSION
Release: 1%{?dist}
Summary: Graphical interface for the Zupt post-quantum backup utility
Summary: Graphical interface for VaptVupt post-quantum backup utility (formerly zupt-gui)
License: AGPL-3.0-or-later
URL: https://git.securityops.co/cristiancmoises/zupt
Source0: zupt-gui-%{version}.tar.gz
Source0: vaptvupt-gui-%{version}.tar.gz
BuildArch: noarch
BuildRequires: python3 >= 3.9
Requires: python3 >= 3.9
Requires: (python3-qt6 or python3-pyside6 or python3-pyqt6)
Requires: zupt >= 2.2.3
Requires: (vaptvupt >= 3.0.0 or zupt >= 2.2.3)
Provides: zupt-gui = %{version}-%{release}
Obsoletes: zupt-gui < 1.2.0
Conflicts: zupt-gui < 1.2.0
%description
PySide6/PyQt6 frontend for Zupt. Supports compression, extraction, key
PySide6/PyQt6 frontend for VaptVupt (renamed from zupt-gui in 1.x). Supports compression, extraction, key
management, and full disk backup/restore. Exposes both legacy --pq and
new --pq-sdk (libzuptsdk: HKDF combiner, key commitment, HPKE binding,
Argon2id) encryption modes. Auto-detects whichever Qt6 binding is
@ -49,44 +52,46 @@ installed at startup.
%install
mkdir -p %{buildroot}%{_bindir}
mkdir -p %{buildroot}%{_libdir}/zupt-gui
mkdir -p %{buildroot}%{_libdir}/vaptvupt-gui
mkdir -p %{buildroot}%{_datadir}/applications
mkdir -p %{buildroot}%{_datadir}/icons/hicolor/256x256/apps
mkdir -p %{buildroot}%{_mandir}/man1
install -m 644 src/zupt_gui.py %{buildroot}%{_libdir}/zupt-gui/
install -m 644 src/zupt_gui.py %{buildroot}%{_libdir}/vaptvupt-gui/
cat > %{buildroot}%{_bindir}/zupt-gui <<'WRAP'
cat > %{buildroot}%{_bindir}/vaptvupt-gui <<'WRAP'
#!/bin/sh
exec python3 %{_libdir}/zupt-gui/zupt_gui.py "\$@"
exec python3 %{_libdir}/vaptvupt-gui/zupt_gui.py "\$@"
WRAP
chmod 755 %{buildroot}%{_bindir}/zupt-gui
chmod 755 %{buildroot}%{_bindir}/vaptvupt-gui
# v3.0.0: legacy zupt-gui symlink for one major version cycle
ln -sf vaptvupt-gui %{buildroot}%{_bindir}/zupt-gui
cat > %{buildroot}%{_datadir}/applications/zupt-gui.desktop <<'DESKTOP'
cat > %{buildroot}%{_datadir}/applications/vaptvupt-gui.desktop <<'DESKTOP'
[Desktop Entry]
Type=Application
Name=Zupt GUI
Name=VaptVupt GUI
GenericName=Backup and Compression Utility
Comment=Post-quantum backup with HKDF combiner, key commitment, HPKE binding
Exec=zupt-gui %f
Icon=zupt-gui
Exec=vaptvupt-gui %f
Icon=vaptvupt-gui
Terminal=false
Categories=Utility;Archiving;Compression;Security;
StartupNotify=true
DESKTOP
[ -f doc/zupt-gui.1 ] && install -m 644 doc/zupt-gui.1 %{buildroot}%{_mandir}/man1/
[ -f assets/zupt-icon.png ] && install -m 644 assets/zupt-icon.png %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/zupt-gui.png || true
[ -f doc/vaptvupt-gui.1 ] && install -m 644 doc/vaptvupt-gui.1 %{buildroot}%{_mandir}/man1/
[ -f assets/zupt-icon.png ] && install -m 644 assets/zupt-icon.png %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/vaptvupt-gui.png || true
# Generate placeholder icon if no real one exists
if [ ! -f %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/zupt-gui.png ]; then
if [ ! -f %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/vaptvupt-gui.png ]; then
python3 -c "
import struct, zlib
def png(w, h, color):
raw = b''.join(b'\\0' + bytes(color) * w for _ in range(h))
def chunk(t, d): return struct.pack('>I', len(d)) + t + d + struct.pack('>I', zlib.crc32(t+d) & 0xffffffff)
return b'\\x89PNG\\r\\n\\x1a\\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 2, 0, 0, 0)) + chunk(b'IDAT', zlib.compress(raw)) + chunk(b'IEND', b'')
open('%{buildroot}%{_datadir}/icons/hicolor/256x256/apps/zupt-gui.png','wb').write(png(256, 256, (88, 92, 215)))
open('%{buildroot}%{_datadir}/icons/hicolor/256x256/apps/vaptvupt-gui.png','wb').write(png(256, 256, (88, 92, 215)))
"
fi
@ -108,21 +113,35 @@ fi
%files
%doc README.md
%license LICENSE
%{_bindir}/vaptvupt-gui
%{_bindir}/zupt-gui
%{_libdir}/zupt-gui/zupt_gui.py
%{_datadir}/applications/zupt-gui.desktop
%{_datadir}/icons/hicolor/256x256/apps/zupt-gui.png
%{_mandir}/man1/zupt-gui.1*
%{_libdir}/vaptvupt-gui/zupt_gui.py
%{_datadir}/applications/vaptvupt-gui.desktop
%{_datadir}/icons/hicolor/256x256/apps/vaptvupt-gui.png
%changelog
* Mon Apr 27 2026 Cristian Cezar Moisés <zupt@riseup.net> - $VERSION-1
* Sun May 25 2026 Cristian Cezar Moisés <zupt@riseup.net> - $VERSION-1
- v1.2.0: package renamed zupt-gui → vaptvupt-gui (parent CLI also
renamed; INPI Brasil trademark on "Zupt"). Legacy /usr/bin/zupt-gui
symlink preserved. GUI binary-discovery bug fix: _find_vaptvupt
with liveness check + discovery log via VAPTVUPT_DEBUG=1.
* Mon Apr 27 2026 Cristian Cezar Moisés <zupt@riseup.net> - 1.1.1-1
- Cross-binding (PySide6 OR PyQt6 auto-detected)
- SDK v2 mode toggles in compress/extract/keygen tabs
- Man page added
EOF
if command -v rpmbuild >/dev/null 2>&1; then
rpmbuild --define "_topdir $RPMROOT" -bb "$RPMROOT/SPECS/zupt-gui.spec" 2>&1 | tail -3
# On Debian/Ubuntu, the host's `rpm` doesn't see `python3` as an RPM
# (it's a deb), so the BuildRequires check would fail. Use --nodeps
# since the runtime check on the target system is what actually
# matters. The Requires: lines still apply on install.
rpmbuild --define "_topdir $RPMROOT" --nodeps -bb "$RPMROOT/SPECS/vaptvupt-gui.spec" 2>&1 | tail -3
if [ -f "$RPMROOT/RPMS/noarch/vaptvupt-gui-${VERSION}-1.noarch.rpm" ]; then
cp "$RPMROOT/RPMS/noarch/vaptvupt-gui-${VERSION}-1.noarch.rpm" \
"/tmp/vaptvupt-gui-${VERSION}-1.noarch.rpm"
echo "Built: /tmp/vaptvupt-gui-${VERSION}-1.noarch.rpm"
fi
cp "$RPMROOT/RPMS/noarch/zupt-gui-$VERSION-1."*.rpm /tmp/ 2>/dev/null || true
ls /tmp/zupt-gui-$VERSION-*.rpm 2>/dev/null
else

View file

@ -1,14 +1,20 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
# Build self-contained zupt RPM. Bundles libzuptsdk.so.2 under
# /usr/lib/zupt/ so users do NOT need a separate libzuptsdk package.
#
# Build self-contained vaptvupt RPM (formerly zupt). Bundles
# libzuptsdk.so.2 under /usr/lib/vaptvupt/ so users do NOT need
# a separate libzuptsdk package. Installs a legacy /usr/bin/zupt
# symlink for one major version cycle.
set -e
cd "$(dirname "$0")/.."
VERSION="${VERSION:-2.2.3}"
VERSION="${VERSION:-3.0.0}"
ARCH="${ARCH:-x86_64}"
RELEASE="1"
PKGNAME="vaptvupt"
LEGACY="zupt"
SDK_LIB="vendor/zuptsdk/libzuptsdk.so.2.0.0"
if [ ! -f "$SDK_LIB" ]; then
@ -16,52 +22,54 @@ if [ ! -f "$SDK_LIB" ]; then
exit 1
fi
# Build zupt and patch RPATH to /usr/lib/zupt
echo "[rpm] Building zupt"
echo "[rpm] Building $PKGNAME"
make clean >/dev/null 2>&1 || true
make -j"$(nproc)" >/dev/null
echo "[rpm] Patching rpath -> /usr/lib/zupt:/usr/lib64/zupt"
patchelf --set-rpath '/usr/lib/zupt:/usr/lib64/zupt' zupt
if ! readelf -d zupt | grep -q "RUNPATH.*\[/usr/lib/zupt:/usr/lib64/zupt\]"; then
echo "ERROR: zupt does not have correct RUNPATH" >&2
echo "[rpm] Patching rpath -> /usr/lib/$PKGNAME:/usr/lib64/$PKGNAME"
patchelf --set-rpath "/usr/lib/$PKGNAME:/usr/lib64/$PKGNAME" $PKGNAME
if ! readelf -d $PKGNAME | grep -q "RUNPATH.*\[/usr/lib/$PKGNAME:/usr/lib64/$PKGNAME\]"; then
echo "ERROR: $PKGNAME does not have correct RUNPATH" >&2
exit 1
fi
if ! command -v rpmbuild >/dev/null 2>&1; then
echo "[rpm] rpmbuild not found; falling back to packaging/build-rpm-manual.py"
if [ ! -d "/tmp/zupt_${VERSION}_amd64" ]; then
echo "[rpm] /tmp/zupt_${VERSION}_amd64 missing; running build-deb.sh first"
bash packaging/build-deb.sh >/dev/null
fi
VERSION="$VERSION" python3 packaging/build-rpm-manual.py
exit 0
echo "[rpm] rpmbuild not found; install rpm package to proceed"
exit 1
fi
# Stage the source tarball that the spec's %install will unpack
RPMROOT="/tmp/rpmbuild-zupt"
RPMROOT="/tmp/rpmbuild-$PKGNAME"
rm -rf "$RPMROOT"
mkdir -p "$RPMROOT"/{BUILD,RPMS,SOURCES,SPECS,SRPMS}
STAGE="/tmp/zupt-rpm-stage-${VERSION}"
STAGE="/tmp/$PKGNAME-rpm-stage-${VERSION}"
rm -rf "$STAGE"
mkdir -p "$STAGE/zupt-${VERSION}"
mkdir -p "$STAGE/$PKGNAME-${VERSION}/completions"
cp zupt "$STAGE/zupt-${VERSION}/zupt"
cp "$SDK_LIB" "$STAGE/zupt-${VERSION}/libzuptsdk.so.2.0.0"
cp README.md CHANGELOG.md SECURITY.md AUDIT.md LICENSE "$STAGE/zupt-${VERSION}/"
[ -f doc/zupt.1 ] && cp doc/zupt.1 "$STAGE/zupt-${VERSION}/zupt.1"
tar -czf "$RPMROOT/SOURCES/zupt-${VERSION}.tar.gz" -C "$STAGE" "zupt-${VERSION}"
cp $PKGNAME "$STAGE/$PKGNAME-${VERSION}/$PKGNAME"
cp "$SDK_LIB" "$STAGE/$PKGNAME-${VERSION}/libzuptsdk.so.2.0.0"
cp "vendor/pqvaptvupt/libpqvaptvupt.so.0.6.0" "$STAGE/$PKGNAME-${VERSION}/libpqvaptvupt.so.0.6.0"
cp README.md CHANGELOG.md SECURITY.md AUDIT.md LICENSE "$STAGE/$PKGNAME-${VERSION}/"
[ -f doc/vaptvupt.1 ] && cp doc/vaptvupt.1 "$STAGE/$PKGNAME-${VERSION}/$PKGNAME.1"
[ -f completions/vaptvupt.bash ] && cp completions/vaptvupt.bash "$STAGE/$PKGNAME-${VERSION}/completions/"
[ -f completions/_vaptvupt ] && cp completions/_vaptvupt "$STAGE/$PKGNAME-${VERSION}/completions/"
[ -f completions/vaptvupt.fish ] && cp completions/vaptvupt.fish "$STAGE/$PKGNAME-${VERSION}/completions/"
tar -czf "$RPMROOT/SOURCES/$PKGNAME-${VERSION}.tar.gz" -C "$STAGE" "$PKGNAME-${VERSION}"
cat > "$RPMROOT/SPECS/zupt.spec" <<EOF
Name: zupt
cat > "$RPMROOT/SPECS/$PKGNAME.spec" <<EOF
Name: $PKGNAME
Version: $VERSION
Release: ${RELEASE}%{?dist}
Summary: Post-quantum backup compression utility
Summary: Post-quantum backup compression utility (formerly zupt)
License: AGPL-3.0-or-later AND GPL-3.0-or-later
URL: https://git.securityops.co/cristiancmoises/zupt
Source0: zupt-%{version}.tar.gz
Source0: $PKGNAME-%{version}.tar.gz
# v3.0.0 rename — INPI Brasil trademark on the prior name "Zupt".
# Cleanly supersede legacy 'zupt' RPMs.
Provides: $LEGACY = %{version}-%{release}
Obsoletes: $LEGACY < 3.0.0
Conflicts: $LEGACY < 3.0.0
# libzuptsdk is bundled under /usr/lib/zupt; no external sdk dep needed.
Requires: libargon2
Requires: openssl-libs >= 3.0
AutoReqProv: no
@ -71,13 +79,18 @@ AutoReqProv: no
%global _build_id_links none
%description
Backup-oriented compression utility with hybrid post-quantum encryption
(ML-KEM-768 + X25519). Provides AES-256-CTR + HMAC-SHA256 authenticated
encryption, multi-threaded compression, full-disk backup/restore,
block-level deduplication, and embeds the VaptVupt 2.48.2 codec for
high-throughput LZ77 + tANS compression with AVX2 and NEON SIMD
acceleration. The libzuptsdk shared library is bundled under
/usr/lib/zupt -- no separate package required.
VaptVupt (renamed from Zupt in v3.0.0 due to INPI Brasil trademark
on the prior name) is a backup-oriented compression utility with
hybrid post-quantum encryption (ML-KEM-768 + X25519). Provides
AES-256-CTR + HMAC-SHA256 authenticated encryption, multi-threaded
compression, full-disk backup/restore, block-level deduplication,
and embeds the VaptVupt 2.48.5 LZ + ANS codec with AVX2 and NEON
SIMD acceleration. The libzuptsdk shared library is bundled under
/usr/lib/$PKGNAME -- no separate package required.
The on-disk archive extension is unchanged (.zupt); v2.x and v3.0.0
archives are bidirectionally compatible. The legacy /usr/bin/zupt
symlink is preserved for one major version cycle.
%prep
%setup -q
@ -87,58 +100,96 @@ acceleration. The libzuptsdk shared library is bundled under
%install
mkdir -p %{buildroot}%{_bindir}
mkdir -p %{buildroot}%{_libdir}/zupt
mkdir -p %{buildroot}%{_docdir}/zupt
mkdir -p %{buildroot}%{_licensedir}/zupt
mkdir -p %{buildroot}%{_libdir}/$PKGNAME
mkdir -p %{buildroot}%{_docdir}/$PKGNAME
mkdir -p %{buildroot}%{_licensedir}/$PKGNAME
mkdir -p %{buildroot}%{_mandir}/man1
mkdir -p %{buildroot}%{_datadir}/bash-completion/completions
mkdir -p %{buildroot}%{_datadir}/zsh/site-functions
mkdir -p %{buildroot}%{_datadir}/fish/vendor_completions.d
install -m 755 zupt %{buildroot}%{_bindir}/zupt
install -m 755 libzuptsdk.so.2.0.0 %{buildroot}%{_libdir}/zupt/libzuptsdk.so.2.0.0
ln -sf libzuptsdk.so.2.0.0 %{buildroot}%{_libdir}/zupt/libzuptsdk.so.2
ln -sf libzuptsdk.so.2.0.0 %{buildroot}%{_libdir}/zupt/libzuptsdk.so
install -m 755 $PKGNAME %{buildroot}%{_bindir}/$PKGNAME
ln -sf $PKGNAME %{buildroot}%{_bindir}/$LEGACY
install -m 644 README.md CHANGELOG.md SECURITY.md AUDIT.md %{buildroot}%{_docdir}/zupt/
install -m 644 LICENSE %{buildroot}%{_licensedir}/zupt/
install -m 755 libzuptsdk.so.2.0.0 %{buildroot}%{_libdir}/$PKGNAME/libzuptsdk.so.2.0.0
ln -sf libzuptsdk.so.2.0.0 %{buildroot}%{_libdir}/$PKGNAME/libzuptsdk.so.2
ln -sf libzuptsdk.so.2.0.0 %{buildroot}%{_libdir}/$PKGNAME/libzuptsdk.so
install -m 755 libpqvaptvupt.so.0.6.0 %{buildroot}%{_libdir}/$PKGNAME/libpqvaptvupt.so.0.6.0
ln -sf libpqvaptvupt.so.0.6.0 %{buildroot}%{_libdir}/$PKGNAME/libpqvaptvupt.so.0
ln -sf libpqvaptvupt.so.0.6.0 %{buildroot}%{_libdir}/$PKGNAME/libpqvaptvupt.so
if [ -f zupt.1 ]; then
install -m 644 zupt.1 %{buildroot}%{_mandir}/man1/zupt.1
gzip -9n %{buildroot}%{_mandir}/man1/zupt.1
install -m 644 README.md CHANGELOG.md SECURITY.md AUDIT.md %{buildroot}%{_docdir}/$PKGNAME/
install -m 644 LICENSE %{buildroot}%{_licensedir}/$PKGNAME/
if [ -f $PKGNAME.1 ]; then
install -m 644 $PKGNAME.1 %{buildroot}%{_mandir}/man1/$PKGNAME.1
gzip -9n %{buildroot}%{_mandir}/man1/$PKGNAME.1
ln -sf $PKGNAME.1.gz %{buildroot}%{_mandir}/man1/$LEGACY.1.gz
fi
if [ -f completions/vaptvupt.bash ]; then
install -m 644 completions/vaptvupt.bash %{buildroot}%{_datadir}/bash-completion/completions/$PKGNAME
ln -sf $PKGNAME %{buildroot}%{_datadir}/bash-completion/completions/$LEGACY
fi
if [ -f completions/_vaptvupt ]; then
install -m 644 completions/_vaptvupt %{buildroot}%{_datadir}/zsh/site-functions/_$PKGNAME
ln -sf _$PKGNAME %{buildroot}%{_datadir}/zsh/site-functions/_$LEGACY
fi
if [ -f completions/vaptvupt.fish ]; then
install -m 644 completions/vaptvupt.fish %{buildroot}%{_datadir}/fish/vendor_completions.d/$PKGNAME.fish
fi
%files
%license %{_licensedir}/zupt/LICENSE
%doc %{_docdir}/zupt/README.md
%doc %{_docdir}/zupt/CHANGELOG.md
%doc %{_docdir}/zupt/SECURITY.md
%doc %{_docdir}/zupt/AUDIT.md
%{_bindir}/zupt
%dir %{_libdir}/zupt
%{_libdir}/zupt/libzuptsdk.so
%{_libdir}/zupt/libzuptsdk.so.2
%{_libdir}/zupt/libzuptsdk.so.2.0.0
%{_mandir}/man1/zupt.1.gz
%license %{_licensedir}/$PKGNAME/LICENSE
%doc %{_docdir}/$PKGNAME/README.md
%doc %{_docdir}/$PKGNAME/CHANGELOG.md
%doc %{_docdir}/$PKGNAME/SECURITY.md
%doc %{_docdir}/$PKGNAME/AUDIT.md
%{_bindir}/$PKGNAME
%{_bindir}/$LEGACY
%dir %{_libdir}/$PKGNAME
%{_libdir}/$PKGNAME/libzuptsdk.so
%{_libdir}/$PKGNAME/libzuptsdk.so.2
%{_libdir}/$PKGNAME/libzuptsdk.so.2.0.0
%{_libdir}/$PKGNAME/libpqvaptvupt.so
%{_libdir}/$PKGNAME/libpqvaptvupt.so.0
%{_libdir}/$PKGNAME/libpqvaptvupt.so.0.6.0
%{_mandir}/man1/$PKGNAME.1.gz
%{_mandir}/man1/$LEGACY.1.gz
%{_datadir}/bash-completion/completions/$PKGNAME
%{_datadir}/bash-completion/completions/$LEGACY
%{_datadir}/zsh/site-functions/_$PKGNAME
%{_datadir}/zsh/site-functions/_$LEGACY
%{_datadir}/fish/vendor_completions.d/$PKGNAME.fish
%changelog
* Sat May 02 2026 Cristian Cezar Moises <zupt@riseup.net> - $VERSION-$RELEASE
- VaptVupt 2.48.2 codec integration (cost-aware lazy parser, format_v2
flag, 4-stream Huffman literal coding, encoder memory hygiene).
- Wrapper defaults: checksum=0 (Zupt outer MAC authenticates),
format_v2=1 for BALANCED/EXTREME (defensive guard against the
upstream-untested format_v2 + ULTRA_FAST combination).
- Makefile arch-detection bug fixed (x86-64 / x86_64 mismatch).
- 22/22 regression tests, 14/14 threaded, 10/10 PQ, 11/11 VaptVupt,
13/13 NIST vectors. ASAN clean across plain/password/PQ-SDK at
levels 1, 5, 9.
* Sun May 25 2026 Cristian Cezar Moises <zupt@riseup.net> - $VERSION-$RELEASE
- v3.0.0: Renamed from "Zupt" to "VaptVupt" because of a prior INPI
Brasil trademark on "Zupt". Archive extension .zupt is preserved;
v2.x and v3.0.0 archives are bidirectionally compatible. Legacy
/usr/bin/zupt is installed as a symlink to /usr/bin/vaptvupt.
- Integrated VaptVupt LZ + ANS codec 2.48.5: fixes csz==0 heap-
buffer-overflow READ in vv_dstream_decompress_chunk (libFuzzer-
found, medium severity), UBSan-safe pointer arithmetic in
vv_copy_match.
- Enhanced manpage (597 lines, was 422): POST-QUANTUM ENCRYPTION,
PERFORMANCE table, SECURITY/threat-model, ENVIRONMENT and
EXIT STATUS sections.
- Fixed GUI binary-discovery bug (PATH-missing-/usr/bin scenario);
GUI now does liveness check + logs discovery to stderr with
VAPTVUPT_DEBUG=1.
- 91/91 distro-safe regression suite green; F-09 byte sweep
0/1827 silent accepts; F-06 HMAC fuzz 0/2000 silent accepts.
EOF
rpmbuild --define "_topdir $RPMROOT" \
--define "_binary_payload w2.gzdio" \
-bb "$RPMROOT/SPECS/zupt.spec" 2>&1 | tail -8
-bb "$RPMROOT/SPECS/$PKGNAME.spec" 2>&1 | tail -5
RPM_PATH=$(find "$RPMROOT/RPMS" -name "zupt-${VERSION}-*.rpm" | head -1)
RPM_PATH=$(find "$RPMROOT/RPMS" -name "$PKGNAME-${VERSION}-*.rpm" | head -1)
if [ -n "$RPM_PATH" ]; then
cp "$RPM_PATH" "/tmp/zupt-${VERSION}-${RELEASE}.${ARCH}.rpm"
cp "$RPM_PATH" "/tmp/$PKGNAME-${VERSION}-${RELEASE}.${ARCH}.rpm"
echo ""
echo "Built: /tmp/zupt-${VERSION}-${RELEASE}.${ARCH}.rpm ($(du -h /tmp/zupt-${VERSION}-${RELEASE}.${ARCH}.rpm | cut -f1))"
rpm -qpi "/tmp/zupt-${VERSION}-${RELEASE}.${ARCH}.rpm" 2>&1 | head -15
echo "Built: /tmp/$PKGNAME-${VERSION}-${RELEASE}.${ARCH}.rpm ($(du -h "/tmp/$PKGNAME-${VERSION}-${RELEASE}.${ARCH}.rpm" | cut -f1))"
rpm -qpi "/tmp/$PKGNAME-${VERSION}-${RELEASE}.${ARCH}.rpm" 2>&1 | head -15
fi

353
packaging/debian/changelog Normal file
View file

@ -0,0 +1,353 @@
vaptvupt (4.0.0-1) UNRELEASED; urgency=high
* Codec upgraded to canonical VaptVupt 2.60.4 (security release):
fixes a high-severity OOB heap write in the AVX2 decode fast path
on exact-content_size buffers; brings CBMC-verified BCJ filters
with automatic ELF/PE/Mach-O detection. Ratio gate verified
byte-identical on identical inputs. New regression suite: 80
exact-size decode cases under ASan + BCJ roundtrips.
* F-16 (data loss, pre-existing, fixed): archives created by <= 3.8.0
at -l 8/-l 9 whose inputs included executables may be undecodable
by any version (write-time defect in the old divergent BCJ
encoder). Re-create such archives with 4.0.0 and verify extraction
before deleting sources. Readers <= 3.8.0 cannot open new archives
where the auto-filter fired (L3+ on executables).
* New --pq-box recipient encryption (envelope 0x05) via vendored
libpqvaptvupt 0.6.0: ML-KEM-768 + X25519 combined through
HKDF-SHA256 with domain separation; magic-tagged keypair files;
13/13 adversarial checks; ASan/UBSan clean. keygen --box generates
keypairs. Legacy --pq and --pq-sdk unchanged and re-verified.
* SHA-NI measured on capable silicon: SHA-256 5.8x over scalar
(204 -> 1184 MB/s); the v3.2.0 [ESTIMATED] label is retired.
Encrypted per-block throughput ~2x the 3.8.0-era figure.
* Toolchain: clang strict build restored (Jasmin .s assembled with
as(1)); vendored codec under explicit upstream warning policy;
test-asan link fixed (vv_bcj.c); codec license comment corrected
to GPL-3.0-or-later.
* Wire format v1.6 unchanged; 8-mode back-compat matrix byte-exact.
26 test suites green; NIST/RFC vectors 16/16.
-- Cristian Cezar Moisés <sac@securityops.co> Wed, 10 Jun 2026 12:00:00 +0000
vaptvupt (3.8.0-1) UNRELEASED; urgency=medium
* Documentation-only release. No source, crypto, or wire-format change
(format v1.6); the binary behaves identically to 3.7.0.
* Add BENCHMARKS.md: a consolidated, reproducible, measured benchmark
set with the test machine and method stated for every table —
compression ratio + encode/decode throughput at level 9 across the
5-fixture suite; encode-speed-vs-level trade-off; encryption overhead
separating the one-time KDF (Argon2id ~741 ms, PBKDF2 ~1562 ms on the
test box) from per-block crypto (~147 MB/s) and plain throughput
(~944 MB/s single-threaded); and a head-to-head ratio comparison
against zstd-3/zstd-19 that plainly shows where VaptVupt loses.
* The SHA-NI speedup is explicitly marked [ESTIMATED] because the test
box has no SHA-NI. Previously the only documented benchmarks were
codec-ratio numbers dated v3.1.0; the crypto-path data measured
across 3.2.0-3.7.0 had never been consolidated.
* README benchmark section re-dated v3.1.0 -> v3.8.0 and linked to
BENCHMARKS.md. Test surface unchanged: test_vectors 16/0, F-09
0/1827, F-06 0/2000.
-- Cristian Cezar Moisés <sac@securityops.co> Sun, 01 Jun 2026 17:30:00 +0000
vaptvupt (3.7.0-1) UNRELEASED; urgency=medium
* Route the ML-KEM-768 decapsulation implicit-rejection comparison
through the single audited constant-time primitive zupt_ct_memeq
(introduced in 3.5.0 for the MAC tag compare), replacing an inline
byte-OR loop over the 1088-byte ciphertext. A timing leak in this
comparison is a KEM decapsulation oracle (distinguishing valid from
invalid ciphertexts), which would break IND-CCA2 security; it is now
the same measured-constant-time code path as the MAC compare. This
was the last security-critical comparison still using a bespoke
inline loop.
* ML-KEM output semantics are unchanged: zupt_ct_memeq returns equality
and the implicit-rejection fail bit is derived as (1 - equal), so a
matching ciphertext yields the success shared secret and a mismatched
one yields the pseudorandom rejection key, exactly as before.
Verified by the FIPS 203 roundtrip (5 trials), the implicit-rejection
vector, PQ-hybrid roundtrip, and wrong-key rejection.
* Extend tests/test_ct_timing to cover the 1088-byte comparison and add
a source-routing guard that fails if the decaps compare stops using
zupt_ct_memeq or a raw 1088-byte inline loop reappears. The 1088-byte
dudect numbers are reported as INFORMATIONAL, not pass/fail: at that
size on a shared vCPU the signal is dominated by memory effects and
plain memcmp is no longer a cleanly-leaking control, so the
environment-relative ratio that is meaningful at 32 bytes does not
transfer. Constant-timeness of the 1088-byte compare instead follows
rigorously from the 32-byte pass plus zupt_ct_memeq being
length-independent by construction (OR-accumulate, no early exit, no
data-dependent branch) plus the source-routing guard.
* No cryptographic-correctness change, no wire-format change (v1.6).
test_vectors 16/0; F-09 byte sweep 0/1827; F-06 HMAC fuzz 0/2000.
-- Cristian Cezar Moisés <sac@securityops.co> Sun, 01 Jun 2026 16:30:00 +0000
vaptvupt (3.6.0-1) UNRELEASED; urgency=medium
* Add NIST SP 800-38A AES-256-CTR known-answer vectors (F.5.5 encrypt,
F.5.6 decrypt) to the test_vectors suite. AES is the bulk cipher but
was previously only tested indirectly via roundtrips; it now has a
standards KAT that validates zupt_aes256_ctr on BOTH the Jasmin
AES-NI path (zupt_aes256_ctr4 + zupt_aes256_blk, x86_64) and the C
T-table fallback. Confirms the Jasmin AES is correct against the
standard (closing the stale "stack-offset" concern). userPreferences
list SP 800-38A as a required vector; this closes that gap.
* Fix an inverted result check in the ML-KEM-768 self-test reporting:
zupt_mlkem768_selftest() returns 0 on success / -1 on failure, but
test_vectors checked `if (ok)` and so printed "OK" precisely when the
self-test FAILED (and would have printed FAIL on success). The check
is now `if (rc == 0)`. The test had been passing vacuously.
* Fix the ML-KEM-768 NTT roundtrip self-test itself. It asserted
ntt∘inv_ntt == identity, which is false for this pqcrystals/Kyber
Montgomery convention (forward ntt divides by R without a prior
to-Montgomery map, so the roundtrip recovers each coefficient scaled
by a fixed constant R^-1 mod q). The self-test now verifies the real
invariant — a CONSISTENT linear scaling across all 256 coefficients —
which still catches genuine NTT bugs (wrong zeta/index) while no
longer emitting a misleading "NTT roundtrip FAILED" line on stderr.
ML-KEM correctness end-to-end was never affected: the K-PKE and KEM
roundtrips and the FIPS 203 roundtrip vectors all pass.
* test_vectors now reports 16 passed, 0 failed (was 14, one vacuous).
No source-crypto behaviour change, no wire-format change (v1.6).
F-09 byte sweep 0/1827, F-06 HMAC fuzz 0/2000.
-- Cristian Cezar Moisés <sac@securityops.co> Sun, 01 Jun 2026 15:30:00 +0000
vaptvupt (3.5.0-1) UNRELEASED; urgency=medium
* Measured constant-time MAC comparison (dudect-style). The MAC tag
compare — the most timing-sensitive operation, where a leak is a
forgery oracle — was previously implemented as three duplicated
inline byte-OR loops marked /* CT-REQUIRED */ but never measured.
Consolidated into a single audited primitive zupt_ct_memeq() (OR-
accumulate, no early exit, volatile sink so the optimiser cannot
reintroduce a branch), used by the v1.6 strict decrypt path and the
F-08 archive-integrity-trailer check.
* New dudect-style timing test tests/test_ct_timing.{c,sh}: Welch's
t-test over fixed-equal vs random-differing tag classes, built at
-O2 (the shipped optimisation level). Verdict is environment-
relative — zupt_ct_memeq's data-dependent timing signal must be a
small fraction (<=20%) of leaky memcmp measured in the same
environment; it lands near 1%. A positive control (memcmp) confirms
the harness can detect a real leak; if the host is too coarse the
test reports INCONCLUSIVE rather than passing vacuously. Wired into
make check and make test.
* Pure internal hardening: turns an asserted constant-time property
into a measured one and a regression guard (a future early-return
refactor fails the t-test). No cryptographic-correctness change, no
wire-format change (v1.6). F-09 byte sweep 0/1827, F-06 HMAC fuzz
0/2000. The formally-verified Jasmin zupt_mac_verify_ct path for the
v1.4/v1.5 legacy compare is unchanged.
-- Cristian Cezar Moisés <sac@securityops.co> Sun, 01 Jun 2026 14:30:00 +0000
vaptvupt (3.4.0-1) UNRELEASED; urgency=medium
* F-15: Argon2id KDF parameter transparency. The 0x04 Argon2id
enc-header previously recorded only [type|salt|nonce] and nothing
about the KDF cost, unlike the PBKDF2 header which records its
iteration count — a latent robustness problem for a long-lived
archive format (if the Argon2id preset ever changed, old archives
could become silently undecryptable). New archives append a one-byte
KDF profile descriptor at offset 33 (ZUPT_ARGON2_PROFILE_MODERATE),
making the header self-describing. The descriptor is covered by the
F-08 archive-integrity trailer, so it cannot be stripped or forged
without failing authentication.
* Back-compatible (additive): the legacy reader checks enc_hdr_len>=33
and reads fixed offsets, so it ignores the trailing byte; existing
33-byte Argon2id archives decrypt unchanged. New readers validate the
profile and refuse an unknown value (fail-closed) rather than
guessing a derivation. Verified byte-exact on pre-3.4.0 encrypted
archives.
* New regression test tests/test_kdf_transparency.{c,sh} (5 checks),
including a build-time KDF cost-floor + determinism guard that fails
if the vendored SDK is swapped for a non-memory-hard stand-in. Wired
into make check and make test.
* No cryptographic-correctness change, no wire-format change (v1.6);
F-09 byte sweep 0/1827, F-06 HMAC fuzz 0/2000. 23/23 suites green.
-- Cristian Cezar Moisés <sac@securityops.co> Sun, 01 Jun 2026 13:30:00 +0000
vaptvupt (3.3.0-1) UNRELEASED; urgency=medium
* Incremental HMAC-SHA256 for the per-block Encrypt-then-MAC hot path.
Adds zupt_hmac_sha256_init/update/final: the ipad/opad key-prefix
blocks are folded once per keyring (not once per block), and the
MAC is streamed segment-by-segment (aad || nonce || ciphertext ||
seq) instead of being concatenated into a freshly malloc'd buffer.
Removes a per-block malloc + full-ciphertext memcpy on BOTH the
encrypt and decrypt sides (for 4 MB blocks: a 4 MB malloc + 4 MB
copy per block per direction), and stops copying secret plaintext-
derived ciphertext into a second heap buffer.
* Byte-identical MAC: RFC 2104 + SHA-256 Merkle-Damgard make streamed
updates equal to a single concatenated hash. Verified by RFC 4231
vectors, a new equivalence test, and byte-exact decryption of
archives produced by 3.2.0 and earlier. No wire-format change
(format v1.6); F-09 byte sweep 0/1827, F-06 HMAC fuzz 0/2000.
* The one-shot zupt_hmac_sha256 is now a thin wrapper over the
incremental API (single source of truth; used by the AIT and other
once-per-archive sites).
* New regression test tests/test_hmac_incremental.{c,sh} wired into
make check and make test. ASan clean on both KDF paths.
-- Cristian Cezar Moisés <sac@securityops.co> Sun, 01 Jun 2026 12:30:00 +0000
vaptvupt (3.2.0-1) UNRELEASED; urgency=medium
* SHA-256 hardware acceleration (Intel SHA-NI). Adds an
SHA256RNDS2/MSG1/MSG2 compression-function path
(src/zupt_sha256_shani.c) with runtime CPUID dispatch
(has_shani, CPUID.07H:EBX[29]) and a multi-block update() that
feeds full blocks straight to the hardware. Accelerates the
Encrypt-then-MAC second pass (HMAC-SHA256) and PBKDF2 on CPUs
with the SHA Extensions (Intel Goldmont+/Ice Lake+, AMD Zen+).
Bit-identical output to the scalar path; the scalar C fallback
runs everywhere SHA-NI is absent (incl. aarch64).
* Security: SHA-NI is constant-time by construction (no data-
dependent memory access or branches), strengthening the side-
channel posture of HMAC verification over attacker-influenced
ciphertext relative to the table-free-but-scalar software path.
* Validation: the 64 SHA-NI round constants are verified bit-
identical to the scalar K[] table; NIST FIPS 180-4 vectors pass
on both paths; streaming-split == one-shot across lengths
0..4096. New regression test tests/test_sha256_shani.{c,sh}
wired into make check and make test.
* No wire-format change: same SHA-256, same HMAC, same bytes.
Format stays v1.6; 3.1.x archives extract unchanged.
-- Cristian Cezar Moisés <sac@securityops.co> Sun, 01 Jun 2026 11:00:00 +0000
vaptvupt (3.1.0-1) UNRELEASED; urgency=medium
* Integrate VaptVupt LZ + ANS codec 2.48.5 -> 2.53.3. Codec API is
byte-identical (vaptvupt.h and all vv_*.h unchanged); only vv_ans.c,
vv_decoder.c, vv_encoder.c changed. Brings the optimal parser
(measured: text -1.95%, binary -1.31%, source -4.72% smaller),
large-window extreme mode, faster decode (now ~on par with zstd-19),
and 6 upstream corrupt-input decoder memory-safety fixes.
* F-14: fix heap-buffer-overflow WRITE in the decode wrapper. Decode
buffers were malloc(uncompressed_size) with no slack; the codec AVX2
over-copy needs >=32 B slack per its documented contract. The old
codec never reached it; the 2.53.3 wider AVX2 hot path does (found
by ASan on a degenerate all-repeats input at L1). Fixed with a shared
ZUPT_VV_DECODE_SLACK (64 B) guard on both the single-threaded
(zupt_format.c) and parallel (zupt_parallel.c) decode paths.
* vv_decoder.c scalar/non-AVX2 build is now -Wall -Wextra -Werror clean
(3 AVX2-only safe-zone vars guarded with #if VV_INLINE_AVX2) — fixes
a -Werror break on the aarch64/Termux scalar target.
* Removed the unverified "1.27x zstd-3 decode" claim from help/version
output and README; replaced with our own measured numbers.
* New regression test tests/test_vv_decode_slack.sh (7 assertions),
wired into make check and make test.
* Wire format unchanged (v1.6); 3.0.3 archives extract byte-exact.
19/19 suites green; ASan 24/24 single-threaded + 15/15 multi-threaded;
300-trial bit-flip fuzz: 0 crashes. F-09 byte sweep 0/1827.
-- Cristian Cezar Moisés <sac@securityops.co> Sat, 31 May 2026 12:00:00 +0000
vaptvupt (3.0.3-1) UNRELEASED; urgency=medium
* Static-analysis cleanup pass:
- Removed dead AND-branch in zupt_decode_varint() and
zupt_read_varint() (the `&& (x&0x80)` part of the s>=64
overflow check was unreachable since the preceding
`if(!(x&0x80))return n;` already handles the terminator
case). Behaviour identical; flagged by cppcheck as
`knownConditionTrueFalse`.
- Explicit (tcflag_t) cast on the ECHO bit-clear in
prompt_password() to silence -Wsign-conversion.
- Explicit (size_t) cast on zupt_encode_varint return value
in zupt_disk_backup() — matches the convention used in
zupt_format.c.
* Our (non-vendored) C source now compiles cleanly under:
gcc -Wall -Wextra -Wpedantic -Wshadow -Wcast-align
-Wstrict-prototypes -Wmissing-prototypes -Wnull-dereference
-Wformat=2 -Wlogical-op -Wjump-misses-init -Wdouble-promotion
-Woverlength-strings -Wconversion -Wsign-conversion -Werror
on 9 source files. Vendored vv_*.c, fips202.c, and zupt_mlkem.c
are kept under the upstream warning policy.
* New regression test tests/test_static_analysis.sh (7 assertions)
wires up cppcheck warning+performance level, error-level, and
pattern-level checks for the v3.0.3 dead-code findings.
Skipped gracefully if cppcheck is not installed. Wired into
make check and make test.
-- Cristian Cezar Moisés <sac@securityops.co> Mon, 26 May 2026 15:00:00 +0000
vaptvupt (3.0.2-1) UNRELEASED; urgency=medium
* F-13: split usage() string literal to stay under C99's 4095-char
limit (was 4121 chars, triggering -Woverlength-strings). Five
logical fprintf sections (synopsis, compress opts, extract opts,
examples, footer) — readable and maintainable.
* Help text refreshed: examples now use `vaptvupt` (not legacy
`zupt`), default codec described as "VaptVupt LZ + ANS 2.48.5"
(was stale "LZ77 + Huffman"), license attribution corrected to
"AGPL-3.0-or-later (VaptVupt)" (was "(Zupt)"), commercial-
licensing contact added, format-version line added.
* -Woverlength-strings now in the default CFLAGS — F-13 type
regressions caught at compile time.
* New regression test tests/test_help_consistency.sh (10 assertions):
parses src/zupt_main.c for the longest fprintf string-literal,
checks help output for command-name consistency, codec naming,
license attribution, KDF default, and format-version reporting.
Wired into make check and make test.
-- Cristian Cezar Moisés <sac@securityops.co> Mon, 26 May 2026 14:00:00 +0000
vaptvupt (3.0.1-1) UNRELEASED; urgency=medium
* GUI license cleanup: removed MIT-license credit line from the
about panel (the GUI is AGPL-3.0-or-later with commercial dual-
licensing; the MIT reference was a templating mistake). Replaced
gui/LICENSE-GUI (was MIT) with AGPL-3.0-or-later, mirroring the
top-level LICENSE. Top-level LICENSE preamble updated to reflect
the v3.0.0 Zupt → VaptVupt rename.
* GUI version-string parsing bug fix: the v3.0.0 GUI used
`replace("zupt ", "")` to peel the product name out of the CLI's
version banner, but that substring also appears inside the v3.0.0
parenthetical "formerly zupt; renamed in v3.0.0", so the parser
produced garbage. Window title, splash header, status bar and
about-panel hero number all now display "3.0.1" cleanly. New
anchored regex `_VERSION_RE` matches the version number only.
* GUI about-panel enhanced: header "ZUPT" → "VAPTVUPT", crypto
stack expanded to include Argon2id (default since v2.4.1), HKDF,
and the VaptVupt LZ + ANS codec attribution as a separate row
with its own copyright + license. Commercial-licensing contact
(sac@securityops.co) now visible.
* New regression test `tests/test_gui_branding.sh` catches future
MIT-line resurgence, the broken `replace("zupt ", ...)` parser
pattern, and the about-panel header still saying "ZUPT".
Wired into `make check` and `make test`. 11 assertions.
-- Cristian Cezar Moisés <sac@securityops.co> Mon, 26 May 2026 13:00:00 +0000
vaptvupt (3.0.0-1) UNRELEASED; urgency=medium
* Renamed from zupt → vaptvupt: prior INPI Brasil trademark on
"Zupt" required a product rename. Archive extension stays .zupt
for format continuity (header magic unchanged). The binary
`zupt` is preserved as a symlink to `vaptvupt` for one major
version cycle.
* Integrated VaptVupt LZ + ANS codec 2.48.5: fixes csz==0 heap-
buffer-overflow READ in vv_dstream_decompress_chunk (fuzzer-
found, medium severity), UBSan-safe pointer arithmetic in
vv_copy_match.
* Enhanced manpage covering all v3.0.0 surface (rename rationale,
PERFORMANCE table, threat model summary, exit codes, ENV vars).
* GUI binary-discovery bug fix: GUI launched from desktop
sessions with minimal PATH (no /usr/bin) now finds the binary
correctly. Discovery log available via VAPTVUPT_DEBUG=1.
* Format unchanged at v1.6. Bidirectional compat with 2.4.x.
-- Cristian Cezar Moisés <sac@securityops.co> Sun, 25 May 2026 13:00:00 +0000
zupt (2.4.8-1) UNRELEASED; urgency=medium
* Initial Debian source package.
* Closes F-12 (encrypted comments), continues from upstream's
no-open-findings security baseline.
-- Cristian Cezar Moisés <sac@securityops.co> Tue, 20 May 2025 12:00:00 +0000

41
packaging/debian/control Normal file
View file

@ -0,0 +1,41 @@
Source: vaptvupt
Section: utils
Priority: optional
Maintainer: Cristian Cezar Moisés <sac@securityops.co>
Build-Depends:
debhelper-compat (= 13),
gcc,
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
Rules-Requires-Root: no
Package: vaptvupt
Architecture: any
Provides: zupt (= ${binary:Version})
Replaces: zupt
Conflicts: zupt
Depends: ${shlibs:Depends}, ${misc:Depends}
Description: Post-quantum backup compression utility (formerly Zupt)
VaptVupt (renamed from Zupt in v3.0.0 due to INPI Brasil trademark) is
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)
* 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
(0 silent-accept positions in the v1.6 exhaustive byte sweep)
* Constant-time cryptographic primitives verified with Jasmin
* NIST/RFC test vectors for SHA-256, SHA-3, ML-KEM-768, AES-256-CTR,
HMAC-SHA256, X25519, PBKDF2, Argon2id
.
The archive extension stays .zupt for format continuity (header magic
unchanged). The binary `zupt` is preserved as a symlink to `vaptvupt`.
.
The archive format includes an integrity trailer that authenticates the
header and footer, per-block HMAC with bound frame-preface AAD, and
optional encrypted comments.

View file

@ -0,0 +1,49 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: zupt
Upstream-Contact: Cristian Cezar Moisés <sac@securityops.co>
Source: https://git.securityops.co/cristiancmoises/zupt
Files: *
Copyright: 2025-2026 Cristian Cezar Moisés
License: AGPL-3.0-or-later
Files: src/vv_*.c include/vaptvupt*.h include/vv_*.h vendor/zuptsdk/include/vv_*.h vendor/zuptsdk/include/vaptvupt*.h
Copyright: 2025-2026 Cristian Cezar Moisés (VaptVupt codec)
License: GPL-3.0-or-later
Files: vendor/zuptsdk/*
Copyright: 2025-2026 Cristian Cezar Moisés (libzuptsdk)
License: GPL-3.0-or-later
Files: debian/*
Copyright: 2025-2026 Cristian Cezar Moisés <sac@securityops.co>
License: AGPL-3.0-or-later
License: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
.
On Debian systems, the full text of the GNU Affero General Public
License version 3 can be found in the file
`/usr/share/common-licenses/AGPL-3'.
License: GPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
.
On Debian systems, the full text of the GNU General Public License
version 3 can be found in the file `/usr/share/common-licenses/GPL-3'.

37
packaging/debian/rules Executable file
View file

@ -0,0 +1,37 @@
#!/usr/bin/make -f
# SPDX-License-Identifier: AGPL-3.0-or-later
# Honour Debian's reproducible-build epoch when set by dpkg-buildpackage.
export SOURCE_DATE_EPOCH ?= 1747699200
# Hardening flags — Debian's defaults are already strong, this adds project-
# specific ones.
export DEB_BUILD_MAINT_OPTIONS = hardening=+all
export DEB_CFLAGS_MAINT_APPEND = -Wall -Wextra -Wpedantic
export DEB_LDFLAGS_MAINT_APPEND = -Wl,--as-needed
%:
dh $@
override_dh_auto_build:
$(MAKE) -j$$(nproc)
override_dh_auto_test:
# Project's own regression suite covers F-06..F-12.
$(MAKE) test
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
override_dh_auto_clean:
$(MAKE) clean
# Skip dh_strip's separate -dbgsym packages for a single-source-package layout.
override_dh_strip:
dh_strip --no-automatic-dbgsym

View file

@ -0,0 +1 @@
3.0 (quilt)

View file

@ -0,0 +1,71 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# Homebrew formula for zupt.
#
# To publish:
# 1. Run `make dist` upstream to produce zupt-VERSION.tar.gz (reproducible).
# 2. Upload to a stable release URL.
# 3. Update `url`, `version`, and `sha256` below.
# 4. Submit to homebrew-core via PR OR host in your own tap
# (e.g. cristiancmoises/homebrew-tap).
#
# Local test:
# brew install --build-from-source ./zupt.rb
# brew test zupt
# brew audit --strict --online zupt
#
# Notes for macOS:
# * Jasmin assembly is disabled at build time on Darwin (no jasminc dep);
# the C fallback for AES-256-CTR / HMAC compare paths is shipped.
# * libzuptsdk is vendored and installed alongside the binary; the binary
# uses @loader_path rpath so users don't have to set DYLD paths.
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.0.0/vaptvupt-4.0.0.tar.gz"
version "4.0.0"
sha256 "REPLACE_WITH_SHA256_OF_RELEASE_TARBALL"
license "AGPL-3.0-or-later"
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.
ENV["CFLAGS"] = "#{ENV.cflags} -O2 -std=c11 -Wall -Wextra"
system "make", "-j#{ENV.make_jobs}"
system "make", "DESTDIR=#{prefix}", "PREFIX=", "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"
end
test do
# End-to-end sanity check: build a real archive, extract it, byte-compare.
(testpath/"input.txt").write("homebrew formula test payload\n")
system bin/"zupt", "c", "-p", "test", "out.zupt", "input.txt"
system bin/"zupt", "info", "out.zupt"
mkdir "extracted"
cd "extracted" do
system bin/"zupt", "x", "-p", "test", "../out.zupt"
end
system "diff", "-q", "input.txt", "extracted/input.txt"
end
end

120
packaging/nix/flake.nix Normal file
View file

@ -0,0 +1,120 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# Nix flake for zupt.
#
# Usage (with flakes enabled):
# nix build .#zupt # build the package
# nix run .#zupt -- version # run zupt directly
# nix develop # drop into a dev shell
# nix flake check # lint the flake
#
# To consume from another flake:
# inputs.zupt.url = "git+https://git.securityops.co/cristiancmoises/zupt?ref=v2.4.4";
# ...packages.x86_64-linux.default = inputs.zupt.packages.x86_64-linux.zupt;
#
# Reproducibility:
# * Nix already pins the source tree by hash.
# * `make dist` is also reproducible (tests/test_dist_reproducible.sh).
# * Together, two independent Nix evaluations of the same flake.lock
# produce byte-identical /nix/store outputs.
{
description = "Zupt post-quantum backup compression utility (C11)";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachSystem [ "x86_64-linux" "aarch64-linux" ] (system:
let
pkgs = import nixpkgs { inherit system; };
zupt = pkgs.stdenv.mkDerivation {
pname = "vaptvupt";
version = "4.0.0";
# When publishing, replace this with `fetchurl` against the
# release tarball. For local development the flake assumes it
# lives in the same directory as the source.
src = ./.;
nativeBuildInputs = with pkgs; [
gcc
gnumake
];
# python3 is only used by the regression-test harness.
checkInputs = [ pkgs.python3 ];
# Build with the project's preferred warning set on top of Nix's
# 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.
buildPhase = ''
runHook preBuild
make -j$NIX_BUILD_CORES
runHook postBuild
'';
# Run the full upstream regression suite. Disable per-package by
# setting doCheck = false; on by default.
doCheck = true;
checkPhase = ''
runHook preCheck
make test
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
# Docs
mkdir -p $out/share/doc/zupt
cp README.md SECURITY.md CHANGELOG.md AUDIT.md $out/share/doc/zupt/
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";
license = with licenses; [ agpl3Plus gpl3Plus ];
maintainers = [ ];
platforms = [ "x86_64-linux" "aarch64-linux" ];
mainProgram = "zupt";
};
};
in {
packages = {
zupt = zupt;
default = zupt;
};
apps.default = {
type = "app";
program = "${zupt}/bin/zupt";
};
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
gcc
gnumake
python3
valgrind
gdb
];
};
});
}

View file

@ -0,0 +1,112 @@
# openSUSE Build Service update for `home:cabelo:innovators/zupt`
This directory contains the three files you need to update your OBS
package from `1.5.5` to `2.4.8`:
| File | Status vs. your current files |
|---------------|---------------------------------------------------------------------|
| `_service` | Updated `revision` to `v2.4.8`. Format unchanged (still `tar_scm`). |
| `zupt.spec` | Version → `2.4.8`. License corrected `MIT``AGPL-3.0-or-later`. `%check` now calls `make check` (new distro-safe target). |
| `zupt.changes`| 13 new entries prepended (2.0.0 → 2.4.8). Your existing 1.0.01.5.4 history is preserved verbatim. |
## What changed in the spec
1. **License correction** — your spec says `License: MIT`, but the
upstream license is **AGPL-3.0-or-later** (dual-licensed
AGPL-3.0-or-later + commercial). This was a bug that should
probably trigger a rebuild even without the version bump.
2. **`%check` target** — your spec calls `test-all` on non-s390x
architectures. In v2.4.x, `test-all` includes threading tests
that are flaky on emulated build hosts (3 false positives on
x86_64 GitHub-Actions-style sandboxes). The new `make check`
target added in 2.4.8 runs a curated subset:
* F-06 HMAC tamper detection (2000 trials)
* F-08 archive-integrity-trailer
* F-09 byte-level integrity preface AAD
* F-10 KDF default
* F-11 auth-fail message
* F-12 encrypted comments
* NIST/RFC vectors (SHA-256, SHA-3, ML-KEM-768, AES-256-CTR,
HMAC, X25519, PBKDF2, Argon2id)
* Path-traversal, argument-order, block-swap regressions
* Quick smoke test
Total ~91 assertions, runs in <2 minutes, no flakes on emulated
hosts. The s390x branch still falls back to just `test-vectors`.
3. **Upstream URL in `URL:` field** updated to
`https://git.securityops.co/cristiancmoises/zupt` (the canonical
project URL). The `_service` file still pulls from GitHub
(`https://github.com/cristiancmoises/zupt`) since that's where
your `tar_scm` is already configured and what works in OBS today.
4. **`BuildRequires: make`** added — newer openSUSE chroots don't
always pull `make` in transitively. Harmless on older targets.
5. **Docs**`%doc README.md SECURITY.md CHANGELOG.md` now ships
the security boundary docs as well as the README. THREAT_MODEL.md
exists upstream but isn't listed here to keep the package small;
add `%doc THREAT_MODEL.md` if you want it included.
## How to apply
```sh
# 1. Check out the package
osc checkout home:cabelo:innovators zupt
cd home:cabelo:innovators/zupt
# 2. Drop the new files in (assuming this README is at
# /path/to/zupt-source/packaging/opensuse/README.md)
cp /path/to/zupt-source/packaging/opensuse/_service .
cp /path/to/zupt-source/packaging/opensuse/zupt.spec .
cp /path/to/zupt-source/packaging/opensuse/zupt.changes .
# 3. Trigger the service locally to fetch v2.4.8 from GitHub
osc service runall
# This produces zupt-2.4.8.tar.gz in the current directory and
# updates zupt.changes with a service-generated entry if you have
# changesgenerate enabled (you don't, so this is a no-op for
# changes; tar_scm just downloads).
# 4. (Optional) Local build to verify before committing
osc build openSUSE_Tumbleweed x86_64
# Expected: build succeeds, %check runs `make check`, all 10 suites
# (~91 assertions) pass, package is produced.
# 5. Commit upstream
osc status # confirm zupt-2.4.8.tar.gz is staged alongside the
# three text files
osc commit -m "Update to 2.4.8: distro-safe make check target; license fix MIT -> AGPL"
```
## Notes for future updates
* The `_service` `revision` is pinned to `v2.4.8`. To track a new
release, just edit that one line and re-run `osc service runall`.
* The spec's `Version:` field is hard-coded — when you bump
`_service` `revision`, also bump `Version:` to match. The
`set_version` service in `_service` will auto-sync at OBS-build
time if you want; it's mode="manual" today, which is safer.
* `BuildRequires` is intentionally minimal (just `gcc gzip make`).
Zupt has no external library dependencies — `libargon2`,
`libcrypto`, etc. used by other Linux packagers come from
*vendored* code that's compiled in. This is a deliberate
design choice; don't add system library BuildRequires.
## Reporting issues
* Upstream bugs: https://git.securityops.co/cristiancmoises/zupt
* openSUSE packaging bugs: https://bugs.opensuse.org/
* Cabelo's OBS project: https://build.opensuse.org/project/show/home:cabelo:innovators
## Author of these update files
Generated against upstream `zupt-2.4.8` source tree. Spec mirrors
cabelo's existing 1.5.5 conventions (minimal `BuildRequires`,
`%autosetup -p1`, `V=1` verbose build, `%ifarch s390x` branch in
`%check`, no separate libzuptsdk subpackage) — only the necessary
fields are changed.

View file

@ -0,0 +1,16 @@
<services>
<service name="tar_scm" mode="manual">
<param name="url">https://github.com/cristiancmoises/zupt</param>
<param name="scm">git</param>
<param name="revision">v4.0.0</param>
<param name="versionformat">@PARENT_TAG@</param>
<param name="versionrewrite-pattern">v(.*)</param>
<param name="submodules">enable</param>
<param name="filename">vaptvupt</param>
</service>
<service name="recompress" mode="manual">
<param name="file">*.tar</param>
<param name="compression">gz</param>
</service>
<service name="set_version" mode="manual"/>
</services>

View file

@ -0,0 +1,400 @@
-------------------------------------------------------------------
Wed Jun 10 12:00:00 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Update to 4.0.0:
* Codec -> canonical VaptVupt 2.60.4 (security: OOB heap write in
AVX2 exact-size decode fixed; CBMC-verified BCJ with
auto-detection; ratio gate byte-identical on identical inputs).
* F-16 disclosed and fixed: <= 3.8.0 wrote undecodable archives on
executable content at L8/L9 (write-time BCJ defect). Re-create
affected archives with 4.0.0.
* New --pq-box mode (libpqvaptvupt 0.6.0): ML-KEM-768 + X25519 via
HKDF-SHA256 domain-separated combiner; keygen --box; 13/13
adversarial checks; ASan/UBSan clean.
* SHA-NI measured 5.8x (scalar 204 -> 1184 MB/s); estimate retired.
* Clang strict build restored; wire format v1.6 unchanged; 26
suites green; vectors 16/16.
-------------------------------------------------------------------
Mon Jun 1 22:31:24 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Update to 3.8.0 (documentation-only; binary identical to 3.7.0, v1.6)
* Add BENCHMARKS.md: consolidated reproducible measured benchmarks
(compression ratio/throughput, encode-speed-vs-level, KDF-vs-per-
block crypto overhead, head-to-head ratio vs zstd showing where
VaptVupt loses) with the test machine + method stated per table.
SHA-NI speedup marked [ESTIMATED] (test box has no SHA-NI).
* README benchmark section re-dated and linked to BENCHMARKS.md.
No source/crypto/wire change; test_vectors 16/0, F-09 0/1827.
-------------------------------------------------------------------
Mon Jun 1 22:15:58 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Update to 3.7.0
* Route the ML-KEM-768 decapsulation implicit-rejection comparison
(1088-byte ciphertext) through the audited constant-time primitive
zupt_ct_memeq, replacing an inline byte-OR loop. A timing leak there
is a KEM decapsulation oracle (breaks IND-CCA2); it now shares the
measured-constant-time path of the MAC compare. ML-KEM output
semantics unchanged (verified by FIPS 203 roundtrip, implicit-
rejection vector, PQ-hybrid roundtrip, wrong-key rejection).
* test_ct_timing extended to the 1088-byte compare + a source-routing
guard; the 1088B dudect numbers are informational (at that size the
signal is memory-dominated and memcmp is not a clean control), with
constant-timeness following from the 32B pass + length-independence
+ routing guard. No wire-format change (v1.6); F-09 0/1827.
-------------------------------------------------------------------
Mon Jun 1 21:33:10 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Update to 3.6.0
* Add NIST SP 800-38A AES-256-CTR known-answer vectors (F.5.5/F.5.6)
to the test_vectors suite. Validates zupt_aes256_ctr against the
standard on both the Jasmin AES-NI path and the C T-table fallback;
AES was previously only roundtrip-tested.
* Fix an inverted result check in the ML-KEM-768 self-test reporting
(printed OK on failure) and fix the NTT roundtrip self-test to
assert the real Montgomery-scaled invariant instead of a false
identity (no more misleading stderr "NTT roundtrip FAILED"). ML-KEM
correctness end-to-end was never affected.
* test_vectors now 16/0 (was 14, one vacuous). No wire-format change
(v1.6); F-09 0/1827, F-06 0/2000.
-------------------------------------------------------------------
Mon Jun 1 16:51:09 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Update to 3.5.0
* Measured constant-time MAC comparison (dudect-style). The three
duplicated inline byte-OR MAC compares are consolidated into one
audited primitive zupt_ct_memeq() (volatile OR-accumulate, no early
exit), used by the v1.6 strict decrypt path and the F-08 integrity
trailer. New timing test tests/test_ct_timing.sh applies Welch's
t-test (fixed vs random tag classes) at -O2 with a leaky-memcmp
positive control; the compare shows ~1% of the leak signal.
* Internal hardening only: asserted constant-time becomes measured +
regression-guarded. No wire-format change (v1.6); F-09 0/1827,
F-06 0/2000. Jasmin zupt_mac_verify_ct path unchanged.
-------------------------------------------------------------------
Mon Jun 1 12:00:57 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Update to 3.4.0
* F-15: Argon2id KDF parameter transparency. New archives append a
one-byte KDF profile descriptor to the 0x04 enc-header, making it
self-describing about the Argon2id cost (the PBKDF2 header already
recorded its iteration count). Covered by the F-08 integrity
trailer; cannot be stripped undetected.
* Back-compatible: legacy 33-byte Argon2id archives decrypt unchanged;
unknown profiles are refused fail-closed. New test
tests/test_kdf_transparency.sh incl. a build-time KDF cost-floor
guard. No wire-format change (v1.6); F-09 0/1827, F-06 0/2000.
-------------------------------------------------------------------
Mon Jun 1 11:46:10 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Update to 3.3.0
* Incremental HMAC-SHA256 for the per-block Encrypt-then-MAC hot
path: ipad/opad folded once per keyring; MAC streamed instead of
concatenated into a malloc'd buffer. Removes a per-block malloc +
full-ciphertext memcpy on both encrypt and decrypt sides.
* Byte-identical MAC; verified by RFC 4231 vectors, an equivalence
test, and byte-exact decryption of 3.2.x archives. No wire-format
change (v1.6); F-09 0/1827, F-06 0/2000.
-------------------------------------------------------------------
Mon Jun 1 11:18:57 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Package renamed zupt -> vaptvupt (project renamed in 3.0.0 due to a
prior INPI Brasil trademark on "Zupt"). Provides/Obsoletes: zupt so
the upgrade is automatic; the binary still installs a /usr/bin/zupt
compatibility symlink and a zupt.1 man-page symlink.
- Update to 3.2.0
* SHA-256 hardware acceleration (Intel SHA-NI): SHA256RNDS2/MSG1/MSG2
compression path with CPUID runtime dispatch; accelerates HMAC-
SHA256 (Encrypt-then-MAC second pass) and PBKDF2 on Zen+/Ice Lake+.
Bit-identical to the scalar path; scalar C fallback elsewhere
(incl. aarch64). SHA-NI is constant-time by construction.
* 64 SHA-NI round constants verified identical to the scalar K[]
table; NIST FIPS 180-4 vectors pass on both paths. New regression
test tests/test_sha256_shani.sh.
* No wire-format change (v1.6); 3.1.x archives extract unchanged.
-------------------------------------------------------------------
Sun May 31 23:41:40 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Update to 3.1.0
* VaptVupt codec 2.48.5 -> 2.53.3 (API byte-identical; 3 .c files).
Optimal parser (text -1.95%, binary -1.31%, source -4.72% smaller),
large-window extreme, faster decode (~on par with zstd-19), and 6
upstream corrupt-input decoder memory-safety fixes.
* F-14: heap-buffer-overflow WRITE fixed in the decode wrapper. The
codec AVX2 over-copy needs >=32 B output slack (documented contract);
our buffers had none. Fixed with ZUPT_VV_DECODE_SLACK (64 B) on both
single-threaded and parallel decode paths. Found by ASan.
* vv_decoder.c scalar build made -Werror clean (aarch64).
* New regression test tests/test_vv_decode_slack.sh.
* Wire format unchanged (v1.6); 3.0.x archives extract byte-exact.
-------------------------------------------------------------------
Tue May 26 02:50:05 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Update to 3.0.3
* Static-analysis cleanup: removed dead AND-branch in varint
decoders (cppcheck knownConditionTrueFalse); explicit casts on
-Wsign-conversion sites. Our non-vendored C now compiles clean
under -Wconversion -Wsign-conversion -Werror.
* New regression test tests/test_static_analysis.sh (7 assertions)
wraps cppcheck + strict GCC; wired into make check. Skipped
cleanly when cppcheck is unavailable on the build host.
* No functional changes; archive format and wire compatibility
unchanged at v1.6.
-------------------------------------------------------------------
Tue May 26 02:27:34 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Update to 3.0.2
* F-13: usage() string literal exceeded C99's 4095-char limit
(was 4121 chars); split into five logical fprintf sections.
-Woverlength-strings added to default CFLAGS so this kind of
regression fails the build under -Werror.
* Help text refreshed: examples now use `vaptvupt` (not legacy
`zupt`), default codec correctly named VaptVupt LZ + ANS 2.48.5
(was stale "LZ77 + Huffman"), license attribution corrected.
* New regression test tests/test_help_consistency.sh (10 assertions)
wired into make check.
-------------------------------------------------------------------
Tue May 26 00:43:52 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Update to 3.0.1
* GUI license cleanup: removed MIT credit line from the about
panel; gui/LICENSE-GUI replaced (was MIT) with AGPL-3.0-or-later
to match the source SPDX header. The GUI was never actually
released under MIT — that was a templating mistake.
* GUI version-string parsing bug fix (the replace("zupt ", ...)
substring also matched inside the v3.0.0 parenthetical). Window
title, splash header, status bar and about-panel hero number now
display "3.0.1" cleanly.
* GUI about-panel enhanced: header VAPTVUPT, crypto stack now
includes Argon2id, HKDF, the VaptVupt codec attribution; the
commercial-licensing contact (sac\@securityops.co) is visible.
* New regression test tests/test_gui_branding.sh (11 assertions)
catches future regressions of all three issues.
-------------------------------------------------------------------
Mon May 25 13:09:04 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Update to 3.0.0 (rename: zupt → vaptvupt)
* Renamed from "Zupt" to "VaptVupt" due to a prior INPI Brasil
trademark registration on the name "Zupt" for unrelated software.
The on-disk archive extension stays .zupt for format continuity
(header magic bytes \x5A\x55\x50\x54\x1A\x00 are unchanged).
Binaries from 2.x extract 3.0.0 archives byte-exact and vice
versa. The C-source identifier prefix (zupt_, ZUPT_) is also
unchanged for ABI continuity with libzuptsdk.
* Legacy /usr/bin/zupt symlink installed alongside vaptvupt for
one major version cycle.
* Integrated VaptVupt LZ + ANS codec 2.48.5: fixes csz==0 heap-
buffer-overflow READ in vv_dstream_decompress_chunk (libFuzzer-
found, medium severity), UBSan-safe pointer arithmetic in
vv_copy_match, const-correctness cleanup in vv_ans entropy
encoder.
* Enhanced manpage (597 lines, was 422). New PERFORMANCE section
with measured numbers against gzip-9 / zstd-3 / zstd-19, ENV
var documentation including VAPTVUPT_BIN and VAPTVUPT_DEBUG,
threat-model summary in the man page itself.
* Fixed GUI binary-discovery bug: zupt-gui (now vaptvupt-gui)
launched from desktop sessions with a minimal PATH that didn't
include /usr/bin failed to locate the binary. New _find_vaptvupt
implementation tries env vars, source-tree paths, shutil.which
on both names, then a curated list of common install paths,
and runs a liveness check (binary actually runs and
exits 0) on each candidate. Diagnostic output via VAPTVUPT_DEBUG=1.
-------------------------------------------------------------------
Sun May 24 13:08:04 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Update to 2.4.8
* New `make check` target — distro-safe regression subset for
OBS %check (no `make clean` mid-stream, no threading-flaky
tests). Spec now calls `make check` on x86_64/aarch64.
* License field corrected: AGPL-3.0-or-later (was MIT in 1.5.x).
Project is dual-licensed AGPL-3.0-or-later + commercial.
* Upstream URL updated to git.securityops.co.
-------------------------------------------------------------------
Sun May 24 13:00:00 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Update to 2.4.7
* Manpage rewrite covering all v2.4.x flags (--kdf, --comment,
--comment-file, --pq-sdk, ML-KEM-768)
* Shell completions for bash, zsh, fish covering 16 critical
CLI flags
* Fixed three stale strings that still mentioned PBKDF2 as the
default KDF after the v2.4.1 flip to Argon2id
-------------------------------------------------------------------
Sun May 24 12:00:00 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Update to 2.4.6
* Comprehensive GitHub Actions CI matrix: 8 jobs covering
GCC+Clang, strict warnings (-Werror + full §6 set),
ASAN/UBSAN, PIE hardening, aarch64 via QEMU, `make dist`
reproducibility, packaging-syntax, tag-triggered release.
* New THREAT_MODEL.md (12 KB): plain-English security boundary
document covering what zupt protects against AND what it
explicitly does NOT.
-------------------------------------------------------------------
Sun May 24 11:00:00 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Update to 2.4.5
* Packaging completion: Fedora/RHEL .spec, NixOS flake.nix,
DISTRIBUTION.md guide. openSUSE inherits this work.
* New tests/test_packaging_syntax.sh asserts cross-recipe
version consistency.
-------------------------------------------------------------------
Sun May 24 10:00:00 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Update to 2.4.4
* Reproducible `make dist` source tarball: sorted file order,
fixed mtime via SOURCE_DATE_EPOCH, uid/gid pinned, gzip -9n.
Two consecutive runs produce byte-identical sha256 (asserted
by tests/test_dist_reproducible.sh).
* Upstream packaging recipes for AUR, Debian, Homebrew added.
-------------------------------------------------------------------
Sun May 24 09:00:00 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Update to 2.4.3
* F-12 closed: encrypted archive comments via new block type
ZUPT_BLOCK_COMMENT (0x05). Comments are UTF-8, up to 4096
bytes, encrypted using the same per-block AEAD pipeline as
data blocks (including F-09 preface AAD). hdr.comment_offset
is in the AIT-signed region.
* CLI flags -c / --comment and --comment-file.
* Exhaustive byte sweep on 1878-byte archive with comment:
0/1878 silent accepts.
-------------------------------------------------------------------
Sun May 24 08:00:00 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Update to 2.4.2
* F-11 closed: wrong-password and tampered-archive error
messages collapsed into one uniform "Authentication failed
(wrong key, wrong password, or tampered archive)" line.
Detailed top-MAC wording moves behind --verbose.
* Eliminates a verbal probe-oracle. No format change.
-------------------------------------------------------------------
Sun May 24 07:00:00 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Update to 2.4.1
* F-10: password-mode KDF default flipped from PBKDF2-SHA256
(600k iter) to Argon2id (memory-hard). Use `--kdf pbkdf2`
for compatibility with v2.4.0-and-older readers.
-------------------------------------------------------------------
Sun May 24 06:00:00 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Update to 2.3.1
* F-09 closed: extended-AAD per-block MAC binds 29-byte
canonical preface (block_type, codec_id, block_flags, sizes,
plaintext-XXH64) into every block's HMAC. Format v1.5 → v1.6.
* Exhaustive byte sweep on 1827-byte PQ-SDK archive:
0/1827 silent accepts. Full byte-level tamper detection on
encrypted archives.
-------------------------------------------------------------------
Sun May 24 05:00:00 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Update to 2.3.0
* F-08 closed via 32-byte archive-integrity-trailer:
HMAC-SHA256(mac_key, hdr[0..63] || footer[0..23]) appended
after the footer. Format v1.4 → v1.5. v1.4 archives still
readable with downgrade warning.
-------------------------------------------------------------------
Sun May 24 04:00:00 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Update to 2.2.5
* F-06 (HIGH) closed: HMAC verifier on the Jasmin path was
silently accepting ~6% of single-bit tampers because
`diff_v2 & diff_v1` cleared zeroed-difference bits. Fixed via
(x|-x)>>63 nonzero-indicator fold before AND. 2000-trial
regression: 0 silent accepts.
* F-07 closed: structural block_type check at index_offset
rejects malformed archives early.
-------------------------------------------------------------------
Sun May 24 03:00:00 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Update to 2.2.4
* Audit batch close (F-01..F-05): help-text newline, flaky
tamper test, -Wshadow cleanup, orphan selftest removed,
const-correct pointer params.
-------------------------------------------------------------------
Sun May 24 02:00:00 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Update to 2.0.0
* Major version bump. libzuptsdk integration: HKDF combiner,
key commitment, HPKE binding for the post-quantum path
(--pq-sdk mode). Argon2id KDF available. New on-disk format
v1.4 with explicit enc_type byte dispatch (0x01=PBKDF2,
0x03=PQ-SDK).
* VaptVupt 2.x codec integrated as first-class compressor.
* AppImage / .deb / .rpm packaging scripts added upstream.
-------------------------------------------------------------------
Thu Apr 2 02:54:23 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Version 1.5.4
* Makefile multiarc
-------------------------------------------------------------------
Thu Apr 2 02:31:57 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Version 1.5.4
* Object files removed
-------------------------------------------------------------------
Thu Apr 2 02:30:11 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Version 1.5.3
* Added man page installation (zupt.1.gz)
* Enabled verbose build output with V=1 support in Makefile
* Fixed Makefile to honor LDFLAGS and support PIE linking
* Improved rpmlint compliance for OBS/openSUSE packaging
-------------------------------------------------------------------
Tue Mar 31 00:24:26 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Version 1.5.2
- Enable C fallback on AArch64
* Jasmin Gate integration behind x86_64 target detection
* Detect target architecture using the compiler triplet
`$(CC) -dumpmachine`
* Prevent Jasmin x86_64 object files from being linked in
AArch64 builds
* Automatically use C fallback on non-x86_64 targets
* Preserve `ZUPT_USE_JASMIN` only when assembly sources are
present and compatible
-------------------------------------------------------------------
Mon Mar 30 22:17:02 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Version 1.5.1
* Binaries removed
-------------------------------------------------------------------
Sun Mar 29 22:10:54 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Version 1.5.0
- Added -Jasmin Assembly Integration
* zupt_mac_verify_ct Jasmin assembly linked into
zupt_decrypt_buffer(). Replaces the C XOR accumulation loop
for HMAC-SHA256 comparison.
* zupt_ct_select_32 Jasmin assembly linked into
zupt_mlkem768_decaps(). Replaces the C cmov() function for
Fujisaki-Okamoto implicit rejection.
* include/zupt_jasmin.h — extern declarations for all Jasmin
functions with ABI documentation.
* #ifdef ZUPT_USE_JASMIN dispatch guards in zupt_crypto.c and
zupt_mlkem.c with clean C fallback.
* Makefile auto-detects jasmin/*.s files, assembles to .o,
links into binary, sets -DZUPT_USE_JASMIN.
-------------------------------------------------------------------
Mon Mar 23 18:46:51 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Initial package
- Version 1.0.0

View file

@ -0,0 +1,106 @@
#
# spec file for package vaptvupt
#
# Copyright (c) 2026 SUSE LLC
# Copyright (c) 2026 Alessandro de Oliveira Faria (A.K.A CABELO) <cabelo@opensuse.org>
# Copyright (c) 2025-2026 Cristian Cezar Moisés <zupt@riseup.net> (upstream)
#
# All modifications and additions to the file contributed by third parties
# remain the property of their copyright owners, unless otherwise agreed
# upon. The license for this file, and modifications and additions to the
# file, is the same license as for the pristine package itself (unless the
# license for the pristine package is not an Open Source License, in which
# case the license is the MIT License). An "Open Source License" is a
# license that conforms to the Open Source Definition (Version 1.9)
# published by the Open Source Initiative.
# Please submit bugfixes or comments via https://bugs.opensuse.org/
#
Name: vaptvupt
Version: 4.0.0
Release: 0
Summary: Post-quantum backup compression with AES-256 + ML-KEM-768 hybrid encryption
License: AGPL-3.0-or-later
Group: Productivity/Archiving/Compression
URL: https://git.securityops.co/cristiancmoises/zupt
Source0: %{name}-%{version}.tar.gz
BuildRequires: gcc
BuildRequires: gzip
BuildRequires: make
# v3.0.0 renamed the project Zupt -> VaptVupt (prior INPI Brasil
# trademark on "Zupt"). Cleanly supersede any installed zupt package;
# the binary still installs a /usr/bin/zupt compatibility symlink.
Provides: zupt = %{version}-%{release}
Obsoletes: zupt < 3.0.0
%description
VaptVupt (formerly Zupt; renamed in v3.0.0 due to a prior INPI Brasil
trademark on the name "Zupt") compresses and encrypts backup archives. LZ77+Huffman compression
(VaptVupt codec, ~2-3 GB/s decompression on x86_64 with AVX2 / aarch64
with NEON), AES-256-CTR + HMAC-SHA256 per-block authenticated
encryption, multi-threaded, with optional ML-KEM-768 + X25519
post-quantum hybrid key encapsulation (FIPS 203 + RFC 7748). The
default password KDF is Argon2id; PBKDF2-SHA256 remains available
via --kdf pbkdf2 for backward compatibility.
Pure C11, vendored libzuptsdk, ~5,000 lines of core code. Constant-
time cryptographic primitives are formally verified with Jasmin on
x86_64 (zupt_mac_verify_ct, zupt_ct_select_32); a clean C fallback
runs on aarch64 and other architectures.
%prep
%autosetup -p1
chmod +x tests/*.sh
%build
%make_build V=1 \
CFLAGS="%{optflags} -fPIE -Wall -Wextra -std=c11 -Iinclude -Isrc" \
LDFLAGS="%{?build_ldflags} -pie" \
LDLIBS="-lm -lpthread"
%check
# `make check` is the distro-safe subset added in 2.4.8: runs the
# security-critical regressions (F-06 HMAC, F-08 AIT, F-09 byte
# integrity, F-10 KDF, F-11 auth-fail, F-12 comments) plus NIST/RFC
# vectors. Skips threaded and dist-reproducibility tests that are
# sensitive to build-host environment.
#
# On s390x, fall back to just the vector tests (Jasmin assembly is
# x86_64-only; threading harness has been flaky on big-endian).
%ifarch s390x
%make_build V=1 \
CFLAGS="%{optflags} -fPIE -Wall -Wextra -std=c11 -Iinclude -Isrc" \
LDFLAGS="%{?build_ldflags} -pie" \
LDLIBS="-lm -lpthread" \
test-vectors
./test_vectors
%else
%make_build V=1 \
CFLAGS="%{optflags} -fPIE -Wall -Wextra -std=c11 -Iinclude -Isrc" \
LDFLAGS="%{?build_ldflags} -pie" \
LDLIBS="-lm -lpthread" \
check
%endif
%install
%make_install PREFIX=%{_prefix}
%files
%license LICENSE
%doc README.md SECURITY.md CHANGELOG.md
%{_bindir}/vaptvupt
%{_bindir}/zupt
%{_mandir}/man1/vaptvupt.1%{?ext_man}
%{_mandir}/man1/zupt.1%{?ext_man}
%dir %{_prefix}/lib/vaptvupt
%{_prefix}/lib/vaptvupt/libzuptsdk.so
%{_prefix}/lib/vaptvupt/libzuptsdk.so.2
%{_prefix}/lib/vaptvupt/libzuptsdk.so.2.0.0
%{_prefix}/lib/vaptvupt/libpqvaptvupt.so
%{_prefix}/lib/vaptvupt/libpqvaptvupt.so.0
%{_prefix}/lib/vaptvupt/libpqvaptvupt.so.0.6.0
%changelog

119
packaging/rpm/vaptvupt.spec Normal file
View file

@ -0,0 +1,119 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# Fedora / RHEL / CentOS RPM spec for zupt.
#
# Build with:
# spectool -g zupt.spec # fetches the upstream tarball
# rpmbuild -ba zupt.spec # builds source + binary RPMs
#
# To bring a release into production:
# 1. Run `make dist` upstream → /tmp/zupt-VERSION.tar.gz (reproducible).
# 2. Upload to a stable release URL (git.securityops.co releases).
# 3. Update %{version} below.
# 4. Run `sha256sum /tmp/zupt-VERSION.tar.gz` and update Source0
# checksum (handled by spectool when configured) or pin via
# sha256sum in a separate manifest if your distro requires it.
# 5. rpmbuild --define '_topdir ~/rpmbuild' -ba zupt.spec
#
# This spec is written for Fedora 38+ and EPEL 9+; it should also work
# on RHEL 8 (with EPEL) by adjusting BuildRequires if Python 3.8+ isn't
# in the base.
Name: vaptvupt
Version: 4.0.0
Release: 1%{?dist}
Summary: Post-quantum backup compression utility (AES-256 + ML-KEM-768 + Argon2id, formerly Zupt)
License: AGPL-3.0-or-later AND GPL-3.0-or-later
URL: https://git.securityops.co/cristiancmoises/zupt
Source0: %{url}/releases/download/v%{version}/%{name}-%{version}.tar.gz
# v3.0.0: legacy `zupt` package is superseded. Renaming was forced
# by a prior INPI Brasil trademark registration on "Zupt". The
# archive extension (.zupt), wire format, magic bytes, and C ABI
# are unchanged.
Provides: zupt = %{version}-%{release}
Obsoletes: zupt < 3.0.0
Conflicts: zupt < 3.0.0
BuildRequires: gcc
BuildRequires: make
BuildRequires: glibc-devel
BuildRequires: python3 >= 3.8
# python3 is only needed for the regression-test harness (byte sweeps,
# tamper injection). The shipped binary has no Python dependency.
Requires: glibc
%description
Zupt is 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)
* Multi-threaded compression with the VaptVupt LZ codec
* Full-disk backup and restore with sparse-region detection
* End-to-end byte-level tamper detection on encrypted archives
(0 silent-accept positions in the v1.6 exhaustive byte sweep)
* Constant-time cryptographic primitives verified with Jasmin
* NIST/RFC test vectors for SHA-256, SHA-3, ML-KEM-768, AES-256-CTR,
HMAC-SHA256, X25519, PBKDF2, Argon2id
The archive format includes an integrity trailer that authenticates
the header and footer, per-block HMAC with bound frame-preface AAD,
and optional encrypted comments.
%global debug_package %{nil}
# Single source RPM, no -debuginfo split for the initial release.
%prep
%autosetup -n %{name}-%{version}
%build
# Use Fedora's default optflags but with the project's preferred warning set.
CFLAGS="%{optflags} -Wall -Wextra -Wpedantic -std=c11" \
LDFLAGS="%{?build_ldflags}" \
%make_build
%check
# Run the upstream regression suite. F-06 HMAC trials, F-08 top-MAC sweep,
# F-09 byte sweep (1827 positions), F-10..F-12 regressions, dist
# reproducibility. ~3 minutes on modern hardware.
%make_build test
%install
%make_install DESTDIR=%{buildroot} PREFIX=/usr
# Install the vendored libzuptsdk into /usr/lib/zupt/ — the binary is
# linked with -Wl,-rpath,$ORIGIN/vendor/zuptsdk so we preserve the same
# layout under /usr/.
install -d %{buildroot}%{_libdir}/%{name}
install -m 0755 vendor/zuptsdk/libzuptsdk.so.2.0.0 \
%{buildroot}%{_libdir}/%{name}/libzuptsdk.so.2.0.0
ln -sf libzuptsdk.so.2.0.0 %{buildroot}%{_libdir}/%{name}/libzuptsdk.so.2
ln -sf libzuptsdk.so.2.0.0 %{buildroot}%{_libdir}/%{name}/libzuptsdk.so
install -m 0755 vendor/pqvaptvupt/libpqvaptvupt.so.0.6.0 \
%{buildroot}%{_libdir}/%{name}/libpqvaptvupt.so.0.6.0
ln -sf libpqvaptvupt.so.0.6.0 %{buildroot}%{_libdir}/%{name}/libpqvaptvupt.so.0
ln -sf libpqvaptvupt.so.0.6.0 %{buildroot}%{_libdir}/%{name}/libpqvaptvupt.so
%files
%license LICENSE
%doc README.md SECURITY.md CHANGELOG.md AUDIT.md
%{_bindir}/zupt
%{_libdir}/%{name}/libzuptsdk.so.2.0.0
%{_libdir}/%{name}/libzuptsdk.so.2
%{_libdir}/%{name}/libzuptsdk.so
%{_libdir}/%{name}/libpqvaptvupt.so.0.6.0
%{_libdir}/%{name}/libpqvaptvupt.so.0
%{_libdir}/%{name}/libpqvaptvupt.so
%if 0%{?_mandir:1}
%{_mandir}/man1/zupt.1*
%endif
%changelog
* Tue May 20 2025 Cristian Cezar Moisés <sac@securityops.co> - 2.4.4-1
- Initial Fedora/EPEL RPM package.
- Tracks upstream v2.4.4: distribution packaging release; archive
format unchanged from v2.4.3 (v1.6, 0/1878 silent-accept byte
tampers).

View file

@ -4,7 +4,7 @@
* Copyright (c) 2025-2026 Cristian Cezar Moisés
*
* ZUPT-COMPAT: thin wrapper over vv_compress/vv_decompress with
* backup-optimized defaults for VaptVupt 2.48.2.
* backup-optimized defaults for VaptVupt 2.60.4.
*
* Defaults applied here (per ZUPT_INTEGRATION.md, Sprint 122):
* - opts.checksum = 0 (Zupt's HMAC-SHA256 / AES-GCM-SIV outer
@ -24,6 +24,8 @@
#include "vaptvupt_api.h"
#include "vaptvupt.h"
#include <stdlib.h>
#include <string.h>
int64_t vvz_compress(const uint8_t *src, size_t src_len,
uint8_t *dst, size_t dst_cap, int level) {
@ -34,7 +36,7 @@ int64_t vvz_compress(const uint8_t *src, size_t src_len,
if (level <= 2) {
opts.mode = VV_MODE_ULTRA_FAST;
/* ULTRA_FAST + format_v2 is NOT in VaptVupt 2.48.2's tested matrix
/* ULTRA_FAST + format_v2 is NOT in VaptVupt's tested matrix
* (test_zupt_integration.c only validates format_v2 with
* EXTREME/BALANCED). The combination produces output the decoder
* rejects with VV_ERR_OVERFLOW. Stay on the v1 frame for ULTRA_FAST. */
@ -42,15 +44,44 @@ int64_t vvz_compress(const uint8_t *src, size_t src_len,
} else if (level <= 7) {
opts.mode = VV_MODE_BALANCED;
opts.format_v2 = 1; /* 4-7% better binary ratio (v2.33.0+ decoders) */
opts.filter_auto = 1; /* BCJ on recognised ELF/PE/Mach-O input
* (codec 2.55.0): no-op on everything else.
* Blocks where a filter fired need a
* v2.54.0+ decoder (tool >= 3.9.0). */
} else {
opts.mode = VV_MODE_EXTREME;
opts.format_v2 = 1; /* 4-7% better binary ratio (v2.33.0+ decoders) */
opts.filter_auto = 1; /* see BALANCED note above */
}
/* Auto window: let adaptive selection choose wlog */
opts.window_log = 0;
return vv_compress(src, src_len, dst, dst_cap, &opts);
int64_t csz = vv_compress(src, src_len, dst, dst_cap, &opts);
if (csz <= 0)
return csz;
/* F-16 (v3.9.0): read-back self-check. The engine has produced, on
* real machine-code content under specific mode x window combinations,
* streams its own decoder rejects (observed: EXTREME + forced
* window_log=20, and BALANCED + auto window, both on the same 3.5 MiB
* ELF slice; reproducible on codec 2.53.3 and 2.60.4 alike). For a
* backup tool a block that cannot be read back is data loss at
* creation time, so every compressed block is decoded and compared
* before it is accepted. On any mismatch this returns -1 and the
* caller's existing fallback stores the block uncompressed instead.
* Cost: one decompress per block (~12 ms per 4 MiB at ~300 MB/s),
* small next to BALANCED/EXTREME encode cost; correctness is not
* negotiable. */
uint8_t *chk = (uint8_t *)malloc(src_len + 64 /* SIMD over-store slack,
mirrors ZUPT_VV_DECODE_SLACK */);
if (!chk)
return -1; /* cannot prove the block reads back -> fail closed */
int64_t dsz = vv_decompress_flags(dst, (size_t)csz, chk, src_len + 64,
VV_DECOMPRESS_SKIP_CHECKSUM);
int ok = (dsz == (int64_t)src_len) && (memcmp(chk, src, src_len) == 0);
free(chk);
return ok ? csz : -1;
}
int64_t vvz_decompress(const uint8_t *src, size_t src_len,

View file

@ -192,13 +192,27 @@ static void build_dec(const uint16_t norm[NSYM], const uint8_t sp[ANS_L],
int flg = ilog2(f);
int nb_max = ANS_LOG - flg;
int low_count = (1 << (flg + 1)) - (int)f;
/* On a VALID normalized table, f ∈ [1, ANS_L) here (f==0 and
* f==ANS_L are handled above), so flg ANS_LOG-1 and nb_max 1,
* and the shifts below are well-defined. A CORRUPT stream can
* carry f > ANS_L (read_hdr_v2 does not range-check the wire
* values), giving flg ANS_LOG and nb_max 0 i.e. a negative
* shift, which is C undefined behaviour (found under UBSan on
* bit-flipped input: "shift exponent is negative"). Clamp nb_max
* to 0 and both shift amounts to 0. For valid tables
* (nb_max 1) this is a no-op, so valid-stream decode output is
* byte-for-byte unchanged; for corrupt tables it merely produces
* a defined (still-wrong) baseline that the downstream
* sequence/offset bounds checks reject. */
if (nb_max < 0) nb_max = 0;
if (k < low_count) {
dec[x].nbits = (uint8_t)nb_max;
dec[x].baseline = (uint16_t)((uint32_t)k << nb_max);
} else {
dec[x].nbits = (uint8_t)(nb_max - 1);
int sh = (nb_max > 0) ? (nb_max - 1) : 0;
dec[x].nbits = (uint8_t)sh;
dec[x].baseline = (uint16_t)(((uint32_t)low_count << nb_max)
+ ((uint32_t)(k - low_count) << (nb_max - 1)));
+ ((uint32_t)(k - low_count) << sh));
}
dec[x].symbol = s;
}
@ -516,12 +530,11 @@ vva_error_t vva_decode(const uint8_t *src, size_t src_len,
return VVA_OK;
}
uint8_t *sp = (uint8_t *)malloc(ANS_L);
uint8_t sp[ANS_L]; /* PERF: scratch for build_dec; stack, not per-block malloc */
vva_dec_entry_t *dec = (vva_dec_entry_t *)malloc(ANS_L * sizeof(*dec));
if (!sp || !dec) { free(sp); free(dec); return VVA_ERR_NOMEM; }
if (!dec) { return VVA_ERR_NOMEM; }
spread_symbols(norm, sp);
build_dec(norm, sp, dec);
free(sp);
if (hdr + 2 > src_len) { free(dec); return VVA_ERR_CORRUPT; }
uint32_t state = (uint32_t)src[hdr] | ((uint32_t)src[hdr + 1] << 8);
@ -535,7 +548,14 @@ vva_error_t vva_decode(const uint8_t *src, size_t src_len,
if (r.n < ANS_LOG) ans_br_fill(&r);
vva_dec_entry_t e = dec[state];
dst[i] = e.symbol;
uint32_t bits = ans_br_read(&r, e.nbits);
/* PERF: the fill above guarantees r.n >= ANS_LOG >= e.nbits, so the
* fill-check inside ans_br_read() is redundant here read inline
* and skip it (one fewer branch per symbol). Byte-identical to
* ans_br_read(): same mask/shift/decrement. */
int nb = e.nbits;
uint32_t bits = (uint32_t)(r.a & (((uint64_t)1 << nb) - 1));
r.a >>= nb;
r.n -= nb;
state = (uint32_t)e.baseline + bits;
if (state >= (uint32_t)ANS_L) { free(dec); return VVA_ERR_CORRUPT; }
}
@ -706,12 +726,11 @@ vva_error_t vva_decode4(const uint8_t *src, size_t src_len,
}
/* Build shared decode table */
uint8_t *sp = (uint8_t *)malloc(ANS_L);
uint8_t sp[ANS_L]; /* PERF: scratch for build_dec; stack, not per-block malloc */
vva_dec_entry_t *dec = (vva_dec_entry_t *)malloc(ANS_L * sizeof(*dec));
if (!sp || !dec) { free(sp); free(dec); return VVA_ERR_NOMEM; }
if (!dec) { return VVA_ERR_NOMEM; }
spread_symbols(norm, sp);
build_dec(norm, sp, dec);
free(sp);
/* Read 4 states (2B) + 4 bitstream sizes (4B) */
const uint8_t *p = src + hdr;
@ -761,23 +780,38 @@ vva_error_t vva_decode4(const uint8_t *src, size_t src_len,
dst[out_pos + 3] = e3.symbol;
out_pos += 4;
/* 4 state updates — use results from lookups above */
/* 4 state updates — use results from lookups above.
* PERF: each fill above guarantees r[i].n >= ANS_LOG >= e.nbits,
* so ans_br_read's internal fill-check is redundant; inline the
* read (mask/shift/decrement) and skip it. Byte-identical.
* SECURITY: validate each updated state < ANS_L before it is used
* to index dec[] in the next iteration (and the tail). The
* single-state path has always done this; the 4-way path did not,
* which let a corrupt ANS4 stream drive s[i] out of range and read
* past dec[] (OOB read found under UBSan on corrupt input). On a
* VALID stream states are always in range, so this never triggers
* and decode output is unchanged. */
if (r[0].n < ANS_LOG) ans_br_fill(&r[0]);
s[0] = (uint32_t)e0.baseline + ans_br_read(&r[0], e0.nbits);
{ int nb=e0.nbits; uint32_t b=(uint32_t)(r[0].a & (((uint64_t)1<<nb)-1)); r[0].a>>=nb; r[0].n-=nb; s[0]=(uint32_t)e0.baseline+b; }
if (r[1].n < ANS_LOG) ans_br_fill(&r[1]);
s[1] = (uint32_t)e1.baseline + ans_br_read(&r[1], e1.nbits);
{ int nb=e1.nbits; uint32_t b=(uint32_t)(r[1].a & (((uint64_t)1<<nb)-1)); r[1].a>>=nb; r[1].n-=nb; s[1]=(uint32_t)e1.baseline+b; }
if (r[2].n < ANS_LOG) ans_br_fill(&r[2]);
s[2] = (uint32_t)e2.baseline + ans_br_read(&r[2], e2.nbits);
{ int nb=e2.nbits; uint32_t b=(uint32_t)(r[2].a & (((uint64_t)1<<nb)-1)); r[2].a>>=nb; r[2].n-=nb; s[2]=(uint32_t)e2.baseline+b; }
if (r[3].n < ANS_LOG) ans_br_fill(&r[3]);
s[3] = (uint32_t)e3.baseline + ans_br_read(&r[3], e3.nbits);
{ int nb=e3.nbits; uint32_t b=(uint32_t)(r[3].a & (((uint64_t)1<<nb)-1)); r[3].a>>=nb; r[3].n-=nb; s[3]=(uint32_t)e3.baseline+b; }
if (VV_UNLIKELY((s[0] | s[1] | s[2] | s[3]) >= (uint32_t)ANS_L)) {
free(dec); return VVA_ERR_CORRUPT;
}
}
/* Scalar tail for remaining 0-3 symbols */
for (size_t i = full_quads * 4; i < num_literals; i++) {
int lane = (int)(i & 3);
if (VV_UNLIKELY(s[lane] >= (uint32_t)ANS_L)) { free(dec); return VVA_ERR_CORRUPT; }
if (r[lane].n < ANS_LOG) ans_br_fill(&r[lane]);
vva_dec_entry_t e = dec[s[lane]];
dst[i] = e.symbol;
@ -804,7 +838,7 @@ vva_error_t vva_decode4(const uint8_t *src, size_t src_len,
* For each non-inherited context c:
* [1B context_id] [2B table_size] [table_data]
*
* ZUPT-COMPAT: this function is available when VV_ANS_STANDALONE defined.
* EMBED-COMPAT: this function is available when VV_ANS_STANDALONE defined.
* Memory: ~4 MB decode tables (L3-resident), allocated per call.
* */
@ -1121,9 +1155,18 @@ vva_error_t vva_decode_ctx(const uint8_t *src, size_t src_len,
cdec[x].baseline = 0;
}
}
/* Corrupt input controls ctx_id and may repeat it. If this slot
* already holds a non-global table, free it before overwriting so
* a duplicated ctx_id leaks nothing (found via ASan leak-check on
* corrupt input). */
if (ctx_dec[ctx_id] != global_dec) free(ctx_dec[ctx_id]);
ctx_dec[ctx_id] = cdec;
}
free(sp);
free(sp); sp = NULL; /* NULL so the ctx_dec_fail path (reachable from
* the checks below) does not free sp twice a
* double-free found under ASan on corrupt input
* that passes the per-context loop but fails a
* later bounds check. */
/* Read 256 initial states */
if (p + 512 > end) goto ctx_dec_fail;
@ -1194,7 +1237,7 @@ ctx_dec_fail:
* Match length codes: 36 codes mapping to lengths 4-65538
* Offset codes: 24 codes mapping to offsets 1-16M
*
* ZUPT-COMPAT: these functions are standalone when VV_ANS_STANDALONE.
* EMBED-COMPAT: these functions are standalone when VV_ANS_STANDALONE.
*
* Output format:
* [2B lit_count] [2B lit_ans_size] [lit_ans_data]
@ -2259,8 +2302,22 @@ vva_error_t vva_decode_sequences_impl(const uint8_t *src, size_t src_len,
* + up to 4095 extra bits = 65535; ML encoding likewise). So
* op_safe_end = op_end - 65535 guarantees any single sequence's
* total writes (literals + match) fit without per-iter overflow
* checking. */
enum { SAFEZONE_MAX_OFFSET = 1u << 20 }; /* Maximum wlog supported */
* checking.
*
* SPRINT 46: raised from 1<<20 to 1<<24. The 3-byte offset wire
* encoding (off_bytes==3 for wlog>16) represents offsets up to
* exactly 2^24, so that is the true maximum legal offset and the
* correct absolute-cap DoS guard. The previous 1<<20 cap assumed
* extreme mode never exceeded a 1 MB window; the Sprint 46
* large-window scaling emits legitimate offsets up to 16 MB on
* multi-block files, which the old cap wrongly rejected as corrupt.
* Consequence: for outputs smaller than 16 MB the safe-zone floor
* is never reached, so the explicit (offset > op - dst_base) check
* runs every iteration correct, just not the fast path. The
* fast-path optimization re-engages only past 16 MB of output.
* An offset > 2^24 remains genuinely corrupt (unrepresentable in
* 3 bytes) and is still rejected, preserving the DoS guard. */
enum { SAFEZONE_MAX_OFFSET = 1u << 24 }; /* 3-byte offset wire max */
enum { SAFEZONE_MAX_RUN = 65535 }; /* litlen or matchlen */
uint8_t *op_safe_end = (dst_cap > SAFEZONE_MAX_RUN)
? op_end - SAFEZONE_MAX_RUN : dst;
@ -2278,7 +2335,7 @@ vva_error_t vva_decode_sequences_impl(const uint8_t *src, size_t src_len,
* decoder.
*
* This was a denial-of-service vulnerability for any service that
* decompressed untrusted input (Zupt's exact threat model).
* decompressed untrusted input (a host application's untrusted-input threat model).
*
* Bound: every well-formed iteration must advance at least ONE of
* the two counters by at least 1 (it's how the wire format is
@ -2328,18 +2385,39 @@ vva_error_t vva_decode_sequences_impl(const uint8_t *src, size_t src_len,
vva_dec_entry_t eof = dec_of[state_of & (ANS_L - 1)];
vva_dec_entry_t eml = dec_ml[state_ml & (ANS_L - 1)];
/* SPRINT 27 (v2.50.1): combine the 3 per-iteration OOB code
* validators into 1 branch. Previously each of ll_code, of_code,
* ml_code had a separate `if (VV_UNLIKELY(code >= MAX)) return`
* three predicted-not-taken branches per iteration. ORing
* the three bool comparisons into a single mask lets the compiler
* use one branch and parallel SIMD-style comparisons.
*
* Found via profile-driven analysis on v2.50.0 (Sprint 27). The
* three branches were each individually cheap when not taken,
* but they sit on the critical path between the table-read
* latency (L1/L2 miss on the random-walk index) and the
* subsequent bit-read, where they delay state-update of the
* NEXT iteration. Folding to one branch removes 2 branch slots
* and lets the comparator ALU run in parallel with the load
* latency for ell/eof/eml.
*
* Note: VVA_LL_CODES == VVA_ML_CODES == 36, VVA_OF_CODES == 27.
* Use the strictest bound (27) as a quick-fail mask; codes 27-35
* are still legal for LL/ML and fall through to the per-code
* tail check below. This catches the most common adversarial
* encoding (high-symbol garbage) at zero cost on the common path. */
if (VV_UNLIKELY(((unsigned)ell.symbol >= VVA_LL_CODES) |
((unsigned)eof.symbol >= VVA_OF_CODES) |
((unsigned)eml.symbol >= VVA_ML_CODES))) {
free(dec_ml); free(dec_of); free(dec_ll); free(lit_buf);
return VVA_ERR_CORRUPT;
}
/* ── Decode LL: state, extra, final litlen ── */
uint32_t ll_bits = ans_br_read(&r, ell.nbits);
state_ll = (uint32_t)ell.baseline + ll_bits;
uint8_t ll_code = ell.symbol;
/* Sprint 109 fix: corrupt frames could encode an LL ANS table
* mapping a state to a symbol >= VVA_LL_CODES, causing an OOB
* read of ll_extra[]/ll_base[]. Validate the code is in range.
* Found by libFuzzer + ASan. */
if (VV_UNLIKELY(ll_code >= VVA_LL_CODES)) {
free(dec_ml); free(dec_of); free(dec_ll); free(lit_buf);
return VVA_ERR_CORRUPT;
}
/* SPRINT 27: ll_code OOB check folded into the combined branch above. */
uint32_t ll_extra_val = ans_br_read(&r, ll_extra[ll_code]);
size_t litlen = ll_decode(ll_code, ll_extra_val);
@ -2392,12 +2470,8 @@ vva_error_t vva_decode_sequences_impl(const uint8_t *src, size_t src_len,
uint32_t of_bits = ans_br_read(&r, eof.nbits);
state_of = (uint32_t)eof.baseline + of_bits;
uint8_t of_code = eof.symbol;
/* Sprint 109 fix: bound of_code to VVA_OF_CODES range. Same
* pattern as ll_code/ml_code OOB protection. */
if (VV_UNLIKELY(of_code >= VVA_OF_CODES)) {
free(dec_ml); free(dec_of); free(dec_ll); free(lit_buf);
return VVA_ERR_CORRUPT;
}
/* SPRINT 27: of_code OOB check folded into the combined branch at
* the top of the loop (after dec_of[] read). */
uint32_t offset;
if (of_code < 3) {
offset = dec_rep[of_code];
@ -2413,11 +2487,8 @@ vva_error_t vva_decode_sequences_impl(const uint8_t *src, size_t src_len,
uint32_t ml_bits = ans_br_read(&r, eml.nbits);
state_ml = (uint32_t)eml.baseline + ml_bits;
uint8_t ml_code = eml.symbol;
/* Sprint 109 fix: bound ml_code to VVA_ML_CODES range. */
if (VV_UNLIKELY(ml_code >= VVA_ML_CODES)) {
free(dec_ml); free(dec_of); free(dec_ll); free(lit_buf);
return VVA_ERR_CORRUPT;
}
/* SPRINT 27: ml_code OOB check folded into the combined branch at
* the top of the loop (after dec_ml[] read). */
uint32_t ml_extra_val = ans_br_read(&r, ml_extra[ml_code]);
uint32_t matchlen = ml_base_tab[ml_code] + ml_extra_val;

232
src/vv_bcj.c Normal file
View file

@ -0,0 +1,232 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* VaptVupt x86 BCJ (Branch/Call/Jump) filter.
*
* Purpose: improve compression of x86/x86-64 machine code. Near CALL (0xE8)
* and JMP (0xE9) instructions carry a 32-bit little-endian *relative*
* displacement. The same call target reached from different instruction
* positions yields a *different* relative displacement, so to the
* compressor these look like noise. Converting the displacement to an
* absolute form (add the instruction's stream position) makes repeated
* references to the same target encode identically, which the LZ+ANS stage
* then compresses well. The inverse runs on decode, before the bytes are
* handed back to the caller.
*
* This is a clean-room reimplementation of the well-known x86 branch-
* converter algorithm (the same transform used by 7-Zip/xz and described
* in the LZMA SDK). The algorithm is exactly reversible on ARBITRARY input
* it is a bijection, so applying the filter to non-x86 data and then
* inverting it reproduces the input byte-for-byte. That property is
* fuzz-verified in tests; do not "optimize" the masking logic without
* re-checking inverse(forward(x)) == x on random and adversarial inputs.
*
* The buffer is transformed in place. `encoding` is non-zero for the
* forward (compress-side) transform, zero for the inverse (decode-side).
* The last up-to-4 bytes are never touched (no room for a full operand),
* which is consistent between forward and inverse.
*/
#include "vv_bcj.h"
#include <stddef.h>
#include <stdint.h>
/* A near-branch displacement's most-significant byte is treated as a sign
* extension: only 0x00 or 0xFF are considered "convertible". */
static inline int bcj_test_msb(uint8_t b) { return b == 0x00 || b == 0xFF; }
/*
* Transform data[0..size) in place. `ip` is the stream position of byte 0
* (always 0 for whole-buffer use). Returns the number of bytes processed
* (the prefix that may have been modified); the caller does not need it for
* whole-buffer use. Mirrors the reference state machine exactly so that the
* forward and inverse are perfect inverses.
*/
size_t vv_bcj_x86(uint8_t *data, size_t size, uint32_t ip, int encoding) {
if (size < 5)
return 0;
size_t pos = 0;
uint32_t mask = 0; /* rolling mask of recent E8/E9 sightings */
size_t limit = size - 4; /* last position with a full 4-byte operand */
ip += 5; /* displacement is relative to end of insn */
for (;;) {
/* Scan forward to the next byte that looks like E8/E9 ( & 0xFE == E8 ). */
uint8_t *p = data + pos;
uint8_t *end = data + limit;
for (; p < end; p++)
if ((*p & 0xFE) == 0xE8)
break;
{
size_t d = (size_t)(p - data) - pos; /* bytes skipped */
pos = (size_t)(p - data);
if (p >= end) {
return pos; /* done */
}
if (d > 2) {
mask = 0;
} else {
mask >>= (unsigned)d;
if (mask != 0 &&
(mask > 4 || mask == 3 ||
bcj_test_msb(p[(size_t)(mask >> 1) + 1]))) {
mask = (mask >> 1) | 4;
pos++;
continue;
}
}
}
if (bcj_test_msb(p[4])) {
uint32_t v = ((uint32_t)p[4] << 24) | ((uint32_t)p[3] << 16) |
((uint32_t)p[2] << 8) | ((uint32_t)p[1]);
uint32_t cur = ip + (uint32_t)pos;
pos += 5;
if (encoding) v += cur; else v -= cur;
if (mask != 0) {
unsigned sh = (mask & 6) << 2;
if (bcj_test_msb((uint8_t)((v >> sh) & 0xFF))) {
v ^= (((uint32_t)0x100 << sh) - 1);
if (encoding) v += cur; else v -= cur;
}
mask = 0;
}
p[1] = (uint8_t)(v & 0xFF);
p[2] = (uint8_t)((v >> 8) & 0xFF);
p[3] = (uint8_t)((v >> 16) & 0xFF);
p[4] = (uint8_t)((0u - ((v >> 24) & 1u)) & 0xFF);
} else {
mask = (mask >> 1) | 4;
pos++;
}
}
}
/*
* AArch64 (ARM64) BL + ADRP filter.
*
* AArch64 instructions are fixed 32-bit, little-endian, 4-byte aligned. Two
* instruction classes carry PC-relative immediates worth converting:
*
* BL (branch-with-link, "call"): opcode bits [31:26] == 0b100101, with a
* 26-bit signed immediate in bits [25:0] giving the target as a *word*
* offset relative to the instruction (byte offset = imm26 * 4).
*
* ADRP (address of 4 KiB page, PC-relative): bit [31] == 1 and bits
* [28:24] == 0b10000 (mask 0x9F000000 == 0x90000000), with a 21-bit signed
* immediate split as immlo = bits [30:29] and immhi = bits [23:5], giving a
* page offset relative to the instruction's own page.
*
* As on x86, the same callee or the same global reached from different sites
* yields different relative immediates; converting them to an absolute form
* (BL: absolute word index; ADRP: absolute page index) makes repeated
* references encode identically, which the LZ+ANS stage then compresses.
*
* Both conversions add/subtract the instruction's own index modulo the
* immediate width (2^26 for BL, 2^21 for ADRP) and write only the immediate
* bits back every opcode/register bit is preserved exactly. The decode
* pass therefore recognises the identical set of instructions, and the
* modular arithmetic is a perfect bijection on arbitrary input: bytes that
* merely look like BL/ADRP are transformed and untransformed identically, so
* the round trip is lossless regardless of content. The unconditional-branch
* encoding (B, opcode 000101) and everything else are left untouched.
*
* `ip` is the stream position of byte 0 (use 0 for whole-buffer transforms).
* `encoding` != 0 = forward (relative -> absolute), 0 = inverse. Bytes are
* processed in aligned 4-byte words; a trailing partial word is left as-is,
* consistently between forward and inverse.
*/
size_t vv_bcj_arm64(uint8_t *data, size_t size, uint32_t ip, int encoding) {
if (size < 4)
return 0;
size_t pos = 0;
size_t limit = size & ~(size_t)3; /* whole 4-byte words only */
for (; pos < limit; pos += 4) {
uint32_t insn = (uint32_t)data[pos] |
((uint32_t)data[pos + 1] << 8) |
((uint32_t)data[pos + 2] << 16) |
((uint32_t)data[pos + 3] << 24);
if ((insn >> 26) == 0x25u) {
/* BL: 26-bit word offset. */
uint32_t imm = insn & 0x03FFFFFFu;
uint32_t cur = (ip + (uint32_t)pos) >> 2; /* word index */
if (encoding) imm = (imm + cur) & 0x03FFFFFFu;
else imm = (imm - cur) & 0x03FFFFFFu;
insn = (insn & 0xFC000000u) | imm;
} else if ((insn & 0x9F000000u) == 0x90000000u) {
/* ADRP: 21-bit page offset, immlo=[30:29], immhi=[23:5]. */
uint32_t imm = ((insn >> 29) & 0x3u) | (((insn >> 5) & 0x7FFFFu) << 2);
uint32_t cur = (ip + (uint32_t)pos) >> 12; /* page index */
if (encoding) imm = (imm + cur) & 0x001FFFFFu;
else imm = (imm - cur) & 0x001FFFFFu;
insn = (insn & 0x9F00001Fu)
| ((imm & 0x3u) << 29)
| (((imm >> 2) & 0x7FFFFu) << 5);
} else {
continue;
}
data[pos] = (uint8_t)(insn & 0xFF);
data[pos + 1] = (uint8_t)((insn >> 8) & 0xFF);
data[pos + 2] = (uint8_t)((insn >> 16) & 0xFF);
data[pos + 3] = (uint8_t)((insn >> 24) & 0xFF);
}
return pos;
}
/*
* Detect the executable architecture of `data` to pick a BCJ filter.
* Recognises ELF, PE (MZ/PE), and little-endian Mach-O thin binaries. Every
* field read is length-checked, so the function is safe on truncated or
* non-executable input; in that case it returns VV_FILTER_NONE.
*/
vv_filter_kind_t vv_bcj_detect(const uint8_t *data, size_t size) {
if (!data)
return VV_FILTER_NONE;
/* ELF: 0x7F 'E' 'L' 'F'. e_ident[EI_DATA] at offset 5 (1=LE, 2=BE);
* e_machine is a 2-byte field at offset 18. */
if (size >= 20 && data[0] == 0x7F && data[1] == 'E' &&
data[2] == 'L' && data[3] == 'F') {
unsigned mach = (data[5] == 2)
? (((unsigned)data[18] << 8) | data[19]) /* big-endian */
: ((unsigned)data[18] | ((unsigned)data[19] << 8)); /* little-endian */
if (mach == 62 || mach == 3) return VV_FILTER_X86; /* EM_X86_64, EM_386 */
if (mach == 183) return VV_FILTER_ARM64; /* EM_AARCH64 */
return VV_FILTER_NONE;
}
/* PE/COFF: "MZ", then a 4-byte PE-header offset at 0x3C, then "PE\0\0"
* and a 2-byte little-endian Machine field. */
if (size >= 0x40 && data[0] == 'M' && data[1] == 'Z') {
uint32_t pe = (uint32_t)data[0x3C] | ((uint32_t)data[0x3D] << 8) |
((uint32_t)data[0x3E] << 16) | ((uint32_t)data[0x3F] << 24);
if ((size_t)pe + 6 <= size && data[pe] == 'P' && data[pe + 1] == 'E' &&
data[pe + 2] == 0 && data[pe + 3] == 0) {
unsigned mach = (unsigned)data[pe + 4] | ((unsigned)data[pe + 5] << 8);
if (mach == 0x8664 || mach == 0x014C) return VV_FILTER_X86; /* AMD64, I386 */
if (mach == 0xAA64) return VV_FILTER_ARM64; /* ARM64 */
}
return VV_FILTER_NONE;
}
/* Mach-O thin (little-endian on disk): magic 0xFEEDFACE/0xFEEDFACF, then
* a 4-byte little-endian cputype. CPU_ARCH_ABI64 = 0x01000000. */
if (size >= 8) {
uint32_t magic = (uint32_t)data[0] | ((uint32_t)data[1] << 8) |
((uint32_t)data[2] << 16) | ((uint32_t)data[3] << 24);
if (magic == 0xFEEDFACEu || magic == 0xFEEDFACFu) {
unsigned cpu = (unsigned)data[4] | ((unsigned)data[5] << 8) |
((unsigned)data[6] << 16) | ((unsigned)data[7] << 24);
if (cpu == 0x01000007u || cpu == 7u) return VV_FILTER_X86; /* x86_64, i386 */
if (cpu == 0x0100000Cu) return VV_FILTER_ARM64; /* arm64 */
}
}
return VV_FILTER_NONE;
}

View file

@ -15,6 +15,7 @@
#include "vv_platform.h"
#include "vv_huffman.h"
#include "vv_ans.h"
#include "vv_bcj.h"
#include <string.h>
#include <stdlib.h>
@ -66,6 +67,37 @@ static inline void match_copy_32(uint8_t *d, const uint8_t *s, size_t n) {
if (n > 0) memcpy(d, s, n);
}
/* SPRINT 53: hot-path match copy for offset >= 32 used ONLY when the
* caller guarantees >= 32 bytes of writable margin past d (the phase-2
* op_safe invariant: op < op_end - 72). Profiling dickens decode showed
* 98.9% of matches have offset >= 32 and average match length 6.9 bytes,
* so match_copy_32's tail `memcpy(d,s,n)` for tiny n was the dominant
* decode operation. An unconditional 32-byte store (lz4's decode trick)
* is branch-free and faster for the common short match; the over-copy
* lands in allocated safe-margin bytes that the next token overwrites.
* offset >= 32 guarantees [s, s+32) does not overlap [d, d+32), so a
* single wide load/store is correct regardless of match length. For
* n > 32 (rare: ~1% of matches), fall back to the chunked copy. */
static inline void match_copy_32_hot(uint8_t *d, const uint8_t *s, size_t n) {
if (VV_LIKELY(n <= 32)) {
wcopy32(d, s); /* single 32-byte store covers all n <= 32 */
} else {
/* n > 32 (rare: ~1% of matches). The 32-byte chunk loop is followed
* by an EXACT tail (16-byte then memcpy) rather than a final 32-byte
* over-store: the caller's room guarantee is only >= 32 bytes (the
* single-store case), not the rounded-up >= ((n+31)&~31) the old
* unconditional tail store needed. An over-store here writes up to
* 32 - (n & 31) bytes past op_end on an exactly-content-sized output
* buffer (heap-buffer-overflow WRITE). Exact tail keeps it in bounds;
* for n > 32 the per-store cost is already amortized so this is not a
* hot-path regression. */
wcopy32(d, s); d += 32; s += 32; n -= 32;
while (n >= 32) { wcopy32(d, s); d += 32; s += 32; n -= 32; }
if (n >= 16) { wcopy16(d, s); d += 16; s += 16; n -= 16; }
if (n > 0) memcpy(d, s, n);
}
}
/* Match copy with offset 16-31: 16-byte chunks, exact tail */
static inline void match_copy_16(uint8_t *d, const uint8_t *s, size_t n) {
while (n >= 16) { wcopy16(d, s); d += 16; s += 16; n -= 16; }
@ -121,9 +153,13 @@ decode_block_tokens_impl(
uint8_t *const op_safe = (dst_cap > 72) ? (op_end - 72) : op;
/* PERF: once we've written enough bytes, any offset ≤ max_dist passes
* the "offset > op - dst_base" check. Max offset is (1 << wlog) - 1,
* at most (1<<20) - 1 for wlog=20. So past this threshold, only
* offset==0 needs checking (invalid/corrupted). */
* the "offset > op - dst_base" check. Max offset is (1 << wlog) - 1:
* at most 0xFFFF for 2-byte offsets (wlog 16), 0xFFFFFF for 3-byte
* offsets (wlog 24, the extreme large-window path since Sprint 46).
* Past this threshold only offset==0 (invalid/corrupted) needs the
* explicit check. The 3-byte ceiling (2^24) is also the absolute DoS
* cap: a larger offset is unrepresentable in the wire format and is
* rejected. */
const uint32_t max_valid_off = (off_bytes == 2) ? 0xFFFF : 0xFFFFFF;
#if VV_INLINE_AVX2
@ -181,7 +217,31 @@ decode_block_tokens_impl(
if (VV_UNLIKELY(offset == 0 || offset > (uint32_t)(op - dst_base)))
return VV_ERR_CORRUPT;
/* Phase-1 warmup previously lacked the match-length output bound that
* phase 2 and the general/tail path carry. A corrupt match-length
* extension can make mlen large enough that match_copy writes past
* op_end (heap-buffer-overflow WRITE, e.g. via match_overlap for
* offset < 8). The op < op_safe loop guard only reserves a 72-byte
* margin and does not bound an extended mlen. On a VALID stream
* op + mlen never exceeds op_end, so this branch is never taken and
* decode output is byte-identical; it only rejects corrupt input. */
if (VV_UNLIKELY((size_t)(op_end - op) < mlen))
return VV_ERR_OVERFLOW;
/* match_copy_32_hot does an unconditional 32-byte store (lz4 trick)
* and so requires >= 32 bytes of writable room past op. The op_safe
* loop guard reserves 72 bytes at loop *entry*, but op advances by ll
* (which can be large via a literal-length extension) before this
* copy, so op can land within 32 bytes of op_end mid-iteration. When
* the remaining room is < 32, use the exact-tail match_copy_32 to
* avoid an over-write past op_end (heap-buffer-overflow WRITE on an
* exactly-content-sized output buffer; the wide-store overshoot is
* up to 32 - mlen bytes). Byte-identical: both copy the same mlen
* bytes; only the harmless trailing over-write differs. */
if (VV_LIKELY(offset >= 32)) {
if (VV_LIKELY((size_t)(op_end - op) >= 32))
match_copy_32_hot(op, op - offset, mlen);
else
match_copy_32(op, op - offset, mlen);
} else if (offset >= 16) {
match_copy_16(op, op - offset, mlen);
@ -241,7 +301,28 @@ decode_block_tokens_impl(
if (VV_UNLIKELY(offset == 0))
return VV_ERR_CORRUPT;
/* Phase-2 previously had NO output-length bound before the match
* copy it relied solely on the op < op_safe loop guard
* (op_safe = op_end - 72). A corrupt token whose match-length
* extension makes mlen large can therefore drive match_copy_32_hot
* to write past op_end (found under ASan on corrupt input). The
* general/tail path already has this exact check (op + mlen >
* op_end OVERFLOW); add it to the hot path too. On a VALID
* stream op + mlen never exceeds op_end, so this branch is never
* taken and decode output/perf is unchanged; it only stops corrupt
* input from over-writing. */
if (VV_UNLIKELY((size_t)(op_end - op) < mlen))
return VV_ERR_OVERFLOW;
/* See phase-1: match_copy_32_hot over-writes a full 32 bytes, so it
* needs >= 32 bytes of room past op. op advances by ll within the
* iteration, so guard against the exact buffer end and fall back to
* the exact-tail match_copy_32 when room < 32. Byte-identical output;
* prevents an OOB write on an exactly-content-sized buffer. */
if (VV_LIKELY(offset >= 32)) {
if (VV_LIKELY((size_t)(op_end - op) >= 32))
match_copy_32_hot(op, op - offset, mlen);
else
match_copy_32(op, op - offset, mlen);
} else if (offset >= 16) {
match_copy_16(op, op - offset, mlen);
@ -574,7 +655,7 @@ int64_t vv_decompress_flags(const uint8_t *src, size_t src_len,
uint8_t *op_end = dst + dst_cap;
/* MULTI-FRAME: a .vv file may contain one or more concatenated frames
* (useful for parallel encode, Zupt-style archives, append-mode
* (useful for parallel encode, multi-frame archives, append-mode
* writes). We decode frames in a loop until input is exhausted. */
while (ip < ip_end) {
if (ip + sizeof(vv_frame_header_t) > ip_end) return VV_ERR_CORRUPT;
@ -679,6 +760,21 @@ int64_t vv_decompress_flags(const uint8_t *src, size_t src_len,
ip += sizeof(vv_frame_footer_t);
}
/* x86 BCJ inverse (flags bit2): the encoder applied the forward
* branch transform to this frame's bytes BEFORE compression, and
* checksummed the transformed bytes, so we invert AFTER the
* checksum check, over exactly this frame's output region. The
* transform was done with ip=0 per frame, so the inverse uses 0
* too. No-op for frames without the flag. */
if (fh.flags & 4) {
vv_bcj_x86(frame_out_start, (size_t)(op - frame_out_start), 0, 0);
}
/* AArch64 BCJ inverse (flags bit3): same contract as the x86 case
* above. A frame carries at most one of bit2/bit3. */
if (fh.flags & 8) {
vv_bcj_arm64(frame_out_start, (size_t)(op - frame_out_start), 0, 0);
}
/* Loop back to try another frame (if input remains) */
}
@ -876,6 +972,12 @@ int vv_dstream_decompress_chunk(vv_dstream_t *ctx,
if (err != VV_OK || actual != dsz) { ctx->state = VV_DSTREAM_ERROR; return err != VV_OK ? err : VV_ERR_CORRUPT; }
} else { /* ENTROPY */
uint32_t csz = (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16);
/* SPRINT 123 (v2.48.5): csz == 0 makes bdata_len underflow
* to SIZE_MAX, causing the entropy decoder to read past
* the input buffer. Found by libFuzzer fuzz_dstream.
* The stateless decoder already has this check at line 631;
* porting it here. */
if (csz < 1) { ctx->state = VV_DSTREAM_ERROR; return VV_ERR_CORRUPT; }
uint8_t tag = p[3];
const uint8_t *bdata = p + 4;
size_t bdata_len = csz - 1;

View file

@ -18,6 +18,7 @@
#include "vv_platform.h"
#include "vv_huffman.h"
#include "vv_ans.h"
#include "vv_bcj.h"
#include <stdlib.h>
#include <string.h>
@ -210,9 +211,40 @@ typedef struct {
uint8_t wlog; /* Window log: controls max offset distance */
uint8_t use_hash4; /* Enable hash4 fallback (binary data only) */
uint8_t use_hash3; /* Enable hash3 fallback (format v2 only) */
uint8_t single_probe; /* SPRINT 58: fast-mode lean match finder.
* When set, compress_block uses
* single_probe_match (a stripped chain walk:
* same depth and match-selection as
* chain_match, so output is identical, but
* without the priming prefetch and the dead
* hash4/hash3 branches). Set ONLY on the real
* ULTRA_FAST matcher; left 0 on the
* balanced/extreme window-selection trial
* matchers so their output stays
* bit-identical. */
uint32_t max_match; /* Max representable matchlen (65535 for v1,
* 65534 for v2: ml_base_v2[35]=32767 with 15
* extra bits only reaches 65534). */
uint32_t accel; /* Position-skip acceleration factor (0 = off).
* When >0, after a run of `f` consecutive
* positions with no match, compress_block
* advances by 1 + ((f*accel) >> 6) instead of 1,
* skipping the hash/insert/rep work on
* unmatchable regions. Massively speeds up
* encode on incompressible / already-compressed
* input (measured ~8-9x on random/gzip data),
* with a small ratio cost on compressible data
* (so it is opt-in; default 0 keeps output
* byte-identical). Skipped positions become
* literals; output stays decodable by any
* decoder. */
uint8_t no_rep; /* 1 = skip rep-match probing in compress_block.
* Measured net-positive on ratio in FAST mode
* (no entropy stage, so rep's short-offset code
* advantage never materializes; it only perturbs
* the greedy parse) and ~10% faster. Opt-in
* (--no-rep); default 0 keeps rep enabled and
* output byte-identical. */
} matcher_t;
/* SPRINT 93 audit: returns 1 on success, 0 on allocation failure.
@ -260,6 +292,9 @@ static int matcher_init(matcher_t *m, uint32_t window_log, uint32_t depth) {
m->wlog = (uint8_t)window_log;
m->use_hash4 = 0; /* Disabled by default — enabled adaptively for binary */
m->use_hash3 = 0; /* Disabled by default — enabled for format v2 */
m->single_probe = 0; /* Disabled by default — set only for ULTRA_FAST encode */
m->accel = 0; /* Position-skip acceleration off by default (opt-in --accel) */
m->no_rep = 0; /* rep-match probing on by default (opt-in --no-rep) */
m->max_match = VV_MAX_MATCH; /* v1 default, see matcher_set_format_v2 */
return 1;
}
@ -323,6 +358,36 @@ static void matcher_reset(matcher_t *m) {
* not adaptive behavior that should clear on reset. */
}
/* SPRINT 29 (v2.50.3): _fast variant of matcher_insert for use inside
* bulk-insert loops where the caller has already ensured pos + 5 <= end.
* Skips both the boundary check and the hash5/hash4 dispatch, going
* straight to hash5. Used inside the post-match-emit insert loops in
* compress_block where we already gate on `j <= end - 5`.
*
* Saves ~3 instructions per insert (one compare, one branch, one
* hash_safe dispatch). For dickens at ~2M inserts per encode, that's
* a measurable win on encode throughput (~+4% on Silesia fast mode,
* Sprint 29 measurement).
*
* SAFETY: caller MUST ensure pos + 5 <= end before calling. No
* runtime check undefined behavior if violated. */
static inline void matcher_insert_fast(matcher_t *m, const uint8_t *data,
int32_t pos) {
uint32_t h = hash5(data + pos);
m->chain[pos & m->chain_mask] = m->table[h];
m->table[h] = pos;
if (m->use_hash4) {
uint32_t h4 = hash4_short(data + pos);
m->hash4_chain[pos & m->chain_mask] = m->table4[h4];
m->table4[h4] = pos;
}
if (m->use_hash3) {
uint32_t h3 = hash3_short(data + pos);
m->hash3_chain[pos & m->chain_mask] = m->table3[h3];
m->table3[h3] = pos;
}
}
static inline void matcher_insert(matcher_t *m, const uint8_t *data,
int32_t pos, int32_t end) {
if (pos + 4 > end) return;
@ -462,14 +527,33 @@ chain_match_ex(const matcher_t *m, const uint8_t *data,
}
}
while (ref >= 0 && ref >= limit && ref < pos && depth-- > 0) {
while (ref >= limit && ref < pos && depth-- > 0) {
int32_t next_ref = chain_arr[ref & chain_mask];
/* Prefetch the link 2-3 iterations ahead so the linked-list
* chain of loads can overlap with match-compare work */
if (next_ref >= limit && next_ref < pos) {
/* SPRINT 30 (v2.50.4): prefetch unconditionally. The previous
* guard `if (next_ref >= limit && next_ref < pos)` cost 2
* branches per iteration in a hot function (chain_match_ex
* fires ~2.5M times per 10 MB encode in fast mode; each call
* walks chain_depth iterations).
*
* __builtin_prefetch tolerates any address a bogus prefetch
* just becomes harmless L1 pollution. The actual data load
* (`memcpy(&b, data + ref, 4)`) and chain step still respect
* the validity invariants. Only the prefetch hint is unguarded.
*
* Also removed the redundant `ref >= 0` from the while condition:
* since `limit >= 0` (clamped at line 410), `ref >= limit` already
* implies `ref >= 0`.
*
* Measured: +2.7% encode on dickens fast (median of 10 runs,
* interleaved). Marginal on sao (+1.3%) and x-ray (+0.6%)
* within measurement noise but directionally consistent. Effect
* is small because chain_depth=4 in fast mode and modern OoO
* cores already speculate past the guard's branches. The change
* still benefits because it (a) strictly removes code, (b) lets
* the hardware prefetcher start deeper, and (c) simplifies the
* inner loop for future optimization. */
__builtin_prefetch(data + next_ref, 0, 0);
__builtin_prefetch(&chain_arr[next_ref & chain_mask], 0, 0);
}
uint32_t b;
memcpy(&b, data + ref, 4);
@ -506,12 +590,12 @@ chain_match_ex(const matcher_t *m, const uint8_t *data,
__builtin_prefetch(data + ref4, 0, 0);
}
while (ref4 >= 0 && ref4 >= limit && ref4 < pos && depth4-- > 0) {
while (ref4 >= limit && ref4 < pos && depth4-- > 0) {
int32_t next_ref4 = m->hash4_chain[ref4 & m->chain_mask];
if (next_ref4 >= limit && next_ref4 < pos) {
/* SPRINT 30: unconditional prefetch (same rationale as
* the hash5 walk above). */
__builtin_prefetch(data + next_ref4, 0, 0);
__builtin_prefetch(&m->hash4_chain[next_ref4 & m->chain_mask], 0, 0);
}
uint32_t b4;
memcpy(&b4, data + ref4, 4);
@ -611,6 +695,76 @@ static int32_t chain_match(const matcher_t *m, const uint8_t *data,
return chain_match_ex(m, data, pos, end, best_off, m->use_hash4);
}
/* ─── SPRINT 58: lean fast-mode match finder (ULTRA_FAST encode) ─────
* A stripped-down chain walk for fast mode. It walks the same hash5
* chain to the same depth as chain_match (m->chain_depth == 4 for fast
* mode) and selects the match by the same rule (first strictly-longest,
* early-out at len >= 256), so it produces BYTE-IDENTICAL output to the
* pre-Sprint-58 chain_match on fast-mode input verified on all 12
* Silesia fixtures. The speed comes purely from what it omits on the
* per-position hot path:
* - the 4-way software-pipelined priming prefetch block,
* - the per-iteration unconditional prefetches,
* - the (always-false in fast mode) hash4 and hash3 fallback branches.
* Measured fast-mode encode: +9-12% on dickens/xml/samba, with decode
* and ratio unchanged (output is identical). The change is measured byte-identical (ratio gate +/- 0).
*
* A depth-1 (true lz4-style single-probe) and a depth-2/3 sweep were
* measured and REJECTED: lowering the depth raises encode further but
* degrades BOTH ratio and decode (shorter matches more tokens/byte
* slower decode), trading the two metrics the SPEED PROGRAM ranks above
* encode. depth-4 is the only point that improves encode at zero cost.
*
* Used by compress_block ONLY when m->single_probe is set, which is
* ONLY the real ULTRA_FAST matcher. The balanced/extreme window trial
* matchers keep single_probe==0 and use chain_match, so their output is
* bit-identical to before this sprint.
*
* Returns match length (>= VV_MIN_MATCH on hit, 0 on miss) and writes
* the offset to *best_off. The 4-byte compare plus extend_match verify
* actual byte equality, so the emitted match is always decode-correct
* regardless of hash collisions. */
static VV_NO_SANITIZE_INTEGER int32_t
single_probe_match(const matcher_t *m, const uint8_t *data,
int32_t pos, int32_t end, int32_t *best_off) {
*best_off = 0;
if (pos + 4 > end) return 0;
int32_t max_dist = (int32_t)((1u << m->wlog) - 1);
int32_t limit = pos - max_dist;
if (limit < 0) limit = 0;
uint32_t h = hash_safe(data + pos, end - pos);
int32_t ref = m->table[h];
uint32_t pos4;
memcpy(&pos4, data + pos, 4);
int32_t best_len = 0;
uint32_t depth = m->chain_depth; /* same depth as chain_match (4 for fast) */
uint32_t chain_mask = m->chain_mask;
const int32_t *chain_arr = m->chain;
int32_t mm = (int32_t)m->max_match;
while (ref >= limit && ref < pos && depth-- > 0) {
int32_t next_ref = chain_arr[ref & chain_mask];
uint32_t b;
memcpy(&b, data + ref, 4);
if (pos4 == b) {
int32_t max = end - pos;
if (max > mm) max = mm;
int32_t len = 4 + extend_match(data + pos + 4, data + ref + 4, max - 4);
if (len > best_len) {
best_len = len;
*best_off = pos - ref;
if (len >= 256) break;
}
}
ref = next_ref;
}
return best_len;
}
/* Update rep offsets (push new offset, shift others down) */
static inline void update_rep(matcher_t *m, uint32_t offset) {
if (offset == m->rep[0]) return;
@ -654,6 +808,243 @@ static size_t emit_seq(uint8_t *dst, const uint8_t *lits,
return (size_t)(op - dst);
}
/* ═══════════════════════════════════════════════════════════════
* SPRINT 42/43: OPTIMAL PARSE (extreme mode only) RATIO PROGRAM
*
* Whole-block forward DP. price[i] = min bits to encode src[start..start+i).
* Matches are SINGLE edges i -> i+len (no windowing, no truncation), which
* is what makes long-match data (mozilla/nci) compress correctly: a long
* match stays one cheap token.
*
* Block size is bounded at VV_MAX_BLOCK_SIZE (1 MB), so price[block_len+1]
* (int32) is at most ~4 MB affordable per block.
*
* WIRE FORMAT NEUTRAL: emits the same (ll, mlen, moff) token stream that
* emit_seq consumes. Verified byte-perfect roundtrip on all fixtures.
* GATED TO EXTREME MODE: balanced/fast keep greedy/lazy.
* */
#define VV_OPT_MAX_CAND 16
#define VV_OPT_PRICE_INF 0x3FFFFFFF
typedef struct { uint32_t off; int32_t len; } opt_cand_t;
/* literal bit price (~6 bits/byte, formalizes Sprint 119 lazy model) */
/* Sprint 44: literal price 8 bits/byte (was 6 in v2.51.0).
*
* Sweep across full Silesia at extreme mode found litp=8 minimizes
* aggregate compressed size:
*
* litp aggregate vs v2.50.11 baseline
* 6 -2.75% geomean (v2.51.0 default)
* 7 -3.45%
* 8 -3.64% chosen
* 9 -3.50%
*
* The flat-6 model under-priced literals: 4-stream Huffman delivers ~6
* bits/byte on text but 7-8 bits/byte on dense binary (sao, x-ray,
* mozilla). Raising the constant to 8 makes the parser less willing to
* substitute a near-match for a literal run on dense data without
* sacrificing text wins. Result vs v2.51.0: 10/12 fixtures improve, only
* nci slightly worse (nci exceeds the 16 MB window; addressed in a
* future window-size sprint).
*
* A future refinement is true per-byte Huffman costs from a first parse
* pass (two-pass repricing) that would let the parser exploit
* byte-frequency skew within a block. Empirically the flat constant
* captures most of the available win at zero added complexity, so this
* sprint ships it and defers the two-pass design until the window-size
* lever has been measured (matters more for nci-class fixtures). */
static inline int32_t opt_lit_price(void) { return 8; }
/* match bit price: cost_const(14) + log2(off) + ml_extra; rep ~2 bits */
static inline int32_t opt_match_price(const matcher_t *m, uint32_t off, int32_t len) {
int is_rep = (off == m->rep[0] || off == m->rep[1] || off == m->rep[2]);
int32_t log2_off = 0; uint32_t o = off;
while (o > 1) { o >>= 1; log2_off++; }
int32_t off_bits = is_rep ? 2 : (14 + log2_off);
int32_t ml_extra = 0, v = len - VV_MIN_MATCH;
if (v >= 15) ml_extra = 8 * (v / 255 + 1);
return off_bits + ml_extra;
}
/* Collect match candidates at pos (longest per distinct offset). */
static int opt_collect(const matcher_t *m, const uint8_t *data,
int32_t pos, int32_t end, opt_cand_t *cands) {
int n = 0;
int32_t max_dist = (int32_t)((1u << m->wlog) - 1);
int32_t limit = pos - max_dist; if (limit < 0) limit = 0;
if (pos + 4 > end) return 0;
int32_t max = end - pos;
if (max > (int32_t)m->max_match) max = (int32_t)m->max_match;
uint32_t pos4; memcpy(&pos4, data + pos, 4);
for (int r = 0; r < 3; r++) {
uint32_t roff = m->rep[r];
if (roff == 0 || (int32_t)roff > pos) continue;
const uint8_t *a = data + pos, *b = data + pos - roff;
int32_t l = 0; while (l < max && a[l] == b[l]) l++;
if (l >= VV_MIN_MATCH && n < VV_OPT_MAX_CAND) { cands[n].off = roff; cands[n].len = l; n++; }
}
uint32_t h = hash_safe(data + pos, end - pos);
int32_t ref = m->table[h];
uint32_t depth = m->chain_depth, chain_mask = m->chain_mask;
int32_t *chain_arr = m->chain;
while (ref >= limit && ref < pos && depth-- > 0 && n < VV_OPT_MAX_CAND) {
uint32_t b; memcpy(&b, data + ref, 4);
if (pos4 == b) {
int32_t l = 4 + extend_match(data + pos + 4, data + ref + 4, max - 4);
uint32_t off = (uint32_t)(pos - ref);
int dup = 0;
for (int k = 0; k < n; k++) if (cands[k].off == off) { if (cands[k].len < l) cands[k].len = l; dup = 1; break; }
if (!dup && l >= VV_MIN_MATCH) { cands[n].off = off; cands[n].len = l; n++; }
}
ref = chain_arr[ref & chain_mask];
}
return n;
}
static size_t compress_block_optimal(const uint8_t *src, size_t start_pos,
size_t block_len, uint8_t *dst,
size_t dst_cap, matcher_t *m, int min_match) {
uint8_t *op = dst;
int32_t base = (int32_t)start_pos;
int32_t end = (int32_t)(start_pos + block_len);
int off_bytes = (m->wlog > 16) ? 3 : 2;
int32_t N = (int32_t)block_len;
/* DP arrays indexed by offset-from-base [0..N]. */
int32_t *price = (int32_t *)malloc(sizeof(int32_t) * (N + 1));
int32_t *plen = (int32_t *)malloc(sizeof(int32_t) * (N + 1));
uint32_t *poff = (uint32_t *)malloc(sizeof(uint32_t) * (N + 1));
opt_cand_t *cands = (opt_cand_t *)malloc(sizeof(opt_cand_t) * VV_OPT_MAX_CAND);
if (!price || !plen || !poff || !cands) { free(price); free(plen); free(poff); free(cands); return 0; }
for (int32_t i = 0; i <= N; i++) { price[i] = VV_OPT_PRICE_INF; plen[i] = 0; poff[i] = 0; }
price[0] = 0;
/* Forward DP. We also must keep the matcher hash chains populated as we
* advance, so matches reference earlier positions correctly. We insert
* every position into the matcher as we visit it (DP order = position
* order since edges only go forward). */
/* SPRINT 43: work budget. The optimal DP is O(N × chain_depth ×
* extend). On adversarial self-similar data (long chains + long
* extends at every position) this degrades to near-quadratic and
* becomes a DoS vector. We bound total candidate-collection work;
* if exceeded, bail (return 0) so emit_block falls to greedy/lazy
* via the raw-store path is NOT what we want instead we cap by
* short-circuiting long matches, which both bounds work AND is the
* correct optimal choice (a very long match is never beaten). */
const int32_t LONG_MATCH = 512; /* take immediately, skip interior DP */
for (int32_t i = 0; i < N; i++) {
if (price[i] >= VV_OPT_PRICE_INF) {
matcher_insert(m, src, base + i, end);
continue;
}
int32_t ip = base + i;
/* literal edge */
int32_t lp = price[i] + opt_lit_price();
if (lp < price[i + 1]) { price[i + 1] = lp; plen[i + 1] = 1; poff[i + 1] = 0; }
/* match edges */
if (ip + min_match <= end) {
int nc = opt_collect(m, src, ip, end, cands);
/* Find the longest candidate. */
int32_t best_len = 0; uint32_t best_off = 0;
for (int c = 0; c < nc; c++) {
if (cands[c].len > best_len) { best_len = cands[c].len; best_off = cands[c].off; }
}
if (best_len >= LONG_MATCH) {
/* LONG MATCH SHORT-CIRCUIT: a match this long is never
* beaten by any combination of shorter tokens. Take it
* as a single edge, skip the per-length relaxation AND
* skip DP/insertion for its interior positions. This
* bounds worst-case work on repetitive data: instead of
* O(match_len) work per interior position, we jump over
* the whole match. */
int32_t use = best_len;
if (i + use > N) use = N - i;
int32_t np = price[i] + opt_match_price(m, best_off, use);
int32_t j = i + use;
if (np < price[j]) { price[j] = np; plen[j] = use; poff[j] = best_off; }
/* Insert boundary positions only (match-skip heuristic),
* then jump the DP cursor to the match end. */
int32_t end5 = end - 5;
for (int32_t q = ip; q < ip + 3 && q <= end5; q++) matcher_insert_fast(m, src, q);
for (int32_t q = ip + use - 3; q < ip + use && q <= end5; q++) matcher_insert_fast(m, src, q);
/* Advance i to j-1 (loop ++ makes it j). price[j] is set;
* intermediate price[i+1..j-1] stay INF, which is fine
* the backtrack follows plen[] from reachable nodes only. */
i = j - 1;
continue;
}
for (int c = 0; c < nc; c++) {
int32_t mlen = cands[c].len; uint32_t moff = cands[c].off;
if (i + mlen > N) mlen = N - i;
if (mlen < min_match) continue;
for (int32_t L = mlen; L >= min_match; L--) {
int32_t np = price[i] + opt_match_price(m, moff, L);
int32_t j = i + L;
if (np < price[j]) { price[j] = np; plen[j] = L; poff[j] = moff; }
if (L > min_match + 8 && L < mlen) L = min_match + 9;
}
}
}
matcher_insert(m, src, ip, end);
}
/* Backtrack from N to 0 to recover the token sequence (reverse). */
/* Worst case every position is a literal: N entries. */
int32_t *seq_len = (int32_t *)malloc(sizeof(int32_t) * (N + 1));
uint32_t *seq_off = (uint32_t *)malloc(sizeof(uint32_t) * (N + 1));
if (!seq_len || !seq_off) { free(price); free(plen); free(poff); free(cands); free(seq_len); free(seq_off); return 0; }
int32_t ns = 0, cur = N;
while (cur > 0) {
int32_t L = plen[cur];
if (L <= 0) L = 1; /* safety: treat as literal */
seq_len[ns] = L; seq_off[ns] = poff[cur]; ns++;
cur -= L;
}
/* Emit forward (reverse the backtrack). Accumulate literals between
* matches into literal runs, exactly like compress_block. */
const uint8_t *lit_start = src + base;
int32_t pos = base;
for (int k = ns - 1; k >= 0; k--) {
int32_t L = seq_len[k]; uint32_t O = seq_off[k];
if (O == 0) {
pos++; /* literal: extend pending run */
} else {
size_t ll = (size_t)(src + pos - lit_start);
size_t needed = 1 + (ll >= 15 ? ll / 255 + 2 : 0) + ll + 2 + ((size_t)L / 255 + 2);
if ((size_t)(op - dst) + needed > dst_cap) {
free(price); free(plen); free(poff); free(cands); free(seq_len); free(seq_off);
return 0;
}
op += emit_seq(op, lit_start, ll, (size_t)L, O, off_bytes, min_match);
update_rep(m, O);
pos += L;
lit_start = src + pos;
}
}
/* trailing literals */
{
size_t ll = (size_t)(src + end - lit_start);
size_t needed = 1 + (ll >= 15 ? ll / 255 + 2 : 0) + ll;
if ((size_t)(op - dst) + needed > dst_cap) {
free(price); free(plen); free(poff); free(cands); free(seq_len); free(seq_off);
return 0;
}
op += emit_seq(op, lit_start, ll, 0, 0, off_bytes, min_match);
}
free(price); free(plen); free(poff); free(cands); free(seq_len); free(seq_off);
return (size_t)(op - dst);
}
/* ═══════════════════════════════════════════════════════════════
* COMPRESS BLOCK: greedy / lazy / lazy-2
*
@ -671,13 +1062,14 @@ static size_t compress_block(const uint8_t *src, size_t start_pos, size_t block_
int32_t end = (int32_t)(start_pos + block_len);
const uint8_t *lit_start = src + start_pos;
int off_bytes = (m->wlog > 16) ? 3 : 2;
uint32_t failures = 0; /* consecutive no-match positions (for --accel skip) */
while (pos < end - min_match) {
int32_t mlen = 0, moff = 0;
/* ─── Step 1: Try rep-match (free, no hash lookup) ─── */
int32_t rep_idx = -1;
int32_t rep_len = try_rep_match(m, src, pos, end, &rep_idx);
int32_t rep_len = m->no_rep ? 0 : try_rep_match(m, src, pos, end, &rep_idx);
if (rep_len >= min_match) {
mlen = rep_len;
@ -687,7 +1079,18 @@ static size_t compress_block(const uint8_t *src, size_t start_pos, size_t block_
/* ─── Step 2: Hash chain match (only if rep didn't find a long one) ─── */
if (mlen < 8) {
int32_t chain_off = 0;
int32_t chain_len = chain_match(m, src, pos, end, &chain_off);
/* SPRINT 58: fast mode (single_probe) uses the lean
* finder; balanced/extreme use the full chain walk. The
* branch is on a matcher flag set only for the real
* ULTRA_FAST encode, so balanced/extreme (and the window-
* selection trial) take the chain_match path exactly as
* before bit-identical output. The lean finder selects
* the same match as chain_match at the same depth, so
* fast-mode output is unchanged too; only the per-position
* search overhead drops. */
int32_t chain_len = m->single_probe
? single_probe_match(m, src, pos, end, &chain_off)
: chain_match(m, src, pos, end, &chain_off);
if (chain_len > mlen) {
mlen = chain_len;
moff = chain_off;
@ -735,7 +1138,7 @@ static size_t compress_block(const uint8_t *src, size_t start_pos, size_t block_
/* Also check rep at pos+1 */
int32_t nri = -1;
int32_t nrl = try_rep_match(m, src, pos + 1, end, &nri);
int32_t nrl = m->no_rep ? 0 : try_rep_match(m, src, pos + 1, end, &nri);
if (nrl > nlen && nri >= 0) { nlen = nrl; noff = (int32_t)m->rep[nri]; }
/* SPRINT 119: cost-aware lazy decision (closes the +1.2%
@ -825,26 +1228,46 @@ static size_t compress_block(const uint8_t *src, size_t start_pos, size_t block_
op += emit_seq(op, lit_start, ll, (size_t)mlen, (uint32_t)moff, off_bytes, min_match);
/* ─── Hash insertion with skip heuristic ─── */
/* SPRINT 29: use matcher_insert_fast inside the bulk loops
* (skips per-iteration hash_safe dispatch and boundary
* check). Bound is `j + 5 <= end` so hash5 is always safe.
* Positions in [end-4, end) are not inserted by this loop;
* for typical block sizes (64KB+) the missed boundary
* position is negligible (1 position).
*
* Saves ~3 instructions per insert. Measured +4% encode
* speedup on Silesia fast mode (Sprint 29). */
if (mlen >= 16) {
/* Long match: only insert boundary positions */
for (int32_t j = pos; j < pos + 3 && j < end - 4; j++)
matcher_insert(m, src, j, end);
for (int32_t j = pos + mlen - 3; j < pos + mlen && j < end - 4; j++)
matcher_insert(m, src, j, end);
int32_t end5 = end - 5;
for (int32_t j = pos; j < pos + 3 && j <= end5; j++)
matcher_insert_fast(m, src, j);
for (int32_t j = pos + mlen - 3; j < pos + mlen && j <= end5; j++)
matcher_insert_fast(m, src, j);
} else {
/* Short match: insert all positions */
for (int32_t j = pos; j < pos + mlen && j < end - 4; j++)
matcher_insert(m, src, j, end);
int32_t end5 = end - 5;
for (int32_t j = pos; j < pos + mlen && j <= end5; j++)
matcher_insert_fast(m, src, j);
}
update_rep(m, (uint32_t)moff);
pos += mlen;
lit_start = src + pos;
failures = 0; /* matched: reset the no-match run */
} else {
matcher_insert(m, src, pos, end);
/* --accel: skip ahead over unmatchable regions. accel==0 keeps
* the byte-identical default (advance 1). The skipped positions
* are not hashed/inserted and simply become literals. */
if (m->accel) {
pos += 1 + (int32_t)(((uint32_t)failures * m->accel) >> 6);
failures++;
} else {
pos++;
}
}
}
/* ─── Trailing literals ─── */
{
@ -961,7 +1384,14 @@ static size_t emit_block(const uint8_t *src, size_t block_start, size_t braw,
int compat_v246_5) {
uint8_t *op = dst;
size_t csz = compress_block(src, block_start, braw, tmp, tcap, m, mode, min_match);
/* SPRINT 42/43 RATIO PROGRAM: extreme mode uses the whole-block optimal
* parser; balanced/fast keep greedy/lazy. csz==0 (overflow/alloc) flows
* into the raw-store branch below. */
size_t csz;
if (mode >= VV_MODE_EXTREME)
csz = compress_block_optimal(src, block_start, braw, tmp, tcap, m, min_match);
else
csz = compress_block(src, block_start, braw, tmp, tcap, m, mode, min_match);
if (csz == 0 || csz >= braw) {
/* Incompressible: store raw */
@ -1004,6 +1434,23 @@ static size_t emit_block(const uint8_t *src, size_t block_start, size_t braw,
size_t ent_block_sz = (size_t)-1;
int try_path_b = 1;
/* PERF / dead-code prune (v2.53.3): Path B (literal-only 'I'/'C'
* entropy) has a measured 0% win rate against Path A (SEQ) across
* all real inputs tested (text, binary, logs, CSV) SEQ always
* codes the same literals at least as small while also coding the
* matches. Path B can only conceivably win on a block where SEQ
* failed to find structure (its compressed size approaches raw).
* So skip Path B's extract_literals + redundant ANS encodes
* whenever SEQ is valid and already beats raw by a clear margin
* (seq_block_sz < braw*7/8). On blocks where SEQ does not compress
* (>= braw*7/8) Path B still runs, preserving the only case it
* could win. Verified byte-identical on all 12 Silesia (balanced +
* extreme) and on binary/log/CSV; the ratio gate guards against any
* regression. This removes redundant per-block work; it is a
* code-cleanliness change, not a measurable speedup (Path B was not
* the encode bottleneck that is the depth-24 chain walk). */
if (seq_valid && seq_block_sz < (braw * 7 / 8))
try_path_b = 0;
if (mode == VV_MODE_BALANCED && seq_valid && seq_block_sz < (braw / 3)) {
/* SPRINT 29 (revised in v2.15): always try Path B in BALANCED
* mode, comparing both costs and picking the smaller. The
@ -1149,9 +1596,53 @@ size_t vv_compress_bound(size_t src_len) {
+ sizeof(vv_frame_header_t) + sizeof(vv_frame_footer_t);
}
/* Public vv_compress: select and apply a reversible BCJ branch filter, then
* compress. A filter may be requested explicitly (filter_x86 / filter_arm64)
* or chosen automatically (filter_auto: sniff the executable header). The
* filter runs on a private copy because the public input is const; the
* matching header flag (bit2 x86 / bit3 ARM64), set by vv_compress_inner from
* the resolved options, tells the decoder to invert it. When no filter
* applies, this is a direct pass-through with no copy and byte-identical
* output. */
int64_t vv_compress_inner(const uint8_t *src, size_t src_len,
uint8_t *dst, size_t dst_cap,
const vv_options_t *opts);
int64_t vv_compress(const uint8_t *src, size_t src_len,
uint8_t *dst, size_t dst_cap,
const vv_options_t *opts) {
int auto_on = opts && opts->filter_auto &&
!opts->filter_x86 && !opts->filter_arm64;
if (opts && (opts->filter_x86 || opts->filter_arm64 || auto_on) &&
src_len > 0 && src) {
vv_options_t eff = *opts;
if (auto_on) {
vv_filter_kind_t k = vv_bcj_detect(src, src_len);
if (k == VV_FILTER_X86) eff.filter_x86 = 1;
else if (k == VV_FILTER_ARM64) eff.filter_arm64 = 1;
/* k == NONE: leave eff with no filter -> falls through below */
}
if (eff.filter_x86 || eff.filter_arm64) {
uint8_t *copy = (uint8_t *)malloc(src_len);
if (!copy) return VV_ERR_NOMEM;
memcpy(copy, src, src_len);
if (eff.filter_x86)
vv_bcj_x86(copy, src_len, 0, 1); /* forward: relative -> absolute */
else
vv_bcj_arm64(copy, src_len, 0, 1); /* AArch64 BL + ADRP */
int64_t r = vv_compress_inner(copy, src_len, dst, dst_cap, &eff);
free(copy);
return r;
}
/* auto-detect found no executable header: fall through unchanged */
}
return vv_compress_inner(src, src_len, dst, dst_cap, opts);
}
int64_t vv_compress_inner(const uint8_t *src, size_t src_len,
uint8_t *dst, size_t dst_cap,
const vv_options_t *opts) {
/* SPRINT 95 audit: accept NULL opts (fall back to defaults) for
* consistency with vv_cstream_create. Also accept src_len=0
* (an empty frame is a valid thing to produce some streaming
@ -1182,6 +1673,11 @@ int64_t vv_compress(const uint8_t *src, size_t src_len,
case VV_MODE_EXTREME: depth = 256; break;
default: depth = 24;
}
/* Opt-in chain-depth override (default 0 = mode default, byte-identical). */
if (opts->depth_override) {
depth = opts->depth_override;
if (depth > 4096) depth = 4096;
}
/* ─── ADAPTIVE WINDOW + HASH4 detection in a single trial.
* PERF: previously this was two separate 128K+64K=192K trials, run
@ -1229,13 +1725,45 @@ int64_t vv_compress(const uint8_t *src, size_t src_len,
wlog = 18;
}
/* SPRINT 46 (RATIO PROGRAM): extreme-mode large-window scaling.
* The trial/override above caps extreme at wlog18-20 (256KB-1MB),
* far too small for the multi-MB Silesia fixtures with long-range
* structure (nci 33MB, webster 41MB, mozilla 51MB). The whole-block
* optimal parser exploits a larger window across block boundaries
* (the matcher chains persist between blocks).
*
* Scale wlog with file size, capped at 2^24 = 16 MB. The cap is a
* HARD wire-format limit: the offset field is 3 bytes (24 bits) for
* wlog>16, so the maximum representable offset is exactly 2^24.
* (Reaching 2^27 like zstd --long requires 4-byte offsets a
* wire-format change deferred to Lever B.)
*
* REQUIRED companion fix (same sprint): the ANS sequence decoder's
* SAFEZONE_MAX_OFFSET was raised from 1<<20 to 1<<24, since it
* previously rejected any offset > 1 MB as corrupt. Without that
* fix this scaling breaks roundtrip on multi-block files (the bug
* diagnosed and reverted in Sprint 45).
*
* Memory at wlog=24: chain[wsz]+hash4_chain[wsz] = 2*4*16M = 128 MB
* matcher. Acceptable for extreme ("max ratio, will wait"). */
if (opts->window_log == 0 && opts->mode >= VV_MODE_EXTREME &&
src_len > (1u << 20)) {
uint8_t want = 20;
uint64_t s = src_len;
while ((1ull << want) < s && want < 24) want++;
if (want > wlog) wlog = want;
}
/* Frame header */
uint8_t *op = dst;
vv_frame_header_t fh;
memset(&fh, 0, sizeof(fh));
fh.magic = VV_MAGIC;
fh.version = 1;
fh.flags = opts->checksum ? 1 : 0;
fh.flags = (opts->checksum ? 1 : 0)
| (opts->filter_x86 ? 4 : 0)
| (opts->filter_arm64 ? 8 : 0);
fh.mode_hint = (uint8_t)opts->mode;
fh.window_log = wlog;
fh.content_size = (uint64_t)src_len;
@ -1248,6 +1776,13 @@ int64_t vv_compress(const uint8_t *src, size_t src_len,
return VV_ERR_NOMEM;
}
m.use_hash4 = (uint8_t)enable_hash4; /* From fused adaptive-window trial */
/* SPRINT 58: enable the single-probe match finder for ULTRA_FAST.
* Set here (not in matcher_init) so the depth-4 chain matchers used
* by the balanced/extreme window-selection trial above stay at
* single_probe==0 and produce bit-identical trial sizes. */
m.single_probe = (opts->mode == VV_MODE_ULTRA_FAST) ? 1 : 0;
m.accel = opts->accel > 64 ? 64 : opts->accel;
m.no_rep = opts->no_rep ? 1 : 0;
/* Format v2 cap applies to EVERY match emitted from this matcher,
* not just those produced via hash3. Set unconditionally when
* opts.format_v2 is active. */
@ -1409,6 +1944,10 @@ vv_cstream_t *vv_cstream_create(const vv_options_t *opts) {
case VV_MODE_EXTREME: depth = 256; break;
default: depth = 24;
}
if (ctx->opts.depth_override) {
depth = ctx->opts.depth_override;
if (depth > 4096) depth = 4096;
}
/* SPRINT 93 audit: matcher_init can fail; cstream returns NULL
* on any allocation error per public API contract. */
@ -1416,6 +1955,11 @@ vv_cstream_t *vv_cstream_create(const vv_options_t *opts) {
free(ctx);
return NULL;
}
/* SPRINT 58: single-probe finder for ULTRA_FAST streaming, matching
* the one-shot fast path. balanced/extreme keep single_probe==0. */
ctx->m.single_probe = (ctx->opts.mode == VV_MODE_ULTRA_FAST) ? 1 : 0;
ctx->m.accel = ctx->opts.accel > 64 ? 64 : ctx->opts.accel;
ctx->m.no_rep = ctx->opts.no_rep ? 1 : 0;
/* Format v2 matchlen cap applies to every match — set whenever
* streaming opts has format_v2 on, not just when hash3 fires.
*
@ -1501,7 +2045,16 @@ int vv_cstream_reset(vv_cstream_t *ctx, const vv_options_t *opts) {
case VV_MODE_EXTREME: depth = 256; break;
default: depth = 24;
}
if (ctx->opts.depth_override) {
depth = ctx->opts.depth_override;
if (depth > 4096) depth = 4096;
}
ctx->m.chain_depth = depth;
/* SPRINT 58: keep the single-probe flag in sync if the mode changed
* across reset (e.g. balanced stream reset to fast). */
ctx->m.single_probe = (ctx->opts.mode == VV_MODE_ULTRA_FAST) ? 1 : 0;
ctx->m.accel = ctx->opts.accel > 64 ? 64 : ctx->opts.accel;
ctx->m.no_rep = ctx->opts.no_rep ? 1 : 0;
matcher_reset(&ctx->m);

View file

@ -53,8 +53,27 @@ static void copy_match_scalar(uint8_t *dst, uint32_t offset, size_t length) {
* writes it to dst[7], corrupting position 7.
*
* Safe implementation: byte-by-byte, where each write feeds the
* next read correctly (the classic LZ "self-reference" pattern). */
for (size_t i = 0; i < length; i++) dst[i] = dst[i - (ptrdiff_t)offset];
* next read correctly (the classic LZ "self-reference" pattern).
*
* SPRINT 123 (v2.48.5): rewritten to avoid UB-risky pointer
* arithmetic. Original form `dst[i - (ptrdiff_t)offset]` expands
* to `*(dst + (i - offset))` which forms an intermediate pointer
* `dst + negative_value` for `i < offset` (always true on the
* first iteration). Even though the caller validates
* `offset <= (op - dst_base)` so the resulting address stays in
* the same allocation, UBSan's pointer-bounds check fires on the
* intermediate value computation. Hoist `dst - offset` into a
* named pointer ONCE outside the loop where it lands in valid
* memory (caller already validated), then index forward only.
* Functionally identical: `match_src[i]` reads `dst[i-offset]`
* which is either a previously-written literal (i >= offset) or
* a byte just written by an earlier iteration (i < offset).
* Found by libpqvaptvupt/libvaptvupt fuzz harness in Sprint 21.
*/
const uint8_t *match_src = dst - offset; /* one valid subtraction */
for (size_t i = 0; i < length; i++) {
dst[i] = match_src[i];
}
}
}

View file

@ -11,7 +11,7 @@
#include <string.h>
/* Global instance */
zupt_cpu_features_t zupt_cpu = {0, 0, 0, 0, 0};
zupt_cpu_features_t zupt_cpu = {0, 0, 0, 0, 0, 0};
/* ═══════════════════════════════════════════════════════════════════
* CPUID intrinsics platform-specific
@ -98,6 +98,13 @@ void zupt_detect_cpu(zupt_cpu_features_t *f) {
/* AVX2 also requires AVX (OS XSAVE) to be usable */
if (f->has_avx && ((ebx >> 5) & 1))
f->has_avx2 = 1;
/* SHA-NI (CPUID.07H:EBX[29]). Uses 128-bit xmm registers and
* legacy-SSE encoding, so unlike AVX it needs no XCR0/OSXSAVE
* gate xmm state is part of the baseline x86-64 ABI. We do
* pair it with SSE4.1 at the call site (the byte-swap shuffle
* for big-endian message scheduling uses pshufb/SSSE3, always
* present on any CPU that has SHA-NI). */
f->has_shani = (ebx >> 29) & 1;
}
}

View file

@ -22,6 +22,36 @@
#include <unistd.h>
#endif
/* ═══════════════════════════════════════════════════════════════════
* CONSTANT-TIME EQUALITY (single audited primitive)
*
* Returns 1 if the two buffers are equal, 0 otherwise, in time that
* depends only on `n` never on the contents or on where the first
* mismatch occurs. This is the one place the MAC-tag comparison is
* implemented; the three former inline byte-OR loops (the v1.6 strict
* decrypt path, the v1.4/v1.5 legacy v2 candidate, and the F-08 archive-
* integrity-trailer check) now all call here, so the property is audited
* and timing-tested in exactly one location (see tests/test_ct_timing).
*
* A timing leak in a MAC comparison is a forgery oracle: if "wrong on
* byte 0" returned faster than "wrong on byte 31", an attacker could
* recover a valid tag byte-by-byte. The accumulator is therefore folded
* with OR (no early exit) and read through a volatile sink so the
* compiler cannot reintroduce a short-circuit or branch.
*
* CT-REQUIRED: no secret-dependent branch or memory access. */
int zupt_ct_memeq(const void *a, const void *b, size_t n) {
const volatile uint8_t *pa = (const volatile uint8_t *)a;
const volatile uint8_t *pb = (const volatile uint8_t *)b;
uint8_t diff = 0;
for (size_t i = 0; i < n; i++)
diff |= (uint8_t)(pa[i] ^ pb[i]); /* CT-REQUIRED: OR-accumulate, no break */
/* Fold 0/non-zero -> 1/0 without a branch:
* diff==0 -> (0-1)>>8 == 0xFF... &1 -> 1
* diff!=0 -> high bits clear &1 -> 0 */
return (int)((uint8_t)((((unsigned)diff) - 1u) >> 8) & 1u);
}
/* ═══════════════════════════════════════════════════════════════════
* RANDOM BYTES (OS-native CSPRNG NO FALLBACK)
*
@ -51,9 +81,9 @@ void zupt_random_bytes(uint8_t *buf, size_t len) {
#endif
FILE *f = fopen("/dev/urandom", "rb");
if (f) {
size_t r = fread(buf, 1, len, f);
size_t nread = fread(buf, 1, len, f);
fclose(f);
if (r == len) return;
if (nread == len) return;
}
fprintf(stderr, "FATAL: /dev/urandom unavailable. Cannot generate secure random bytes.\n");
exit(1);
@ -77,43 +107,62 @@ void zupt_random_bytes(uint8_t *buf, size_t len) {
void zupt_hmac_sha256(const uint8_t *key, size_t klen,
const uint8_t *data, size_t dlen,
uint8_t mac[32]) {
zupt_hmac_ctx c;
zupt_hmac_sha256_init(&c, key, klen);
zupt_hmac_sha256_update(&c, data, dlen);
zupt_hmac_sha256_final(&c, mac);
}
/* Incremental HMAC-SHA256 (RFC 2104).
*
* _init seeds two SHA-256 contexts with the ipad/opad key-prefix blocks
* (one 64-byte compression each), so repeated MACs under the same key
* never recompute those prefixes and the caller can stream the message
* with _update instead of building a concat buffer. RFC 2104 defines
* HMAC(K,m) = H( (K^opad) || H( (K^ipad) || m ) )
* and SHA-256's Merkle-Damgard update() is associative over the message,
* so streaming m in segments yields byte-identical output to hashing a
* single concatenated buffer. */
void zupt_hmac_sha256_init(zupt_hmac_ctx *c, const uint8_t *key, size_t klen) {
uint8_t k_pad[64];
uint8_t k_hash[32];
/* If key > 64 bytes, hash it first */
/* If key > 64 bytes, hash it first (RFC 2104). */
if (klen > 64) {
zupt_sha256(key, klen, k_hash);
key = k_hash; klen = 32;
}
/* ipad = key XOR 0x36 */
/* inner = SHA256 seeded with (key XOR ipad) */
memset(k_pad, 0x36, 64);
for (size_t i = 0; i < klen; i++) k_pad[i] ^= key[i];
zupt_sha256_init(&c->inner);
zupt_sha256_update(&c->inner, k_pad, 64);
/* inner = SHA256(ipad || data) */
zupt_sha256_ctx ctx;
zupt_sha256_init(&ctx);
zupt_sha256_update(&ctx, k_pad, 64);
zupt_sha256_update(&ctx, data, dlen);
uint8_t inner[32];
zupt_sha256_final(&ctx, inner);
/* opad = key XOR 0x5c */
/* outer = SHA256 seeded with (key XOR opad) */
memset(k_pad, 0x5c, 64);
for (size_t i = 0; i < klen; i++) k_pad[i] ^= key[i];
zupt_sha256_init(&c->outer);
zupt_sha256_update(&c->outer, k_pad, 64);
/* mac = SHA256(opad || inner) */
zupt_sha256_init(&ctx);
zupt_sha256_update(&ctx, k_pad, 64);
zupt_sha256_update(&ctx, inner, 32);
zupt_sha256_final(&ctx, mac);
/* Wipe sensitive data */
zupt_secure_wipe(k_pad, 64);
zupt_secure_wipe(inner, 32);
zupt_secure_wipe(k_hash, 32);
}
void zupt_hmac_sha256_update(zupt_hmac_ctx *c, const uint8_t *data, size_t dlen) {
zupt_sha256_update(&c->inner, data, dlen);
}
void zupt_hmac_sha256_final(zupt_hmac_ctx *c, uint8_t mac[32]) {
uint8_t inner[32];
zupt_sha256_final(&c->inner, inner);
zupt_sha256_update(&c->outer, inner, 32);
zupt_sha256_final(&c->outer, mac);
zupt_secure_wipe(inner, 32);
/* Wipe the residual context state (contains key-dependent material). */
zupt_secure_wipe(c, sizeof(*c));
}
/* ═══════════════════════════════════════════════════════════════════
* PBKDF2-HMAC-SHA256 (RFC 8018)
* */
@ -325,9 +374,19 @@ void zupt_derive_keys(zupt_keyring_t *kr, const char *pw,
@ assigns *olen;
@ ensures *olen == 16 + plen + 32;
*/
uint8_t *zupt_encrypt_buffer(const zupt_keyring_t *kr,
/* F-09 of v2.3.1: extended-AAD encrypt.
*
* The MAC input becomes aad_extra || nonce || ciphertext || aad_seq.
* Original zupt_encrypt_buffer is a thin wrapper with aad_extra=NULL, len=0
* to preserve byte-exact MAC output for archives that don't bind the
* preface. New v1.6 callers pass the canonical (block_type, codec_id,
* block_flags, usz, csz, plaintext-XXH64) bytes to bind the per-block
* frame preface into the MAC. */
uint8_t *zupt_encrypt_buffer_aad(const zupt_keyring_t *kr,
const uint8_t *plain, size_t plen,
uint64_t block_seq, size_t *olen) {
uint64_t block_seq,
const uint8_t *aad_extra, size_t aad_extra_len,
size_t *olen) {
*olen = ZUPT_NONCE_SIZE + plen + ZUPT_HMAC_SIZE;
uint8_t *pkg = (uint8_t *)malloc(*olen);
if (!pkg) return NULL;
@ -344,31 +403,31 @@ uint8_t *zupt_encrypt_buffer(const zupt_keyring_t *kr,
/* Encrypt */
zupt_aes256_ctr(kr->enc_key, nonce, plain, pkg + 16, plen);
/* MAC over nonce + ciphertext + block_seq (AAD).
*
* Binding block_seq into the MAC prevents block-swap (reordering)
* attacks: an attacker who swaps two valid encrypted blocks would
* have to forge a MAC that includes the new position. v2.2.2+
* archives all use this binding (signaled by ZUPT_FLAG_AAD_SEQ).
*/
uint8_t aad_seq[8];
for (int i = 0; i < 8; i++) aad_seq[i] = (uint8_t)(block_seq >> (i * 8));
/* Compute MAC by feeding nonce || ciphertext || aad_seq into HMAC.
* Single-call interface forces a concat into a temp buffer. */
uint8_t *mac_input = (uint8_t *)malloc(16 + plen + 8);
if (!mac_input) { free(pkg); return NULL; }
memcpy(mac_input, pkg, 16 + plen);
memcpy(mac_input + 16 + plen, aad_seq, 8);
zupt_hmac_sha256(kr->mac_key, ZUPT_HMAC_SIZE,
mac_input, 16 + plen + 8,
pkg + 16 + plen);
zupt_secure_wipe(mac_input, 16 + plen + 8);
free(mac_input);
/* MAC over aad_extra || nonce || ciphertext || aad_seq, streamed
* directly through the incremental HMAC no concat buffer, no copy
* of the (up to multi-MB) ciphertext. The segment order is identical
* to the legacy concat layout, so the MAC bytes are unchanged: the
* extra AAD goes first so a v1.6 reader with aad_extra_len=0
* reproduces the legacy v2 MAC exactly (no positional drift). */
zupt_hmac_ctx hctx;
zupt_hmac_sha256_init(&hctx, kr->mac_key, ZUPT_HMAC_SIZE);
if (aad_extra_len) zupt_hmac_sha256_update(&hctx, aad_extra, aad_extra_len);
zupt_hmac_sha256_update(&hctx, pkg, 16 + plen); /* nonce || ciphertext */
zupt_hmac_sha256_update(&hctx, aad_seq, 8);
zupt_hmac_sha256_final(&hctx, pkg + 16 + plen);
return pkg;
}
uint8_t *zupt_encrypt_buffer(const zupt_keyring_t *kr,
const uint8_t *plain, size_t plen,
uint64_t block_seq, size_t *olen) {
return zupt_encrypt_buffer_aad(kr, plain, plen, block_seq, NULL, 0, olen);
}
/* FRAMA-C: Decrypt with MAC verification (Encrypt-then-MAC) */
/*@ requires \valid_read(&kr->enc_key[0..31]);
@ requires \valid_read(&kr->mac_key[0..31]);
@ -382,48 +441,85 @@ uint8_t *zupt_encrypt_buffer(const zupt_keyring_t *kr,
@ behavior auth_fail:
@ ensures \result == \null ==> *olen == pkglen - 48;
*/
uint8_t *zupt_decrypt_buffer(const zupt_keyring_t *kr,
/* F-09 of v2.3.1: extended-AAD decrypt.
*
* When aad_extra_len > 0: the MAC input is
* aad_extra || nonce || ciphertext || aad_seq
* and ONLY this candidate is checked. There is no v1-legacy fallback
* the caller signals "this archive uses extended AAD" by the very act of
* passing aad_extra, and an attacker can't downgrade by clearing
* aad_extra because the caller (decompress_block) gates the AAD on the
* archive-level ZUPT_FLAG_AAD_PREFACE flag, which is itself MAC-protected
* by the v1.5+ archive-integrity-trailer (F-08).
*
* When aad_extra_len == 0: identical to the legacy zupt_decrypt_buffer
* tries v2 MAC (with aad_seq) and falls back to v1 (no AAD). This path
* preserves byte-exact behavior for v1.4 and v1.5 archives. */
uint8_t *zupt_decrypt_buffer_aad(const zupt_keyring_t *kr,
const uint8_t *pkg, size_t pkglen,
uint64_t block_seq, size_t *olen) {
uint64_t block_seq,
const uint8_t *aad_extra, size_t aad_extra_len,
size_t *olen) {
if (pkglen < ZUPT_NONCE_SIZE + ZUPT_HMAC_SIZE) return NULL;
size_t clen = pkglen - ZUPT_NONCE_SIZE - ZUPT_HMAC_SIZE;
*olen = clen;
const uint8_t *stored_mac = pkg + ZUPT_NONCE_SIZE + clen;
/* Verify HMAC.
*
* v2.2.2+ archives bind block_seq into the MAC as AAD (anti-block-swap).
* Older archives don't include the AAD. We try the AAD-bound MAC first;
* if it fails, fall back to legacy MAC for backward compat. Both
* computations are always performed (constant-time policy: don't reveal
* via timing which path matched).
*/
uint8_t aad_seq[8];
for (int i = 0; i < 8; i++) aad_seq[i] = (uint8_t)(block_seq >> (i * 8));
/* v1.6 extended-AAD path: strict, single candidate. */
if (aad_extra_len > 0) {
uint8_t expected[32];
/* Stream aad_extra || nonce || ciphertext || aad_seq through the
* incremental HMAC same segment order as the encrypt side and
* the legacy concat layout, so the expected MAC is byte-identical
* with no per-block concat buffer or ciphertext copy. */
zupt_hmac_ctx hctx;
zupt_hmac_sha256_init(&hctx, kr->mac_key, ZUPT_HMAC_SIZE);
zupt_hmac_sha256_update(&hctx, aad_extra, aad_extra_len);
zupt_hmac_sha256_update(&hctx, pkg, ZUPT_NONCE_SIZE + clen);
zupt_hmac_sha256_update(&hctx, aad_seq, 8);
zupt_hmac_sha256_final(&hctx, expected);
/* CT-REQUIRED: constant-time MAC compare via the audited primitive. */
int mac_ok = zupt_ct_memeq(expected, stored_mac, 32);
zupt_secure_wipe(expected, sizeof(expected));
/* CT-REQUIRED: always decrypt even on MAC failure (timing-oracle
* protection match the legacy path's behaviour). */
uint8_t *plain = (uint8_t *)malloc(clen);
if (!plain) return NULL;
uint8_t nonce[16];
memcpy(nonce, pkg, 16);
zupt_aes256_ctr(kr->enc_key, nonce, pkg + 16, plain, clen);
if (!mac_ok) {
zupt_secure_wipe(plain, clen);
free(plain);
return NULL;
}
return plain;
}
/* v1.4/v1.5 legacy fallback: original two-candidate path. */
uint8_t expected_mac_v2[32];
uint8_t expected_mac_v1[32];
/* v2: AAD = 8-byte block_seq LE */
{
uint8_t aad_seq[8];
for (int i = 0; i < 8; i++) aad_seq[i] = (uint8_t)(block_seq >> (i * 8));
uint8_t *mac_input = (uint8_t *)malloc(ZUPT_NONCE_SIZE + clen + 8);
if (!mac_input) return NULL;
memcpy(mac_input, pkg, ZUPT_NONCE_SIZE + clen);
memcpy(mac_input + ZUPT_NONCE_SIZE + clen, aad_seq, 8);
zupt_hmac_sha256(kr->mac_key, ZUPT_HMAC_SIZE,
mac_input, ZUPT_NONCE_SIZE + clen + 8,
expected_mac_v2);
zupt_secure_wipe(mac_input, ZUPT_NONCE_SIZE + clen + 8);
free(mac_input);
/* v2 candidate: nonce || ciphertext || aad_seq, streamed (no copy). */
zupt_hmac_ctx hctx;
zupt_hmac_sha256_init(&hctx, kr->mac_key, ZUPT_HMAC_SIZE);
zupt_hmac_sha256_update(&hctx, pkg, ZUPT_NONCE_SIZE + clen);
zupt_hmac_sha256_update(&hctx, aad_seq, 8);
zupt_hmac_sha256_final(&hctx, expected_mac_v2);
}
/* v1 legacy: no AAD */
zupt_hmac_sha256(kr->mac_key, ZUPT_HMAC_SIZE,
pkg, ZUPT_NONCE_SIZE + clen,
expected_mac_v1);
const uint8_t *stored_mac = pkg + ZUPT_NONCE_SIZE + clen;
/* Constant-time comparison against both candidates */
#ifdef ZUPT_USE_JASMIN
uint64_t diff_v2 = zupt_mac_verify_ct(expected_mac_v2, stored_mac);
uint64_t diff_v1 = zupt_mac_verify_ct(expected_mac_v1, stored_mac);
@ -435,30 +531,34 @@ uint8_t *zupt_decrypt_buffer(const zupt_keyring_t *kr,
}
#endif
/* Authentication succeeds iff at least one candidate matches.
* AND-with-zero pattern keeps comparison constant-time. */
uint64_t diff = diff_v2 & diff_v1;
/* F-06 fix retained: fold to nonzero-indicator bit before AND. */
uint64_t nz_v2 = (diff_v2 | (uint64_t)(-(int64_t)diff_v2)) >> 63; /* CT-REQUIRED */
uint64_t nz_v1 = (diff_v1 | (uint64_t)(-(int64_t)diff_v1)) >> 63; /* CT-REQUIRED */
uint64_t diff = nz_v2 & nz_v1;
zupt_secure_wipe(expected_mac_v2, 32);
zupt_secure_wipe(expected_mac_v1, 32);
zupt_secure_wipe(expected_mac_v2, sizeof(expected_mac_v2));
zupt_secure_wipe(expected_mac_v1, sizeof(expected_mac_v1));
/* CT-REQUIRED: Always decrypt even on MAC failure to prevent timing oracle. */
/* CT-REQUIRED: always decrypt even on MAC failure (timing-oracle protection). */
uint8_t *plain = (uint8_t *)malloc(clen);
if (!plain) return NULL;
const uint8_t *nonce = pkg;
zupt_aes256_ctr(kr->enc_key, nonce, pkg + 16, plain, clen);
const uint8_t *nonce_ptr = pkg;
zupt_aes256_ctr(kr->enc_key, nonce_ptr, pkg + 16, plain, clen);
if (diff != 0) {
/* Authentication failed — wipe and discard decrypted data */
zupt_secure_wipe(plain, clen);
free(plain);
return NULL;
}
return plain;
}
uint8_t *zupt_decrypt_buffer(const zupt_keyring_t *kr,
const uint8_t *pkg, size_t pkglen,
uint64_t block_seq, size_t *olen) {
return zupt_decrypt_buffer_aad(kr, pkg, pkglen, block_seq, NULL, 0, olen);
}
/* ═══════════════════════════════════════════════════════════════════
* HYBRID POST-QUANTUM KEM: ML-KEM-768 + X25519 (v0.7.0)
*

181
src/zupt_crypto_pqbox.c Normal file
View file

@ -0,0 +1,181 @@
/*
* SPDX-License-Identifier: AGPL-3.0-or-later
* Copyright (c) 2026 Cristian Cezar Moisés
*
* zupt_crypto_pqbox.c ZUPT_ENC_PQ_BOX_V1 (0x05): hybrid PQ sealed-box
* recipient encryption backed by vendored libpqvaptvupt (v0.6.0).
*
* Why a third PQ mode:
* - legacy --pq (0x02) combines the ML-KEM and X25519 shared secrets
* with XOR+SHA3 functional, but not the modern recommendation;
* - --pq-sdk (0x03) is libzuptsdk's v2 envelope (kept for back-compat);
* - --pq-box (0x05) uses libpqvaptvupt's sealed box, which combines the
* two KEM secrets through HKDF-SHA256 Extract/Expand with a
* domain-separating info string ("pqvv-seal-v1") the construction
* this project's own crypto standing orders prescribe. AES-256-CTR +
* HMAC-SHA256 Encrypt-then-MAC inside the box; if either KEM is
* broken later, the other still protects the session key.
*
* Envelope layout inside the ENC_HEADER block payload:
* [1B] enc_type = ZUPT_ENC_PQ_BOX_V1 (0x05)
* [4B] sealed_len (LE)
* [..] pqvv_seal(recipient_pk, session_key[32]) 32 + PQVV_OVERHEAD
*
* The 32-byte random session key is split into the archive's enc/mac keys
* with domain-separated SHA3-256, mirroring the SDK path exactly so the
* per-block AEAD machinery is shared and already regression-tested.
*
* Key files (this module owns the format; magic prevents cross-mode
* key-type confusion at the file level):
* [8B] "PQVVBOX1"
* [1B] role: 'P' (public) | 'S' (secret)
* [..] raw key bytes (PQVV_PUBLICKEYBYTES / PQVV_SECRETKEYBYTES)
*/
#include "zupt.h"
#include "zupt_keccak.h"
#include "pqvaptvupt.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define PQBOX_MAGIC "PQVVBOX1"
#define PQBOX_MAGIC_LEN 8
#define PQBOX_HDR_LEN (PQBOX_MAGIC_LEN + 1)
#define PQBOX_SEALED_SESSION (32u + PQVV_OVERHEAD)
static int pqbox_write_keyfile(const char *path, char role,
const uint8_t *key, size_t klen) {
FILE *f = fopen(path, "wb");
if (!f) return -1;
int ok = fwrite(PQBOX_MAGIC, 1, PQBOX_MAGIC_LEN, f) == PQBOX_MAGIC_LEN
&& fputc(role, f) != EOF
&& fwrite(key, 1, klen, f) == klen;
if (fclose(f) != 0) ok = 0;
return ok ? 0 : -1;
}
/* Reads and validates a key file. Returns 0 and fills `key` on success. */
static int pqbox_read_keyfile(const char *path, char role,
uint8_t *key, size_t klen) {
FILE *f = fopen(path, "rb");
if (!f) return -1;
uint8_t hdr[PQBOX_HDR_LEN];
int ok = fread(hdr, 1, PQBOX_HDR_LEN, f) == PQBOX_HDR_LEN
&& memcmp(hdr, PQBOX_MAGIC, PQBOX_MAGIC_LEN) == 0
&& hdr[PQBOX_MAGIC_LEN] == (uint8_t)role
&& fread(key, 1, klen, f) == klen
&& fgetc(f) == EOF; /* exact size — no trailing bytes */
fclose(f);
return ok ? 0 : -1;
}
int zupt_pqbox_keygen(const char *privkeyfile, const char *pubkeyfile) {
uint8_t pk[PQVV_PUBLICKEYBYTES];
uint8_t sk[PQVV_SECRETKEYBYTES];
if (pqvv_keygen(pk, sk) != PQVV_OK) return -1;
int rc = 0;
if (pqbox_write_keyfile(privkeyfile, 'S', sk, sizeof(sk)) != 0) rc = -1;
if (rc == 0 && pqbox_write_keyfile(pubkeyfile, 'P', pk, sizeof(pk)) != 0) rc = -1;
zupt_secure_wipe(sk, sizeof(sk));
return rc;
}
/* Encrypt-init: seal a fresh 32-byte session key to the recipient and
* emit the ENC_HEADER payload. Mirrors zupt_sdk_hybrid_encrypt_init. */
int zupt_pqbox_encrypt_init(zupt_keyring_t *kr, const char *pubkeyfile,
uint8_t *enc_hdr, size_t *enc_hdr_len) {
uint8_t pk[PQVV_PUBLICKEYBYTES];
if (pqbox_read_keyfile(pubkeyfile, 'P', pk, sizeof(pk)) != 0) {
fprintf(stderr, "Error: '%s' is not a pq-box PUBLIC key file.\n", pubkeyfile);
return -1;
}
uint8_t session_key[32];
zupt_random_bytes(session_key, 32);
uint8_t *sealed = NULL;
size_t sealed_len = 0;
if (pqvv_seal(pk, session_key, 32, &sealed, &sealed_len) != PQVV_OK
|| sealed_len != PQBOX_SEALED_SESSION) {
free(sealed);
zupt_secure_wipe(session_key, sizeof(session_key));
return -1;
}
enc_hdr[0] = ZUPT_ENC_PQ_BOX_V1;
enc_hdr[1] = (uint8_t)(sealed_len & 0xff);
enc_hdr[2] = (uint8_t)((sealed_len >> 8) & 0xff);
enc_hdr[3] = (uint8_t)((sealed_len >> 16) & 0xff);
enc_hdr[4] = (uint8_t)((sealed_len >> 24) & 0xff);
memcpy(enc_hdr + 5, sealed, sealed_len);
*enc_hdr_len = 5 + sealed_len;
free(sealed);
/* Session key → enc/mac keys, domain-separated SHA3 (identical shape
* to the SDK path so all per-block machinery is shared). */
uint8_t kdf_buf[32 + 16];
memcpy(kdf_buf, session_key, 32);
memcpy(kdf_buf + 32, "ZUPT-BOX-ENC-KEY", 16);
zupt_sha3_256(kdf_buf, sizeof(kdf_buf), kr->enc_key);
memcpy(kdf_buf + 32, "ZUPT-BOX-MAC-KEY", 16);
zupt_sha3_256(kdf_buf, sizeof(kdf_buf), kr->mac_key);
zupt_secure_wipe(kdf_buf, sizeof(kdf_buf));
kr->canary_head = ZUPT_CANARY;
zupt_random_bytes(kr->base_nonce, ZUPT_NONCE_SIZE);
kr->iterations = 0;
kr->active = 1;
kr->canary_tail = ZUPT_CANARY;
zupt_secure_wipe(session_key, sizeof(session_key));
return 0;
}
/* Decrypt-init: parse the 0x05 envelope, open with the recipient secret
* key, rebuild the keyring. Fail-closed on any mismatch. */
int zupt_pqbox_decrypt_init(zupt_keyring_t *kr, const char *privkeyfile,
const uint8_t *payload, size_t payload_len) {
if (payload_len < 5 || payload[0] != ZUPT_ENC_PQ_BOX_V1) return -1;
uint32_t sealed_len = (uint32_t)payload[1]
| ((uint32_t)payload[2] << 8)
| ((uint32_t)payload[3] << 16)
| ((uint32_t)payload[4] << 24);
if (sealed_len != PQBOX_SEALED_SESSION || payload_len < 5 + (size_t)sealed_len)
return -1;
uint8_t sk[PQVV_SECRETKEYBYTES];
if (pqbox_read_keyfile(privkeyfile, 'S', sk, sizeof(sk)) != 0) {
fprintf(stderr, "Error: '%s' is not a pq-box SECRET key file.\n", privkeyfile);
return -1;
}
uint8_t *pt = NULL;
size_t pt_len = 0;
int rc = pqvv_open(sk, payload + 5, sealed_len, &pt, &pt_len);
zupt_secure_wipe(sk, sizeof(sk));
if (rc != PQVV_OK || pt_len != 32 || !pt) {
if (pt) { zupt_secure_wipe(pt, pt_len); free(pt); }
return -1; /* wrong key, tampered envelope — generic at call site */
}
uint8_t session_key[32];
memcpy(session_key, pt, 32);
zupt_secure_wipe(pt, pt_len);
free(pt);
uint8_t kdf_buf[32 + 16];
memcpy(kdf_buf, session_key, 32);
memcpy(kdf_buf + 32, "ZUPT-BOX-ENC-KEY", 16);
zupt_sha3_256(kdf_buf, sizeof(kdf_buf), kr->enc_key);
memcpy(kdf_buf + 32, "ZUPT-BOX-MAC-KEY", 16);
zupt_sha3_256(kdf_buf, sizeof(kdf_buf), kr->mac_key);
zupt_secure_wipe(kdf_buf, sizeof(kdf_buf));
kr->canary_head = ZUPT_CANARY;
kr->iterations = 0;
kr->active = 1;
kr->canary_tail = ZUPT_CANARY;
zupt_secure_wipe(session_key, sizeof(session_key));
return 0;
}

View file

@ -169,11 +169,17 @@ int zupt_sdk_password_encrypt_init(zupt_keyring_t *kr, const char *password,
uint8_t key[32];
if (zuptsdk_easy_derive_key(password, salt, key) != 0) return -1;
/* Layout: [1B type=0x04][16B salt][16B nonce] */
/* Layout: [1B type=0x04][16B salt][16B nonce][1B kdf-profile].
* The profile byte (v3.4.0) makes the header self-describing about
* which Argon2id cost produced the archive see ZUPT_ARGON2_PROFILE_*
* in zupt.h. Readers older than 3.4.0 ignore it (they read fixed
* offsets and only require len >= 33); it is covered by the F-08
* archive-integrity trailer so it can't be stripped undetected. */
enc_hdr[0] = ZUPT_ENC_PW_ARGON2;
memcpy(enc_hdr + 1, salt, 16);
zupt_random_bytes(enc_hdr + 17, 16);
*enc_hdr_len = 33;
enc_hdr[33] = ZUPT_ARGON2_PROFILE_MODERATE;
*enc_hdr_len = ZUPT_ARGON2_HDR_LEN_V2;
extern void zupt_sha3_256(const uint8_t *in, size_t inlen, uint8_t out[32]);
uint8_t kdf_buf[32 + 16];
@ -197,9 +203,23 @@ int zupt_sdk_password_encrypt_init(zupt_keyring_t *kr, const char *password,
int zupt_sdk_password_decrypt_init(zupt_keyring_t *kr, const char *password,
const uint8_t *enc_hdr, size_t enc_hdr_len) {
if (enc_hdr_len < 33) return -1;
if (enc_hdr_len < ZUPT_ARGON2_HDR_LEN_V1) return -1;
if (enc_hdr[0] != ZUPT_ENC_PW_ARGON2) return -1;
/* v3.4.0 self-describing KDF profile. Absent (33-byte header) means
* the implicit legacy profile; present (>=34 bytes) names it
* explicitly. Both currently map to the same libzuptsdk MODERATE
* Argon2id derivation, so the key is identical and old archives keep
* decrypting. An unrecognised profile is refused rather than guessed
* better a clear failure than a wrong key derivation. */
if (enc_hdr_len >= ZUPT_ARGON2_HDR_LEN_V2) {
uint8_t profile = enc_hdr[33];
if (profile != ZUPT_ARGON2_PROFILE_LEGACY &&
profile != ZUPT_ARGON2_PROFILE_MODERATE) {
return -1; /* unknown KDF profile — cannot derive correctly */
}
}
const uint8_t *salt = enc_hdr + 1;
const uint8_t *nonce = enc_hdr + 17;

View file

@ -431,7 +431,7 @@ zupt_error_t zupt_disk_backup(const char *output_path, const char *source_path,
/* Path (varint length + bytes) */
size_t path_len = strlen(source_path);
if (path_len > ZUPT_MAX_PATH - 1) path_len = ZUPT_MAX_PATH - 1;
idx_pos += zupt_encode_varint(idx_buf + idx_pos, path_len);
idx_pos += (size_t)zupt_encode_varint(idx_buf + idx_pos, path_len);
memcpy(idx_buf + idx_pos, source_path, path_len);
idx_pos += path_len;
@ -480,6 +480,22 @@ zupt_error_t zupt_disk_backup(const char *output_path, const char *source_path,
ft.footer_version = 1;
fwrite(&ft, sizeof(ft), 1, out);
/* F-08 of v2.3.0: archive-integrity-trailer.
*
* Disk-image archives always pass through opts->keyring just like file
* archives the disk path uses the same write_enc_header/keyring setup
* earlier in this function. opts->encrypt distinguishes encrypted vs
* plaintext disk images. */
{
extern int zupt_format_ait_write(FILE *f, const zupt_archive_header_t *hdr,
const zupt_footer_t *ft,
const zupt_keyring_t *kr_or_null);
const zupt_keyring_t *kr = opts->encrypt ? &opts->keyring : NULL;
if (zupt_format_ait_write(out, &hdr, &ft, kr) != 0) {
fprintf(stderr, "Error: failed to write archive-integrity-trailer\n");
}
}
/* Get final archive size before closing */
uint64_t out_bytes = (uint64_t)ftello(out);
@ -600,8 +616,27 @@ zupt_error_t zupt_disk_restore(const char *archive_path, const char *target_path
/* ─── Read footer to get total block count ─── */
int64_t after_enc_pos = ftello(f); /* Save position after enc header */
fseeko(f, -(int64_t)sizeof(zupt_footer_t), SEEK_END);
/* F-08 of v2.3.0: footer may be at EOF-32 (v1.4) or EOF-64 (v1.5, with
* a 32-byte AIT trailing). Try v1.5 first; fall back to v1.4. */
zupt_footer_t ft;
uint8_t ait_buf[ZUPT_AIT_SIZE];
int has_ait = 0;
fseeko(f, 0, SEEK_END);
int64_t restore_file_size = ftello(f);
if (restore_file_size >= (int64_t)(sizeof(ft) + ZUPT_AIT_SIZE)) {
fseeko(f, -(int64_t)(sizeof(ft) + ZUPT_AIT_SIZE), SEEK_END);
zupt_footer_t cand;
if (fread(&cand, sizeof(cand), 1, f) == 1 &&
cand.footer_magic[0] == 'Z' && cand.footer_magic[1] == 'E' &&
cand.footer_magic[2] == 'N' && cand.footer_magic[3] == 'D' &&
fread(ait_buf, sizeof(ait_buf), 1, f) == 1) {
ft = cand;
has_ait = 1;
}
}
if (!has_ait) {
fseeko(f, -(int64_t)sizeof(zupt_footer_t), SEEK_END);
if (fread(&ft, sizeof(ft), 1, f) != 1) {
fclose(f);
return ZUPT_ERR_CORRUPT;
@ -612,6 +647,37 @@ zupt_error_t zupt_disk_restore(const char *archive_path, const char *target_path
fprintf(stderr, "Error: Invalid footer magic\n");
return ZUPT_ERR_BAD_MAGIC;
}
}
/* F-08: verify the archive-integrity-trailer if present. For v1.4 disk
* archives we emit the same downgrade warning as zupt extract does. */
if (has_ait) {
extern zupt_error_t zupt_format_ait_verify_extern(
const zupt_archive_header_t *hdr, const zupt_footer_t *ft,
const uint8_t ait[ZUPT_AIT_SIZE], const zupt_keyring_t *kr_or_null);
int is_encrypted = (hdr.global_flags & ZUPT_FLAG_ENCRYPTED) != 0;
const zupt_keyring_t *kr = is_encrypted ? &opts->keyring : NULL;
zupt_error_t aerr = zupt_format_ait_verify_extern(&hdr, &ft, ait_buf, kr);
if (aerr != ZUPT_OK) {
fclose(f);
/* F-11 of v2.4.2: same collapse-wrong-key-with-tamper logic as
* open_archive in src/zupt_format.c. */
if (is_encrypted) {
if (opts->verbose) {
fprintf(stderr, "Error: archive-integrity-trailer (top-MAC) verification failed.\n"
" This means EITHER wrong password/key OR a tampered\n"
" header or footer.\n");
}
fprintf(stderr, "Error: Authentication failed (wrong key, wrong password, or tampered archive).\n");
} else {
fprintf(stderr, "Error: archive-integrity-trailer (XXH64) verification failed.\n"
" The disk image header or footer has been corrupted or tampered with.\n");
}
return aerr;
}
} else if (hdr.global_flags & ZUPT_FLAG_ENCRYPTED) {
fprintf(stderr, "Warning: legacy v1.4 disk image without top-MAC (F-08).\n");
}
/* ─── Seek back to first data block ─── */
fseeko(f, after_enc_pos, SEEK_SET);

View file

@ -23,6 +23,18 @@
#ifndef _WIN32
#include <fcntl.h>
#include <unistd.h>
/* F-08 of v2.3.0: forward decls — definitions are below read_block(), but the
* compress write paths at zupt_compress_files()/_solid() and disk-restore at
* zupt_disk.c need to see ait_write/ait_verify_extern. The extern decl in
* zupt_disk.c mirrors zupt_format_ait_verify_extern's signature. */
int zupt_format_ait_write(FILE *f, const zupt_archive_header_t *hdr,
const zupt_footer_t *ft,
const zupt_keyring_t *kr_or_null);
zupt_error_t zupt_format_ait_verify_extern(const zupt_archive_header_t *hdr,
const zupt_footer_t *ft,
const uint8_t ait[ZUPT_AIT_SIZE],
const zupt_keyring_t *kr_or_null);
#endif
#ifdef _WIN32
@ -35,6 +47,9 @@
* UTILITY
* */
/* ZUPT_VV_DECODE_SLACK (SIMD decode over-copy guard) is defined in
* zupt.h so both this file and zupt_parallel.c share one definition. */
const char *zupt_strerror(zupt_error_t e) {
switch (e) {
case ZUPT_OK: return "Success";
@ -147,10 +162,13 @@ int zupt_decode_varint(const uint8_t *b, size_t blen, uint64_t *v) {
uint64_t x=b[n];
*v|=(x&0x7F)<<s;
n++;
if(!(x&0x80))return n;
if(!(x&0x80))return n; /* terminator byte — done */
s+=7;
/* Reject continuation past 64 bits — same defense as file variant */
if(s>=64 && (x&0x80))return -1;
/* Invariant: control flow reaches here only with x&0x80 set
* (the !(x&0x80) check above returned otherwise). The 10-byte
* loop bound + 7-bit shift means s >= 64 means an 11th byte
* would be needed, which we refuse. */
if(s>=64) return -1;
}
return -1;
}
@ -159,9 +177,15 @@ int zupt_write_varint(FILE *f, uint64_t v) {
}
int zupt_read_varint(FILE *f, uint64_t *v) {
*v=0; int s=0;
for(int i=0;i<10;i++){int c=fgetc(f);if(c==EOF)return -1;
*v|=(uint64_t)(c&0x7F)<<s;if(!(c&0x80))return i+1;s+=7;
if(s>=64 && (c&0x80))return -1;} return -1;
for(int i=0;i<10;i++){
int c=fgetc(f); if(c==EOF) return -1;
*v|=(uint64_t)(c&0x7F)<<s;
if(!(c&0x80)) return i+1; /* terminator byte — done */
s+=7;
/* Same invariant as zupt_decode_varint: only reached with c&0x80 set. */
if(s>=64) return -1;
}
return -1;
}
/* ═══════════════════════════════════════════════════════════════════
@ -295,7 +319,36 @@ zupt_error_t write_enc_header(FILE *out, zupt_archive_header_t *hdr,
zupt_options_t *opts) {
hdr->encryption_header_off = (uint64_t)ftello(out);
if (opts->sdk_mode && opts->pq_mode) {
if (opts->box_mode && opts->pq_mode) {
/* ─── PQ-BOX MODE (libpqvaptvupt sealed box: HKDF-SHA256 combiner) ─── */
hdr->global_flags |= ZUPT_FLAG_PQ_HYBRID;
uint8_t enc_hdr_buf[1500];
size_t enc_hdr_len = 0;
if (!opts->quiet)
fprintf(stderr, " PQ sealed box via libpqvaptvupt (ML-KEM-768 + X25519, HKDF-SHA256)...\n");
if (zupt_pqbox_encrypt_init(&opts->keyring, opts->keyfile,
enc_hdr_buf, &enc_hdr_len) != 0) {
fprintf(stderr, "Error: pq-box key encapsulation failed.\n");
return ZUPT_ERR_AUTH_FAIL;
}
zupt_w8(out, ZUPT_BLOCK_MAGIC_0); zupt_w8(out, ZUPT_BLOCK_MAGIC_1);
zupt_w8(out, ZUPT_BLOCK_ENC_HEADER);
zupt_w16le(out, ZUPT_CODEC_STORE); zupt_w16le(out, 0);
zupt_write_varint(out, enc_hdr_len);
zupt_write_varint(out, enc_hdr_len);
zupt_w64le(out, zupt_xxh64(enc_hdr_buf, enc_hdr_len, 0));
if (fwrite(enc_hdr_buf, 1, enc_hdr_len, out) != enc_hdr_len)
return ZUPT_ERR_IO;
fseeko(out, 0, SEEK_SET);
if (fwrite(hdr, sizeof(*hdr), 1, out) != 1) return ZUPT_ERR_IO;
fseeko(out, 0, SEEK_END);
if (!opts->quiet)
fprintf(stderr, " Encryption: AES-256-CTR + HMAC-SHA256 (Encrypt-then-MAC)\n\n");
} else if (opts->sdk_mode && opts->pq_mode) {
/* ─── SDK V2 PQ MODE (libzuptsdk: HKDF combiner + commitment + HPKE) ─── */
hdr->global_flags |= ZUPT_FLAG_PQ_HYBRID;
@ -355,13 +408,27 @@ zupt_error_t write_enc_header(FILE *out, zupt_archive_header_t *hdr,
if (!opts->quiet)
fprintf(stderr, " Encryption: PQ Hybrid (ML-KEM-768 + X25519) + AES-256-CTR + HMAC-SHA256\n\n");
} else {
/* ─── PASSWORD MODE (PBKDF2) ─── */
/* ─── PASSWORD MODE ───
*
* v2.4.1+: default to Argon2id (libzuptsdk path, enc_type=0x04).
* PBKDF2-SHA256 (enc_type=0x01) is available via --kdf pbkdf2 for
* compatibility with v2.4.0 and older readers. Argon2id is the
* OWASP recommendation for password KDFs; PBKDF2 with 600k
* iterations is fine but lacks the memory-hardness that makes
* Argon2id resistant to GPU/ASIC attacks.
*
* Both paths produce the same downstream keyring (kr->enc_key,
* mac_key, base_nonce) and feed the same AES-256-CTR + HMAC-SHA256
* + F-09 preface-AAD per-block pipeline. Only the KDF and
* enc-header bytes differ. Read-path dispatch on enc_type byte
* at offset 0 of the enc-header block already handles both. */
if (opts->kdf_legacy_pbkdf2) {
uint8_t salt[ZUPT_SALT_SIZE], nonce[ZUPT_NONCE_SIZE];
zupt_random_bytes(salt, ZUPT_SALT_SIZE);
zupt_random_bytes(nonce, ZUPT_NONCE_SIZE);
if (!opts->quiet)
fprintf(stderr, " Deriving encryption key (PBKDF2-SHA256, %d iterations)...\n",
fprintf(stderr, " Deriving encryption key (PBKDF2-SHA256, %d iterations, --kdf pbkdf2 legacy)...\n",
ZUPT_KDF_ITERATIONS);
zupt_derive_keys(&opts->keyring, opts->password, salt, nonce, ZUPT_KDF_ITERATIONS);
@ -378,6 +445,26 @@ zupt_error_t write_enc_header(FILE *out, zupt_archive_header_t *hdr,
zupt_write_varint(out, 53); zupt_write_varint(out, 53);
zupt_w64le(out, zupt_xxh64(enc_hdr, 53, 0));
if (fwrite(enc_hdr, 1, 53, out) != 53) return ZUPT_ERR_IO;
} else {
/* Argon2id default (v2.4.1+) */
uint8_t enc_hdr[33];
size_t enc_hdr_len = 0;
if (!opts->quiet)
fprintf(stderr, " Deriving encryption key (Argon2id, libzuptsdk)...\n");
if (zupt_sdk_password_encrypt_init(&opts->keyring, opts->password,
enc_hdr, &enc_hdr_len) != 0) {
fprintf(stderr, "Error: Argon2id key derivation failed.\n"
" Pass --kdf pbkdf2 to fall back to PBKDF2-SHA256.\n");
return ZUPT_ERR_AUTH_FAIL;
}
zupt_w8(out, ZUPT_BLOCK_MAGIC_0); zupt_w8(out, ZUPT_BLOCK_MAGIC_1);
zupt_w8(out, ZUPT_BLOCK_ENC_HEADER);
zupt_w16le(out, ZUPT_CODEC_STORE); zupt_w16le(out, 0);
zupt_write_varint(out, enc_hdr_len); zupt_write_varint(out, enc_hdr_len);
zupt_w64le(out, zupt_xxh64(enc_hdr, enc_hdr_len, 0));
if (fwrite(enc_hdr, 1, enc_hdr_len, out) != enc_hdr_len) return ZUPT_ERR_IO;
}
fseeko(out, 0, SEEK_SET);
if (fwrite(hdr, sizeof(*hdr), 1, out) != 1) return ZUPT_ERR_IO;
@ -484,6 +571,50 @@ static uint64_t get_mtime(const char *path) {
}
/* Safe ftello wrapper: returns 0 on error (caller should check context) */
/* F-09 of v2.3.1: serialize the canonical per-block frame preface for use as
* extended-AAD input to the per-block MAC. Format is fixed-width little-endian
* (NOT the on-disk varint encoding varints are non-canonical, two encodings
* of the same logical value would produce different MACs and either break
* roundtrip or open a malleability window).
*
* Layout: block_type (1B) || codec_id (2B LE) || block_flags (2B LE)
* || uncompressed_size (8B LE) || compressed_size (8B LE)
* || plaintext_checksum (8B LE)
* = 29 bytes
*
* Excludes block_magic (constant `bb 01`, structurally rejected by read_block
* if tampered) and the AES nonce (already part of the existing MAC input). */
#define ZUPT_PREFACE_AAD_LEN 29
static void zupt_serialize_preface_aad(const zupt_block_t *b, uint8_t out[ZUPT_PREFACE_AAD_LEN]) {
out[0] = (uint8_t)b->block_type;
out[1] = (uint8_t)(b->codec_id & 0xFF);
out[2] = (uint8_t)((b->codec_id >> 8) & 0xFF);
out[3] = (uint8_t)(b->block_flags & 0xFF);
out[4] = (uint8_t)((b->block_flags >> 8) & 0xFF);
for (int i = 0; i < 8; i++) out[5 + i] = (uint8_t)(b->uncompressed_size >> (i * 8));
for (int i = 0; i < 8; i++) out[13 + i] = (uint8_t)(b->compressed_size >> (i * 8));
for (int i = 0; i < 8; i++) out[21 + i] = (uint8_t)(b->checksum >> (i * 8));
}
/* Write-side variant: build the canonical preface AAD from raw scalars
* known at MAC time but before the block struct exists. Same byte layout
* as zupt_serialize_preface_aad both sides must produce identical bytes
* for the same logical block, or the roundtrip MAC won't match. */
static void zupt_serialize_preface_aad_scalars(
uint8_t block_type, uint16_t codec_id, uint16_t block_flags,
uint64_t uncompressed_size, uint64_t compressed_size, uint64_t checksum,
uint8_t out[ZUPT_PREFACE_AAD_LEN])
{
out[0] = block_type;
out[1] = (uint8_t)(codec_id & 0xFF);
out[2] = (uint8_t)((codec_id >> 8) & 0xFF);
out[3] = (uint8_t)(block_flags & 0xFF);
out[4] = (uint8_t)((block_flags >> 8) & 0xFF);
for (int i = 0; i < 8; i++) out[5 + i] = (uint8_t)(uncompressed_size >> (i * 8));
for (int i = 0; i < 8; i++) out[13 + i] = (uint8_t)(compressed_size >> (i * 8));
for (int i = 0; i < 8; i++) out[21 + i] = (uint8_t)(checksum >> (i * 8));
}
static uint64_t safe_ftello(FILE *f) {
int64_t pos = ftello(f);
if (pos < 0) return 0;
@ -513,6 +644,88 @@ static uint32_t index_get_u32(const uint8_t *buf) {
* COMPRESSION
* */
/* F-12 of v2.4.3: write an optional comment block to the archive between
* the data blocks and the central index. Stores plaintext UTF-8 text
* (max ZUPT_MAX_COMMENT_LEN bytes) under a new block type
* ZUPT_BLOCK_COMMENT. Encrypted archives use the same per-block AEAD
* pipeline as data blocks (AES-256-CTR + HMAC-SHA256 + v1.6 preface-AAD
* if use_preface_aad is set). aad_seq is the sentinel 0xFFFF...FFFE,
* one less than the index block's 0xFFFF...FFFF, so it cannot collide
* with file-block AAD seqs (which are bounded above by 0xFFFFFFFF00000000
* since they encode (fi+1, block_seq) in the upper/lower 32-bit halves).
*
* The caller must:
* 1. Have written all data blocks already.
* 2. Have computed `hdr` in memory but not committed comment_offset yet.
* 3. Call this; on return, hdr->comment_offset is set and one extra
* block has been written to disk.
* 4. Rewrite the archive header on disk (offset 0) so subsequent AIT
* computation matches what's on disk.
*
* Returns ZUPT_OK on success, an error code on I/O or crypto failure.
* If !opts->has_comment, this is a no-op (hdr->comment_offset stays 0).
*/
#define ZUPT_COMMENT_AAD_SEQ 0xFFFFFFFFFFFFFFFEULL
static zupt_error_t write_comment_block(FILE *out, zupt_archive_header_t *hdr,
zupt_options_t *opts,
uint64_t *total_blocks) {
if (!opts->has_comment) return ZUPT_OK;
size_t clen = strnlen(opts->comment, ZUPT_MAX_COMMENT_LEN);
if (clen == 0) {
/* Empty comment string: treat as not-supplied. */
return ZUPT_OK;
}
uint64_t comment_off = (uint64_t)ftello(out);
uint64_t cksum = zupt_xxh64(opts->comment, clen, 0);
const uint8_t *payload = (const uint8_t *)opts->comment;
uint64_t payload_size = clen;
uint16_t bflags = 0;
uint8_t *enc_payload = NULL;
if (opts->encrypt && opts->keyring.active) {
size_t enc_len;
if (opts->keyring.use_preface_aad) {
uint8_t preface[ZUPT_PREFACE_AAD_LEN];
uint64_t predicted_csz = 16 + payload_size + 32;
zupt_serialize_preface_aad_scalars(
ZUPT_BLOCK_COMMENT, ZUPT_CODEC_STORE,
(uint16_t)ZUPT_BFLAG_ENCRYPTED,
payload_size, predicted_csz, cksum, preface);
enc_payload = zupt_encrypt_buffer_aad(&opts->keyring,
payload, payload_size, ZUPT_COMMENT_AAD_SEQ,
preface, ZUPT_PREFACE_AAD_LEN, &enc_len);
zupt_secure_wipe(preface, sizeof(preface));
} else {
enc_payload = zupt_encrypt_buffer(&opts->keyring,
payload, payload_size, ZUPT_COMMENT_AAD_SEQ, &enc_len);
}
if (!enc_payload) return ZUPT_ERR_NOMEM;
payload = enc_payload;
payload_size = enc_len;
bflags |= ZUPT_BFLAG_ENCRYPTED;
}
int write_err = 0;
w8(out, ZUPT_BLOCK_MAGIC_0); w8(out, ZUPT_BLOCK_MAGIC_1);
w8(out, ZUPT_BLOCK_COMMENT);
w16le(out, ZUPT_CODEC_STORE); w16le(out, bflags);
zupt_write_varint(out, clen); /* uncompressed_size */
zupt_write_varint(out, payload_size); /* compressed_size (= encrypted length when bflags has ENCRYPTED) */
w64le(out, cksum); /* plaintext XXH64 — F-09 strict validation reads this */
if (fwrite(payload, 1, (size_t)payload_size, out) != (size_t)payload_size) write_err = 1;
free(enc_payload);
if (write_err) return ZUPT_ERR_IO;
hdr->comment_offset = comment_off;
(*total_blocks)++;
return ZUPT_OK;
}
zupt_error_t zupt_compress_files(const char *output_path,
const char **arc_paths,
const char **disk_paths,
@ -535,7 +748,13 @@ zupt_error_t zupt_compress_files(const char *output_path,
hdr.magic[3]=ZUPT_MAGIC_3; hdr.magic[4]=ZUPT_MAGIC_4; hdr.magic[5]=ZUPT_MAGIC_5;
hdr.version_major = ZUPT_FORMAT_MAJOR; hdr.version_minor = ZUPT_FORMAT_MINOR;
hdr.global_flags = ZUPT_FLAG_CKSUM_XXH64;
if (opts->encrypt) hdr.global_flags |= ZUPT_FLAG_ENCRYPTED | ZUPT_FLAG_AAD_SEQ;
if (opts->encrypt) {
hdr.global_flags |= ZUPT_FLAG_ENCRYPTED | ZUPT_FLAG_AAD_SEQ;
/* F-09 of v2.3.1: bind frame preface into per-block MAC. v1.6 archives
* always set this; flag is anchored by the v1.5 AIT (F-08). */
hdr.global_flags |= ZUPT_FLAG_AAD_PREFACE;
opts->keyring.use_preface_aad = 1;
}
if (opts->threads > 1) hdr.global_flags |= ZUPT_FLAG_MULTITHREADED;
if (opts->dedup) hdr.global_flags |= ZUPT_FLAG_DEDUP;
hdr.creation_time = now_ns();
@ -814,7 +1033,22 @@ zupt_error_t zupt_compress_files(const char *output_path,
} else {
aad_seq = (((uint64_t)(fi + 1)) << 32) | block_seq;
}
/* F-09 of v2.3.1: bind frame preface into MAC for v1.6 archives.
* Predicted compressed_size = nonce(16) + payload + hmac(32). */
if (opts->keyring.use_preface_aad) {
uint8_t preface[ZUPT_PREFACE_AAD_LEN];
uint16_t predicted_bflags = (uint16_t)(ZUPT_BFLAG_ENCRYPTED);
uint64_t predicted_csz = 16 + payload_size + 32;
zupt_serialize_preface_aad_scalars(
ZUPT_BLOCK_DATA, codec, predicted_bflags,
nread, predicted_csz, checksum, preface);
enc_payload = zupt_encrypt_buffer_aad(&opts->keyring,
payload, payload_size, aad_seq,
preface, ZUPT_PREFACE_AAD_LEN, &enc_len);
zupt_secure_wipe(preface, sizeof(preface));
} else {
enc_payload = zupt_encrypt_buffer(&opts->keyring, payload, payload_size, aad_seq, &enc_len);
}
if (!enc_payload) { fclose(inf); free(index); free(rbuf); free(cbuf); fclose(out); return ZUPT_ERR_NOMEM; }
payload = enc_payload;
payload_size = enc_len;
@ -875,6 +1109,29 @@ zupt_error_t zupt_compress_files(const char *output_path,
return ZUPT_ERR_IO;
}
/* ─── F-12 of v2.4.3: optional comment block ─── */
{
zupt_error_t cerr = write_comment_block(out, &hdr, opts, &total_blocks);
if (cerr != ZUPT_OK) {
fprintf(stderr, "Error: Failed to write comment block\n");
free(index); free(rbuf); free(cbuf); fclose(out);
return cerr;
}
if (opts->has_comment && hdr.comment_offset != 0) {
/* Rewrite the archive header so on-disk hdr.comment_offset
* matches the in-memory hdr that the AIT will sign at the
* end of the function. */
int64_t save = ftello(out);
fseeko(out, 0, SEEK_SET);
if (fwrite(&hdr, sizeof(hdr), 1, out) != 1) {
fprintf(stderr, "Error: Failed to update header with comment offset\n");
free(index); free(rbuf); free(cbuf); fclose(out);
return ZUPT_ERR_IO;
}
fseeko(out, save, SEEK_SET);
}
}
/* ─── Central Index ─── */
uint64_t index_offset = safe_ftello(out);
size_t icap = (size_t)num_files * (ZUPT_MAX_PATH + 128);
@ -908,17 +1165,30 @@ zupt_error_t zupt_compress_files(const char *output_path,
ic_pay = ic; ic_plen = ic_size;
}
uint64_t ic_ck = zupt_xxh64(ibuf, ip, 0); /* compute checksum BEFORE encrypt for AAD */
uint8_t *enc_idx = NULL;
uint16_t idx_bflags = 0;
if (opts->encrypt && opts->keyring.active) {
size_t enc_len;
/* Index uses sentinel seq (matches decrypt site at line ~1515) */
/* F-09: bind frame preface (block_type=INDEX, codec, flags, sizes, ck) */
if (opts->keyring.use_preface_aad) {
uint8_t preface[ZUPT_PREFACE_AAD_LEN];
uint64_t predicted_csz = 16 + ic_plen + 32;
zupt_serialize_preface_aad_scalars(
ZUPT_BLOCK_INDEX, ic_codec, (uint16_t)ZUPT_BFLAG_ENCRYPTED,
ip, predicted_csz, ic_ck, preface);
enc_idx = zupt_encrypt_buffer_aad(&opts->keyring, ic_pay, ic_plen,
0xFFFFFFFFFFFFFFFFULL, preface, ZUPT_PREFACE_AAD_LEN, &enc_len);
zupt_secure_wipe(preface, sizeof(preface));
} else {
enc_idx = zupt_encrypt_buffer(&opts->keyring, ic_pay, ic_plen, 0xFFFFFFFFFFFFFFFFULL, &enc_len);
}
ic_pay = enc_idx; ic_plen = enc_len;
idx_bflags |= ZUPT_BFLAG_ENCRYPTED;
}
uint64_t ic_ck = zupt_xxh64(ibuf, ip, 0);
w8(out, ZUPT_BLOCK_MAGIC_0); w8(out, ZUPT_BLOCK_MAGIC_1);
w8(out, ZUPT_BLOCK_INDEX);
w16le(out, ic_codec); w16le(out, idx_bflags);
@ -936,6 +1206,14 @@ zupt_error_t zupt_compress_files(const char *output_path,
ft.footer_magic[0]='Z'; ft.footer_magic[1]='E'; ft.footer_magic[2]='N'; ft.footer_magic[3]='D';
ft.footer_version = 1;
if (fwrite(&ft, sizeof(ft), 1, out) != 1) write_err = 1;
/* F-08 of v2.3.0: archive-integrity-trailer follows the footer.
* Encrypted: HMAC over hdr || ft[0..23]. Plaintext: XXH64 best-effort. */
if (!write_err) {
const zupt_keyring_t *kr = opts->encrypt ? &opts->keyring : NULL;
if (zupt_format_ait_write(out, &hdr, &ft, kr) != 0) write_err = 1;
}
fclose(out);
if (write_err) {
@ -1010,7 +1288,11 @@ zupt_error_t zupt_compress_solid(const char *output_path,
hdr.magic[3]=ZUPT_MAGIC_3; hdr.magic[4]=ZUPT_MAGIC_4; hdr.magic[5]=ZUPT_MAGIC_5;
hdr.version_major = ZUPT_FORMAT_MAJOR; hdr.version_minor = ZUPT_FORMAT_MINOR;
hdr.global_flags = ZUPT_FLAG_CKSUM_XXH64 | ZUPT_FLAG_SOLID;
if (opts->encrypt) hdr.global_flags |= ZUPT_FLAG_ENCRYPTED | ZUPT_FLAG_AAD_SEQ;
if (opts->encrypt) {
hdr.global_flags |= ZUPT_FLAG_ENCRYPTED | ZUPT_FLAG_AAD_SEQ;
hdr.global_flags |= ZUPT_FLAG_AAD_PREFACE; /* F-09 of v2.3.1 */
opts->keyring.use_preface_aad = 1;
}
hdr.creation_time = now_ns();
gen_uuid(hdr.archive_id);
if (fwrite(&hdr, sizeof(hdr), 1, out) != 1) write_err = 1;
@ -1137,7 +1419,19 @@ zupt_error_t zupt_compress_solid(const char *output_path,
/* Solid mode treats whole archive as fi=0 with global block_seq.
* AAD = (1 << 32) | block_seq still gives unique values per block. */
uint64_t aad_seq = ((uint64_t)1 << 32) | block_seq;
if (opts->keyring.use_preface_aad) {
uint8_t preface[ZUPT_PREFACE_AAD_LEN];
uint64_t predicted_csz = 16 + payload_size + 32;
zupt_serialize_preface_aad_scalars(
ZUPT_BLOCK_DATA, codec, (uint16_t)ZUPT_BFLAG_ENCRYPTED,
(uint64_t)chunk, predicted_csz, checksum, preface);
enc_pay = zupt_encrypt_buffer_aad(&opts->keyring,
payload, payload_size, aad_seq,
preface, ZUPT_PREFACE_AAD_LEN, &enc_len);
zupt_secure_wipe(preface, sizeof(preface));
} else {
enc_pay = zupt_encrypt_buffer(&opts->keyring, payload, payload_size, aad_seq, &enc_len);
}
if (enc_pay) { payload = enc_pay; payload_size = enc_len; bflags |= ZUPT_BFLAG_ENCRYPTED; }
}
@ -1159,6 +1453,26 @@ zupt_error_t zupt_compress_solid(const char *output_path,
for (int fi = 0; fi < num_files; fi++) index[fi].block_count = 0;
/* ─── F-12 of v2.4.3: optional comment block (solid mode) ─── */
{
zupt_error_t cerr = write_comment_block(out, &hdr, opts, &total_blocks);
if (cerr != ZUPT_OK) {
fprintf(stderr, "Error: Failed to write comment block (solid mode)\n");
free(solid_buf); free(cbuf); free(index); fclose(out);
return cerr;
}
if (opts->has_comment && hdr.comment_offset != 0) {
int64_t save = ftello(out);
fseeko(out, 0, SEEK_SET);
if (fwrite(&hdr, sizeof(hdr), 1, out) != 1) {
fprintf(stderr, "Error: Failed to update header with comment offset (solid)\n");
free(solid_buf); free(cbuf); free(index); fclose(out);
return ZUPT_ERR_IO;
}
fseeko(out, save, SEEK_SET);
}
}
/* Write central index (LE serialization) */
uint64_t index_offset = safe_ftello(out);
size_t icap = (size_t)num_files * (ZUPT_MAX_PATH + 128);
@ -1189,11 +1503,24 @@ zupt_error_t zupt_compress_solid(const char *output_path,
if (ic_size == 0 || ic_size >= ip) { ic_codec = ZUPT_CODEC_STORE; ic_pay = ibuf; ic_plen = ip; }
else { ic_pay = ic; ic_plen = ic_size; }
uint64_t ic_ck_solid = zupt_xxh64(ibuf, ip, 0); /* computed before encrypt so AAD can use it */
uint8_t *enc_idx = NULL; uint16_t idx_bflags = 0;
if (opts->encrypt && opts->keyring.active) {
size_t enc_len;
/* Index uses sentinel seq (matches decrypt site at line ~1515) */
if (opts->keyring.use_preface_aad) {
uint8_t preface[ZUPT_PREFACE_AAD_LEN];
uint64_t predicted_csz = 16 + ic_plen + 32;
zupt_serialize_preface_aad_scalars(
ZUPT_BLOCK_INDEX, ic_codec, (uint16_t)ZUPT_BFLAG_ENCRYPTED,
(uint64_t)ip, predicted_csz, ic_ck_solid, preface);
enc_idx = zupt_encrypt_buffer_aad(&opts->keyring, ic_pay, ic_plen,
0xFFFFFFFFFFFFFFFFULL, preface, ZUPT_PREFACE_AAD_LEN, &enc_len);
zupt_secure_wipe(preface, sizeof(preface));
} else {
enc_idx = zupt_encrypt_buffer(&opts->keyring, ic_pay, ic_plen, 0xFFFFFFFFFFFFFFFFULL, &enc_len);
}
if (enc_idx) { ic_pay = enc_idx; ic_plen = enc_len; idx_bflags |= ZUPT_BFLAG_ENCRYPTED; }
}
@ -1202,7 +1529,7 @@ zupt_error_t zupt_compress_solid(const char *output_path,
w16le(out, ic_codec); w16le(out, idx_bflags);
zupt_write_varint(out, (uint64_t)ip);
zupt_write_varint(out, ic_plen);
w64le(out, zupt_xxh64(ibuf, ip, 0));
w64le(out, ic_ck_solid);
if (fwrite(ic_pay, 1, (size_t)ic_plen, out) != (size_t)ic_plen) write_err = 1;
total_blocks++;
@ -1215,6 +1542,13 @@ zupt_error_t zupt_compress_solid(const char *output_path,
ft.footer_magic[0]='Z'; ft.footer_magic[1]='E'; ft.footer_magic[2]='N'; ft.footer_magic[3]='D';
ft.footer_version = 1;
if (fwrite(&ft, sizeof(ft), 1, out) != 1) write_err = 1;
/* F-08 of v2.3.0: archive-integrity-trailer (see compress-flat path). */
if (!write_err) {
const zupt_keyring_t *kr = opts->encrypt ? &opts->keyring : NULL;
if (zupt_format_ait_write(out, &hdr, &ft, kr) != 0) write_err = 1;
}
fclose(out);
if (write_err) {
@ -1261,14 +1595,145 @@ static zupt_error_t read_header(FILE *f, zupt_archive_header_t *h) {
return ZUPT_OK;
}
static zupt_error_t read_footer(FILE *f, zupt_footer_t *ft) {
/* F-08 of v2.3.0: locate_footer_v15 supersedes the old read_footer().
*
* locate_footer_v15 detects which on-disk layout this file uses:
* v1.5: [header][...blocks...][index][footer 32B][AIT 32B] (current)
* v1.4: [header][...blocks...][index][footer 32B] (legacy)
*
* It reads the last 64 bytes and looks for the "ZEND" magic at offsets
* EOF-64 (v1.5) and EOF-32 (v1.4). The header's version_minor is informative
* but NOT load-bearing here: we trust the on-disk footer position because
* the version field is itself uncovered metadata in v1.4 archives and the
* point of F-08 is to stop trusting uncovered metadata. If the magic appears
* at neither offset, the archive is corrupt.
*
* On v1.5 archives, *has_ait is set to 1 and *ait_buf is filled with the
* 32 trailing bytes (caller is responsible for verifying them, since the
* keyring isn't available at this point in open_archive's flow). On v1.4
* archives, *has_ait is 0. */
static zupt_error_t locate_footer_v15(FILE *f, zupt_footer_t *ft,
int *has_ait, uint8_t ait_buf[ZUPT_AIT_SIZE]) {
fseeko(f, 0, SEEK_END);
int64_t file_size = ftello(f);
if (file_size < (int64_t)sizeof(zupt_footer_t)) return ZUPT_ERR_CORRUPT;
/* Try v1.5: footer at EOF-64, AIT at EOF-32 */
if (file_size >= (int64_t)sizeof(zupt_footer_t) + ZUPT_AIT_SIZE) {
zupt_footer_t cand;
fseeko(f, -(int64_t)(sizeof(zupt_footer_t) + ZUPT_AIT_SIZE), SEEK_END);
if (fread(&cand, sizeof(cand), 1, f) == 1 &&
cand.footer_magic[0]=='Z' && cand.footer_magic[1]=='E' &&
cand.footer_magic[2]=='N' && cand.footer_magic[3]=='D' &&
cand.footer_version == 1) {
if (fread(ait_buf, ZUPT_AIT_SIZE, 1, f) != 1) return ZUPT_ERR_IO;
*ft = cand;
*has_ait = 1;
return ZUPT_OK;
}
}
/* Fall back to v1.4: footer at EOF-32, no AIT */
fseeko(f, -(int64_t)sizeof(zupt_footer_t), SEEK_END);
if (fread(ft, sizeof(*ft), 1, f) != 1) return ZUPT_ERR_IO;
if (ft->footer_magic[0]!='Z'||ft->footer_magic[1]!='E'||
ft->footer_magic[2]!='N'||ft->footer_magic[3]!='D') return ZUPT_ERR_CORRUPT;
if (ft->footer_version != 1) return ZUPT_ERR_BAD_VERSION;
*has_ait = 0;
return ZUPT_OK;
}
/* AIT MAC input: header[0..63] || footer[0..23].
*
* Excludes footer[24..31] = footer_magic[4] || footer_version (u32). The magic
* is structurally validated by locate_footer_v15; the version_field is
* informational. Including them would not add tamper resistance a flipped
* magic byte already causes locate to fail before we reach the MAC step. */
static void ait_serialize_mac_input(const zupt_archive_header_t *hdr,
const zupt_footer_t *ft,
uint8_t buf[ZUPT_AIT_MAC_INPUT_LEN]) {
memcpy(buf, hdr, sizeof(*hdr));
memcpy(buf + sizeof(*hdr), ft, 24); /* index_offset + total_blocks + archive_checksum */
}
/* Compute the trailing AIT field and emit ZUPT_AIT_SIZE bytes through fwrite.
*
* Non-static so the disk-backup writer in src/zupt_disk.c can reuse it.
* Other cross-file linkage in this codebase (read_block, decompress_block,
* read_enc_header, write_enc_header) follows the same "extern by default"
* convention no internal header. The matching extern decl in zupt_disk.c
* is the single source of truth for the prototype on the caller side. */
int zupt_format_ait_write(FILE *f, const zupt_archive_header_t *hdr,
const zupt_footer_t *ft,
const zupt_keyring_t *kr_or_null) {
uint8_t mac_input[ZUPT_AIT_MAC_INPUT_LEN];
uint8_t ait[ZUPT_AIT_SIZE];
memset(ait, 0, sizeof(ait));
ait_serialize_mac_input(hdr, ft, mac_input);
if (kr_or_null && kr_or_null->active) {
zupt_hmac_sha256(kr_or_null->mac_key, ZUPT_HMAC_SIZE,
mac_input, ZUPT_AIT_MAC_INPUT_LEN, ait);
} else {
uint64_t x = zupt_xxh64(mac_input, ZUPT_AIT_MAC_INPUT_LEN, 0);
for (int i = 0; i < 8; i++) ait[i] = (uint8_t)(x >> (i * 8));
}
int ok = (fwrite(ait, sizeof(ait), 1, f) == 1);
zupt_secure_wipe(mac_input, ZUPT_AIT_MAC_INPUT_LEN);
zupt_secure_wipe(ait, sizeof(ait));
return ok ? 0 : -1;
}
/* Verify the AIT field against header+footer.
*
* Encrypted archives: HMAC-SHA256 with constant-time tag compare.
* Plaintext archives: XXH64 in the first 8 bytes, byte-wise compare of the
* remaining 24 bytes against zero.
* Returns ZUPT_OK iff the trailer authenticates the header+footer. */
/* Verify the AIT field against header+footer.
*
* Encrypted archives: HMAC-SHA256 with constant-time tag compare.
* Plaintext archives: XXH64 in the first 8 bytes, byte-wise compare of the
* remaining 24 bytes against zero.
* Returns ZUPT_OK iff the trailer authenticates the header+footer. */
static zupt_error_t ait_verify(const zupt_archive_header_t *hdr,
const zupt_footer_t *ft,
const uint8_t ait[ZUPT_AIT_SIZE],
const zupt_keyring_t *kr_or_null) {
uint8_t mac_input[ZUPT_AIT_MAC_INPUT_LEN];
ait_serialize_mac_input(hdr, ft, mac_input);
zupt_error_t result;
if (kr_or_null && kr_or_null->active) {
uint8_t expected[ZUPT_AIT_SIZE];
zupt_hmac_sha256(kr_or_null->mac_key, ZUPT_HMAC_SIZE,
mac_input, ZUPT_AIT_MAC_INPUT_LEN, expected);
/* CT-REQUIRED: constant-time compare via the audited primitive. */
int eq = zupt_ct_memeq(expected, ait, ZUPT_AIT_SIZE);
zupt_secure_wipe(expected, sizeof(expected));
result = eq ? ZUPT_OK : ZUPT_ERR_AUTH_FAIL;
} else {
uint64_t got = zupt_xxh64(mac_input, ZUPT_AIT_MAC_INPUT_LEN, 0);
uint8_t expected[ZUPT_AIT_SIZE];
memset(expected, 0, sizeof(expected));
for (int i = 0; i < 8; i++) expected[i] = (uint8_t)(got >> (i * 8));
/* No timing concern in plaintext mode (no secret involved). */
int eq = (memcmp(expected, ait, ZUPT_AIT_SIZE) == 0);
result = eq ? ZUPT_OK : ZUPT_ERR_BAD_CHECKSUM;
}
zupt_secure_wipe(mac_input, ZUPT_AIT_MAC_INPUT_LEN);
return result;
}
/* Public wrapper around ait_verify() for cross-file callers (src/zupt_disk.c).
* Mirrors the zupt_format_ait_write() naming convention. The matching extern
* decl lives in zupt_disk.c's restore path. */
zupt_error_t zupt_format_ait_verify_extern(const zupt_archive_header_t *hdr,
const zupt_footer_t *ft,
const uint8_t ait[ZUPT_AIT_SIZE],
const zupt_keyring_t *kr_or_null) {
return ait_verify(hdr, ft, ait, kr_or_null);
}
zupt_error_t read_block(FILE *f, zupt_block_t *b) {
uint8_t m[2];
if (fread(m,1,2,f)!=2) return ZUPT_ERR_IO;
@ -1304,7 +1769,23 @@ zupt_error_t decompress_block(const zupt_block_t *b, const zupt_keyring_t *kr,
if (b->block_flags & ZUPT_BFLAG_ENCRYPTED) {
if (!kr || !kr->active) return ZUPT_ERR_AUTH_FAIL;
size_t dec_len;
/* F-09 of v2.3.1: the archive's global ZUPT_FLAG_AAD_PREFACE bit
* (in opts->global_flags, threaded through kr->use_preface_aad)
* tells us whether to bind the canonical preface bytes into the
* MAC. The flag itself is MAC-protected at archive level by the
* v1.5 archive-integrity-trailer (F-08), so an attacker can't
* flip it without auth-fail at open_archive time. */
if (kr->use_preface_aad) {
uint8_t preface[ZUPT_PREFACE_AAD_LEN];
zupt_serialize_preface_aad(b, preface);
dec_payload = zupt_decrypt_buffer_aad(kr, comp_data, comp_len,
block_seq,
preface, ZUPT_PREFACE_AAD_LEN,
&dec_len);
zupt_secure_wipe(preface, sizeof(preface));
} else {
dec_payload = zupt_decrypt_buffer(kr, comp_data, comp_len, block_seq, &dec_len);
}
if (!dec_payload) return ZUPT_ERR_AUTH_FAIL;
comp_data = dec_payload;
comp_len = dec_len;
@ -1312,7 +1793,12 @@ zupt_error_t decompress_block(const zupt_block_t *b, const zupt_keyring_t *kr,
*olen = (size_t)b->uncompressed_size;
if (*olen == 0) { *out = NULL; free(dec_payload); return ZUPT_OK; }
*out = (uint8_t*)malloc(*olen);
/* Over-allocate by ZUPT_VV_DECODE_SLACK so the VaptVupt AVX2 decode
* over-copy (up to 32 B past the logical end) lands in owned memory.
* *olen still reports the true uncompressed size to the caller; the
* slack bytes are never part of the output. See the constant's
* definition near the top of this file. */
*out = (uint8_t*)malloc(*olen + ZUPT_VV_DECODE_SLACK);
if (!*out) { free(dec_payload); return ZUPT_ERR_NOMEM; }
zupt_error_t result = ZUPT_OK;
@ -1365,7 +1851,13 @@ zupt_error_t decompress_block(const zupt_block_t *b, const zupt_keyring_t *kr,
}
/* VAPTVUPT: VaptVupt codec decompress path (v1.4.0 cross-block decode) */
else if (b->codec_id == ZUPT_CODEC_VAPTVUPT) {
int64_t dsz = vvz_decompress(comp_data, comp_len, *out, *olen);
/* Pass the padded capacity (*olen + slack): the codec's AVX2
* over-copy needs op_end to sit past the logical output end so
* its 32-byte SIMD stores stay in bounds. We still require the
* returned size to equal the true *olen, so the slack never
* affects correctness. */
int64_t dsz = vvz_decompress(comp_data, comp_len, *out,
*olen + ZUPT_VV_DECODE_SLACK);
if (dsz < 0 || (size_t)dsz != *olen) result = ZUPT_ERR_CORRUPT;
} else {
result = ZUPT_ERR_UNSUPPORTED;
@ -1400,10 +1892,62 @@ zupt_error_t read_enc_header(FILE *f, zupt_archive_header_t *hdr, zupt_options_t
zupt_error_t err = read_block(f, &eb);
if (err != ZUPT_OK) return err;
/* F-09 of v2.3.1 (same pattern as F-07 of v2.2.5): the block at
* encryption_header_off MUST identify itself as ENC_HEADER. The
* block_type byte sits outside the cryptographic MAC (the SDK-v2
* envelope authenticates only its own contents, not the surrounding
* frame preface), so a tampering attacker could flip it without
* detection. Structurally reject mismatches here.
*
* The same logic extends to the rest of the enc-header frame preface:
* the codec MUST be STORE (the envelope isn't compressed), block_flags
* MUST be zero (the envelope contains its own crypto), and
* compressed_size MUST equal uncompressed_size (no length games). These
* cover bytes 67-72 of the v1.6 sweep. The plaintext-XXH64 field
* (bytes 75-82) is checked against the actual payload contents below. */
if (eb.block_type != ZUPT_BLOCK_ENC_HEADER) {
free(eb.payload);
return ZUPT_ERR_CORRUPT;
}
if (eb.codec_id != ZUPT_CODEC_STORE ||
eb.block_flags != 0 ||
eb.compressed_size != eb.uncompressed_size) {
free(eb.payload);
return ZUPT_ERR_CORRUPT;
}
{
uint64_t actual_ck = zupt_xxh64(eb.payload, (size_t)eb.compressed_size, 0);
if (actual_ck != eb.checksum) {
free(eb.payload);
return ZUPT_ERR_BAD_CHECKSUM;
}
}
if (eb.compressed_size < 1) { free(eb.payload); return ZUPT_ERR_CORRUPT; }
uint8_t enc_type = eb.payload[0];
if (enc_type == ZUPT_ENC_PQ_BOX_V1) {
if (!opts->pq_mode || opts->keyfile[0] == '\0') {
fprintf(stderr, "Error: Archive uses pq-box encryption. Use --pq-box <keyfile>.\n");
free(eb.payload);
return ZUPT_ERR_AUTH_FAIL;
}
if (zupt_pqbox_decrypt_init(&opts->keyring, opts->keyfile,
eb.payload, (size_t)eb.compressed_size) != 0) {
if (opts->verbose) {
fprintf(stderr, "Error: pq-box envelope decryption failed.\n"
" This means wrong key, tampered envelope, or unsupported format.\n");
}
fprintf(stderr, "Error: Authentication failed (wrong key, wrong password, or tampered archive).\n");
free(eb.payload);
return ZUPT_ERR_AUTH_FAIL;
}
opts->box_mode = 1;
free(eb.payload);
return ZUPT_OK;
}
if (enc_type == ZUPT_ENC_PQ_SDK_V2) {
if (!opts->pq_mode || opts->keyfile[0] == '\0') {
fprintf(stderr, "Error: Archive uses SDK-v2 PQ encryption. Use --pq <keyfile>.\n");
@ -1412,7 +1956,14 @@ zupt_error_t read_enc_header(FILE *f, zupt_archive_header_t *hdr, zupt_options_t
}
if (zupt_sdk_hybrid_decrypt_init(&opts->keyring, opts->keyfile,
eb.payload, (size_t)eb.compressed_size) != 0) {
fprintf(stderr, "Error: SDK-v2 PQ decryption failed (wrong key, tampered, or unsupported).\n");
/* F-11 of v2.4.2: same generic phrasing as the AIT-fail path
* in open_archive(), for consistency across all key-mode
* failures. Verbose mode adds the technical-detail line. */
if (opts->verbose) {
fprintf(stderr, "Error: SDK-v2 PQ envelope decryption failed.\n"
" This means wrong key, tampered envelope, or unsupported format.\n");
}
fprintf(stderr, "Error: Authentication failed (wrong key, wrong password, or tampered archive).\n");
free(eb.payload);
return ZUPT_ERR_AUTH_FAIL;
}
@ -1427,7 +1978,11 @@ zupt_error_t read_enc_header(FILE *f, zupt_archive_header_t *hdr, zupt_options_t
}
if (zupt_sdk_password_decrypt_init(&opts->keyring, opts->password,
eb.payload, (size_t)eb.compressed_size) != 0) {
fprintf(stderr, "Error: Argon2id password decryption failed (wrong password?).\n");
/* F-11 of v2.4.2: aligned with the AIT-fail path. */
if (opts->verbose) {
fprintf(stderr, "Error: Argon2id password verification failed at envelope step.\n");
}
fprintf(stderr, "Error: Authentication failed (wrong key, wrong password, or tampered archive).\n");
free(eb.payload);
return ZUPT_ERR_AUTH_FAIL;
}
@ -1530,11 +2085,125 @@ static zupt_error_t open_archive(FILE *f, zupt_options_t *opts,
zupt_index_entry_t **entries, int *num_entries) {
zupt_error_t err = read_header(f, hdr);
if (err != ZUPT_OK) return err;
err = read_footer(f, ft);
/* F-08 of v2.3.0: locate footer with v1.5 archive-integrity-trailer
* awareness. has_ait=1 means an AIT was found at EOF-32; verification
* is deferred until after read_enc_header() initialises the keyring. */
int has_ait = 0;
uint8_t ait_buf[ZUPT_AIT_SIZE];
err = locate_footer_v15(f, ft, &has_ait, ait_buf);
if (err != ZUPT_OK) return err;
err = read_enc_header(f, hdr, opts);
if (err != ZUPT_OK) return err;
/* F-08 of v2.3.0: verify the archive-integrity-trailer.
*
* For encrypted archives, the AIT is HMAC-SHA256(mac_key, hdr || ft[0..23])
* and MUST authenticate before we read any further. For plaintext archives,
* the AIT is XXH64 best-effort.
*
* v1.4 archives (no AIT) keep extracting unchanged backward compatibility
* was the explicit design constraint when F-08 was opened. They emit a
* warning on stderr in encrypted modes so users notice the integrity
* downgrade. The warning text is stable (it's part of the threat model
* surface) and the message comes from one place. */
if (has_ait) {
int is_encrypted = (hdr->global_flags & ZUPT_FLAG_ENCRYPTED) != 0;
const zupt_keyring_t *kr = is_encrypted ? &opts->keyring : NULL;
zupt_error_t aerr = ait_verify(hdr, ft, ait_buf, kr);
if (aerr != ZUPT_OK) {
/* F-11 of v2.4.2: a wrong password or wrong PQ key produces a
* mac_key that doesn't match the AIT, exactly like a real
* tamper. Pre-2.4.2 we printed "archive header or footer has
* been tampered with" in both cases, which mislead users
* into thinking valid archives were corrupted. The fix:
*
* - Encrypted archives default message is the same single
* "Authentication failed" line that the downstream
* decrypt path emits. Users see one consistent message
* regardless of which check fired first. The detailed
* top-MAC wording moves behind --verbose for debugging.
*
* - Plaintext archives no key was supplied, so wrong-key
* is impossible by construction. The failure IS a tamper
* (or corruption). Keep the detailed message.
*
* The default message is identical regardless of which
* candidate (wrong key vs real tamper) caused the AIT
* mismatch, which avoids creating a verbal probe-oracle.
* Timing is unchanged: ait_verify always runs the HMAC and
* returns branchlessly.
*/
if (is_encrypted) {
if (opts->verbose) {
fprintf(stderr, "Error: archive-integrity-trailer (top-MAC) verification failed.\n"
" This means EITHER wrong password/key OR a tampered\n"
" header or footer. v2.4.2+ collapses both into one\n"
" error to avoid a verbal side channel.\n");
}
fprintf(stderr, "Error: Authentication failed (wrong key, wrong password, or tampered archive).\n");
} else {
/* Plaintext archive: no key involvement, so this is
* unambiguously corruption or tamper. */
fprintf(stderr, "Error: archive-integrity-trailer (XXH64) verification failed.\n"
" The archive header or footer has been corrupted or tampered with.\n");
}
return aerr;
}
} else if (hdr->global_flags & ZUPT_FLAG_ENCRYPTED) {
fprintf(stderr, "Warning: legacy v1.4 archive without top-MAC (F-08).\n"
" File contents are integrity-protected, but header\n"
" and footer metadata (timestamps, UUID, counts) are not.\n");
}
/* F-09 of v2.3.1: propagate the archive-level preface-AAD policy into
* the keyring so decompress_block knows whether to bind the per-block
* preface into the MAC. This flag is MAC-protected by the AIT (when
* has_ait) an attacker can't flip it on tampered v1.5+ archives. On
* v1.4 archives (no AIT) the flag is also absent, so this stays 0. */
if (hdr->global_flags & ZUPT_FLAG_AAD_PREFACE) {
opts->keyring.use_preface_aad = 1;
}
/* F-12 of v2.4.3: read the optional comment block. comment_offset is
* part of hdr[0..63] and therefore covered by the v1.5+ AIT, so a
* tampered pointer is rejected before we reach this code. The block
* itself is covered by the per-block HMAC (with preface AAD in v1.6
* archives), so its bytes are also tamper-protected. */
if (hdr->comment_offset != 0) {
int64_t save_pos = ftello(f);
fseeko(f, (int64_t)hdr->comment_offset, SEEK_SET);
zupt_block_t cb;
memset(&cb, 0, sizeof(cb));
zupt_error_t cerr = read_block(f, &cb);
if (cerr != ZUPT_OK) {
free(cb.payload);
return cerr;
}
if (cb.block_type != ZUPT_BLOCK_COMMENT ||
cb.uncompressed_size == 0 ||
cb.uncompressed_size > ZUPT_MAX_COMMENT_LEN) {
free(cb.payload);
return ZUPT_ERR_CORRUPT;
}
uint8_t *plain = NULL;
size_t plen = 0;
cerr = decompress_block(&cb, &opts->keyring, ZUPT_COMMENT_AAD_SEQ, &plain, &plen);
if (cerr != ZUPT_OK) {
free(cb.payload);
return cerr;
}
if (plen > ZUPT_MAX_COMMENT_LEN - 1) plen = ZUPT_MAX_COMMENT_LEN - 1;
memcpy(opts->comment, plain, plen);
opts->comment[plen] = '\0';
opts->has_comment = 1;
zupt_secure_wipe(plain, plen);
free(plain);
free(cb.payload);
fseeko(f, save_pos, SEEK_SET);
}
/* Validate index_offset is within file bounds before seeking. */
int64_t cur_pos2 = ftello(f);
fseeko(f, 0, SEEK_END);
@ -1549,6 +2218,19 @@ static zupt_error_t open_archive(FILE *f, zupt_options_t *opts,
err = read_block(f, &ib);
if (err != ZUPT_OK) return err;
/* F-07 of v2.2.5: the block at index_offset MUST identify itself as an
* INDEX block. The block_type byte is not covered by per-block HMAC
* (which protects nonce||ciphertext||aad_seq only), so a tampering
* attacker can flip it without authentication failure. Pre-F-07 the
* parser ignored block_type at this position and decoded whatever it
* found making the byte truly unauthenticated. Now it is structurally
* validated (rejected at parse time on mismatch), which is the
* OPAQUE-class coverage promised by PROMPT.md §5. */
if (ib.block_type != ZUPT_BLOCK_INDEX) {
free(ib.payload);
return ZUPT_ERR_CORRUPT;
}
uint8_t *id; size_t idlen;
err = decompress_block(&ib, &opts->keyring, 0xFFFFFFFFFFFFFFFFULL, &id, &idlen);
free(ib.payload);
@ -1950,6 +2632,12 @@ file_done:
if (fail > 0) fprintf(stderr, ", %d error(s)", fail);
fprintf(stderr, "\n");
/* F-12 of v2.4.3: print the archive comment, if any. open_archive
* decrypted+stored it in opts->comment when the keyring was active. */
if (opts->has_comment && opts->comment[0] != '\0') {
fprintf(stderr, "\n Comment: %s\n", opts->comment);
}
free(ents); fclose(f);
return fail>0 ? ZUPT_ERR_CORRUPT : ZUPT_OK;
}
@ -2108,10 +2796,25 @@ zupt_error_t zupt_archive_info(const char *path) {
char sz_buf[32];
zupt_format_size(file_size, sz_buf, sizeof(sz_buf));
/* Try to read footer for block count */
/* Try to read footer for block count.
* F-08 of v2.3.0: also detect whether the v1.5 archive-integrity-trailer
* is present, so `zupt info` can report it. The footer can be at EOF-32
* (v1.4) or EOF-64 (v1.5, with 32-byte AIT trailing). */
uint64_t total_blocks = 0;
int has_footer = 0;
if (file_size > sizeof(zupt_footer_t)) {
int has_ait = 0;
if (file_size >= sizeof(zupt_footer_t) + ZUPT_AIT_SIZE) {
fseeko(f, -(int64_t)(sizeof(zupt_footer_t) + ZUPT_AIT_SIZE), SEEK_END);
zupt_footer_t ft;
if (fread(&ft, sizeof(ft), 1, f) == 1 &&
ft.footer_magic[0]=='Z' && ft.footer_magic[1]=='E' &&
ft.footer_magic[2]=='N' && ft.footer_magic[3]=='D') {
total_blocks = ft.total_blocks;
has_footer = 1;
has_ait = 1;
}
}
if (!has_footer && file_size > sizeof(zupt_footer_t)) {
fseeko(f, -(int64_t)sizeof(zupt_footer_t), SEEK_END);
zupt_footer_t ft;
if (fread(&ft, sizeof(ft), 1, f) == 1 &&
@ -2148,6 +2851,9 @@ zupt_error_t zupt_archive_info(const char *path) {
printf(" Archive: %s\n", path);
printf(" Size: %s (%llu bytes)\n", sz_buf, (unsigned long long)file_size);
printf(" Format: v%u.%u\n", hdr.version_major, hdr.version_minor);
printf(" Top-MAC: %s\n",
has_ait ? ((fl & ZUPT_FLAG_ENCRYPTED) ? "YES (HMAC-SHA256)" : "YES (XXH64)")
: "no (v1.4 legacy)");
printf(" UUID: %s\n", uuid);
printf(" Created: %s\n", timebuf);
if (has_footer)
@ -2163,6 +2869,8 @@ zupt_error_t zupt_archive_info(const char *path) {
printf(" Dedup: YES (block-level)\n");
if (fl & ZUPT_FLAG_DISK_IMAGE)
printf(" Disk image: YES\n");
if (hdr.comment_offset != 0)
printf(" Comment: present (use 'zupt x' with the right key to read)\n");
printf(" Flags: 0x%04X\n", fl);
printf("\n");

View file

@ -21,27 +21,41 @@
static void banner(void) {
fprintf(stderr,
"Zupt %s - Next-Generation Compression Utility\n"
"Format v%d.%d | Codec: Zupt-LZ | Checksum: XXH64\n"
"Encryption: AES-256-CTR + HMAC-SHA256 | KDF: PBKDF2-SHA256\n\n",
ZUPT_VERSION_STRING, ZUPT_FORMAT_MAJOR, ZUPT_FORMAT_MINOR);
"%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",
ZUPT_PRODUCT_NAME, ZUPT_VERSION_STRING, ZUPT_PRODUCT_TAGLINE,
ZUPT_FORMAT_MAJOR, ZUPT_FORMAT_MINOR);
}
static void usage(void) {
banner();
/* usage() text exceeds C99's 4095-char string-literal limit, so we
* split it into logical sections, one fprintf call per section.
* Don't merge these back into a single literal see F-13 in
* AUDIT.md for the regression test (tests/test_help_consistency.sh)
* that asserts this. */
/* ── Section 1: synopsis ── */
fprintf(stderr,
"Usage:\n"
" zupt compress [OPTIONS] <output.zupt> <files/dirs...>\n"
" zupt extract [OPTIONS] <archive.zupt>\n"
" zupt list [OPTIONS] <archive.zupt>\n"
" zupt test [OPTIONS] <archive.zupt>\n"
" zupt info <archive.zupt> Archive metadata (no password needed)\n"
" zupt bench <files/dirs...> Compare levels 1-9\n"
" zupt disk backup|restore Full-disk backup/restore\n"
" zupt keygen Key generation"
" zupt version\n"
" zupt help\n"
" vaptvupt compress [OPTIONS] <output.zupt> <files/dirs...>\n"
" vaptvupt extract [OPTIONS] <archive.zupt>\n"
" vaptvupt list [OPTIONS] <archive.zupt>\n"
" vaptvupt test [OPTIONS] <archive.zupt>\n"
" vaptvupt info <archive.zupt> Archive metadata (no password needed)\n"
" vaptvupt bench <files/dirs...> Compare levels 1-9\n"
" vaptvupt disk backup|restore Full-disk backup/restore\n"
" vaptvupt keygen Key generation\n"
" vaptvupt version\n"
" vaptvupt help\n"
"\n"
"Note: archive extension stays .zupt for format continuity.\n"
" The `zupt` command is preserved as a legacy alias.\n"
"\n");
/* ── Section 2: compress options ── */
fprintf(stderr,
"Compress Options:\n"
" -l, --level <1-9> Compression level (default: 7)\n"
" 1-2: fast, small window\n"
@ -51,23 +65,32 @@ static void usage(void) {
" -b, --block <SIZE> Block size in bytes (default: 128KB)\n"
" -s, --store Store without compression\n"
" -f, --fast Use fast LZ codec (less compression)\n"
" --vv, --vaptvupt Use VaptVupt codec (fast LZ + ANS entropy)\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"
" Use 'pbkdf2' for v2.4.0-and-older reader compatibility.\n"
" -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 encryption (legacy XOR+SHA3 combiner)\n"
" --pq-sdk <pubkey> Post-quantum encryption via libzuptsdk\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"
" --dedup, -D Block-level deduplication\n"
" --solid Solid mode (single stream)\n"
" -v, --verbose Verbose per-file output\n"
" -t, --threads <N> Thread count (0=auto, 1=single, 2-64=explicit)\n"
"\n"
"\n");
/* ── Section 3: extract/list/test options ── */
fprintf(stderr,
"Extract/List/Test Options:\n"
" -o, --output <DIR> Output directory (extract only)\n"
" -p, --password <PW> Decryption password\n"
" --pq <privkey> Post-quantum decryption (legacy combiner)\n"
" --pq-sdk <privkey> Post-quantum decryption via libzuptsdk\n"
" --pq-box <privkey> Post-quantum sealed-box decryption (libpqvaptvupt)\n"
" -v, --verbose Verbose output\n"
" -t, --threads <N> Thread count for decompression\n"
"\n"
@ -76,35 +99,43 @@ static void usage(void) {
" --pub Export public key from existing private key (-k)\n"
" -k <privkey> Source private keyfile (with --pub)\n"
" --sdk, --pq-sdk Generate SDK v2 keypair (writes <file> and <file>.pub)\n"
" Use these keys with --pq-sdk on compress/extract.\n"
" --box, --pq-box Generate pq-box keypair (libpqvaptvupt; writes <file> and <file>.pub)\n"
" Use these keys with --pq-sdk / --pq-box respectively.\n"
"\n"
"Directories are traversed recursively.\n"
"\n"
"\n");
/* ── Section 4: examples ── */
fprintf(stderr,
"Examples:\n"
" # Legacy PQ workflow\n"
" zupt keygen -o mykey.key # Generate keypair\n"
" zupt keygen --pub -o pub.key -k mykey.key # Export public key\n"
" zupt compress --pq pub.key backup.zupt ~/Documents/ # Encrypt\n"
" zupt extract --pq mykey.key backup.zupt -o ~/restored/ # Decrypt\n"
" vaptvupt keygen -o mykey.key # Generate keypair\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"
" zupt keygen --sdk -o mykey.priv # Writes mykey.priv + .pub\n"
" zupt compress --pq-sdk mykey.priv.pub backup.zupt files/ # Encrypt (HKDF+commit+HPKE)\n"
" zupt extract --pq-sdk mykey.priv backup.zupt # Decrypt\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"
"\n"
" # Conventional / password\n"
" zupt compress backup.zupt ~/Documents/ # No encryption\n"
" zupt compress -l 9 -p mysecret secure.zupt data/ # Password + max compression\n"
" zupt list secure.zupt -p mysecret # List with password\n"
" zupt extract -o restored/ -p mysecret secure.zupt # Extract with password\n"
" zupt bench ~/Documents/ # Benchmark\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");
/* ── 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"
"Format: v1.6 (since v2.3.1); archives byte-compatible with v2.3.1+\n"
"\n"
"Compression: LZ77 (1MB window) + Huffman entropy coding\n"
"Security: AES-256-CTR + HMAC-SHA256 (Encrypt-then-MAC)\n"
"KDF: PBKDF2-SHA256 (600,000 iterations)\n"
"\n"
"License: AGPL-3.0-or-later (Zupt) + GPL-3.0-or-later (VaptVupt codec)\n"
" Commercial license available: sac@securityops.co\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"
);
}
@ -126,7 +157,10 @@ static void prompt_password(const char *prompt, char *buf, size_t cap) {
struct termios old, new_t;
tcgetattr(0, &old);
new_t = old;
new_t.c_lflag &= ~ECHO;
/* Clear the ECHO bit. ~ECHO is `int` (negative); c_lflag is
* tcflag_t (unsigned int). The cast makes the conversion
* explicit and silences -Wsign-conversion. */
new_t.c_lflag &= (tcflag_t)~ECHO;
tcsetattr(0, TCSANOW, &new_t);
if (fgets(buf, (int)cap, stdin)) {
size_t len = strlen(buf);
@ -149,13 +183,25 @@ int main(int argc, char **argv) {
if (streq(cmd,"help")||streq(cmd,"--help")||streq(cmd,"-h")) { usage(); return 0; }
if (streq(cmd,"version")||streq(cmd,"--version")||streq(cmd,"-V")) {
printf("zupt %s\nFormat: v%d.%d\nCodec: Zupt-LZ (0x%04X)\n"
"Encryption: AES-256-CTR+HMAC-SHA256\nKDF: PBKDF2-SHA256 (%d iter)\n"
"License: AGPL-3.0-or-later (Zupt) + GPL-3.0-or-later (VaptVupt)\n"
printf("vaptvupt %s (formerly zupt; renamed in v3.0.0 — INPI Brasil trademark)\n"
"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"
"KDF: Argon2id (default) / PBKDF2-SHA256 %d iter (--kdf pbkdf2)\n"
"Post-quantum: ML-KEM-768 + X25519 hybrid (FIPS 203 + RFC 7748)\n"
"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"
"Commercial: sac@securityops.co\n",
ZUPT_VERSION_STRING, ZUPT_FORMAT_MAJOR, ZUPT_FORMAT_MINOR,
ZUPT_CODEC_ZUPT_LZ, ZUPT_KDF_ITERATIONS);
ZUPT_CODEC_VAPTVUPT, ZUPT_KDF_ITERATIONS);
/* Runtime crypto hardware acceleration (reflects this CPU). */
printf("HW accel (this CPU):");
int any = 0;
if (zupt_cpu.has_aesni && zupt_cpu.has_avx) { printf(" AES-NI"); any = 1; }
if (zupt_cpu.has_shani) { printf(" SHA-NI"); any = 1; }
if (zupt_cpu.has_avx2) { printf(" AVX2(codec)"); any = 1; }
printf("%s\n", any ? "" : " none (portable C fallback)");
return 0;
}
@ -204,12 +250,21 @@ int main(int argc, char **argv) {
opts.threads=atoi(argv[++ai]);
if(opts.threads<0)opts.threads=0;
if(opts.threads>ZUPT_MAX_THREADS)opts.threads=ZUPT_MAX_THREADS;
} else if (streq(argv[ai],"--pq-sdk")&&ai+1<argc) {
opts.pq_mode=1; opts.sdk_mode=1; opts.encrypt=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-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);
@ -218,6 +273,40 @@ int main(int argc, char **argv) {
strncpy(opts.keyfile, argv[++ai], sizeof(opts.keyfile)-1);
} else if (streq(argv[ai],"--dedup")||streq(argv[ai],"-D")) {
opts.dedup=1;
} else if ((streq(argv[ai],"-c")||streq(argv[ai],"--comment"))&&ai+1<argc) {
/* v2.4.3: free-form archive comment. Encrypted along with
* data blocks when -p/--pq is also set. */
ai++;
strncpy(opts.comment, argv[ai], ZUPT_MAX_COMMENT_LEN - 1);
opts.comment[ZUPT_MAX_COMMENT_LEN - 1] = '\0';
opts.has_comment = 1;
} else if (streq(argv[ai],"--comment-file")&&ai+1<argc) {
ai++;
FILE *cf = fopen(argv[ai], "rb");
if (!cf) {
fprintf(stderr, "Error: --comment-file: cannot open '%s'\n", argv[ai]);
return 1;
}
size_t n = fread(opts.comment, 1, ZUPT_MAX_COMMENT_LEN - 1, cf);
opts.comment[n] = '\0';
while (n > 0 && (opts.comment[n-1] == '\n' || opts.comment[n-1] == '\r')) {
opts.comment[--n] = '\0';
}
opts.has_comment = (n > 0);
fclose(cf);
} else if (streq(argv[ai],"--kdf")&&ai+1<argc) {
/* v2.4.1: explicit KDF selection for password mode.
* Default (without --kdf): Argon2id. Use --kdf pbkdf2
* for compatibility with v2.4.0 and older readers. */
ai++;
if (streq(argv[ai],"pbkdf2")) {
opts.kdf_legacy_pbkdf2 = 1;
} else if (streq(argv[ai],"argon2id") || streq(argv[ai],"argon2")) {
opts.kdf_legacy_pbkdf2 = 0;
} else {
fprintf(stderr, "Error: --kdf must be 'argon2id' or 'pbkdf2', got '%s'\n", argv[ai]);
return 1;
}
} else {
fprintf(stderr,"Error: Unknown option '%s'\n",argv[ai]); return 1;
}
@ -315,7 +404,10 @@ int main(int argc, char **argv) {
if(opts.threads<0)opts.threads=0;
if(opts.threads>ZUPT_MAX_THREADS)opts.threads=ZUPT_MAX_THREADS;
}
else if (streq(argv[ai],"--pq-sdk")&&ai+1<argc) {
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")&&ai+1<argc) {
@ -348,7 +440,10 @@ int main(int argc, char **argv) {
if (ai+1<argc && !isopt(argv[ai+1])) strncpy(opts.password,argv[++ai],sizeof(opts.password)-1);
else prompt_password("Password: ", opts.password, sizeof(opts.password));
}
else if (streq(argv[ai],"--pq-sdk")&&ai+1<argc) {
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")&&ai+1<argc) {
@ -380,7 +475,10 @@ int main(int argc, char **argv) {
if (ai+1<argc && !isopt(argv[ai+1])) strncpy(opts.password,argv[++ai],sizeof(opts.password)-1);
else prompt_password("Password: ", opts.password, sizeof(opts.password));
}
else if (streq(argv[ai],"--pq-sdk")&&ai+1<argc) {
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")&&ai+1<argc) {
@ -650,6 +748,40 @@ int main(int argc, char **argv) {
strncpy(opts.keyfile, argv[++ai], sizeof(opts.keyfile)-1);
} else if (streq(argv[ai],"--dedup")||streq(argv[ai],"-D")) {
opts.dedup=1;
} else if ((streq(argv[ai],"-c")||streq(argv[ai],"--comment"))&&ai+1<argc) {
/* v2.4.3: free-form archive comment. Encrypted along with
* data blocks when -p/--pq is also set. */
ai++;
strncpy(opts.comment, argv[ai], ZUPT_MAX_COMMENT_LEN - 1);
opts.comment[ZUPT_MAX_COMMENT_LEN - 1] = '\0';
opts.has_comment = 1;
} else if (streq(argv[ai],"--comment-file")&&ai+1<argc) {
ai++;
FILE *cf = fopen(argv[ai], "rb");
if (!cf) {
fprintf(stderr, "Error: --comment-file: cannot open '%s'\n", argv[ai]);
return 1;
}
size_t n = fread(opts.comment, 1, ZUPT_MAX_COMMENT_LEN - 1, cf);
opts.comment[n] = '\0';
while (n > 0 && (opts.comment[n-1] == '\n' || opts.comment[n-1] == '\r')) {
opts.comment[--n] = '\0';
}
opts.has_comment = (n > 0);
fclose(cf);
} else if (streq(argv[ai],"--kdf")&&ai+1<argc) {
/* v2.4.1: explicit KDF selection for password mode.
* Default (without --kdf): Argon2id. Use --kdf pbkdf2
* for compatibility with v2.4.0 and older readers. */
ai++;
if (streq(argv[ai],"pbkdf2")) {
opts.kdf_legacy_pbkdf2 = 1;
} else if (streq(argv[ai],"argon2id") || streq(argv[ai],"argon2")) {
opts.kdf_legacy_pbkdf2 = 0;
} else {
fprintf(stderr, "Error: --kdf must be 'argon2id' or 'pbkdf2', got '%s'\n", argv[ai]);
return 1;
}
} else {
fprintf(stderr,"Error: Unknown option '%s'\n",argv[ai]); return 1;
}
@ -688,6 +820,7 @@ int main(int argc, char **argv) {
const char *privfile = NULL;
int export_pub = 0;
int sdk_mode = 0;
int box_mode = 0;
int ai = 2;
while (ai < argc && isopt(argv[ai])) {
if ((streq(argv[ai],"-o")||streq(argv[ai],"--output")) && ai+1 < argc)
@ -698,6 +831,8 @@ int main(int argc, char **argv) {
export_pub = 1;
else if (streq(argv[ai],"--sdk")||streq(argv[ai],"--pq-sdk"))
sdk_mode = 1;
else if (streq(argv[ai],"--box")||streq(argv[ai],"--pq-box"))
box_mode = 1;
else { fprintf(stderr, "Unknown option '%s'\n", argv[ai]); return 1; }
ai++;
}
@ -717,6 +852,16 @@ int main(int argc, char **argv) {
fprintf(stderr, "Error: Failed to export public key.\n"); return 1;
}
fprintf(stderr, " Public key written to: %s\n", outfile);
} else if (box_mode) {
fprintf(stderr, " Generating ML-KEM-768 + X25519 keypair (pq-box format)...\n");
char pubfile[512];
snprintf(pubfile, sizeof(pubfile), "%s.pub", outfile);
if (zupt_pqbox_keygen(outfile, pubfile) != 0) {
fprintf(stderr, "Error: pq-box key generation failed.\n"); return 1;
}
fprintf(stderr, " Private key: %s\n", outfile);
fprintf(stderr, " Public key: %s\n", pubfile);
fprintf(stderr, " SECURITY: Keep the private key file secret.\n");
} else if (sdk_mode) {
fprintf(stderr, " Generating ML-KEM-768 + X25519 keypair (SDK-v2 format)...\n");
char pubfile[512];

View file

@ -592,10 +592,14 @@ int zupt_mlkem768_decaps(uint8_t ss[32], const uint8_t ct[1088],
uint8_t ct_prime[1088];
kpke_encrypt(ct_prime, pk, m_prime, kr + 32);
/* CT-REQUIRED: Compare ct and ct' in constant time */
uint8_t diff = 0;
for (int i = 0; i < 1088; i++)
diff |= ct[i] ^ ct_prime[i];
/* CT-REQUIRED: Compare ct and ct' via the single audited constant-time
* primitive (the same one used for MAC-tag verification; timing-tested
* by tests/test_ct_timing). A timing leak here would be a KEM
* decapsulation oracle distinguishing valid from invalid ciphertexts
* breaks IND-CCA2 so this comparison must be constant-time over all
* 1088 ciphertext bytes. zupt_ct_memeq returns 1 if the buffers are
* equal (ct matches success), 0 otherwise. */
int ct_equal = zupt_ct_memeq(ct, ct_prime, 1088);
/* Compute success key: K = KDF(kr[0:32] ‖ H(ct)) */
uint8_t h_ct[32];
@ -615,12 +619,9 @@ int zupt_mlkem768_decaps(uint8_t ss[32], const uint8_t ct[1088],
zupt_shake256(kdf_reject, 64, ss_reject, 32);
/* CT-REQUIRED: Select success or reject key without branching.
* If diff == 0 (ct matches): use ss_success.
* If diff != 0 (ct differs): use ss_reject (implicit rejection).
*
* Convert diff (0 or nonzero) to fail (0 or 1) using constant-time
* bit trick: fail = ((-(uint64_t)diff) >> 63) & 1 */
uint8_t fail = (uint8_t)(((-(int64_t)(uint64_t)diff) >> 63) & 1);
* ct_equal == 1 (ct matches): use ss_success fail = 0.
* ct_equal == 0 (ct differs): use ss_reject (implicit rejection) fail = 1. */
uint8_t fail = (uint8_t)(1 - ct_equal);
#ifdef ZUPT_USE_JASMIN
/* JASMIN-VERIFIED: CT select — proven by Jasmin type system.
* fail=0 ss_success, fail=1 ss_reject */
@ -648,7 +649,20 @@ int zupt_mlkem768_decaps(uint8_t ss[32], const uint8_t ct[1088],
int zupt_mlkem768_selftest(void) {
int ok = 1;
/* Test 1: NTT roundtrip — ntt then inv_ntt should recover original */
/* Test 1: NTT roundtrip.
*
* This implementation follows the pqcrystals/Kyber convention where
* the forward ntt() applies a bare montgomery_reduce in each butterfly
* (dividing by R = 2^16) without first mapping the input into the
* Montgomery domain, and inv_ntt() applies the final f = 1441 scaling.
* As a result nttinv_ntt is NOT the identity on a plain-domain input:
* it recovers each coefficient scaled by a fixed constant (R^{-1} mod
* Q). The real pipeline accounts for this via basemul + tomont, so the
* meaningful, implementation-correct invariant to assert here is that
* the roundtrip is a CONSISTENT LINEAR SCALING: every coefficient is
* multiplied by the same nonzero constant. (A genuine NTT bug wrong
* zeta, wrong butterfly index breaks that consistency and is caught;
* the K-PKE and KEM roundtrips below catch end-to-end errors.) */
{
poly a, b;
for (int i = 0; i < 256; i++) a[i] = (int16_t)(i * 17 % Q);
@ -656,10 +670,20 @@ int zupt_mlkem768_selftest(void) {
ntt(b);
inv_ntt(b);
int ntt_ok = 1;
int factor = -1; /* (b[i] * a[i]^{-1}) mod Q, must be constant */
for (int i = 0; i < 256; i++) {
int16_t diff = (int16_t)((b[i] % Q + Q) % Q) - (int16_t)((a[i] % Q + Q) % Q);
if ((diff % Q + Q) % Q != 0) { ntt_ok = 0; break; }
int av = ((a[i] % Q) + Q) % Q;
int bv = ((b[i] % Q) + Q) % Q;
if (av == 0) { if (bv != 0) { ntt_ok = 0; break; } continue; }
/* f_i = bv / av mod Q */
int ainv = 1, base = av, e = Q - 2; /* a^{Q-2} = a^{-1} mod prime Q */
while (e) { if (e & 1) ainv = (int)(((long)ainv * base) % Q);
base = (int)(((long)base * base) % Q); e >>= 1; }
int f_i = (int)(((long)bv * ainv) % Q);
if (factor < 0) factor = f_i;
else if (f_i != factor) { ntt_ok = 0; break; }
}
if (ntt_ok && factor <= 0) ntt_ok = 0; /* must be a real nonzero scaling */
if (!ntt_ok) { fprintf(stderr, " MLKEM selftest: NTT roundtrip FAILED\n"); ok = 0; }
}

View file

@ -27,6 +27,7 @@
*/
#include "zupt_parallel.h"
#include "vaptvupt.h" /* VAPTVUPT: VaptVupt codec integration */
#include "vaptvupt_api.h" /* F-16: single codec-option policy (vvz_*) */
#include <stdlib.h>
#include <string.h>
@ -90,20 +91,23 @@ static void worker_compress(zpar_slot_t *slot, const zupt_keyring_t *kr) {
} else if (codec == ZUPT_CODEC_ZUPT_LZ) {
comp_size = zupt_lz_compress(rbuf, nread, cbuf, zupt_lz_bound(nread), level);
}
/* VAPTVUPT: VaptVupt codec in parallel compress worker */
/* VAPTVUPT: VaptVupt codec in parallel compress worker.
*
* F-16 (v3.9.0): this worker previously built its own vv_options_t
* and had drifted from the serial wrapper (forced window_log=20, a
* different ULTRA_FAST cutoff, checksum=1, no format_v2, no BCJ, no
* ULTRA_FAST/format_v2 gate). EXTREME + window_log=20 produced
* blocks the decoder rejects on real ELF content a corrupt archive
* at creation time. Codec option policy now lives in exactly one
* place: vvz_compress() (src/vaptvupt_api.c), which also self-checks
* every produced block (decode + memcmp) and fails closed so the
* caller falls back to STORE rather than ever writing an unreadable
* block. Serial and parallel paths emit identical streams. */
else if (codec == ZUPT_CODEC_VAPTVUPT) {
vv_options_t vv_opts;
vv_default_options(&vv_opts);
if (level <= 3) vv_opts.mode = VV_MODE_ULTRA_FAST;
else if (level <= 7) vv_opts.mode = VV_MODE_BALANCED;
else vv_opts.mode = VV_MODE_EXTREME;
vv_opts.checksum = 0;
vv_opts.window_log = (nread > (1u << 16)) ? 20 : 16;
size_t vv_cap = vv_compress_bound(nread);
size_t vv_cap = vvz_compress_bound(nread);
uint8_t *vv_tmp = (uint8_t *)malloc(vv_cap);
if (vv_tmp) {
int64_t csz = vv_compress(rbuf, nread, vv_tmp, vv_cap, &vv_opts);
int64_t csz = vvz_compress(rbuf, nread, vv_tmp, vv_cap, level);
if (csz > 0 && (size_t)csz < nread) {
if ((size_t)csz <= cbuf_cap) {
memcpy(cbuf, vv_tmp, (size_t)csz);
@ -184,7 +188,10 @@ static void worker_decompress(zpar_slot_t *slot, const zupt_keyring_t *kr) {
return;
}
uint8_t *out = (uint8_t *)malloc(olen);
/* Over-allocate by ZUPT_VV_DECODE_SLACK for the VaptVupt AVX2 decode
* over-copy (see zupt.h). olen still tracks the true uncompressed
* size for the XXH64 verify and the returned output_len. */
uint8_t *out = (uint8_t *)malloc(olen + ZUPT_VV_DECODE_SLACK);
if (!out) { free(dec_payload); slot->error = ZUPT_ERR_NOMEM; return; }
zupt_error_t result = ZUPT_OK;
@ -226,7 +233,10 @@ static void worker_decompress(zpar_slot_t *slot, const zupt_keyring_t *kr) {
}
/* VAPTVUPT: VaptVupt codec in parallel decompress worker */
else if (codec == ZUPT_CODEC_VAPTVUPT) {
int64_t dsz = vv_decompress(comp_data, comp_len, out, olen);
/* Pass padded capacity (olen + slack) for the AVX2 over-copy;
* require the returned size to equal the true olen. */
int64_t dsz = vv_decompress(comp_data, comp_len, out,
olen + ZUPT_VV_DECODE_SLACK);
if (dsz < 0 || (size_t)dsz != olen) result = ZUPT_ERR_CORRUPT;
} else {
result = ZUPT_ERR_UNSUPPORTED;

View file

@ -7,8 +7,16 @@
*/
#include "zupt.h"
#include "zupt_acsl.h"
#include "zupt_cpuid.h"
#include <string.h>
/* zupt_sha256_transform_shani() is declared in zupt.h (x86 only) and
* defined in src/zupt_sha256_shani.c. The macro gates the dispatch
* call below so non-x86 builds never reference the symbol. */
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86)
#define ZUPT_SHA256_HAVE_SHANI 1
#endif
static const uint32_t K[64] = {
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
@ -59,16 +67,48 @@ void zupt_sha256_init(zupt_sha256_ctx *c) {
c->count=0;
}
/* Process one or more full 64-byte blocks, dispatching to SHA-NI when
* the CPU supports it (multi-block in a single call for throughput),
* else the scalar transform one block at a time. Bit-identical results. */
static void sha256_blocks(zupt_sha256_ctx *c, const uint8_t *data, size_t blocks) {
if (blocks == 0) return;
#ifdef ZUPT_SHA256_HAVE_SHANI
if (zupt_cpu.has_shani) {
zupt_sha256_transform_shani(c->state, data, blocks);
return;
}
#endif
for (size_t i = 0; i < blocks; i++)
sha256_transform(c, data + i * 64);
}
void zupt_sha256_update(zupt_sha256_ctx *c, const uint8_t *d, size_t n) {
while (n > 0) {
size_t off = (size_t)(c->count % 64);
/* 1. Top up a partially-filled buffer to a full block first. */
if (off != 0) {
size_t chunk = 64 - off;
if (chunk > n) chunk = n;
memcpy(c->buf + off, d, chunk);
c->count += chunk;
d += chunk; n -= chunk;
if (c->count % 64 == 0)
sha256_transform(c, c->buf);
if ((c->count % 64) == 0)
sha256_blocks(c, c->buf, 1);
}
/* 2. Bulk-process all full blocks directly from the input. */
if (n >= 64) {
size_t full = n / 64;
sha256_blocks(c, d, full);
size_t consumed = full * 64;
c->count += consumed;
d += consumed; n -= consumed;
}
/* 3. Buffer the trailing partial block. */
if (n > 0) {
memcpy(c->buf, d, n);
c->count += n;
}
}

284
src/zupt_sha256_shani.c Normal file
View file

@ -0,0 +1,284 @@
/*
* SPDX-License-Identifier: AGPL-3.0-or-later
* Copyright (c) 2025-2026 Cristian Cezar Moisés
* ZUPT SHA-256 hardware path (Intel SHA-NI)
*
* Implements the FIPS 180-4 SHA-256 compression function using the
* Intel SHA Extensions (SHA-NI: SHA256RNDS2, SHA256MSG1, SHA256MSG2).
* This is the same compression function as the scalar path in
* zupt_sha256.c bit-identical output but 3-8x faster on CPUs that
* implement the extensions (Intel Goldmont+/Ice Lake+, AMD Zen+).
*
* Security note: SHA-NI is constant-time by construction. It performs
* no data-dependent memory accesses or branches, so it has a strictly
* stronger side-channel posture than any table- or branch-based
* software SHA-256. Since Zupt's authentication is HMAC-SHA256 over
* attacker-influenced ciphertext, a constant-time compression function
* is the right default wherever the hardware provides it.
*
* Dispatch: sha256_transform() in zupt_sha256.c calls
* zupt_sha256_transform_shani() when zupt_cpu.has_shani is set. On
* non-x86_64 targets this file compiles to nothing (the symbol is
* never referenced because has_shani is always 0).
*
* Reference: Intel SHA Extensions whitepaper (Gulley, Gopal, Yap,
* Feghali, Guilford, Wolrich, 2013) and the public-domain intrinsic
* reference by Jeffrey Walton. This implementation was written against
* the FIPS 180-4 spec and validated bit-exact against the scalar path
* and the NIST FIPS 180-4 test vectors on both paths.
*/
#include "zupt.h"
#include "zupt_cpuid.h"
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86)
#include <immintrin.h>
#include <stdint.h>
#include <string.h>
/* Process `blocks` 64-byte message blocks starting at `data`, updating
* the eight 32-bit state words in `state` in place. Big-endian message
* words per FIPS 180-4. */
void zupt_sha256_transform_shani(uint32_t state[8],
const uint8_t *data, size_t blocks)
{
__m128i STATE0, STATE1;
__m128i MSG, TMP;
__m128i MSG0, MSG1, MSG2, MSG3;
__m128i ABEF_SAVE, CDGH_SAVE;
/* Byte-swap mask: SHA-NI consumes big-endian message words, but
* _mm_loadu_si128 reads little-endian, so each 32-bit lane is
* reversed with pshufb (SSSE3). */
const __m128i MASK = _mm_set_epi64x(
(long long)0x0c0d0e0f08090a0bULL,
(long long)0x0405060700010203ULL);
/* Load initial state. The SHA-NI register layout interleaves the
* eight state words as two 128-bit halves:
* STATE0 = { C, D, G, H } (after the shuffles below)
* STATE1 = { A, B, E, F }
* We start from the natural {A,B,C,D} / {E,F,G,H} order and permute
* into the SHA-NI ABEF/CDGH arrangement. */
TMP = _mm_loadu_si128((const __m128i *)&state[0]); /* A B C D */
STATE1 = _mm_loadu_si128((const __m128i *)&state[4]); /* E F G H */
TMP = _mm_shuffle_epi32(TMP, 0xB1); /* C D A B */
STATE1 = _mm_shuffle_epi32(STATE1, 0x1B); /* H G F E */
STATE0 = _mm_alignr_epi8(TMP, STATE1, 8); /* F E A B -> ABEF */
STATE1 = _mm_blend_epi16(STATE1, TMP, 0xF0); /* C D G H -> CDGH */
while (blocks > 0) {
/* Save current state for the feed-forward add at the end. */
ABEF_SAVE = STATE0;
CDGH_SAVE = STATE1;
/* Rounds 0-3 */
MSG0 = _mm_loadu_si128((const __m128i *)(data + 0));
MSG0 = _mm_shuffle_epi8(MSG0, MASK);
MSG = _mm_add_epi32(MSG0,
_mm_set_epi64x((long long)0xE9B5DBA5B5C0FBCFULL,
(long long)0x71374491428A2F98ULL));
STATE1 = _mm_sha256rnds2_epu32(STATE1, STATE0, MSG);
MSG = _mm_shuffle_epi32(MSG, 0x0E);
STATE0 = _mm_sha256rnds2_epu32(STATE0, STATE1, MSG);
/* Rounds 4-7 */
MSG1 = _mm_loadu_si128((const __m128i *)(data + 16));
MSG1 = _mm_shuffle_epi8(MSG1, MASK);
MSG = _mm_add_epi32(MSG1,
_mm_set_epi64x((long long)0xAB1C5ED5923F82A4ULL,
(long long)0x59F111F13956C25BULL));
STATE1 = _mm_sha256rnds2_epu32(STATE1, STATE0, MSG);
MSG = _mm_shuffle_epi32(MSG, 0x0E);
STATE0 = _mm_sha256rnds2_epu32(STATE0, STATE1, MSG);
MSG0 = _mm_sha256msg1_epu32(MSG0, MSG1);
/* Rounds 8-11 */
MSG2 = _mm_loadu_si128((const __m128i *)(data + 32));
MSG2 = _mm_shuffle_epi8(MSG2, MASK);
MSG = _mm_add_epi32(MSG2,
_mm_set_epi64x((long long)0x550C7DC3243185BEULL,
(long long)0x12835B01D807AA98ULL));
STATE1 = _mm_sha256rnds2_epu32(STATE1, STATE0, MSG);
MSG = _mm_shuffle_epi32(MSG, 0x0E);
STATE0 = _mm_sha256rnds2_epu32(STATE0, STATE1, MSG);
MSG1 = _mm_sha256msg1_epu32(MSG1, MSG2);
/* Rounds 12-15 */
MSG3 = _mm_loadu_si128((const __m128i *)(data + 48));
MSG3 = _mm_shuffle_epi8(MSG3, MASK);
MSG = _mm_add_epi32(MSG3,
_mm_set_epi64x((long long)0xC19BF1749BDC06A7ULL,
(long long)0x80DEB1FE72BE5D74ULL));
STATE1 = _mm_sha256rnds2_epu32(STATE1, STATE0, MSG);
TMP = _mm_alignr_epi8(MSG3, MSG2, 4);
MSG0 = _mm_add_epi32(MSG0, TMP);
MSG0 = _mm_sha256msg2_epu32(MSG0, MSG3);
MSG = _mm_shuffle_epi32(MSG, 0x0E);
STATE0 = _mm_sha256rnds2_epu32(STATE0, STATE1, MSG);
MSG2 = _mm_sha256msg1_epu32(MSG2, MSG3);
/* Rounds 16-19 */
MSG = _mm_add_epi32(MSG0,
_mm_set_epi64x((long long)0x240CA1CC0FC19DC6ULL,
(long long)0xEFBE4786E49B69C1ULL));
STATE1 = _mm_sha256rnds2_epu32(STATE1, STATE0, MSG);
TMP = _mm_alignr_epi8(MSG0, MSG3, 4);
MSG1 = _mm_add_epi32(MSG1, TMP);
MSG1 = _mm_sha256msg2_epu32(MSG1, MSG0);
MSG = _mm_shuffle_epi32(MSG, 0x0E);
STATE0 = _mm_sha256rnds2_epu32(STATE0, STATE1, MSG);
MSG3 = _mm_sha256msg1_epu32(MSG3, MSG0);
/* Rounds 20-23 */
MSG = _mm_add_epi32(MSG1,
_mm_set_epi64x((long long)0x76F988DA5CB0A9DCULL,
(long long)0x4A7484AA2DE92C6FULL));
STATE1 = _mm_sha256rnds2_epu32(STATE1, STATE0, MSG);
TMP = _mm_alignr_epi8(MSG1, MSG0, 4);
MSG2 = _mm_add_epi32(MSG2, TMP);
MSG2 = _mm_sha256msg2_epu32(MSG2, MSG1);
MSG = _mm_shuffle_epi32(MSG, 0x0E);
STATE0 = _mm_sha256rnds2_epu32(STATE0, STATE1, MSG);
MSG0 = _mm_sha256msg1_epu32(MSG0, MSG1);
/* Rounds 24-27 */
MSG = _mm_add_epi32(MSG2,
_mm_set_epi64x((long long)0xBF597FC7B00327C8ULL,
(long long)0xA831C66D983E5152ULL));
STATE1 = _mm_sha256rnds2_epu32(STATE1, STATE0, MSG);
TMP = _mm_alignr_epi8(MSG2, MSG1, 4);
MSG3 = _mm_add_epi32(MSG3, TMP);
MSG3 = _mm_sha256msg2_epu32(MSG3, MSG2);
MSG = _mm_shuffle_epi32(MSG, 0x0E);
STATE0 = _mm_sha256rnds2_epu32(STATE0, STATE1, MSG);
MSG1 = _mm_sha256msg1_epu32(MSG1, MSG2);
/* Rounds 28-31 */
MSG = _mm_add_epi32(MSG3,
_mm_set_epi64x((long long)0x1429296706CA6351ULL,
(long long)0xD5A79147C6E00BF3ULL));
STATE1 = _mm_sha256rnds2_epu32(STATE1, STATE0, MSG);
TMP = _mm_alignr_epi8(MSG3, MSG2, 4);
MSG0 = _mm_add_epi32(MSG0, TMP);
MSG0 = _mm_sha256msg2_epu32(MSG0, MSG3);
MSG = _mm_shuffle_epi32(MSG, 0x0E);
STATE0 = _mm_sha256rnds2_epu32(STATE0, STATE1, MSG);
MSG2 = _mm_sha256msg1_epu32(MSG2, MSG3);
/* Rounds 32-35 */
MSG = _mm_add_epi32(MSG0,
_mm_set_epi64x((long long)0x53380D134D2C6DFCULL,
(long long)0x2E1B213827B70A85ULL));
STATE1 = _mm_sha256rnds2_epu32(STATE1, STATE0, MSG);
TMP = _mm_alignr_epi8(MSG0, MSG3, 4);
MSG1 = _mm_add_epi32(MSG1, TMP);
MSG1 = _mm_sha256msg2_epu32(MSG1, MSG0);
MSG = _mm_shuffle_epi32(MSG, 0x0E);
STATE0 = _mm_sha256rnds2_epu32(STATE0, STATE1, MSG);
MSG3 = _mm_sha256msg1_epu32(MSG3, MSG0);
/* Rounds 36-39 */
MSG = _mm_add_epi32(MSG1,
_mm_set_epi64x((long long)0x92722C8581C2C92EULL,
(long long)0x766A0ABB650A7354ULL));
STATE1 = _mm_sha256rnds2_epu32(STATE1, STATE0, MSG);
TMP = _mm_alignr_epi8(MSG1, MSG0, 4);
MSG2 = _mm_add_epi32(MSG2, TMP);
MSG2 = _mm_sha256msg2_epu32(MSG2, MSG1);
MSG = _mm_shuffle_epi32(MSG, 0x0E);
STATE0 = _mm_sha256rnds2_epu32(STATE0, STATE1, MSG);
MSG0 = _mm_sha256msg1_epu32(MSG0, MSG1);
/* Rounds 40-43 */
MSG = _mm_add_epi32(MSG2,
_mm_set_epi64x((long long)0xC76C51A3C24B8B70ULL,
(long long)0xA81A664BA2BFE8A1ULL));
STATE1 = _mm_sha256rnds2_epu32(STATE1, STATE0, MSG);
TMP = _mm_alignr_epi8(MSG2, MSG1, 4);
MSG3 = _mm_add_epi32(MSG3, TMP);
MSG3 = _mm_sha256msg2_epu32(MSG3, MSG2);
MSG = _mm_shuffle_epi32(MSG, 0x0E);
STATE0 = _mm_sha256rnds2_epu32(STATE0, STATE1, MSG);
MSG1 = _mm_sha256msg1_epu32(MSG1, MSG2);
/* Rounds 44-47 */
MSG = _mm_add_epi32(MSG3,
_mm_set_epi64x((long long)0x106AA070F40E3585ULL,
(long long)0xD6990624D192E819ULL));
STATE1 = _mm_sha256rnds2_epu32(STATE1, STATE0, MSG);
TMP = _mm_alignr_epi8(MSG3, MSG2, 4);
MSG0 = _mm_add_epi32(MSG0, TMP);
MSG0 = _mm_sha256msg2_epu32(MSG0, MSG3);
MSG = _mm_shuffle_epi32(MSG, 0x0E);
STATE0 = _mm_sha256rnds2_epu32(STATE0, STATE1, MSG);
MSG2 = _mm_sha256msg1_epu32(MSG2, MSG3);
/* Rounds 48-51 */
MSG = _mm_add_epi32(MSG0,
_mm_set_epi64x((long long)0x34B0BCB52748774CULL,
(long long)0x1E376C0819A4C116ULL));
STATE1 = _mm_sha256rnds2_epu32(STATE1, STATE0, MSG);
TMP = _mm_alignr_epi8(MSG0, MSG3, 4);
MSG1 = _mm_add_epi32(MSG1, TMP);
MSG1 = _mm_sha256msg2_epu32(MSG1, MSG0);
MSG = _mm_shuffle_epi32(MSG, 0x0E);
STATE0 = _mm_sha256rnds2_epu32(STATE0, STATE1, MSG);
MSG3 = _mm_sha256msg1_epu32(MSG3, MSG0);
/* Rounds 52-55 */
MSG = _mm_add_epi32(MSG1,
_mm_set_epi64x((long long)0x682E6FF35B9CCA4FULL,
(long long)0x4ED8AA4A391C0CB3ULL));
STATE1 = _mm_sha256rnds2_epu32(STATE1, STATE0, MSG);
TMP = _mm_alignr_epi8(MSG1, MSG0, 4);
MSG2 = _mm_add_epi32(MSG2, TMP);
MSG2 = _mm_sha256msg2_epu32(MSG2, MSG1);
MSG = _mm_shuffle_epi32(MSG, 0x0E);
STATE0 = _mm_sha256rnds2_epu32(STATE0, STATE1, MSG);
/* Rounds 56-59 */
MSG = _mm_add_epi32(MSG2,
_mm_set_epi64x((long long)0x8CC7020884C87814ULL,
(long long)0x78A5636F748F82EEULL));
STATE1 = _mm_sha256rnds2_epu32(STATE1, STATE0, MSG);
TMP = _mm_alignr_epi8(MSG2, MSG1, 4);
MSG3 = _mm_add_epi32(MSG3, TMP);
MSG3 = _mm_sha256msg2_epu32(MSG3, MSG2);
MSG = _mm_shuffle_epi32(MSG, 0x0E);
STATE0 = _mm_sha256rnds2_epu32(STATE0, STATE1, MSG);
/* Rounds 60-63 */
MSG = _mm_add_epi32(MSG3,
_mm_set_epi64x((long long)0xC67178F2BEF9A3F7ULL,
(long long)0xA4506CEB90BEFFFAULL));
STATE1 = _mm_sha256rnds2_epu32(STATE1, STATE0, MSG);
MSG = _mm_shuffle_epi32(MSG, 0x0E);
STATE0 = _mm_sha256rnds2_epu32(STATE0, STATE1, MSG);
/* Feed-forward: add the saved state. */
STATE0 = _mm_add_epi32(STATE0, ABEF_SAVE);
STATE1 = _mm_add_epi32(STATE1, CDGH_SAVE);
data += 64;
blocks -= 1;
}
/* Permute the ABEF/CDGH halves back to the natural ABCD/EFGH order
* and store. */
TMP = _mm_shuffle_epi32(STATE0, 0x1B); /* FEBA */
STATE1 = _mm_shuffle_epi32(STATE1, 0xB1); /* DCHG */
STATE0 = _mm_blend_epi16(TMP, STATE1, 0xF0); /* DCBA */
STATE1 = _mm_alignr_epi8(STATE1, TMP, 8); /* ABEF -> HGFE */
_mm_storeu_si128((__m128i *)&state[0], STATE0);
_mm_storeu_si128((__m128i *)&state[4], STATE1);
}
#else /* non-x86: never referenced (has_shani is always 0) */
/* Translation unit must not be empty under -Wpedantic. */
typedef int zupt_sha256_shani_translation_unit_not_empty;
#endif

View file

@ -24,4 +24,9 @@ $Z keygen -o "$T/k.key" 2>/dev/null && $Z keygen --pub -o "$T/p.key" -k "$T/k.ke
$Z compress --pq "$T/p.key" "$T/8.zupt" "$T/d/" 2>/dev/null && $Z extract --pq "$T/k.key" -o "$T/o8" "$T/8.zupt" 2>/dev/null
E=$(find "$T/o8" -name a.txt -type f 2>/dev/null|head -1); [ -n "$E" ] && diff -q "$T/d/a.txt" "$E" >/dev/null 2>&1 && ok "PQ" || fl "PQ"
R=$($Z test "$T/1.zupt" 2>&1); echo "$R"|grep -q "0 failed" && ok "Integrity" || fl "Integrity"
echo ""; echo " Results: $P passed, $F failed (9 tests)"; [ "$F" -eq 0 ] && exit 0 || exit 1
# F-01 (2.2.4): every help command verb starts its own line.
# Pre-fix output ran "keygen … Key generation zupt version" on one
# wrapped line due to a missing \n in src/zupt_main.c:41.
HC=$($Z help 2>&1 | grep -cE '^ (vaptvupt|zupt) ')
[ "$HC" -ge 10 ] && ok "Help command lines ($HC)" || fl "Help command lines ($HC, need ≥10)"
echo ""; echo " Results: $P passed, $F failed (10 tests)"; [ "$F" -eq 0 ] && exit 0 || exit 1

View file

@ -35,7 +35,23 @@ mkdir -p eb && (cd eb && "$ZUPT_BIN" x --pq legacy.key ../a.zupt > /dev/null 2>&
B=$([ ! -f eb/input.txt ] && echo 1 || echo 0)
DCHK "Wrong key rejected (SDK key + legacy key paths)" "$A" "$B"
# A2. Tamper at byte position N detected (path A: pos 200) (path B: pos at end)
# A2. Tamper at byte position N detected.
#
# F-02 (Zupt 2.2.4): the previous version flipped a byte at len-50 for
# path B. SDK-PQ archive sizes vary by 1-2 bytes per run (ciphertext
# encoding), so len-50 occasionally landed inside the *index* region
# (between footer.index_offset and the trailing 32-byte footer), which
# is NOT covered by the per-block HMAC. Roughly 10% of runs would let
# the tampered file extract successfully and the suite would flake.
#
# Fix: tamper at absolute offsets known to be inside the encrypted
# body of any non-empty SDK-PQ archive. With "data\n" (5 bytes) as
# input, the archive is ~1769-1771 bytes and the body runs from
# offset ~80 to ~1610. Offsets 200 (early-body) and 500 (mid-body)
# are both deterministically authenticated.
#
# The unauthenticated index region is now tracked as F-02b (deferred
# to v2.2.5 format-v1.5).
cp a.zupt t1.zupt; cp a.zupt t2.zupt
python3 -c "
b = bytearray(open('t1.zupt','rb').read())
@ -43,13 +59,13 @@ b[200] ^= 1
open('t1.zupt','wb').write(bytes(b))" 2>/dev/null
python3 -c "
b = bytearray(open('t2.zupt','rb').read())
b[len(b)-50] ^= 1
b[500] ^= 1
open('t2.zupt','wb').write(bytes(b))" 2>/dev/null
mkdir -p t1e && (cd t1e && "$ZUPT_BIN" x --pq-sdk ../k.priv ../t1.zupt > /dev/null 2>&1)
mkdir -p t2e && (cd t2e && "$ZUPT_BIN" x --pq-sdk ../k.priv ../t2.zupt > /dev/null 2>&1)
A=$([ ! -f t1e/input.txt ] && echo 1 || echo 0)
B=$([ ! -f t2e/input.txt ] && echo 1 || echo 0)
DCHK "Tamper detected at any byte position" "$A" "$B"
DCHK "Tamper detected at body offset 200 and 500" "$A" "$B"
echo " [B. Format security]"

85
tests/test_audit_flake.sh Executable file
View file

@ -0,0 +1,85 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# Flake-stress harness — §3 of PROMPT.md.
#
# Runs every short test suite N times (default 50) and aborts on the
# first non-deterministic outcome. Specifically targeted at the audit
# suite, which historically flaked when archive-size variance caused a
# byte-position-based tamper to land in unauthenticated bytes
# (finding F-02 in docs/FINDINGS-2.x.md).
#
# Usage: bash tests/test_audit_flake.sh [N]
#
# Exit codes:
# 0 — all N runs of every targeted suite passed identically.
# 1 — at least one run differed (test is flaky). Output names the run.
set -u
# Default N=20 across 5 suites (~10 min). Pass an arg to override.
# F-02's repro needed 50 runs to be statistically convincing (~10%
# baseline flake rate), but at 20 runs we still have ~88% chance of
# catching a 10%-flake — fine for routine CI. For a hardened audit
# pass, invoke with 50 or 100 (see PROMPT.md §3).
N="${1:-20}"
ZUPT_BIN="${ZUPT_BIN:-./zupt}"
if [ ! -x "$ZUPT_BIN" ]; then
echo "$ZUPT_BIN not found or not executable. Run 'make' first." >&2
exit 1
fi
run_suite() {
local name="$1"; shift
local cmd="$*"
local pass=0 fail=0 i first_failed_log=""
echo ""
echo " ─── $name × $N ───"
for i in $(seq 1 "$N"); do
local out
out=$(bash -c "$cmd" 2>&1)
# The convention used by every short suite under tests/ is to
# finish with "<N> passed, 0 failed" when the suite is green.
if echo "$out" | grep -qE '[0-9]+ passed, 0 failed'; then
pass=$((pass+1))
else
fail=$((fail+1))
if [ -z "$first_failed_log" ]; then
first_failed_log=$(mktemp)
printf '%s\n' "$out" > "$first_failed_log"
fi
fi
done
if [ "$fail" -eq 0 ]; then
echo "$name: $pass/$N green (deterministic)"
return 0
else
echo "$name: $pass passed, $fail failed — FLAKY"
echo " First failing run captured at: $first_failed_log"
echo " --- first 30 lines of failing output ---"
head -30 "$first_failed_log" | sed 's/^/ /'
return 1
fi
}
GLOBAL_FAIL=0
run_suite "tests/test_audit.sh" "bash tests/test_audit.sh" || GLOBAL_FAIL=1
run_suite "tests/test_path_traversal.sh" "bash tests/test_path_traversal.sh" || GLOBAL_FAIL=1
run_suite "tests/test_arg_order.sh" "bash tests/test_arg_order.sh" || GLOBAL_FAIL=1
run_suite "tests/test_block_swap.sh" "bash tests/test_block_swap.sh" || GLOBAL_FAIL=1
run_suite "tests/test_dedup_props.sh" "bash tests/test_dedup_props.sh" || GLOBAL_FAIL=1
echo ""
if [ "$GLOBAL_FAIL" -eq 0 ]; then
echo " ═══════════════════════════════════════════"
echo " Flake-stress PASS — $N runs × 5 suites all deterministic"
echo " ═══════════════════════════════════════════"
exit 0
else
echo " ═══════════════════════════════════════════"
echo " Flake-stress FAIL — see captured log above"
echo " ═══════════════════════════════════════════"
exit 1
fi

View file

@ -0,0 +1,120 @@
/*
* SPDX-License-Identifier: AGPL-3.0-or-later
* Copyright (c) 2026 Cristian Cezar Moisés
*
* Exact-content_size decode regression (v4.0.0; codec 2.60.4).
*
* Codec 2.60.4 fixes a high-severity OOB heap WRITE in the AVX2 decode
* fast path, reachable on a VALID stream when the destination buffer is
* sized to exactly content_size two variants: a single wide store for
* tails n <= 32, and the tail store for n > 32. The tool itself always
* over-allocates (ZUPT_VV_DECODE_SLACK, F-14), so it was shielded; this
* test pins the vendored codec directly so the defect class cannot
* silently return via a future codec drop-in.
*
* Method: for payloads chosen to exercise (a) sizes whose tail mod 32
* spans 1..32 and >32, (b) compressible text, (c) BCJ-triggering
* ELF-like content (auto-filter on), compress with the same options the
* tool's shim uses (BALANCED and EXTREME, format_v2, filter_auto), then
* decompress into a heap buffer of EXACTLY the original size, under
* AddressSanitizer. Any OOB write aborts the test. Output must also be
* byte-identical to the input (BCJ inverse correctness).
*/
#include "vaptvupt.h"
#include "vaptvupt_api.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
static int run_case(const uint8_t *src, size_t n, int mode, const char *label) {
vv_options_t opts;
vv_default_options(&opts);
opts.checksum = 0;
opts.compat_v246_5_decoder = 0;
opts.mode = mode;
opts.format_v2 = 1;
opts.filter_auto = 1;
opts.window_log = 0;
size_t cap = vv_compress_bound(n);
uint8_t *comp = (uint8_t *)malloc(cap);
if (!comp) { fprintf(stderr, " OOM\n"); return 1; }
int64_t csz = vv_compress(src, n, comp, cap, &opts);
if (csz <= 0) { fprintf(stderr, " %s: compress failed (%lld)\n", label, (long long)csz); free(comp); return 1; }
/* EXACT-size destination — the CVE trigger. ASan owns the verdict on
* any out-of-bounds write. */
uint8_t *out = (uint8_t *)malloc(n ? n : 1);
if (!out) { free(comp); return 1; }
int64_t dsz = vv_decompress_flags(comp, (size_t)csz, out, n,
VV_DECOMPRESS_SKIP_CHECKSUM);
int rc = 0;
if (dsz != (int64_t)n) { fprintf(stderr, " %s: size %lld != %zu\n", label, (long long)dsz, n); rc = 1; }
else if (memcmp(out, src, n) != 0) { fprintf(stderr, " %s: payload mismatch\n", label); rc = 1; }
free(out); free(comp);
return rc;
}
/* Synthetic ELF-ish buffer: real ELF magic + class/endian bytes so
* vv_bcj_detect engages the x86 filter, then bytes containing E8/E9
* (call/jmp rel32) patterns that the filter actually rewrites. */
static void fill_elfish(uint8_t *p, size_t n) {
static const uint8_t elf_hdr[20] = {
0x7f,'E','L','F', 2,1,1,0, 0,0,0,0,0,0,0,0, 2,0, 0x3e,0
};
memset(p, 0, n);
memcpy(p, elf_hdr, n < 20 ? n : 20);
for (size_t i = 24; i + 5 < n; i += 7) {
p[i] = (i % 3) ? 0xE8 : 0xE9; /* call / jmp */
uint32_t rel = (uint32_t)(i * 2654435761u);
memcpy(p + i + 1, &rel, 4);
}
}
int main(void) {
printf("Codec exact-content_size decode (OOB regression, codec 2.60.4)\n");
srand(424242);
int fail = 0, pass = 0;
/* Tail coverage: n mod 32 in {1, 7, 31, 32 (0), >32 leftovers} at
* block-ish sizes, plus tiny buffers. */
static const size_t sizes[] = {
1, 7, 31, 32, 33, 63, 64, 65, 96, 4095, 4096, 4097,
65536 + 1, 65536 + 31, 65536 + 33, 1048576 + 17
};
enum { NSZ = sizeof(sizes)/sizeof(sizes[0]) };
uint8_t *buf = (uint8_t *)malloc(1048576 + 64);
if (!buf) return 1;
for (int k = 0; k < NSZ; k++) {
size_t n = sizes[k];
char label[96];
/* compressible text-like */
for (size_t i = 0; i < n; i++) buf[i] = (uint8_t)("abcdef \n"[i % 8]);
snprintf(label, sizeof label, "text n=%zu BALANCED", n);
if (run_case(buf, n, VV_MODE_BALANCED, label)) fail++; else pass++;
snprintf(label, sizeof label, "text n=%zu EXTREME", n);
if (run_case(buf, n, VV_MODE_EXTREME, label)) fail++; else pass++;
/* BCJ-triggering ELF-ish (filter_auto fires) */
fill_elfish(buf, n);
snprintf(label, sizeof label, "elf n=%zu BALANCED", n);
if (run_case(buf, n, VV_MODE_BALANCED, label)) fail++; else pass++;
snprintf(label, sizeof label, "elf n=%zu EXTREME", n);
if (run_case(buf, n, VV_MODE_EXTREME, label)) fail++; else pass++;
/* incompressible (stored path) */
for (size_t i = 0; i < n; i++) buf[i] = (uint8_t)rand();
snprintf(label, sizeof label, "rand n=%zu BALANCED", n);
if (run_case(buf, n, VV_MODE_BALANCED, label)) fail++; else pass++;
}
free(buf);
printf("\n ───────────────────────────────────────\n");
printf(" exact-size decode: %d passed, %d failed\n", pass, fail);
printf(" ───────────────────────────────────────\n");
return fail ? 1 : 0;
}

52
tests/test_codec_exact_size.sh Executable file
View file

@ -0,0 +1,52 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2026 Cristian Cezar Moisés
#
# Exact-content_size decode OOB regression (codec 2.60.4 fix) under ASan,
# plus a tool-level BCJ roundtrip on a real binary fixture at the levels
# where the auto-filter engages. Codec sources are compiled directly with
# -fsanitize=address (never link sanitized objects against the project's
# non-sanitized .o files — ASan static-archive poisoning).
set -u
ARCH=$(uname -m)
SIMD=""
[ "$ARCH" = "x86_64" ] && SIMD="-mavx2"
TMP=$(mktemp -d)
rc=0
echo "Codec exact-size + BCJ roundtrip"
if gcc -Iinclude -Wall -Wextra -Werror -O1 -g -std=c11 -fsanitize=address $SIMD \
tests/test_codec_exact_size.c \
src/vaptvupt_api.c src/vv_ans.c src/vv_bcj.c src/vv_decoder.c \
src/vv_encoder.c src/vv_huffman.c src/vv_simd.c src/vv_xxh64.c \
-o "$TMP/t" 2>"$TMP/cc.log"; then
"$TMP/t"; rc=$?
else
echo " ✗ exact-size test failed to compile"; head -12 "$TMP/cc.log" | sed 's/^/ /'; rc=1
fi
# Tool-level BCJ roundtrip: real binary fixture at L5 (BALANCED+auto-filter)
# and L9 (EXTREME+auto-filter); byte-exact extraction required. Guards the
# F-16 defect class (old in-tree BCJ wrote undecodable streams).
FX=/tmp/bench/fixtures/binary.dat
if [ -f "$FX" ] && [ -x ./vaptvupt ]; then
for L in 5 9; do
rm -rf "$TMP/o$L"; mkdir -p "$TMP/o$L"
./vaptvupt c -l $L "$TMP/a$L.zupt" "$FX" >/dev/null 2>&1
./vaptvupt x -o "$TMP/o$L" "$TMP/a$L.zupt" >/dev/null 2>&1
F=$(find "$TMP/o$L" -type f | head -1)
if [ -n "$F" ] && diff -q "$F" "$FX" >/dev/null 2>&1; then
echo " ✓ BCJ roundtrip L$L (binary fixture) byte-exact"
else
echo " ✗ BCJ roundtrip L$L FAILED"; rc=1
fi
done
else
echo " - BCJ tool roundtrip skipped (fixture or binary missing)"
fi
rm -rf "$TMP"
exit $rc

224
tests/test_completions_manpage.sh Executable file
View file

@ -0,0 +1,224 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# Sprint 2.4.7 regression: shell completions + manpage.
#
# Asserts:
# - completions/vaptvupt.bash has bash-clean syntax
# - completions/_vaptvupt has zsh-clean syntax (if zsh available)
# - completions/vaptvupt.fish has fish-clean syntax (if fish available)
# - Each completion file mentions all the major CLI flags the binary
# actually parses (--kdf, --comment, --pq-sdk, --dedup, ...)
# - doc/zupt.1 mentions current v2.4.x features (--kdf, --comment,
# Argon2id, F-11, comment-file)
# - doc/zupt.1 has the standard sections (NAME, SYNOPSIS, DESCRIPTION,
# COMMANDS, EXAMPLES)
set -u
PASS=0
FAIL=0
P() { PASS=$((PASS+1)); echo "$1"; }
F() { FAIL=$((FAIL+1)); echo "$1"; }
SKIP() { echo " - skipped: $1"; }
cd "$(dirname "$0")/.."
VERSION=$(grep '^#define ZUPT_VERSION_STRING' include/zupt.h | awk -F'"' '{print $2}')
echo "Completions + manpage (vaptvupt $VERSION)"
# ─── Bash completion ───
if [ -f completions/vaptvupt.bash ]; then
if bash -n completions/vaptvupt.bash 2>/dev/null; then
P "bash completion: syntax clean"
else
F "bash completion: syntax error"
fi
# Should define a _zupt function and register it via complete -F
if grep -q "^_vaptvupt()" completions/vaptvupt.bash; then
P "bash completion: defines _vaptvupt function"
else
F "bash completion: missing _vaptvupt function"
fi
if grep -qE "^complete -F _vaptvupt (vaptvupt|zupt)" completions/vaptvupt.bash; then
P "bash completion: registers via complete -F"
else
F "bash completion: missing complete -F registration"
fi
else
F "completions/vaptvupt.bash missing"
fi
# ─── Zsh completion ───
if [ -f completions/_vaptvupt ]; then
if command -v zsh >/dev/null 2>&1; then
if zsh -n completions/_vaptvupt 2>/dev/null; then
P "zsh completion: syntax clean"
else
F "zsh completion: syntax error"
fi
else
SKIP "zsh not installed — skipping syntax check"
fi
# Should have #compdef directive
if grep -qE "^#compdef vaptvupt( zupt)?$" completions/_vaptvupt; then
P "zsh completion: has #compdef vaptvupt directive"
else
F "zsh completion: missing #compdef directive"
fi
else
F "completions/_vaptvupt missing"
fi
# ─── Fish completion ───
if [ -f completions/vaptvupt.fish ]; then
if command -v fish >/dev/null 2>&1; then
if fish -n completions/vaptvupt.fish 2>/dev/null; then
P "fish completion: syntax clean"
else
F "fish completion: syntax error"
fi
else
SKIP "fish not installed — skipping syntax check"
fi
# Should have complete -c zupt entries
if grep -qE "^complete -c (vaptvupt|zupt)" completions/vaptvupt.fish; then
P "fish completion: has complete -c vaptvupt entries"
else
F "fish completion: no complete -c vaptvupt entries"
fi
else
F "completions/vaptvupt.fish missing"
fi
# ─── Flag-coverage check (across all three completion files) ───
# Every flag the binary actually parses should appear in every completion file.
# Each completion format has its own way of writing long options:
# bash: --flag
# zsh: --flag
# fish: -l flag (or --flag in comments)
critical_flags=(kdf comment comment-file pq pq-sdk dedup solid verbose quiet threads level block store fast lzhp vaptvupt)
for f in completions/vaptvupt.bash completions/_vaptvupt; do
[ -f "$f" ] || continue
name=$(basename "$f")
missing=""
for flag in "${critical_flags[@]}"; do
if ! grep -qF -- "--$flag" "$f"; then
missing="$missing --$flag"
fi
done
if [ -z "$missing" ]; then
P "$name: covers all ${#critical_flags[@]} critical flags"
else
F "$name: missing flags:$missing"
fi
done
if [ -f completions/vaptvupt.fish ]; then
name="vaptvupt.fish"
missing=""
for flag in "${critical_flags[@]}"; do
# fish uses `-l flag-name` for long opts
if ! grep -qE -- "(-l $flag|--$flag)" completions/vaptvupt.fish; then
missing="$missing $flag"
fi
done
if [ -z "$missing" ]; then
P "$name: covers all ${#critical_flags[@]} critical flags (via -l form)"
else
F "$name: missing flags:$missing"
fi
fi
# ─── Manpage refresh ───
if [ -f doc/zupt.1 ]; then
# v2.4.x features must be mentioned. Use shell-friendly regexes that
# match groff's `\-\-` escape (literal backslash, dash, backslash, dash).
declare -a manpage_checks=(
"kdf:--kdf option"
"comment:--comment option"
"argon2id:Argon2id KDF"
"Argon2id:Argon2id KDF (capital)"
"verbal probe-oracle:F-11 message change"
"ML-KEM-768:post-quantum KEM"
)
manpage_misses=0
for entry in "${manpage_checks[@]}"; do
key="${entry%%:*}"
desc="${entry#*:}"
if grep -qF "$key" doc/zupt.1; then
:
else
F "manpage: doesn't mention '$desc' (looking for '$key')"
manpage_misses=$((manpage_misses+1))
fi
done
# Two additional checks for groff-escaped hyphens (--comment-file, --pq-sdk
# render as `\-\-comment\-file` and `\-\-pq\-sdk` in the source)
if grep -qE "comment\\\\-file|comment-file" doc/zupt.1; then
:
else
F "manpage: doesn't mention --comment-file (looking for comment\\-file or comment-file)"
manpage_misses=$((manpage_misses+1))
fi
if grep -qE "pq\\\\-sdk|pq-sdk" doc/zupt.1; then
:
else
F "manpage: doesn't mention --pq-sdk (looking for pq\\-sdk or pq-sdk)"
manpage_misses=$((manpage_misses+1))
fi
if [ "$manpage_misses" = 0 ]; then
P "manpage: mentions all v2.4.x features"
fi
# Required sections
for section in NAME SYNOPSIS DESCRIPTION COMMANDS EXAMPLES; do
if grep -qE "^\.SH $section" doc/zupt.1; then
:
else
F "manpage: missing section '.SH $section'"
fi
done
P "manpage: required sections present"
# Version header
if grep -qE "\"(vaptvupt|zupt) $VERSION\"" doc/zupt.1; then
P "manpage: TH version matches include/zupt.h ($VERSION)"
else
F "manpage: TH version doesn't match include/zupt.h"
fi
# Try to render with groff if available
if command -v groff >/dev/null 2>&1; then
if groff -mandoc -Tutf8 doc/zupt.1 > /tmp/render.txt 2>/tmp/groff_warn.txt; then
LINES=$(wc -l < /tmp/render.txt)
if [ "$LINES" -gt 50 ]; then
P "manpage: renders cleanly with groff ($LINES lines)"
else
F "manpage: groff produced suspiciously short output ($LINES lines)"
fi
else
F "manpage: groff rendering failed"
fi
rm -f /tmp/render.txt /tmp/groff_warn.txt
elif command -v mandoc >/dev/null 2>&1; then
if mandoc -Tlint doc/zupt.1 >/tmp/mandoc.out 2>&1; then
P "manpage: mandoc lint clean"
else
P "manpage: mandoc lint had warnings (acceptable)"
fi
rm -f /tmp/mandoc.out
else
SKIP "no groff or mandoc — skipping render lint"
fi
else
F "doc/zupt.1 missing"
fi
echo ""
echo " ───────────────────────────────────────"
echo " completions + manpage: $PASS passed, $FAIL failed"
echo " ───────────────────────────────────────"
[ "$FAIL" = 0 ] || exit 1

257
tests/test_ct_timing.c Normal file
View file

@ -0,0 +1,257 @@
/*
* SPDX-License-Identifier: AGPL-3.0-or-later
* Copyright (c) 2025-2026 Cristian Cezar Moisés
*
* Constant-time verification of zupt_ct_memeq (v3.5.0) dudect-style.
*
* The MAC-tag comparison is the most timing-sensitive operation in the
* codebase: if "wrong on byte 0" were measurably faster than "wrong on
* byte 31", an attacker could forge a tag byte-by-byte. zupt_ct_memeq
* is written to be constant-time; this test MEASURES that, rather than
* trusting the source comment.
*
* Method (Reparaz, Balasch, Verbauwhede "Dude, is my code constant
* time?", DATE 2017): time the function on two input classes and apply
* Welch's t-test to the timing distributions.
*
* Class FIX: compare a fixed reference tag against an IDENTICAL copy
* (the all-equal case the slowest, full-scan path).
* Class RND: compare the reference tag against a RANDOM tag (differs
* at a random, usually early, position).
*
* A non-constant-time compare (e.g. memcmp with early return) finishes
* class RND much sooner than class FIX, so the means diverge and |t|
* grows without bound as samples accumulate. A constant-time compare
* keeps the two distributions statistically indistinguishable, so |t|
* stays bounded.
*
* Robustness: wall-clock nanosecond timing on a shared CI vCPU is noisy,
* so we (a) discard the slowest 10% of each class as scheduling outliers
* (standard dudect "cropping"), (b) require the result to hold on the
* cropped data, and (c) use a deliberately loose threshold (|t| < 8;
* dudect's own leak threshold is |t| > 10 over millions of samples).
* The point is to catch a gross leak (early-return / memcmp), which
* produces |t| in the hundreds, not to certify against a sub-nanosecond
* microarchitectural side channel that needs dedicated hardware.
*
* As a positive control, the test also times plain memcmp() the same
* way and asserts it DOES leak (|t| large) proving the harness can
* actually detect a non-CT compare on this host. If the control fails
* to show a leak the host is too noisy to draw a conclusion, and the
* test reports INCONCLUSIVE (skips) rather than passing vacuously.
*/
#include "zupt.h"
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <stdlib.h>
#include <time.h>
#include <math.h>
#define TAG_LEN 32
#define N_SAMPLES 200000
#define CROP_FRAC 0.10 /* drop slowest 10% of each class */
/* Volatile sink so the compiler can't discard the compared result. */
static volatile int g_sink;
static uint64_t now_ns(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
}
/* Welch's t-statistic for two samples. */
static double welch_t(const double *x, size_t nx, const double *y, size_t ny) {
double mx = 0, my = 0;
for (size_t i = 0; i < nx; i++) mx += x[i];
mx /= (double)nx;
for (size_t i = 0; i < ny; i++) my += y[i];
my /= (double)ny;
double vx = 0, vy = 0;
for (size_t i = 0; i < nx; i++) { double d = x[i] - mx; vx += d * d; }
for (size_t i = 0; i < ny; i++) { double d = y[i] - my; vy += d * d; }
vx /= (double)(nx - 1);
vy /= (double)(ny - 1);
double denom = sqrt(vx / (double)nx + vy / (double)ny);
if (denom == 0.0) return 0.0;
return (mx - my) / denom;
}
/* Measure |t| for a comparison function over FIX vs RND input classes.
* fn returns nonzero on "equal" (zupt_ct_memeq) we only care about
* timing, not the return value. */
typedef int (*cmp_fn)(const void *, const void *, size_t);
/* proper double comparator for qsort cropping */
static int cmp_dbl(const void *a, const void *b) {
double x = *(const double *)a, y = *(const double *)b;
return (x > y) - (x < y);
}
/* memcmp wrapper matching the cmp_fn signature (positive control). */
static int memcmp_wrap(const void *a, const void *b, size_t n) {
return memcmp(a, b, n) == 0;
}
/* Time `fn` over FIX (equal) vs RND (differing) classes and return
* Welch |t| on the cropped samples.
*
* Both classes use the SAME two small buffers (ref, cmp) so the memory
* footprint and cache behaviour are identical the only difference is
* the bytes in `cmp`. For each sample we (1) prepare cmp OUTSIDE the
* timed region (either copy ref for FIX, or fill random for RND), then
* (2) time a single fn() call. Class order is decided by a coin flip per
* iteration so any first-vs-second ordering bias cancels across the two
* distributions rather than loading onto one of them. */
static double measure_t2_len(cmp_fn fn, size_t buflen) {
static uint8_t ref[2048], cmp[2048];
static double tfix[N_SAMPLES], trnd[N_SAMPLES];
size_t nfix = 0, nrnd = 0;
if (buflen > sizeof(ref)) buflen = sizeof(ref);
for (size_t i = 0; i < buflen; i++) ref[i] = (uint8_t)(0xA5 ^ (i * 7));
/* Warm up. */
memcpy(cmp, ref, buflen);
for (int w = 0; w < 2000; w++) g_sink = fn(ref, cmp, buflen);
for (size_t i = 0; i < 2 * N_SAMPLES; i++) {
int is_rnd = rand() & 1;
if (is_rnd) {
for (size_t j = 0; j < buflen; j++) cmp[j] = (uint8_t)rand();
} else {
memcpy(cmp, ref, buflen);
}
/* Single timed call — identical buffers, only contents differ. */
uint64_t t0 = now_ns();
g_sink = fn(ref, cmp, buflen);
uint64_t t1 = now_ns();
double dt = (double)(t1 - t0);
if (is_rnd) { if (nrnd < N_SAMPLES) trnd[nrnd++] = dt; }
else { if (nfix < N_SAMPLES) tfix[nfix++] = dt; }
if (nfix >= N_SAMPLES && nrnd >= N_SAMPLES) break;
}
qsort(tfix, nfix, sizeof(double), cmp_dbl);
qsort(trnd, nrnd, sizeof(double), cmp_dbl);
size_t kf = (size_t)((double)nfix * (1.0 - CROP_FRAC));
size_t kr = (size_t)((double)nrnd * (1.0 - CROP_FRAC));
return welch_t(tfix, kf, trnd, kr);
}
/* 32-byte (MAC tag) convenience wrapper. */
static double measure_t2(cmp_fn fn) { return measure_t2_len(fn, TAG_LEN); }
int main(void) {
printf("Constant-time compares (dudect-style): MAC tag + ML-KEM ciphertext\n");
srand(12345);
int pass = 0, fail = 0;
/* Median of a few measurements to damp single-run vCPU noise. */
double ct_runs[5], mc_runs[5];
for (int r = 0; r < 5; r++) {
mc_runs[r] = fabs(measure_t2(memcmp_wrap));
ct_runs[r] = fabs(measure_t2(zupt_ct_memeq));
}
qsort(mc_runs, 5, sizeof(double), cmp_dbl);
qsort(ct_runs, 5, sizeof(double), cmp_dbl);
double t_memcmp = mc_runs[2]; /* median */
double t_ct = ct_runs[2]; /* median */
printf(" memcmp (control, expected to leak): |t| = %8.2f\n", t_memcmp);
printf(" zupt_ct_memeq (expected constant): |t| = %8.2f\n", t_ct);
/* Environment-relative criterion, made robust against vCPU noise.
*
* Absolute |t| thresholds are not portable: on a shared CI vCPU the
* clock_gettime overhead and scheduler noise put even a perfectly
* constant-time 32-byte compare at |t| in the low tens, while a
* dedicated box sits near 0. The portable signal is the RATIO to a
* deliberately leaky baseline (memcmp with early return) measured in
* the SAME environment BUT that ratio is only meaningful when the
* baseline leaks STRONGLY and cleanly.
*
* Observed on this shared vCPU: when the host is quiet, the memcmp
* control reaches |t| 6001500 and zupt_ct_memeq sits at |t| 570
* (ratio 0.010.05 clearly flat). When the host is under
* contention, BOTH collapse into a common noise band (control 210,
* ct_memeq 190): the measurement simply cannot separate them, and
* the ratio ( 0.9) is an artifact of noise, not a real leak. The
* tell is that a contended control barely clears 200 while a quiet
* one is 37× higher.
*
* So we only render a pass/fail verdict when the control leaks
* STRONGLY (|t| >= 400 comfortably above the ~210 contention band
* and far below the ~600+ quiet floor). Below that we report
* INCONCLUSIVE rather than risk a noise-driven false failure. A
* genuine early-return regression still fails: on a quiet host the
* leaky function tracks the control (ratio ~1.0) while the control
* is well above 400. */
const double CONTROL_STRONG = 400.0; /* control must leak THIS strongly for a valid verdict */
const double MAX_RATIO = 0.20; /* when control is strong: CT compare <= 20% of it */
if (t_memcmp < CONTROL_STRONG) {
printf(" - control |t|=%.1f below %.0f: host under contention this run;\n", t_memcmp, CONTROL_STRONG);
printf(" control and ct_memeq are in a common noise band, ratio not meaningful\n");
printf(" - INCONCLUSIVE this run (zupt_ct_memeq is OR-accumulate, no branch; rerun on a quiet host)\n");
printf(" Constant-time: 0 passed, 0 failed (inconclusive — measurement env)\n");
return 0;
}
printf(" \xE2\x9C\x93 control: memcmp leaks strongly (|t|=%.1f, harness is sensitive)\n", t_memcmp);
pass++;
double ratio = t_ct / t_memcmp;
printf(" ratio zupt_ct_memeq/memcmp = %.3f (must be <= %.2f)\n", ratio, MAX_RATIO);
if (ratio <= MAX_RATIO) {
printf(" \xE2\x9C\x93 zupt_ct_memeq shows no data-dependent timing (%.1f%% of leak signal)\n",
ratio * 100.0);
pass++;
} else {
printf(" \xE2\x9C\x97 zupt_ct_memeq timing tracks the data (%.1f%% of leak signal) — NOT constant-time\n",
ratio * 100.0);
fail++;
}
/* ── ML-KEM-768 decaps ciphertext compare (1088 bytes) ──
*
* The implicit-rejection check in zupt_mlkem768_decaps compares the
* re-encrypted ciphertext against the received one over all 1088
* bytes via this same zupt_ct_memeq. A timing leak there is a KEM
* decapsulation oracle that breaks IND-CCA2.
*
* IMPORTANT why this measurement is INFORMATIONAL, not pass/fail:
* at 1088 bytes the dudect signal is dominated by memory/cache
* effects rather than the compare's control flow, and plain memcmp
* over 1088 bytes is no longer a cleanly-leaking control (its own
* timing is data-dependent in ways unrelated to early-exit). The
* environment-relative ratio that is meaningful at 32 bytes is not
* meaningful here on a shared vCPU. What actually establishes the
* property is: (a) the 32-byte pass/fail check above proves
* zupt_ct_memeq is constant-time, and (b) zupt_ct_memeq is
* length-independent by construction (OR-accumulate, no early exit,
* no data-dependent branch same code path for every byte and every
* length). The decaps compare uses exactly this primitive (verified
* by the source-routing assertion in tests/test_ct_timing.sh), so its
* constant-timeness follows from (a)+(b). We print the 1088B numbers
* for transparency but do not gate on them. */
printf("\n -- ML-KEM ciphertext compare (1088 bytes, informational) --\n");
double mc1088_runs[5], ct1088_runs[5];
for (int r = 0; r < 5; r++) {
mc1088_runs[r] = fabs(measure_t2_len(memcmp_wrap, 1088));
ct1088_runs[r] = fabs(measure_t2_len(zupt_ct_memeq, 1088));
}
qsort(mc1088_runs, 5, sizeof(double), cmp_dbl);
qsort(ct1088_runs, 5, sizeof(double), cmp_dbl);
printf(" memcmp 1088B: |t| = %8.2f (not a clean control at this size)\n",
mc1088_runs[2]);
printf(" zupt_ct_memeq 1088B: |t| = %8.2f\n", ct1088_runs[2]);
printf(" note: constant-timeness of the 1088B decaps compare follows from the\n");
printf(" 32B pass above + zupt_ct_memeq being length-independent by\n");
printf(" construction; the decaps path uses this exact primitive.\n");
printf("\n ───────────────────────────────────────\n");
printf(" Constant-time: %d passed, %d failed\n", pass, fail);
printf(" ───────────────────────────────────────\n");
return fail ? 1 : 0;
}

63
tests/test_ct_timing.sh Executable file
View file

@ -0,0 +1,63 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# dudect-style constant-time verification of zupt_ct_memeq (v3.5.0).
# Builds at -O2 (the shipped optimisation level — so this tests the code
# as users run it, including that the volatile accumulator survives the
# optimiser) and runs the Welch t-test harness.
#
# Verdict is environment-relative: zupt_ct_memeq's data-dependent timing
# signal must be a small fraction (<=20%) of leaky memcmp measured in the
# same environment. On a dedicated box the ratio is ~0; on this shared
# vCPU it lands near 1%. If the host is too coarse for even memcmp to
# show a leak, the test reports INCONCLUSIVE (exit 0) rather than
# passing vacuously.
set -u
SDK_DIR="${ZUPTSDK_DIR:-vendor/zuptsdk}"
ARCH=$(uname -m)
if [ "$ARCH" = "x86_64" ] || [ "$ARCH" = "i686" ]; then
SHANI="-msha -mssse3 -msse4.1"
else
SHANI=""
fi
TMP=$(mktemp -d)
if gcc -Iinclude -Isrc -I"$SDK_DIR/include" -Wall -Wextra -Werror $SHANI -O2 -std=c11 \
tests/test_ct_timing.c \
src/zupt_crypto.c src/zupt_sha256.c src/zupt_sha256_shani.c src/zupt_aes256.c \
src/zupt_xxh.c src/zupt_keccak.c src/zupt_x25519.c src/zupt_mlkem.c \
src/zupt_cpuid.c src/zupt_mlock.c \
-L"$SDK_DIR" -lzuptsdk -Wl,-rpath,"$(cd "$SDK_DIR" && pwd)" -lm \
-o "$TMP/t" 2>"$TMP/cc.log"; then
"$TMP/t"; rc=$?
else
echo " ✗ constant-time test failed to compile"
head -15 "$TMP/cc.log" | sed 's/^/ /'
rc=1
fi
rm -rf "$TMP"
# Source-routing guard: the security-critical compares must use the single
# audited zupt_ct_memeq primitive, not a reintroduced inline byte-OR loop.
# This is what makes the 32-byte timing proof transfer to the ML-KEM
# 1088-byte decaps compare (same function, length-independent).
echo ""
echo " -- source routing (audited primitive) --"
ROUTE_OK=0
if grep -q "zupt_ct_memeq(ct, ct_prime, 1088)" src/zupt_mlkem.c; then
echo " ✓ ML-KEM decaps compare routes through zupt_ct_memeq"
else
echo " ✗ ML-KEM decaps compare does NOT use zupt_ct_memeq (inline loop regressed?)"
ROUTE_OK=1
fi
# The decaps path must not contain a raw 1088-byte inline OR-compare anymore.
if grep -qE "for *\(int i = 0; i < 1088;" src/zupt_mlkem.c; then
echo " ✗ raw 1088-byte inline compare loop present in zupt_mlkem.c"
ROUTE_OK=1
else
echo " ✓ no raw 1088-byte inline compare loop in zupt_mlkem.c"
fi
[ "$rc" = 0 ] && rc=$ROUTE_OK
exit $rc

159
tests/test_dist_reproducible.sh Executable file
View file

@ -0,0 +1,159 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# Sprint 2.4.4 regression test: `make dist` reproducibility.
#
# Asserts that running `make dist` twice on the same source tree
# produces byte-identical tarballs (same sha256, same size). This is
# the foundational property for downstream Debian / AUR / Homebrew
# packaging — without it, distros can't pin a sha256 for the source
# tarball in their recipes.
#
# Also asserts that the dist tarball contains the right things:
# - source code (src/, include/, tests/)
# - the three libzuptsdk symlinks + the real .so file
# - no built binaries (zupt, test_vectors, *.o)
# - no .git/ tree
#
# Exit non-zero on first failure.
set -u
PASS=0
FAIL=0
P() { PASS=$((PASS+1)); echo "$1"; }
F() { FAIL=$((FAIL+1)); echo "$1"; }
# Run from the project root regardless of where the test was invoked.
cd "$(dirname "$0")/.."
# 1. First dist build
make dist >/tmp/dist1.log 2>&1
RC=$?
if [ $RC -ne 0 ]; then
echo " ✗ make dist failed on first run; see /tmp/dist1.log"
tail -10 /tmp/dist1.log
exit 1
fi
VERSION=$(grep '^#define ZUPT_VERSION_STRING' include/zupt.h | awk -F'"' '{print $2}')
# v3.0.0: TARGET=vaptvupt, so the tarball is now /tmp/vaptvupt-${VERSION}.tar.gz.
# Test both possible filenames so this works on any future rename.
TARBALL="/tmp/vaptvupt-${VERSION}.tar.gz"
[ ! -f "$TARBALL" ] && TARBALL="/tmp/zupt-${VERSION}.tar.gz"
# Derive top-level dir inside the tarball from the filename
TARBALL_BASE=$(basename "$TARBALL" .tar.gz) # e.g. vaptvupt-3.0.0
if [ ! -f "$TARBALL" ]; then
echo " ✗ expected $TARBALL not produced"
exit 1
fi
P "first make dist produced $TARBALL"
SHA1=$(sha256sum "$TARBALL" | awk '{print $1}')
SIZE1=$(wc -c < "$TARBALL")
cp "$TARBALL" "${TARBALL%.tar.gz}.first.tar.gz"
# 2. Second dist build — should produce byte-identical tarball
make dist >/tmp/dist2.log 2>&1
RC=$?
if [ $RC -ne 0 ]; then
echo " ✗ make dist failed on second run; see /tmp/dist2.log"
tail -10 /tmp/dist2.log
exit 1
fi
SHA2=$(sha256sum "$TARBALL" | awk '{print $1}')
SIZE2=$(wc -c < "$TARBALL")
if [ "$SHA1" = "$SHA2" ]; then
P "byte-identical sha256 across two runs: $SHA1"
else
F "sha256 diverged: $SHA1 vs $SHA2"
fi
if [ "$SIZE1" = "$SIZE2" ]; then
P "byte-identical size: $SIZE1"
else
F "size diverged: $SIZE1 vs $SIZE2"
fi
# 3. Content checks
NUM_FILES=$(tar tzf "$TARBALL" | wc -l)
if [ "$NUM_FILES" -gt 100 ]; then
P "tarball has $NUM_FILES entries (sanity: > 100)"
else
F "tarball suspiciously small: $NUM_FILES entries"
fi
if tar tzf "$TARBALL" | grep -q "${TARBALL_BASE}/src/zupt_format.c"; then
P "src/zupt_format.c present"
else
F "src/zupt_format.c missing"
fi
if tar tzf "$TARBALL" | grep -q "${TARBALL_BASE}/include/zupt.h"; then
P "include/zupt.h present"
else
F "include/zupt.h missing"
fi
# All three libzuptsdk variants
SO_REAL=$(tar tzf "$TARBALL" | grep -c "libzuptsdk.so.2.0.0$")
SO_LINKS=$(tar tzf "$TARBALL" | grep -cE "libzuptsdk.so$|libzuptsdk.so.2$")
if [ "$SO_REAL" = "1" ] && [ "$SO_LINKS" = "2" ]; then
P "libzuptsdk: 1 real .so + 2 symlinks"
else
F "libzuptsdk shipping wrong: real=$SO_REAL links=$SO_LINKS (expected 1 + 2)"
fi
# No built binaries (vaptvupt or legacy zupt symlink or test_* harnesses)
if tar tzf "$TARBALL" | grep -qE "(vaptvupt|zupt)-${VERSION}/(vaptvupt|zupt)(\$|_asan\$)|(vaptvupt|zupt)-${VERSION}/test_vectors\$|(vaptvupt|zupt)-${VERSION}/test_vaptvupt\$"; then
F "tarball contains built binaries"
else
P "tarball contains no built binaries"
fi
# No .o files
if tar tzf "$TARBALL" | grep -qE "\.o$"; then
F "tarball contains stale .o files"
else
P "tarball contains no .o files"
fi
# No .git
if tar tzf "$TARBALL" | grep -q "\.git/"; then
F "tarball contains .git/ tree"
else
P "tarball contains no .git/ tree"
fi
# 4. Build & smoke-test from the dist tarball
WORK=$(mktemp -d)
( cd "$WORK" && tar xzf "$TARBALL" && cd "${TARBALL_BASE}" && make -j"$(nproc)" >/tmp/distbuild.log 2>&1 ) || {
F "build from dist tarball failed; see /tmp/distbuild.log"
rm -rf "$WORK"
[ "$FAIL" = 0 ] || exit 1
}
# v3.0.0: binary may be named `vaptvupt` (default) or legacy `zupt`.
# Pick whichever the dist-tarball build produced.
DISTBIN=""
for cand in vaptvupt zupt; do
if [ -x "$WORK/${TARBALL_BASE}/$cand" ]; then DISTBIN="$WORK/${TARBALL_BASE}/$cand"; break; fi
done
if [ -n "$DISTBIN" ]; then
P "binary builds from dist tarball ($(basename "$DISTBIN"))"
"$DISTBIN" version > /tmp/distver.txt 2>&1
if grep -q "$VERSION" /tmp/distver.txt; then
P "built binary reports correct version ($VERSION)"
else
F "binary version mismatch: $(cat /tmp/distver.txt)"
fi
else
F "no binary produced from dist build"
fi
rm -rf "$WORK"
# Cleanup
rm -f "/tmp/zupt-${VERSION}.first.tar.gz"
echo ""
echo " ───────────────────────────────────────"
echo " dist reproducibility: $PASS passed, $FAIL failed"
echo " ───────────────────────────────────────"
[ "$FAIL" = 0 ] || exit 1

109
tests/test_f06_hmac.c Normal file
View file

@ -0,0 +1,109 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later
* Copyright (c) 2025-2026 Cristian Cezar Moisés
*
* F-06 regression test (Zupt 2.2.5).
*
* The original combined-diff in zupt_decrypt_buffer was
* uint64_t diff = diff_v2 & diff_v1;
* which on the Jasmin path (full 64-bit OR-of-4-chunks accumulators) accepts
* single-bit MAC tampers with probability 4/64 6% wherever the bit
* flipped in diff_v2 happens to fall on one of the rare zero bits of diff_v1.
*
* This test produces N independent encrypt(plaintext) packages with fresh
* keys, flips one bit of the stored MAC in each, and asserts every single
* tampered package is rejected on decrypt. With N=2000 the bug, if present,
* would surface ~120 acceptances; we accept zero. */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "zupt.h"
extern uint8_t *zupt_encrypt_buffer(const zupt_keyring_t *kr,
const uint8_t *plain, size_t plen,
uint64_t block_seq, size_t *olen);
extern uint8_t *zupt_decrypt_buffer(const zupt_keyring_t *kr,
const uint8_t *pkg, size_t pkglen,
uint64_t block_seq, size_t *olen);
extern void zupt_random_bytes(uint8_t *buf, size_t len);
static int run_trial(uint64_t seed_offset) {
zupt_keyring_t kr;
zupt_keyring_init(&kr);
zupt_random_bytes(kr.enc_key, sizeof(kr.enc_key));
zupt_random_bytes(kr.mac_key, sizeof(kr.mac_key));
zupt_random_bytes(kr.base_nonce, sizeof(kr.base_nonce));
kr.active = 1;
/* Plaintext: short and not all-zero, so the ciphertext doesn't accidentally
* leak structural cues if the test ever inspects it. */
const char *plain = "F-06 regression plaintext payload — block_seq matters";
size_t plen = strlen(plain);
size_t pkg_len = 0;
uint8_t *pkg = zupt_encrypt_buffer(&kr, (const uint8_t *)plain, plen,
0x0123456789ABCDEFULL + seed_offset, &pkg_len);
if (!pkg) return -1;
/* Sanity: untouched package decrypts. */
{
size_t dlen = 0;
uint8_t *dec = zupt_decrypt_buffer(&kr, pkg, pkg_len,
0x0123456789ABCDEFULL + seed_offset, &dlen);
if (!dec || dlen != plen || memcmp(dec, plain, plen) != 0) {
free(dec); free(pkg);
return -2; /* honest roundtrip broken — separate bug */
}
zupt_secure_wipe(dec, dlen);
free(dec);
}
/* F-06 probe: flip one bit of the stored MAC (last 32 bytes of pkg).
* The bit chosen rotates across trials to exercise the full HMAC
* surface, not just one position. */
size_t mac_off = pkg_len - ZUPT_HMAC_SIZE;
size_t bit_pos = seed_offset & 0xFF; /* 0..255 → bit within HMAC */
size_t byte_within_mac = bit_pos >> 3; /* 0..31 */
uint8_t bit_mask = (uint8_t)(1u << (bit_pos & 7));
pkg[mac_off + byte_within_mac] ^= bit_mask;
/* Expectation: decrypt MUST return NULL (auth fail). */
size_t dlen = 0;
uint8_t *dec = zupt_decrypt_buffer(&kr, pkg, pkg_len,
0x0123456789ABCDEFULL + seed_offset, &dlen);
int silent_accept = (dec != NULL);
if (dec) {
zupt_secure_wipe(dec, dlen);
free(dec);
}
free(pkg);
return silent_accept;
}
int main(void) {
const int N = 2000;
int accepted = 0;
int sanity_fails = 0;
int roundtrip_fails = 0;
fputs("F-06 regression: 2000 trials, 1-bit HMAC tamper each\n", stderr);
for (int i = 0; i < N; i++) {
int r = run_trial((uint64_t)i);
if (r == 1) accepted++;
else if (r == -1) sanity_fails++;
else if (r == -2) roundtrip_fails++;
}
fprintf(stderr, " honest roundtrips OK: %d/%d\n", N - sanity_fails - roundtrip_fails, N);
fprintf(stderr, " encrypt-buffer failures: %d (must be 0)\n", sanity_fails);
fprintf(stderr, " roundtrip mismatches: %d (must be 0)\n", roundtrip_fails);
fprintf(stderr, " silent-accepted tampers: %d (must be 0)\n", accepted);
if (sanity_fails || roundtrip_fails || accepted) {
fprintf(stderr, "F-06 regression: FAIL\n");
return 1;
}
fprintf(stderr, "F-06 regression: PASS\n");
return 0;
}

155
tests/test_f08_topmac.sh Executable file
View file

@ -0,0 +1,155 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# F-08 regression test (Zupt 2.3.0).
#
# Two directions:
# 1. v1.5 archive: tamper at each previously-cosmetic header/footer byte
# MUST be detected (top-MAC verifies header+footer[0..23]).
# 2. v1.4 archive (built by Zupt 2.2.5 binary, embedded as a fixture):
# MUST extract cleanly with the legacy-downgrade warning on stderr.
#
# The v1.4 fixture is built at test time IF a 2.2.5 binary is available
# under tests/fixtures/, else direction #2 is skipped with a NOTE.
set -u
PASS=0
FAIL=0
ZUPT="${ZUPT_BIN:-./zupt}"
# Resolve to absolute path so the test continues to find the binary after cd.
case "$ZUPT" in
/*) ;;
*) ZUPT="$PWD/$ZUPT" ;;
esac
ROOT="$PWD"
P() { PASS=$((PASS+1)); echo "$1"; }
F() { FAIL=$((FAIL+1)); echo "$1"; }
if [ ! -x "$ZUPT" ]; then
echo "$ZUPT not found — run 'make' first" >&2
exit 1
fi
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
cd "$TMPDIR"
echo " [Direction 1: v1.5 archive detects header+footer tamper]"
"$ZUPT" keygen --sdk -o k.priv >/dev/null 2>&1
echo "data" > input.txt
"$ZUPT" c --pq-sdk k.priv.pub a.zupt input.txt >/dev/null 2>&1
SZ=$(wc -c < a.zupt)
if [ "$SZ" -lt 100 ]; then F "couldn't build v1.5 archive"; exit 1; fi
# Sanity: untouched archive extracts.
mkdir -p clean
( cd clean && "$ZUPT" x --pq-sdk ../k.priv ../a.zupt >/dev/null 2>&1 )
if [ -f clean/input.txt ]; then P "clean v1.5 archive extracts"; else F "clean v1.5 archive extract"; fi
# Confirm any v1.5+ archive with top-MAC HMAC-SHA256 is reported.
# (The original write path made v1.5; from 2.3.1 onwards it's v1.6+. The
# test only cares that the AIT is present and reported.)
INFO=$("$ZUPT" info a.zupt 2>&1)
if echo "$INFO" | grep -qE "Format: *v1\.(5|6|7|8|9)" && echo "$INFO" | grep -q "Top-MAC: *YES (HMAC-SHA256)"; then
P "zupt info reports v1.5+ / Top-MAC HMAC-SHA256"
else
F "zupt info v1.5+ report"
fi
# Tamper at each header byte (0..63) and each footer byte (SZ-64..SZ-33).
TAMPER_POSITIONS="8 9 10 11 12 14 16 20 24 28 32 36 40 44 48 52 56 60"
FOOTER_START=$((SZ - 64))
for f in 0 4 8 12 16 20 23; do
TAMPER_POSITIONS="$TAMPER_POSITIONS $((FOOTER_START + f))"
done
ALL_DETECTED=1
for POS in $TAMPER_POSITIONS; do
cp a.zupt t.zupt
python3 -c "
b=bytearray(open('t.zupt','rb').read())
b[$POS] ^= 1
open('t.zupt','wb').write(bytes(b))"
rm -rf out && mkdir out
( cd out && "$ZUPT" x --pq-sdk ../k.priv ../t.zupt >/dev/null 2>&1 )
if [ -f out/input.txt ]; then
ALL_DETECTED=0
echo " silent-accepted tamper at byte $POS"
fi
done
if [ "$ALL_DETECTED" = 1 ]; then
P "all 25 header+footer tamper positions rejected"
else
F "some header+footer tampers silently accepted"
fi
# Also: tamper SHOULD trigger a clear error message. Post-F-11 (v2.4.2)
# the default message is generic ("Authentication failed (wrong key,
# wrong password, or tampered archive)") to avoid a verbal probe-oracle;
# the technical "top-MAC" wording is only shown with --verbose. The F-08
# assertion is that the user is *informed* and the extract is refused —
# either wording satisfies that.
cp a.zupt t.zupt
python3 -c "
b=bytearray(open('t.zupt','rb').read())
b[20] ^= 1 # archive_id byte
open('t.zupt','wb').write(bytes(b))"
rm -rf out && mkdir out
ERR=$( cd out && "$ZUPT" x --pq-sdk ../k.priv ../t.zupt 2>&1 || true )
if echo "$ERR" | grep -qE "Authentication failed|top-MAC"; then
P "tamper produces a clear auth/integrity error"
else
F "tamper error message ambiguous: $ERR"
fi
# Verbose mode: top-MAC wording must still surface for debugging
rm -rf out && mkdir out
ERR_V=$( cd out && "$ZUPT" x --verbose --pq-sdk ../k.priv ../t.zupt 2>&1 || true )
if echo "$ERR_V" | grep -q "top-MAC"; then
P "tamper with --verbose surfaces top-MAC detail"
else
F "tamper --verbose did not surface top-MAC: $ERR_V"
fi
echo ""
echo " [Direction 2: v1.4 backward-compat]"
FIXTURE_BIN="$ROOT/tests/fixtures/zupt-2.2.5"
if [ -x "$FIXTURE_BIN" ]; then
# Build v1.4 archive using the 2.2.5 binary.
"$FIXTURE_BIN" keygen --sdk -o k14.priv >/dev/null 2>&1
"$FIXTURE_BIN" c --pq-sdk k14.priv.pub a14.zupt input.txt >/dev/null 2>&1
# v2.3.0 info should say v1.4 / no top-MAC.
INFO14=$("$ZUPT" info a14.zupt 2>&1)
if echo "$INFO14" | grep -q "Format: *v1.4" && echo "$INFO14" | grep -q "Top-MAC: *no"; then
P "v1.4 archive reported as v1.4 / no top-MAC"
else
F "v1.4 info report wrong"
fi
# v2.3.0 extract should succeed with warning.
mkdir out14
OUT=$( cd out14 && "$ZUPT" x --pq-sdk ../k14.priv ../a14.zupt 2>&1 )
if [ -f out14/input.txt ] && echo "$OUT" | grep -qi "legacy v1.4 archive"; then
P "v1.4 archive extracts with legacy warning"
else
F "v1.4 backward-compat broken: $OUT"
fi
else
echo " NOTE: tests/fixtures/zupt-2.2.5 not present — direction 2 skipped"
echo " (build it once with: cd tests/fixtures && tar xzf zupt-2.2.5.tar.gz"
echo " && cd zupt-2.2.5 && make && cp zupt ../zupt-2.2.5)"
fi
echo ""
echo " ───────────────────────────────────────"
echo " F-08 regression: $PASS passed, $FAIL failed"
echo " ───────────────────────────────────────"
[ "$FAIL" = 0 ] || exit 1

90
tests/test_f09_preface.sh Executable file
View file

@ -0,0 +1,90 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# F-09 regression test (Zupt 2.3.1).
#
# F-09 closed the per-block frame preface tamper window by:
# 1. Binding the canonical preface (block_type, codec_id, block_flags,
# sizes, plaintext-XXH64) into the per-block HMAC via the new v1.6
# ZUPT_FLAG_AAD_PREFACE policy.
# 2. Adding strict structural validation of the encryption-header
# block's frame preface in read_enc_header (same pattern as F-07
# for the index block in v2.2.5).
#
# This test does the full exhaustive byte sweep on a small v1.6 PQ-SDK
# archive: every byte from 0 to N-1 is flipped one at a time, and we
# assert the extract fails for ALL of them. With pre-F-09 code this
# would show 15-18 silent acceptances; post-F-09 it must show zero.
#
# Why limit to PQ-SDK encrypted: plaintext archives have no HMAC at
# all (XXH64 best-effort only), so per-byte coverage is intentionally
# weaker and a different, separately-tracked promise.
set -u
ZUPT="${ZUPT_BIN:-./zupt}"
case "$ZUPT" in
/*) ;;
*) ZUPT="$PWD/$ZUPT" ;;
esac
if [ ! -x "$ZUPT" ]; then
echo "$ZUPT not found — run 'make' first" >&2
exit 1
fi
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
cd "$TMPDIR"
"$ZUPT" keygen --sdk -o k.priv >/dev/null 2>&1
echo "F-09 regression test payload" > input.txt
"$ZUPT" c --pq-sdk k.priv.pub a.zupt input.txt >/dev/null 2>&1
SZ=$(wc -c < a.zupt)
if [ "$SZ" -lt 100 ] || [ "$SZ" -gt 10000 ]; then
echo " ✗ unexpected archive size $SZ" >&2
exit 1
fi
# Sanity: clean archive extracts.
mkdir -p clean
( cd clean && "$ZUPT" x --pq-sdk ../k.priv ../a.zupt >/dev/null 2>&1 )
if [ ! -f clean/input.txt ]; then
echo " ✗ clean v1.6 PQ-SDK archive doesn't extract" >&2
exit 1
fi
# Exhaustive sweep.
echo " [F-09: exhaustive byte sweep of $SZ-byte v1.6 PQ-SDK archive]"
UNDETECTED_POSITIONS=""
TAMPER_SAMPLED=0
for POS in $(seq 0 $((SZ - 1))); do
cp a.zupt t.zupt
python3 -c "
b=bytearray(open('t.zupt','rb').read())
b[$POS] ^= 1
open('t.zupt','wb').write(bytes(b))"
rm -rf out && mkdir out
( cd out && "$ZUPT" x --pq-sdk ../k.priv ../t.zupt >/dev/null 2>&1 )
TAMPER_SAMPLED=$((TAMPER_SAMPLED + 1))
if [ -f out/input.txt ]; then
UNDETECTED_POSITIONS="$UNDETECTED_POSITIONS $POS"
fi
done
UNDETECTED_COUNT=$(echo $UNDETECTED_POSITIONS | wc -w)
echo ""
echo " ───────────────────────────────────────"
if [ "$UNDETECTED_COUNT" = 0 ]; then
echo " F-09 regression: $TAMPER_SAMPLED tamper positions tested, 0 silent-accepted ✓"
echo " ───────────────────────────────────────"
exit 0
else
echo " F-09 regression: $UNDETECTED_COUNT silent-accepted positions (must be 0)"
echo " positions:$UNDETECTED_POSITIONS"
echo " ───────────────────────────────────────"
exit 1
fi

140
tests/test_f10_kdf_default.sh Executable file
View file

@ -0,0 +1,140 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# F-10 regression test (Zupt 2.4.1).
#
# F-10: default password-mode KDF flipped from PBKDF2-SHA256 to Argon2id.
# PBKDF2 remains available via --kdf pbkdf2 for compatibility with
# v2.4.0-and-older readers.
#
# Three assertions:
# 1. `zupt c -p PW out.zupt input` writes an enc-header with type byte
# 0x04 (ZUPT_ENC_PW_ARGON2), and the stderr message says Argon2id.
# 2. `zupt c -p PW --kdf pbkdf2 out.zupt input` writes type byte 0x01
# (ZUPT_ENC_PBKDF2), and the stderr message says PBKDF2.
# 3. Both archive types roundtrip byte-exact via `zupt x -p PW`.
# 4. Wrong password is rejected for both archive types.
set -u
ZUPT="${ZUPT_BIN:-./zupt}"
case "$ZUPT" in
/*) ;;
*) ZUPT="$PWD/$ZUPT" ;;
esac
if [ ! -x "$ZUPT" ]; then
echo "$ZUPT not found — run 'make' first" >&2
exit 1
fi
PASS=0
FAIL=0
P() { PASS=$((PASS+1)); echo "$1"; }
F() { FAIL=$((FAIL+1)); echo "$1"; }
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
cd "$TMPDIR"
echo "F-10 regression: password-mode KDF default"
# Helper: read the enc_type byte (payload[0] of the enc-header block).
enc_type_of() {
python3 -c "
import sys
b = open('$1','rb').read()
off = int.from_bytes(b[36:44],'little')
def vread(buf,o):
v=0;s=0
while True:
x=buf[o]; o+=1; v|=(x&0x7f)<<s
if not (x&0x80): break
s+=7
return v,o
v1,p = vread(b, off+7); v2,p2 = vread(b, p)
print(f'{b[p2+8]:02x}')
"
}
echo "secret payload for KDF test" > input.txt
# 1. Default → Argon2id (0x04)
STDERR_DEFAULT=$("$ZUPT" c -p secret default.zupt input.txt 2>&1)
ETYPE=$(enc_type_of default.zupt)
if [ "$ETYPE" = "04" ]; then
P "default: enc_type = 0x04 (ZUPT_ENC_PW_ARGON2)"
else
F "default: enc_type = 0x$ETYPE (expected 0x04)"
fi
if echo "$STDERR_DEFAULT" | grep -qi "Argon2id"; then
P "default: stderr message names Argon2id"
else
F "default: stderr message doesn't name Argon2id"
fi
# 2. --kdf pbkdf2 → PBKDF2 (0x01)
STDERR_PB=$("$ZUPT" c -p secret --kdf pbkdf2 legacy.zupt input.txt 2>&1)
ETYPE2=$(enc_type_of legacy.zupt)
if [ "$ETYPE2" = "01" ]; then
P "--kdf pbkdf2: enc_type = 0x01 (ZUPT_ENC_PBKDF2)"
else
F "--kdf pbkdf2: enc_type = 0x$ETYPE2 (expected 0x01)"
fi
if echo "$STDERR_PB" | grep -qi "PBKDF2"; then
P "--kdf pbkdf2: stderr message names PBKDF2"
else
F "--kdf pbkdf2: stderr message doesn't name PBKDF2"
fi
# 3. Roundtrips
mkdir out_a && (cd out_a && "$ZUPT" x -p secret ../default.zupt >/dev/null 2>&1)
if [ -f out_a/input.txt ] && diff -q input.txt out_a/input.txt >/dev/null 2>&1; then
P "Argon2id archive roundtrips byte-exact"
else
F "Argon2id roundtrip"
fi
mkdir out_p && (cd out_p && "$ZUPT" x -p secret ../legacy.zupt >/dev/null 2>&1)
if [ -f out_p/input.txt ] && diff -q input.txt out_p/input.txt >/dev/null 2>&1; then
P "PBKDF2 archive roundtrips byte-exact"
else
F "PBKDF2 roundtrip"
fi
# 4. Wrong password rejected (both)
mkdir out_wa && (cd out_wa && "$ZUPT" x -p wrong ../default.zupt >/dev/null 2>&1)
if [ ! -f out_wa/input.txt ]; then
P "Argon2id: wrong password rejected"
else
F "Argon2id: wrong password accepted"
fi
mkdir out_wp && (cd out_wp && "$ZUPT" x -p wrong ../legacy.zupt >/dev/null 2>&1)
if [ ! -f out_wp/input.txt ]; then
P "PBKDF2: wrong password rejected"
else
F "PBKDF2: wrong password accepted"
fi
# 5. --kdf argon2id (explicit form) → same as default
STDERR_E=$("$ZUPT" c -p secret --kdf argon2id explicit.zupt input.txt 2>&1)
ETYPE3=$(enc_type_of explicit.zupt)
if [ "$ETYPE3" = "04" ]; then
P "--kdf argon2id (explicit): enc_type = 0x04"
else
F "--kdf argon2id (explicit): enc_type = 0x$ETYPE3"
fi
# 6. --kdf garbage → reject
if "$ZUPT" c -p secret --kdf garbage garbage.zupt input.txt >/dev/null 2>&1; then
F "--kdf garbage was accepted (should reject)"
else
P "--kdf garbage rejected"
fi
echo ""
echo " ───────────────────────────────────────"
echo " F-10 regression: $PASS passed, $FAIL failed"
echo " ───────────────────────────────────────"
[ "$FAIL" = 0 ] || exit 1

View file

@ -0,0 +1,157 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# F-11 regression test (Zupt 2.4.2).
#
# F-11: pre-2.4.2 the AIT-fail message said "archive header or footer has
# been tampered with" in both the actual-tamper case AND the wrong-password
# case (because wrong password → wrong mac_key → AIT mismatch). This
# misled users into thinking valid archives were corrupted when they had
# just mistyped a password.
#
# v2.4.2 collapses both cases into the same generic message by default:
# "Authentication failed (wrong key, wrong password, or tampered archive)"
# and moves the detailed top-MAC wording behind --verbose. Identical
# message for both cases eliminates a verbal probe-oracle. Plaintext-mode
# tamper detection (no key involvement) keeps detailed wording.
set -u
ZUPT="${ZUPT_BIN:-./zupt}"
case "$ZUPT" in
/*) ;;
*) ZUPT="$PWD/$ZUPT" ;;
esac
if [ ! -x "$ZUPT" ]; then
echo "$ZUPT not found — run 'make' first" >&2
exit 1
fi
PASS=0
FAIL=0
P() { PASS=$((PASS+1)); echo "$1"; }
F() { FAIL=$((FAIL+1)); echo "$1"; }
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
cd "$TMPDIR"
echo "F-11 regression: error-message hygiene"
echo "F-11 payload" > input.txt
# Test 1: wrong-password message on Argon2id default (no --verbose)
"$ZUPT" c -p correct argon.zupt input.txt >/dev/null 2>&1
mkdir out1
ERR=$( (cd out1 && "$ZUPT" x -p wrong ../argon.zupt) 2>&1 || true )
if echo "$ERR" | grep -q "Authentication failed (wrong key, wrong password, or tampered archive)"; then
P "Argon2id wrong-pw default: generic auth-fail message"
else
F "Argon2id wrong-pw default: message wrong: '$ERR'"
fi
# Must NOT contain the standalone "header or footer has been tampered with"
if ! echo "$ERR" | grep -q "header or footer has been tampered with"; then
P "Argon2id wrong-pw default: no standalone tamper claim"
else
F "Argon2id wrong-pw default: still claims archive tampered"
fi
# Must NOT contain the verbose top-MAC line
if ! echo "$ERR" | grep -q "archive-integrity-trailer (top-MAC)"; then
P "Argon2id wrong-pw default: no top-MAC technical detail"
else
F "Argon2id wrong-pw default: top-MAC leaked without --verbose"
fi
# Test 2: --verbose surfaces the technical detail
mkdir out2
ERR_V=$( (cd out2 && "$ZUPT" x -p wrong --verbose ../argon.zupt) 2>&1 || true )
if echo "$ERR_V" | grep -q "top-MAC"; then
P "Argon2id wrong-pw --verbose: top-MAC detail shown"
else
F "Argon2id wrong-pw --verbose: top-MAC missing"
fi
if echo "$ERR_V" | grep -q "Authentication failed (wrong key, wrong password, or tampered archive)"; then
P "Argon2id wrong-pw --verbose: still has the generic line"
else
F "Argon2id wrong-pw --verbose: missing generic line"
fi
# Test 3: PBKDF2 archive same behaviour
"$ZUPT" c -p correct --kdf pbkdf2 pbkdf.zupt input.txt >/dev/null 2>&1
mkdir out3
ERR3=$( (cd out3 && "$ZUPT" x -p wrong ../pbkdf.zupt) 2>&1 || true )
if echo "$ERR3" | grep -q "Authentication failed (wrong key, wrong password, or tampered archive)"; then
P "PBKDF2 wrong-pw default: generic auth-fail message"
else
F "PBKDF2 wrong-pw default: wrong"
fi
# Test 4: actual header tamper on encrypted archive emits the SAME generic
# message — this is the probe-oracle property.
cp argon.zupt tampered.zupt
python3 -c "
b = bytearray(open('tampered.zupt','rb').read())
b[15] ^= 1 # creation_time byte
open('tampered.zupt','wb').write(bytes(b))"
mkdir out4
ERR4=$( (cd out4 && "$ZUPT" x -p correct ../tampered.zupt) 2>&1 || true )
if echo "$ERR4" | grep -q "Authentication failed (wrong key, wrong password, or tampered archive)"; then
P "Actual tamper (encrypted): same generic message — no verbal oracle"
else
F "Actual tamper (encrypted): message diverges from wrong-pw case"
fi
# Sanity: extract failed
if [ ! -f out4/input.txt ]; then
P "Actual tamper (encrypted): extract correctly refused"
else
F "Actual tamper (encrypted): extract succeeded — bug"
fi
# Test 5: plaintext archive tamper keeps detailed wording (no key, no oracle)
"$ZUPT" c plain.zupt input.txt >/dev/null 2>&1
cp plain.zupt ptamp.zupt
python3 -c "
b = bytearray(open('ptamp.zupt','rb').read())
b[10] ^= 1
open('ptamp.zupt','wb').write(bytes(b))"
mkdir out5
ERR5=$( (cd out5 && "$ZUPT" x ../ptamp.zupt) 2>&1 || true )
if echo "$ERR5" | grep -q "corrupted or tampered"; then
P "Plaintext tamper: detailed XXH64-failure message kept"
else
F "Plaintext tamper: detailed message missing"
fi
if echo "$ERR5" | grep -q "Authentication failed (wrong key"; then
F "Plaintext tamper: shouldn't say 'wrong key' (no key involved)"
else
P "Plaintext tamper: doesn't conflate with key-mode wording"
fi
# Test 6: correct password still extracts successfully
mkdir out6
(cd out6 && "$ZUPT" x -p correct ../argon.zupt >/dev/null 2>&1)
if [ -f out6/input.txt ] && diff -q input.txt out6/input.txt >/dev/null 2>&1; then
P "Correct password: clean extract preserved"
else
F "Correct password: regression — extract broken"
fi
# Test 7: PQ-SDK wrong key triggers the same generic message
"$ZUPT" keygen --sdk -o k.priv >/dev/null 2>&1
"$ZUPT" keygen --sdk -o other.priv >/dev/null 2>&1
"$ZUPT" c --pq-sdk k.priv.pub pq.zupt input.txt >/dev/null 2>&1
mkdir out7
ERR7=$( (cd out7 && "$ZUPT" x --pq-sdk ../other.priv ../pq.zupt) 2>&1 || true )
if echo "$ERR7" | grep -q "Authentication failed (wrong key, wrong password, or tampered archive)"; then
P "PQ-SDK wrong-key: generic auth-fail message"
else
F "PQ-SDK wrong-key: didn't get generic message: '$ERR7'"
fi
echo ""
echo " ───────────────────────────────────────"
echo " F-11 regression: $PASS passed, $FAIL failed"
echo " ───────────────────────────────────────"
[ "$FAIL" = 0 ] || exit 1

175
tests/test_f12_comment.sh Executable file
View file

@ -0,0 +1,175 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# F-12 regression test (Zupt 2.4.3).
#
# F-12: implement the reserved `comment_offset` field in zupt_archive_header_t.
# Adds ZUPT_BLOCK_COMMENT (0x05) block type written between data blocks and
# the central index. Comments are plaintext UTF-8 (max 4096 B), encrypted
# along with data blocks when -p/--pq is set. Old readers (v2.4.2 and prior)
# ignore the comment_offset field and skip the block; new readers extract
# and display the comment after the file extraction summary.
#
# Assertions:
# 1. Roundtrip the comment text in plaintext mode.
# 2. Roundtrip the comment text in Argon2id-password mode.
# 3. Roundtrip the comment text in PBKDF2-password mode.
# 4. Roundtrip the comment text in PQ-SDK mode.
# 5. `zupt info` reports the presence of a comment without revealing it
# (encrypted archives shouldn't leak comment plaintext via info).
# 6. Tampering the comment block payload is rejected (per-block HMAC).
# 7. Tampering hdr.comment_offset is rejected (covered by AIT).
# 8. An archive without a comment shows no Comment: line in info.
# 9. --comment-file path reads the comment from disk.
# 10. Empty comment string is treated as no-comment (header offset stays 0).
set -u
ZUPT="${ZUPT_BIN:-./zupt}"
case "$ZUPT" in
/*) ;;
*) ZUPT="$PWD/$ZUPT" ;;
esac
if [ ! -x "$ZUPT" ]; then
echo "$ZUPT not found — run 'make' first" >&2
exit 1
fi
PASS=0
FAIL=0
P() { PASS=$((PASS+1)); echo "$1"; }
F() { FAIL=$((FAIL+1)); echo "$1"; }
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
cd "$TMPDIR"
echo "F-12 regression: archive comments"
echo "F-12 payload" > input.txt
COMMENT="archive comment for sprint 2.4.3 test"
# Test 1: plaintext roundtrip
"$ZUPT" c -c "$COMMENT" plain.zupt input.txt >/dev/null 2>&1
mkdir out_p
OUT=$( (cd out_p && "$ZUPT" x ../plain.zupt) 2>&1 )
if echo "$OUT" | grep -qF "$COMMENT"; then
P "plaintext: comment roundtrips"
else
F "plaintext: comment not shown on extract"
fi
# Test 2: Argon2id-password roundtrip
"$ZUPT" c -c "$COMMENT" -p secret arg.zupt input.txt >/dev/null 2>&1
mkdir out_a
OUT=$( (cd out_a && "$ZUPT" x -p secret ../arg.zupt) 2>&1 )
if echo "$OUT" | grep -qF "$COMMENT"; then
P "Argon2id: comment roundtrips"
else
F "Argon2id: comment not shown"
fi
# Test 3: PBKDF2-password roundtrip
"$ZUPT" c -c "$COMMENT" -p secret --kdf pbkdf2 pb.zupt input.txt >/dev/null 2>&1
mkdir out_pb
OUT=$( (cd out_pb && "$ZUPT" x -p secret ../pb.zupt) 2>&1 )
if echo "$OUT" | grep -qF "$COMMENT"; then
P "PBKDF2: comment roundtrips"
else
F "PBKDF2: comment not shown"
fi
# Test 4: PQ-SDK roundtrip
"$ZUPT" keygen --sdk -o k.priv >/dev/null 2>&1
"$ZUPT" c -c "$COMMENT" --pq-sdk k.priv.pub pq.zupt input.txt >/dev/null 2>&1
mkdir out_pq
OUT=$( (cd out_pq && "$ZUPT" x --pq-sdk ../k.priv ../pq.zupt) 2>&1 )
if echo "$OUT" | grep -qF "$COMMENT"; then
P "PQ-SDK: comment roundtrips"
else
F "PQ-SDK: comment not shown"
fi
# Test 5: info doesn't leak comment plaintext for encrypted archives
INFO=$("$ZUPT" info arg.zupt 2>&1)
if echo "$INFO" | grep -qF "$COMMENT"; then
F "info leaks comment plaintext for encrypted archive"
else
P "info doesn't leak comment plaintext for encrypted archive"
fi
if echo "$INFO" | grep -q "Comment:.*present"; then
P "info reports comment presence"
else
F "info doesn't report comment presence"
fi
# Test 6: tampering the comment block payload is rejected
# Find the comment block offset: it's stored in hdr[44..51] (comment_offset).
COMM_OFF=$(python3 -c "
b = open('pq.zupt','rb').read()
print(int.from_bytes(b[44:52],'little'))
")
# Tamper a byte inside the comment block payload (skip the 2-byte magic).
# Pick offset COMM_OFF + 20 which should land inside encrypted payload bytes.
cp pq.zupt tamp_comment.zupt
python3 -c "
b = bytearray(open('tamp_comment.zupt','rb').read())
b[$COMM_OFF + 20] ^= 1
open('tamp_comment.zupt','wb').write(bytes(b))"
mkdir out_tc
ERR=$( (cd out_tc && "$ZUPT" x --pq-sdk ../k.priv ../tamp_comment.zupt) 2>&1 || true )
if [ ! -f out_tc/input.txt ]; then
P "comment-block tamper rejected (per-block HMAC)"
else
F "comment-block tamper silently accepted"
fi
# Test 7: tampering hdr.comment_offset is rejected (covered by AIT)
cp pq.zupt tamp_offset.zupt
python3 -c "
b = bytearray(open('tamp_offset.zupt','rb').read())
b[44] ^= 1 # low byte of comment_offset field
open('tamp_offset.zupt','wb').write(bytes(b))"
mkdir out_to
ERR=$( (cd out_to && "$ZUPT" x --pq-sdk ../k.priv ../tamp_offset.zupt) 2>&1 || true )
if [ ! -f out_to/input.txt ]; then
P "comment_offset tamper rejected (AIT covers header)"
else
F "comment_offset tamper silently accepted"
fi
# Test 8: archive without comment shows no Comment: line
"$ZUPT" c -p secret nocomment.zupt input.txt >/dev/null 2>&1
INFO2=$("$ZUPT" info nocomment.zupt 2>&1)
if ! echo "$INFO2" | grep -q "Comment:"; then
P "no-comment archive: info has no Comment: line"
else
F "no-comment archive: info shows Comment: anyway"
fi
# Test 9: --comment-file reads from disk
echo -n "comment from a file" > cf.txt
"$ZUPT" c --comment-file cf.txt -p secret cf.zupt input.txt >/dev/null 2>&1
mkdir out_cf
OUT=$( (cd out_cf && "$ZUPT" x -p secret ../cf.zupt) 2>&1 )
if echo "$OUT" | grep -qF "comment from a file"; then
P "--comment-file: comment roundtrips"
else
F "--comment-file: comment lost: $OUT"
fi
# Test 10: empty -c is treated as no-comment
"$ZUPT" c -c "" empty.zupt input.txt >/dev/null 2>&1
INFO3=$("$ZUPT" info empty.zupt 2>&1)
if ! echo "$INFO3" | grep -q "Comment:"; then
P "empty -c treated as no-comment"
else
F "empty -c written as a comment block (should be no-op)"
fi
echo ""
echo " ───────────────────────────────────────"
echo " F-12 regression: $PASS passed, $FAIL failed"
echo " ───────────────────────────────────────"
[ "$FAIL" = 0 ] || exit 1

136
tests/test_gui_branding.sh Executable file
View file

@ -0,0 +1,136 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# Regression test for GUI branding + licensing.
#
# History: in v3.0.0 the GUI shipped with two real bugs:
# 1. An MIT license credit line in the about panel — the GUI is
# AGPL-3.0-or-later with commercial dual-licensing; "MIT" was
# false and inherited from an early templating mistake.
# 2. A version-string parser using `replace("zupt ", "")` which
# matched the wrong substring after the v3.0.0 rename. The
# version banner became `vaptvupt 3.0.0 (formerly zupt;
# renamed in v3.0.0 — INPI Brasil trademark)` and that
# `replace` chewed up "zupt " inside the parenthetical too.
#
# This test asserts both classes of bug stay fixed.
set -u
PASS=0; FAIL=0
P() { echo "$1"; PASS=$((PASS+1)); }
F() { echo "$1"; FAIL=$((FAIL+1)); }
GUI=gui/src/zupt_gui.py
[ ! -f "$GUI" ] && { echo "ERROR: $GUI missing — run from repo root"; exit 2; }
echo "GUI branding + licensing"
# ─── MIT reference checks ───
# Any MIT credit line in the GUI source is a bug.
if grep -nE '"MIT"|"MIT [Ll]icense"| MIT[^A-Za-z]' "$GUI" >/dev/null 2>&1; then
F "GUI source contains an MIT reference"
grep -nE '"MIT"|"MIT [Ll]icense"| MIT[^A-Za-z]' "$GUI" | sed 's/^/ /'
else
P "GUI source contains no MIT references"
fi
# The GUI's own LICENSE-GUI file must be AGPL (or pointed to AGPL).
if [ -f gui/LICENSE-GUI ]; then
if grep -q "GNU AFFERO GENERAL PUBLIC LICENSE\|AGPL" gui/LICENSE-GUI; then
P "gui/LICENSE-GUI is AGPL-licensed"
else
F "gui/LICENSE-GUI is not AGPL — got: $(head -1 gui/LICENSE-GUI)"
fi
# Specifically, it shouldn't START with "MIT License"
if head -1 gui/LICENSE-GUI | grep -qE "^MIT License"; then
F "gui/LICENSE-GUI starts with 'MIT License' — that's the bug we just fixed"
else
P "gui/LICENSE-GUI does not start with 'MIT License'"
fi
fi
# ─── SPDX header check ───
# The .py source's SPDX header must be AGPL-3.0-or-later.
if head -5 "$GUI" | grep -q "SPDX-License-Identifier: AGPL-3.0-or-later"; then
P "GUI SPDX header is AGPL-3.0-or-later"
else
F "GUI SPDX header is missing or wrong"
fi
# ─── Version-parsing bug check ───
# The buggy pattern was `ZUPT_VER_SHORT.replace("zupt ", ...)`.
# That regex must not appear in CODE — it produces garbage on v3.0.x
# version strings. The explanatory comment in _get_version that
# documents the historical fix is fine.
if grep -nE 'replace\("zupt ' "$GUI" | grep -vE '^[0-9]+:#' >/dev/null 2>&1; then
F "GUI uses the broken replace(\"zupt \", ...) version parser"
grep -nE 'replace\("zupt ' "$GUI" | grep -vE '^[0-9]+:#' | sed 's/^/ /'
else
P "GUI does not use the broken replace(\"zupt \", ...) parser (in code)"
fi
# A proper version regex must be present.
if grep -qE '_VERSION_RE\s*=\s*re\.compile|re\.match.*vaptvupt' "$GUI"; then
P "GUI defines a strict anchored version regex"
else
F "GUI is missing the anchored version regex (_VERSION_RE)"
fi
# ─── Brand-string check ───
# Splash and about-panel headers should say VAPTVUPT (the v3.0.0 name),
# not ZUPT.
if grep -q 'QLabel("ZUPT")' "$GUI"; then
F "GUI still uses QLabel(\"ZUPT\") — should be QLabel(\"VAPTVUPT\")"
else
P "GUI uses VAPTVUPT (not ZUPT) in QLabel headers"
fi
# Crypto stack should include Argon2id (the default since v2.4.1).
if grep -q 'Argon2id' "$GUI"; then
P "GUI about-panel crypto stack includes Argon2id"
else
F "GUI about-panel crypto stack is missing Argon2id"
fi
# Crypto stack should include the VaptVupt codec attribution.
if grep -q 'VaptVupt LZ + ANS\|VaptVupt LZ' "$GUI"; then
P "GUI about-panel mentions the VaptVupt codec"
else
F "GUI about-panel doesn't mention the VaptVupt codec"
fi
# Commercial-licensing contact must be visible.
if grep -q 'sac@securityops.co' "$GUI"; then
P "GUI shows the commercial-licensing contact (sac@securityops.co)"
else
F "GUI is missing the commercial-licensing contact"
fi
# ─── Functional check ───
# If the CLI binary is available, exercise _VERSION_RE end-to-end.
if [ -x ./vaptvupt ] || [ -x ./zupt ]; then
BIN=./vaptvupt
[ ! -x "$BIN" ] && BIN=./zupt
OUT=$("$BIN" version 2>&1 | head -1)
EXTRACTED=$(python3 -c "
import re, sys
s = sys.argv[1]
m = re.match(r'^(?:vaptvupt|zupt)\s+(\d+\.\d+\.\d+(?:[._A-Za-z0-9-]*)?)', s)
print(m.group(1) if m else 'NONE')
" "$OUT")
EXPECTED=$(grep -E '^#define ZUPT_VERSION_STRING' include/zupt.h | awk -F'"' '{print $2}')
if [ "$EXTRACTED" = "$EXPECTED" ]; then
P "version regex extracts $EXTRACTED (matches include/zupt.h)"
else
F "version regex extracted '$EXTRACTED', expected '$EXPECTED'"
fi
else
echo " - skipped: ./vaptvupt not built — skipping functional version test"
fi
echo ""
echo " ───────────────────────────────────────"
echo " GUI branding + licensing: $PASS passed, $FAIL failed"
echo " ───────────────────────────────────────"
[ "$FAIL" = 0 ] || exit 1

137
tests/test_help_consistency.sh Executable file
View file

@ -0,0 +1,137 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# Regression test for the `vaptvupt help` output.
#
# History:
# F-13 (v3.0.2): the usage() string literal exceeded C99's 4095-char
# limit (4121 chars), triggering -Woverlength-strings. Also, the
# help text had drifted out of date during the v3.0.0 rename:
# - Examples still said `zupt compress`, `zupt extract`, etc.
# - "Compression: LZ77 (1MB window) + Huffman entropy coding" —
# false; the default codec is now VaptVupt LZ + ANS 2.48.5
# - "License: AGPL-3.0-or-later (Zupt)" — should be (VaptVupt)
#
# This test asserts the help output stays consistent with reality.
# Run from repo root after a build.
set -u
PASS=0; FAIL=0
P() { echo "$1"; PASS=$((PASS+1)); }
F() { echo "$1"; FAIL=$((FAIL+1)); }
BIN=./vaptvupt
[ -x ./vaptvupt ] || BIN=./zupt
[ -x "$BIN" ] || { echo "ERROR: no built binary found"; exit 2; }
HELP=$("$BIN" help 2>&1)
echo "Help consistency"
# ─── F-13 guard: usage() string-literal size ───
# Each fprintf-passed string literal (after adjacent concatenation)
# must be under C99's 4095-char limit. We use a python helper to
# walk fprintf(...) calls and measure the concatenated literal.
python3 > /tmp/usage_size_check.txt <<'PYEOF'
import re
src = open('src/zupt_main.c').read()
pattern = re.compile(r'fprintf\(\s*\w+\s*,\s*((?:"(?:[^"\\]|\\.)*"\s*)+)', re.S)
worst = 0
worst_lineno = 0
for m in pattern.finditer(src):
block = m.group(1)
literals = re.findall(r'"((?:[^"\\]|\\.)*)"', block)
concat = ''.join(literals)
actual = len(re.sub(r'\\.', 'X', concat))
if actual > worst:
worst = actual
worst_lineno = src[:m.start()].count('\n') + 1
if worst >= 4095:
print(f"FAIL:{worst}:{worst_lineno}")
else:
print(f"PASS:{worst}:{worst_lineno}")
PYEOF
RES=$(tail -1 /tmp/usage_size_check.txt)
if [[ "$RES" == PASS:* ]]; then
L=${RES#PASS:}; L=${L%:*}
P "usage() string literals are under C99 4095-char limit (worst: $L chars)"
else
L=${RES#FAIL:}; LINE=${L##*:}; L=${L%:*}
F "usage() has a string literal of $L chars at line $LINE — over C99 4095 limit (F-13 regression)"
fi
# ─── Brand consistency ───
# The help output must use the new binary name in examples, not the old one.
if echo "$HELP" | grep -qE '^\s+vaptvupt (compress|extract|list|test|bench|keygen|info|disk)'; then
P "examples use 'vaptvupt' command name"
else
F "examples don't use 'vaptvupt' — still saying 'zupt'?"
fi
# Conversely, the example lines shouldn't start with `zupt ` (the
# bare legacy name in example commands is the drift we just fixed).
LEGACY_EX=$(echo "$HELP" | grep -cE '^\s{1,4}zupt (compress|extract|list|test|bench|keygen) ')
if [ "$LEGACY_EX" -eq 0 ]; then
P "no examples use the bare legacy 'zupt' command name"
else
F "$LEGACY_EX example lines still use the legacy 'zupt' command name"
fi
# ─── Codec consistency ───
# Help text must mention the actual default codec, not the v2.x one.
if echo "$HELP" | grep -q "VaptVupt LZ + ANS"; then
P "help mentions VaptVupt LZ + ANS as the default codec"
else
F "help doesn't mention VaptVupt LZ + ANS — still claiming LZ77+Huffman?"
fi
# Conversely, the BARE phrase "LZ77 (1MB window) + Huffman" was the v2.x
# default-codec description; if it's still there, the help text is stale.
if echo "$HELP" | grep -q "LZ77 (1MB window) + Huffman entropy coding"; then
F "help still has the stale v2.x 'LZ77 (1MB window) + Huffman' description"
else
P "help doesn't have the stale v2.x default-codec description"
fi
# ─── License consistency ───
if echo "$HELP" | grep -q "AGPL-3.0-or-later (VaptVupt)"; then
P "help shows the correct license attribution (VaptVupt)"
else
F "help has wrong license attribution — should say AGPL-3.0-or-later (VaptVupt)"
fi
# Commercial-licensing contact visible.
if echo "$HELP" | grep -q "sac@securityops.co"; then
P "help shows the commercial-licensing contact (sac@securityops.co)"
else
F "help is missing the commercial-licensing contact"
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"
else
F "help doesn't identify Argon2id as the default KDF"
fi
# ─── Format consistency ───
if echo "$HELP" | grep -qE "Format:\s+v1\.6"; then
P "help reports the correct format version (v1.6)"
else
F "help doesn't report the correct format version"
fi
# ─── Functional check: help command works ───
if "$BIN" help >/dev/null 2>&1; then
P "vaptvupt help exits successfully"
else
F "vaptvupt help exits with non-zero status"
fi
echo ""
echo " ───────────────────────────────────────"
echo " Help consistency: $PASS passed, $FAIL failed"
echo " ───────────────────────────────────────"
[ "$FAIL" = 0 ] || exit 1

View file

@ -0,0 +1,144 @@
/*
* SPDX-License-Identifier: AGPL-3.0-or-later
* Copyright (c) 2025-2026 Cristian Cezar Moisés
*
* Incremental HMAC-SHA256 equivalence test (v3.3.0).
*
* The per-block Encrypt-then-MAC hot path was changed from
* one-shot HMAC over a malloc'd (aad || nonce || ciphertext || seq)
* concat buffer
* to
* incremental HMAC streamed segment-by-segment (no concat, no copy).
*
* RFC 2104 + SHA-256's Merkle-Damgard update() guarantee these produce
* identical tags, but that guarantee is load-bearing for wire-format
* compatibility (old archives must still authenticate). This test pins
* it down:
* 1. zupt_hmac_sha256 one-shot == manual init/update/final, single seg.
* 2. Streaming the message in arbitrary chunk splits == one-shot over
* the whole message, across many lengths and split points.
* 3. The exact per-block segment pattern used by the codec
* (aad_extra || nonce || ciphertext || aad_seq) streamed in 4
* updates == one-shot over the concatenation. This is the precise
* invariant the encrypt/decrypt paths rely on.
* 4. RFC 4231 Test Case 2 known-answer (sanity that the base HMAC is
* still correct after the refactor).
*/
#include "zupt.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
static int pass = 0, fail = 0;
static void ok(const char *m) { printf(" \xE2\x9C\x93 %s\n", m); pass++; }
static void bad(const char *m) { printf(" \xE2\x9C\x97 %s\n", m); fail++; }
static int eq32(const uint8_t a[32], const uint8_t b[32]) {
return memcmp(a, b, 32) == 0;
}
int main(void) {
printf("Incremental HMAC-SHA256 equivalence\n");
uint8_t key[32];
for (int i = 0; i < 32; i++) key[i] = (uint8_t)(i * 7 + 1);
/* 1. one-shot == manual init/update/final (single segment) */
{
const uint8_t msg[] = "the quick brown fox";
uint8_t a[32], b[32];
zupt_hmac_sha256(key, 32, msg, sizeof(msg) - 1, a);
zupt_hmac_ctx c;
zupt_hmac_sha256_init(&c, key, 32);
zupt_hmac_sha256_update(&c, msg, sizeof(msg) - 1);
zupt_hmac_sha256_final(&c, b);
if (eq32(a, b)) ok("one-shot == init/update/final (single segment)");
else bad("one-shot != incremental (single segment)");
}
/* 2. arbitrary chunk splits == one-shot, many lengths */
{
size_t lens[] = {0, 1, 31, 32, 33, 63, 64, 65, 127, 128, 1000, 4096, 100000};
int all_ok = 1;
uint8_t *buf = (uint8_t *)malloc(100000);
for (size_t i = 0; i < 100000; i++) buf[i] = (uint8_t)(i * 131 + 17);
for (size_t li = 0; li < sizeof(lens)/sizeof(lens[0]); li++) {
size_t n = lens[li];
uint8_t ref[32];
zupt_hmac_sha256(key, 32, buf, n, ref);
/* split into 1, 2, and 3 pieces at varied points */
for (int parts = 1; parts <= 3; parts++) {
uint8_t got[32];
zupt_hmac_ctx c;
zupt_hmac_sha256_init(&c, key, 32);
size_t off = 0;
for (int p = 0; p < parts; p++) {
size_t remain = n - off;
size_t chunk = (p == parts - 1) ? remain : remain / (size_t)(parts - p);
zupt_hmac_sha256_update(&c, buf + off, chunk);
off += chunk;
}
zupt_hmac_sha256_final(&c, got);
if (!eq32(ref, got)) { all_ok = 0; }
}
}
free(buf);
if (all_ok) ok("streamed splits (1/2/3 parts) == one-shot, lengths 0..100000");
else bad("streamed split != one-shot for some length/split");
}
/* 3. exact per-block segment pattern: aad || nonce || ct || seq */
{
uint8_t aad[29], nonce[16], seq[8];
uint8_t ct[5000];
for (int i = 0; i < 29; i++) aad[i] = (uint8_t)(i + 100);
for (int i = 0; i < 16; i++) nonce[i] = (uint8_t)(i * 3);
for (int i = 0; i < 8; i++) seq[i] = (uint8_t)(i + 200);
for (int i = 0; i < 5000; i++) ct[i] = (uint8_t)(i * 53 + 9);
/* one-shot over the concatenation (the OLD method) */
size_t total = 29 + 16 + 5000 + 8;
uint8_t *concat = (uint8_t *)malloc(total);
size_t o = 0;
memcpy(concat + o, aad, 29); o += 29;
memcpy(concat + o, nonce, 16); o += 16;
memcpy(concat + o, ct, 5000); o += 5000;
memcpy(concat + o, seq, 8); o += 8;
uint8_t ref[32];
zupt_hmac_sha256(key, 32, concat, total, ref);
free(concat);
/* streamed (the NEW method) */
uint8_t got[32];
zupt_hmac_ctx c;
zupt_hmac_sha256_init(&c, key, 32);
zupt_hmac_sha256_update(&c, aad, 29);
zupt_hmac_sha256_update(&c, nonce, 16);
zupt_hmac_sha256_update(&c, ct, 5000);
zupt_hmac_sha256_update(&c, seq, 8);
zupt_hmac_sha256_final(&c, got);
if (eq32(ref, got)) ok("per-block pattern (aad||nonce||ct||seq) streamed == concat one-shot");
else bad("per-block streamed pattern != concat one-shot");
}
/* 4. RFC 4231 Test Case 2 known-answer */
{
/* Key = "Jefe", Data = "what do ya want for nothing?" */
const uint8_t k[] = "Jefe";
const uint8_t d[] = "what do ya want for nothing?";
uint8_t mac[32];
zupt_hmac_sha256(k, 4, d, 28, mac);
char hx[65];
for (int i = 0; i < 32; i++) sprintf(hx + i*2, "%02x", mac[i]);
if (strcmp(hx, "5bdcc146bf60754e6a042426089575c7"
"5a003f089d2739839dec58b964ec3843") == 0)
ok("RFC 4231 TC2 known-answer correct");
else { bad("RFC 4231 TC2 WRONG"); printf(" got %s\n", hx); }
}
printf("\n ───────────────────────────────────────\n");
printf(" Incremental HMAC: %d passed, %d failed\n", pass, fail);
printf(" ───────────────────────────────────────\n");
return fail ? 1 : 0;
}

32
tests/test_hmac_incremental.sh Executable file
View file

@ -0,0 +1,32 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# Builds and runs the incremental HMAC-SHA256 equivalence test (v3.3.0).
# The per-block MAC path streams segments through an incremental HMAC
# instead of concatenating into a malloc'd buffer; this pins the
# byte-equivalence that wire-format compatibility depends on.
set -u
ARCH=$(uname -m)
if [ "$ARCH" = "x86_64" ] || [ "$ARCH" = "i686" ]; then
SHANI="-msha -mssse3 -msse4.1"
else
SHANI=""
fi
TMP=$(mktemp -d)
if gcc -Iinclude -Isrc -Wall -Wextra -Werror $SHANI -O2 -std=c11 \
tests/test_hmac_incremental.c \
src/zupt_sha256.c src/zupt_sha256_shani.c src/zupt_crypto.c \
src/zupt_aes256.c src/zupt_xxh.c src/zupt_keccak.c \
src/zupt_x25519.c src/zupt_mlkem.c src/zupt_cpuid.c src/zupt_mlock.c \
-o "$TMP/t" -lm 2>"$TMP/cc.log"; then
"$TMP/t"; rc=$?
else
echo " ✗ incremental-HMAC test failed to compile"
head -15 "$TMP/cc.log" | sed 's/^/ /'
rc=1
fi
rm -rf "$TMP"
exit $rc

View file

@ -0,0 +1,124 @@
/*
* SPDX-License-Identifier: AGPL-3.0-or-later
* Copyright (c) 2025-2026 Cristian Cezar Moisés
*
* F-15 Argon2id KDF parameter transparency (v3.4.0).
*
* The 0x04 Argon2id enc-header historically recorded only
* [type|salt|nonce] and nothing about the KDF cost, unlike the PBKDF2
* header which records its iteration count. A non-self-describing KDF
* header is a latent robustness/security problem for an archive format
* meant to last years: if the Argon2id cost preset ever changed, old
* archives could silently become undecryptable.
*
* v3.4.0 appends a one-byte KDF profile descriptor at offset 33. This
* test pins:
* 1. A newly written Argon2id header is 34 bytes and carries the
* MODERATE profile (0x01).
* 2. decrypt-init accepts a legacy 33-byte header (profile implicit)
* and an explicit 34-byte MODERATE header, and derives the SAME
* keys for both (so old archives keep opening).
* 3. decrypt-init REFUSES an unknown profile rather than guessing a
* derivation (fail-closed).
* 4. The underlying libzuptsdk Argon2id KDF is deterministic and
* memory-hard (a coarse cost floor) this catches an SDK that has
* been swapped for a fast/weak stand-in at build time, before a
* user discovers their backup won't open or is under-protected.
*/
#include "zupt.h"
#include <stdio.h>
#include <string.h>
#include <time.h>
/* easy-derive is the only KDF symbol the vendored SDK exports. */
int zuptsdk_easy_derive_key(const char *password, const uint8_t salt[16], uint8_t key_out[32]);
int zupt_sdk_password_encrypt_init(zupt_keyring_t *kr, const char *password,
uint8_t *enc_hdr, size_t *enc_hdr_len);
int zupt_sdk_password_decrypt_init(zupt_keyring_t *kr, const char *password,
const uint8_t *enc_hdr, size_t enc_hdr_len);
static int pass = 0, fail = 0;
static void ok(const char *m) { printf(" \xE2\x9C\x93 %s\n", m); pass++; }
static void bad(const char *m) { printf(" \xE2\x9C\x97 %s\n", m); fail++; }
int main(void) {
printf("F-15 Argon2id KDF transparency\n");
/* 1. New header shape */
zupt_keyring_t kr; memset(&kr, 0, sizeof kr);
uint8_t hdr[64]; size_t hlen = 0;
if (zupt_sdk_password_encrypt_init(&kr, "correct horse", hdr, &hlen) != 0) {
bad("encrypt-init failed"); printf(" F-15: %d/%d\n", pass, fail); return 1;
}
if (hlen == ZUPT_ARGON2_HDR_LEN_V2 &&
hdr[0] == ZUPT_ENC_PW_ARGON2 &&
hdr[33] == ZUPT_ARGON2_PROFILE_MODERATE)
ok("new Argon2id header is 34 bytes with explicit MODERATE profile");
else
bad("new Argon2id header missing/incorrect profile descriptor");
/* 2. Legacy 33B and explicit 34B derive identical keys. */
{
/* Build a fixed header (known salt) both ways. */
uint8_t base[34]; memset(base, 0, sizeof base);
base[0] = ZUPT_ENC_PW_ARGON2;
for (int i = 0; i < 16; i++) base[1 + i] = (uint8_t)(i + 1); /* salt */
for (int i = 0; i < 16; i++) base[17 + i] = (uint8_t)(i + 100); /* nonce */
base[33] = ZUPT_ARGON2_PROFILE_MODERATE;
zupt_keyring_t k33; memset(&k33, 0, sizeof k33);
zupt_keyring_t k34; memset(&k34, 0, sizeof k34);
int r33 = zupt_sdk_password_decrypt_init(&k33, "pw", base, ZUPT_ARGON2_HDR_LEN_V1);
int r34 = zupt_sdk_password_decrypt_init(&k34, "pw", base, ZUPT_ARGON2_HDR_LEN_V2);
if (r33 == 0 && r34 == 0 &&
memcmp(k33.enc_key, k34.enc_key, 32) == 0 &&
memcmp(k33.mac_key, k34.mac_key, 32) == 0)
ok("legacy 33B and explicit 34B headers derive identical keys");
else
bad("33B vs 34B header key mismatch (back-compat broken)");
}
/* 3. Unknown profile is refused (fail-closed). */
{
uint8_t bad_hdr[34]; memset(bad_hdr, 0, sizeof bad_hdr);
bad_hdr[0] = ZUPT_ENC_PW_ARGON2;
bad_hdr[33] = 0x99; /* not a known profile */
zupt_keyring_t kx; memset(&kx, 0, sizeof kx);
int r = zupt_sdk_password_decrypt_init(&kx, "pw", bad_hdr, ZUPT_ARGON2_HDR_LEN_V2);
if (r != 0) ok("unknown KDF profile is refused (fail-closed, no wrong-key guess)");
else bad("unknown KDF profile was accepted");
}
/* 4. KDF is deterministic and memory-hard (coarse cost floor). */
{
uint8_t salt[16]; memset(salt, 7, 16);
uint8_t k1[32], k2[32];
struct timespec a, b;
clock_gettime(CLOCK_MONOTONIC, &a);
int r1 = zuptsdk_easy_derive_key("benchmark-pw", salt, k1);
clock_gettime(CLOCK_MONOTONIC, &b);
int r2 = zuptsdk_easy_derive_key("benchmark-pw", salt, k2);
double ms = (double)(b.tv_sec - a.tv_sec) * 1000.0
+ (double)(b.tv_nsec - a.tv_nsec) / 1e6;
if (r1 == 0 && r2 == 0 && memcmp(k1, k2, 32) == 0)
ok("Argon2id KDF is deterministic (same password+salt -> same key)");
else
bad("Argon2id KDF not deterministic");
/* Memory-hard Argon2id at the MODERATE preset takes hundreds of ms
* on current hardware. A sub-20ms derivation almost certainly means
* the SDK was replaced with a non-memory-hard stand-in refuse to
* pass so the regression is caught at build time, not by a user. */
if (ms >= 20.0)
ok("Argon2id KDF cost floor met (memory-hard preset active)");
else {
char buf[96];
snprintf(buf, sizeof buf, "Argon2id KDF suspiciously fast (%.1f ms) — weak/stub SDK?", ms);
bad(buf);
}
}
printf("\n ───────────────────────────────────────\n");
printf(" F-15 KDF transparency: %d passed, %d failed\n", pass, fail);
printf(" ───────────────────────────────────────\n");
return fail ? 1 : 0;
}

32
tests/test_kdf_transparency.sh Executable file
View file

@ -0,0 +1,32 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# F-15 — Argon2id KDF parameter transparency (v3.4.0).
# Builds and runs tests/test_kdf_transparency.c against the vendored SDK.
set -u
SDK_DIR="${ZUPTSDK_DIR:-vendor/zuptsdk}"
ARCH=$(uname -m)
if [ "$ARCH" = "x86_64" ] || [ "$ARCH" = "i686" ]; then
SHANI="-msha -mssse3 -msse4.1"
else
SHANI=""
fi
TMP=$(mktemp -d)
if gcc -Iinclude -Isrc -I"$SDK_DIR/include" -Wall -Wextra -Werror $SHANI -O2 -std=c11 \
tests/test_kdf_transparency.c \
src/zupt_crypto_sdk.c src/zupt_crypto.c src/zupt_sha256.c src/zupt_sha256_shani.c \
src/zupt_aes256.c src/zupt_xxh.c src/zupt_keccak.c src/zupt_x25519.c \
src/zupt_mlkem.c src/zupt_cpuid.c src/zupt_mlock.c \
-L"$SDK_DIR" -lzuptsdk -Wl,-rpath,"$(cd "$SDK_DIR" && pwd)" -lm \
-o "$TMP/t" 2>"$TMP/cc.log"; then
"$TMP/t"; rc=$?
else
echo " ✗ KDF-transparency test failed to compile"
head -15 "$TMP/cc.log" | sed 's/^/ /'
rc=1
fi
rm -rf "$TMP"
exit $rc

339
tests/test_packaging_syntax.sh Executable file
View file

@ -0,0 +1,339 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# Sprint 2.4.5 regression: packaging-recipe syntax checks.
#
# Ensures the recipes under packaging/{aur,debian,rpm,homebrew,nix}/
# are syntactically valid. Doesn't try to actually build the packages
# (that needs distro-specific tooling), but catches:
# - shell syntax errors in PKGBUILD
# - malformed Debian control / changelog / copyright
# - missing fields in RPM spec
# - Ruby syntax errors in the Homebrew formula (if ruby is available)
# - Nix flake parse errors (if nix is available)
#
# Plus structural checks that don't need external tools:
# - debian/rules is executable
# - all recipes reference the same version as include/zupt.h
set -u
PASS=0
FAIL=0
P() { PASS=$((PASS+1)); echo "$1"; }
F() { FAIL=$((FAIL+1)); echo "$1"; }
SKIP() { echo " - skipped: $1"; }
cd "$(dirname "$0")/.."
VERSION=$(grep '^#define ZUPT_VERSION_STRING' include/zupt.h | awk -F'"' '{print $2}')
echo "Packaging syntax checks (zupt $VERSION)"
# ─── AUR PKGBUILD ───
if [ -f packaging/aur/PKGBUILD ]; then
if bash -n packaging/aur/PKGBUILD 2>/dev/null; then
P "AUR PKGBUILD: bash syntax clean"
else
F "AUR PKGBUILD: bash syntax error"
fi
if grep -q "^pkgver=$VERSION$" packaging/aur/PKGBUILD; then
P "AUR PKGBUILD: pkgver matches include/zupt.h ($VERSION)"
else
F "AUR PKGBUILD: pkgver mismatch (expected $VERSION; got $(grep '^pkgver=' packaging/aur/PKGBUILD))"
fi
for field in pkgname pkgver pkgrel pkgdesc arch url license depends; do
if grep -qE "^$field=" packaging/aur/PKGBUILD; then
:
else
F "AUR PKGBUILD: missing required field '$field'"
continue
fi
done
P "AUR PKGBUILD: required fields present (pkgname, pkgver, pkgrel, pkgdesc, arch, url, license, depends)"
else
F "AUR PKGBUILD: file missing"
fi
# ─── Debian source package ───
for f in control rules changelog copyright source/format; do
if [ -f "packaging/debian/$f" ]; then
:
else
F "Debian: packaging/debian/$f missing"
fi
done
if [ -f packaging/debian/control ] && [ -f packaging/debian/rules ]; then
P "Debian: control, rules, changelog, copyright, source/format all present"
fi
if [ -x packaging/debian/rules ]; then
P "Debian: rules is executable"
else
F "Debian: rules is not executable"
fi
if grep -qE "^Source: (vaptvupt|zupt)$" packaging/debian/control; then
P "Debian control: Source field correct"
else
F "Debian control: Source field wrong/missing"
fi
if grep -qE "^(vaptvupt|zupt) \($VERSION-[0-9]+\) " packaging/debian/changelog; then
P "Debian changelog: top entry matches $VERSION"
else
F "Debian changelog: top entry version doesn't match include/zupt.h"
fi
if command -v dpkg-parsechangelog >/dev/null 2>&1; then
if dpkg-parsechangelog -l packaging/debian/changelog >/dev/null 2>&1; then
P "Debian changelog: dpkg-parsechangelog accepts it"
else
F "Debian changelog: dpkg-parsechangelog rejected it"
fi
else
SKIP "dpkg-parsechangelog not available (dpkg-dev not installed)"
fi
if [ "$(cat packaging/debian/source/format)" = "3.0 (quilt)" ]; then
P "Debian source/format: 3.0 (quilt)"
else
F "Debian source/format: wrong content"
fi
# ─── RPM spec ───
if [ -f packaging/rpm/vaptvupt.spec ]; then
for field in Name Version Release Summary License URL Source0; do
if grep -qE "^$field:" packaging/rpm/vaptvupt.spec; then
:
else
F "RPM spec: missing tag '$field:'"
fi
done
P "RPM spec: required header tags present"
SPEC_VER=$(grep -E "^Version:" packaging/rpm/vaptvupt.spec | awk '{print $2}')
if [ "$SPEC_VER" = "$VERSION" ]; then
P "RPM spec: Version: matches include/zupt.h ($VERSION)"
else
F "RPM spec: Version: '$SPEC_VER' != include/zupt.h '$VERSION'"
fi
for section in "%prep" "%build" "%install" "%files" "%changelog"; do
if grep -qF "$section" packaging/rpm/vaptvupt.spec; then
:
else
F "RPM spec: missing section '$section'"
fi
done
P "RPM spec: %prep, %build, %install, %files, %changelog sections present"
if command -v rpmlint >/dev/null 2>&1; then
rpmlint packaging/rpm/vaptvupt.spec >/tmp/rpmlint.out 2>&1
if [ -s /tmp/rpmlint.out ] && grep -qE " E: " /tmp/rpmlint.out; then
F "RPM spec: rpmlint errors (see /tmp/rpmlint.out):"
grep " E: " /tmp/rpmlint.out | head -3
else
P "RPM spec: rpmlint clean (warnings allowed)"
fi
else
SKIP "rpmlint not available"
fi
else
F "RPM spec: file missing"
fi
# ─── Homebrew formula ───
if [ -f packaging/homebrew/vaptvupt.rb ]; then
HB_VER=$(grep -E '^\s*version\s' packaging/homebrew/vaptvupt.rb | head -1 | awk -F'"' '{print $2}')
if [ "$HB_VER" = "$VERSION" ]; then
P "Homebrew formula: version matches include/zupt.h ($VERSION)"
else
F "Homebrew formula: version '$HB_VER' != include/zupt.h '$VERSION'"
fi
if command -v ruby >/dev/null 2>&1; then
if ruby -c packaging/homebrew/vaptvupt.rb >/dev/null 2>&1; then
P "Homebrew formula: ruby syntax clean"
else
F "Homebrew formula: ruby syntax error"
ruby -c packaging/homebrew/vaptvupt.rb 2>&1 | head -3
fi
else
SKIP "ruby not available — skipping Homebrew syntax parse"
fi
for kw in 'class (Vaptvupt|Zupt)' 'desc ' 'homepage ' 'url ' 'version ' 'sha256 ' 'license '; do
if grep -qE "^\s*${kw}" packaging/homebrew/vaptvupt.rb; then
:
else
F "Homebrew formula: missing DSL line starting with '$kw'"
fi
done
# install is a method definition; test is a block
if grep -qE "^\s*def\s+install\b" packaging/homebrew/vaptvupt.rb; then
:
else
F "Homebrew formula: missing method 'def install'"
fi
if grep -qE "^\s*test\s+do\b" packaging/homebrew/vaptvupt.rb; then
:
else
F "Homebrew formula: missing 'test do' block"
fi
P "Homebrew formula: class + required DSL keywords + install method + test block present"
else
F "Homebrew formula: file missing"
fi
# ─── Nix flake ───
if [ -f packaging/nix/flake.nix ]; then
if command -v nix >/dev/null 2>&1 && nix --version 2>/dev/null | grep -qE "nix \(Nix\) [2-9]"; then
if nix flake metadata packaging/nix --no-update-lock-file >/dev/null 2>&1; then
P "Nix flake: nix accepts metadata"
else
F "Nix flake: nix flake metadata failed"
fi
else
SKIP "nix not available — skipping flake check"
fi
NIX_VER=$(grep -E 'version = "' packaging/nix/flake.nix | head -1 | awk -F'"' '{print $2}')
if [ "$NIX_VER" = "$VERSION" ]; then
P "Nix flake: version matches include/zupt.h ($VERSION)"
else
F "Nix flake: version '$NIX_VER' != include/zupt.h '$VERSION'"
fi
# Structural check: must have outputs and a zupt package definition
if grep -qE "outputs\s*=" packaging/nix/flake.nix && \
grep -qE 'pname = "(vaptvupt|zupt)"' packaging/nix/flake.nix; then
P "Nix flake: outputs + zupt package definition present"
else
F "Nix flake: structure incomplete"
fi
else
F "Nix flake: file missing"
fi
# ─── openSUSE OBS recipe (renamed zupt.* -> vaptvupt.* in 3.2.0) ───
if [ -f packaging/opensuse/vaptvupt.spec ] && [ -f packaging/opensuse/vaptvupt.changes ] && [ -f packaging/opensuse/_service ]; then
P "openSUSE OBS files: all three present (vaptvupt.spec, vaptvupt.changes, _service)"
# Validate the spec parses
if command -v rpm >/dev/null 2>&1; then
if rpm --specfile packaging/opensuse/vaptvupt.spec >/dev/null 2>&1; then
P "openSUSE vaptvupt.spec: rpm --specfile parses cleanly"
else
F "openSUSE vaptvupt.spec: rpm --specfile rejected it"
fi
SUSE_VER=$(grep -E "^Version:" packaging/opensuse/vaptvupt.spec | awk '{print $2}')
if [ "$SUSE_VER" = "$VERSION" ]; then
P "openSUSE vaptvupt.spec: Version matches include/zupt.h ($VERSION)"
else
F "openSUSE vaptvupt.spec: Version '$SUSE_VER' != include/zupt.h '$VERSION'"
fi
# Name must be vaptvupt, and it must supersede the old zupt package.
if grep -qE "^Name:[[:space:]]+vaptvupt$" packaging/opensuse/vaptvupt.spec; then
P "openSUSE vaptvupt.spec: Name is vaptvupt"
else
F "openSUSE vaptvupt.spec: Name is not vaptvupt"
fi
if grep -qE "^Provides:[[:space:]]+zupt" packaging/opensuse/vaptvupt.spec && \
grep -qE "^Obsoletes:[[:space:]]+zupt" packaging/opensuse/vaptvupt.spec; then
P "openSUSE vaptvupt.spec: Provides/Obsoletes zupt (clean upgrade)"
else
F "openSUSE vaptvupt.spec: missing Provides/Obsoletes zupt"
fi
else
SKIP "rpm not available — skipping openSUSE spec parse"
fi
# Validate _service is well-formed XML
if command -v python3 >/dev/null 2>&1; then
if python3 -c "import xml.etree.ElementTree as ET; ET.parse('packaging/opensuse/_service')" 2>/dev/null; then
P "openSUSE _service: XML well-formed"
else
F "openSUSE _service: XML parse error"
fi
fi
# _service filename should be vaptvupt now
if grep -qE "<param name=\"filename\">vaptvupt</param>" packaging/opensuse/_service; then
P "openSUSE _service: filename is vaptvupt"
else
F "openSUSE _service: filename not updated to vaptvupt"
fi
# .changes: check standard 67-dash separator (openSUSE convention is exactly 67)
SEP_COUNT=$(grep -cE "^-{67}$" packaging/opensuse/vaptvupt.changes)
if [ "$SEP_COUNT" -ge 1 ]; then
P "openSUSE vaptvupt.changes: $SEP_COUNT entries with proper separator"
else
F "openSUSE vaptvupt.changes: missing or wrong separator format"
fi
else
F "openSUSE OBS files incomplete (need vaptvupt.spec, vaptvupt.changes, _service)"
fi
if [ -f DISTRIBUTION.md ]; then
P "DISTRIBUTION.md present"
for distro in "Arch Linux" "Debian / Ubuntu" "Fedora" "macOS" "NixOS"; do
if grep -q "$distro" DISTRIBUTION.md; then
:
else
F "DISTRIBUTION.md: doesn't mention '$distro'"
fi
done
P "DISTRIBUTION.md: covers all 5 distros"
else
F "DISTRIBUTION.md missing"
fi
# ─── GitHub Actions CI workflow ───
if [ -f .github/workflows/ci.yml ]; then
if command -v python3 >/dev/null 2>&1; then
# Write the validator to a temp file rather than inline -c so quoting/
# indentation can't bite.
cat > /tmp/ci_validate.py << 'PYEOF'
import yaml, sys
try:
with open('.github/workflows/ci.yml') as f:
doc = yaml.safe_load(f)
except Exception as e:
sys.stderr.write(f"YAML_PARSE_ERROR: {e}\n")
sys.exit(1)
jobs = list(doc.get('jobs', {}).keys())
expected = ['build-and-test', 'strict-warnings', 'sanitizers',
'dist-reproducibility', 'packaging-syntax', 'release']
missing = [j for j in expected if j not in jobs]
if missing:
sys.stderr.write(f"MISSING_JOBS: {missing}\n")
sys.exit(1)
print(f"JOBS_OK ({len(jobs)} jobs)")
PYEOF
if python3 /tmp/ci_validate.py 2>/tmp/ci_check.err; then
P "CI workflow: YAML valid + expected jobs present"
else
F "CI workflow: $(cat /tmp/ci_check.err)"
fi
rm -f /tmp/ci_validate.py /tmp/ci_check.err
else
SKIP "python3 unavailable — skipping CI YAML check"
fi
else
F "CI workflow .github/workflows/ci.yml missing"
fi
# ─── THREAT_MODEL.md ───
if [ -f THREAT_MODEL.md ]; then
P "THREAT_MODEL.md present"
# Verify the document is substantive (>3000 bytes) and covers the
# required sections per userPreferences ("plain English. State
# explicitly what the system does NOT protect against.")
SZ=$(wc -c < THREAT_MODEL.md)
if [ "$SZ" -ge 3000 ]; then
P "THREAT_MODEL.md: substantive ($SZ bytes)"
else
F "THREAT_MODEL.md: too short ($SZ bytes, expected >= 3000)"
fi
for section in "What Zupt protects against" "What Zupt does NOT protect against" "Cryptographic assumptions"; do
if grep -qF "$section" THREAT_MODEL.md; then
:
else
F "THREAT_MODEL.md: missing section '$section'"
fi
done
P "THREAT_MODEL.md: required sections present"
else
F "THREAT_MODEL.md missing"
fi
echo ""
echo " ───────────────────────────────────────"
echo " packaging syntax: $PASS passed, $FAIL failed"
echo " ───────────────────────────────────────"
[ "$FAIL" = 0 ] || exit 1

80
tests/test_pqbox.sh Executable file
View file

@ -0,0 +1,80 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2026 Cristian Cezar Moisés
#
# ZUPT_ENC_PQ_BOX_V1 (--pq-box, vendored libpqvaptvupt) — functional and
# adversarial coverage: keygen file format, byte-exact roundtrips on both
# frame formats, wrong-key and key-type-confusion rejection, envelope and
# data tampering, and cross-mode isolation.
set -u
P=0; F=0
ok() { echo "$1"; P=$((P+1)); }
bad() { echo "$1"; F=$((F+1)); }
T=$(mktemp -d)
FX=/tmp/bench/fixtures
BIN=./vaptvupt
echo "pq-box mode (ZUPT_ENC_PQ_BOX_V1)"
# 1. keygen + file format
$BIN keygen --box -o $T/k.key >/dev/null 2>&1
[ "$(stat -c%s $T/k.key 2>/dev/null)" = "2441" ] && ok "secret keyfile size (9+2432)" || bad "secret keyfile size"
[ "$(stat -c%s $T/k.key.pub 2>/dev/null)" = "1225" ] && ok "public keyfile size (9+1216)" || bad "public keyfile size"
head -c8 $T/k.key | grep -q "PQVVBOX1" && ok "keyfile magic" || bad "keyfile magic"
# 2. roundtrips: L1 (v1 frame) and L9 (format_v2 + auto-filter), text + binary
for case in "1 text" "9 text" "9 binary"; do
set -- $case; L=$1; fx=$2
$BIN c -l $L --pq-box $T/k.key.pub $T/a$L$fx.zupt $FX/$fx.dat >/dev/null 2>&1
rm -rf $T/o$L$fx; mkdir -p $T/o$L$fx
$BIN x --pq-box $T/k.key -o $T/o$L$fx $T/a$L$fx.zupt >/dev/null 2>&1
Fp=$(find $T/o$L$fx -type f | head -1)
[ -n "$Fp" ] && diff -q "$Fp" $FX/$fx.dat >/dev/null 2>&1 \
&& ok "roundtrip L$L $fx byte-exact" || bad "roundtrip L$L $fx"
done
# 3. wrong key rejected
$BIN keygen --box -o $T/w.key >/dev/null 2>&1
rm -rf $T/ow; mkdir -p $T/ow
$BIN x --pq-box $T/w.key -o $T/ow $T/a9text.zupt >/dev/null 2>&1 \
&& bad "wrong key accepted" || ok "wrong key rejected"
# 4. key-type confusion rejected (pub-as-priv, priv-as-pub, legacy key)
rm -rf $T/oc; mkdir -p $T/oc
$BIN x --pq-box $T/k.key.pub -o $T/oc $T/a9text.zupt >/dev/null 2>&1 \
&& bad "PUBLIC key accepted as secret" || ok "public-as-secret rejected"
$BIN c -l 1 --pq-box $T/k.key $T/cc.zupt $FX/text.dat >/dev/null 2>&1 \
&& bad "SECRET key accepted as public" || ok "secret-as-public rejected"
$BIN keygen -o $T/legacy.key >/dev/null 2>&1
rm -rf $T/ol; mkdir -p $T/ol
$BIN x --pq-box $T/legacy.key -o $T/ol $T/a9text.zupt >/dev/null 2>&1 \
&& bad "legacy key accepted on box archive" || ok "legacy-key-on-box rejected"
# 5. tamper: envelope byte (offset inside the sealed blob) and data region
for spot in 64 -1024; do
cp $T/a9text.zupt $T/t.zupt
python3 - "$T/t.zupt" "$spot" << 'PY'
import sys
p, off = sys.argv[1], int(sys.argv[2])
d = bytearray(open(p,'rb').read())
i = off if off >= 0 else len(d)+off
d[i] ^= 0x01
open(p,'wb').write(d)
PY
rm -rf $T/ot; mkdir -p $T/ot
$BIN x --pq-box $T/k.key -o $T/ot $T/t.zupt >/dev/null 2>&1 \
&& bad "tamper@$spot accepted" || ok "tamper@$spot rejected"
done
# 6. cross-mode isolation: box archive demands box key, not password
rm -rf $T/op; mkdir -p $T/op
$BIN x -p somepass -o $T/op $T/a9text.zupt >/dev/null 2>&1 \
&& bad "password accepted on box archive" || ok "password-on-box rejected"
echo ""
echo " ───────────────────────────────────────"
echo " pq-box: $P passed, $F failed"
echo " ───────────────────────────────────────"
rm -rf $T
exit $([ $F -eq 0 ] && echo 0 || echo 1)

View file

@ -48,11 +48,14 @@ cd ..
"$ZUPT_BIN" x --pq-sdk other.priv small.zupt > /dev/null 2>&1
chk_neg "SDK wrong key rejected"
# Tamper detected
# Tamper detected.
# F-02 (Zupt 2.2.4): use a deterministic body-region offset, not
# len-50 which occasionally landed in the unauthenticated index
# region. See docs/FINDINGS-2.x.md F-02 for the full analysis.
cp small.zupt tampered.zupt
python3 -c "
b = bytearray(open('tampered.zupt','rb').read())
b[len(b)-50] ^= 1
b[200] ^= 1
open('tampered.zupt','wb').write(bytes(b))
"
"$ZUPT_BIN" x --pq-sdk key.priv tampered.zupt > /dev/null 2>&1

130
tests/test_sha256_shani.c Normal file
View file

@ -0,0 +1,130 @@
/*
* SPDX-License-Identifier: AGPL-3.0-or-later
* Copyright (c) 2025-2026 Cristian Cezar Moisés
*
* SHA-NI correctness test (v3.2.0).
*
* Drives zupt_sha256_transform_shani() DIRECTLY (not via runtime
* dispatch) so the hardware path is exercised even on a build host
* whose CPU reports no SHA-NI. Validates:
* 1. SHA-NI single-block transform == scalar zupt_sha256 for the
* empty message and "abc" (NIST FIPS 180-4 examples).
* 2. SHA-NI multi-block transform == scalar over a range of full-
* block-aligned lengths (64..65536 bytes), bit-exact.
* 3. The known NIST FIPS 180-4 digests for "" and "abc".
*
* Requires SHA-NI in the CPU to run the SHA-NI path itself; if absent,
* the test SKIPS the SHA-NI assertions (the scalar path is covered by
* the existing test_vectors). On SHA-NI hardware it runs fully.
*
* Built and run by tests/test_sha256_shani.sh, which compiles with
* -msha -mssse3 -msse4.1 on x86_64.
*/
#include "zupt.h"
#include "zupt_cpuid.h"
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86)
#define HAVE_SHANI_BUILD 1
#endif
static const uint32_t IV[8] = {
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19
};
static int pass = 0, fail = 0;
static void ok(const char *m) { printf(" \xE2\x9C\x93 %s\n", m); pass++; }
static void bad(const char *m) { printf(" \xE2\x9C\x97 %s\n", m); fail++; }
/* Hash a full-block-aligned buffer using the SHA-NI transform + manual
* final block. Only valid when total length is a multiple of 64 here;
* we build the padded message ourselves for the digest comparison. */
static void hex(const uint8_t *b, int n, char *out) {
for (int i = 0; i < n; i++) sprintf(out + i*2, "%02x", b[i]);
}
int main(void) {
zupt_detect_cpu(&zupt_cpu);
#ifndef HAVE_SHANI_BUILD
printf(" - non-x86 build: SHA-NI path not present, skipping\n");
printf(" SHA-NI: 0 passed, 0 failed (skipped)\n");
return 0;
#else
if (!zupt_cpu.has_shani) {
printf(" - CPU has no SHA-NI; cannot execute SHA256RNDS2 here.\n");
printf(" - SHA-NI code compiled OK; correctness is validated on SHA-NI hardware.\n");
printf(" SHA-NI: 0 passed, 0 failed (skipped — no CPU support)\n");
return 0;
}
/* 1. Multi-block agreement with the scalar one-shot, over a range of
* block-aligned lengths. We compare the raw chained state (no
* padding) by feeding the same blocks through both paths. */
static uint8_t buf[65536];
for (size_t i = 0; i < sizeof(buf); i++) buf[i] = (uint8_t)(i * 31u + 7u);
for (size_t blocks = 1; blocks <= sizeof(buf)/64; blocks <<= 1) {
/* SHA-NI chained state */
uint32_t st_ni[8]; memcpy(st_ni, IV, sizeof(IV));
zupt_sha256_transform_shani(st_ni, buf, blocks);
/* Scalar chained state: replicate sha256_transform via the public
* streaming API on the same blocks, then read intermediate state.
* The public API adds padding at final(), so instead we compare
* the SHA-NI multi-block result against a SHA-NI single-block
* loop (both hardware) AND against a fresh scalar recompute using
* the one-shot over identical bytes with a matching manual pad. */
uint32_t st_loop[8]; memcpy(st_loop, IV, sizeof(IV));
for (size_t b = 0; b < blocks; b++)
zupt_sha256_transform_shani(st_loop, buf + b*64, 1);
if (memcmp(st_ni, st_loop, sizeof(st_ni)) != 0) {
bad("SHA-NI multi-block != SHA-NI single-block loop");
return 1;
}
}
ok("SHA-NI multi-block == single-block loop (64B..64KiB)");
/* 2. Full-digest agreement with the scalar public API.
* We hash messages of many lengths through zupt_sha256 (which now
* dispatches to SHA-NI internally on this CPU) and recompute the
* same with a forced-scalar reference. Since zupt_sha256 uses the
* hardware path here, this checks end-to-end (update+final). The
* reference is the published NIST digest below + cross-length
* self-consistency (idempotent re-hash). */
for (size_t n = 0; n <= 4096; n = (n == 0 ? 1 : n * 2)) {
uint8_t d1[32], d2[32];
zupt_sha256(buf, n, d1);
/* Re-hash in two halves; must equal one-shot (streaming consistency) */
zupt_sha256_ctx c; zupt_sha256_init(&c);
zupt_sha256_update(&c, buf, n/2);
zupt_sha256_update(&c, buf + n/2, n - n/2);
zupt_sha256_final(&c, d2);
if (memcmp(d1, d2, 32) != 0) { bad("streaming split != one-shot"); return 1; }
}
ok("SHA-NI streaming (split updates) == one-shot, lengths 0..4096");
/* 3. NIST FIPS 180-4 known-answer: "abc" and "" */
{
uint8_t d[32]; char h[65];
zupt_sha256((const uint8_t*)"abc", 3, d); hex(d, 32, h);
if (strcmp(h, "ba7816bf8f01cfea414140de5dae2223"
"b00361a396177a9cb410ff61f20015ad") == 0)
ok("NIST \"abc\" digest correct (SHA-NI path)");
else { bad("NIST \"abc\" digest WRONG"); printf(" got %s\n", h); }
zupt_sha256((const uint8_t*)"", 0, d); hex(d, 32, h);
if (strcmp(h, "e3b0c44298fc1c149afbf4c8996fb924"
"27ae41e4649b934ca495991b7852b855") == 0)
ok("NIST empty-string digest correct (SHA-NI path)");
else { bad("NIST empty digest WRONG"); printf(" got %s\n", h); }
}
printf(" SHA-NI: %d passed, %d failed\n", pass, fail);
return fail ? 1 : 0;
#endif
}

95
tests/test_sha256_shani.sh Executable file
View file

@ -0,0 +1,95 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# SHA-256 SHA-NI hardware-path test (v3.2.0).
#
# The SHA-NI compression function in src/zupt_sha256_shani.c accelerates
# HMAC-SHA256 (the Encrypt-then-MAC second pass and PBKDF2) on CPUs with
# the Intel SHA Extensions. This test validates:
#
# 1. The 64 SHA-NI round constants are bit-identical to the scalar
# K[] table in zupt_sha256.c (catches transcription errors — the
# single most likely bug in a hand-written SHA-NI routine). This
# check runs on ALL hosts, SHA-NI or not.
# 2. The SHA-NI object compiles cleanly with -msha -mssse3 -msse4.1.
# 3. zupt_cpu gains has_shani and the dispatch is wired (source check).
# 4. On SHA-NI hardware: the SHA-NI path's digests match NIST FIPS
# 180-4 vectors and the scalar path bit-exact (executed by the C
# test). On non-SHA-NI hosts this step SKIPS — the instructions
# cannot be executed — but steps 1-3 still gate the build.
set -u
PASS=0; FAIL=0
P() { echo "$1"; PASS=$((PASS+1)); }
F() { echo "$1"; FAIL=$((FAIL+1)); }
echo "SHA-256 SHA-NI hardware path"
# ── 1. Round-constant equivalence (host-independent) ──
python3 - <<'PYEOF'
import re, sys
scalar = open('src/zupt_sha256.c').read()
m = re.search(r'static const uint32_t K\[64\]\s*=\s*\{(.*?)\};', scalar, re.S)
ks = [int(x,16) for x in re.findall(r'0x[0-9a-fA-F]{8}', m.group(1))]
shani = open('src/zupt_sha256_shani.c').read()
pairs = re.findall(r'_mm_set_epi64x\(\(long long\)0x([0-9A-Fa-f]{16})ULL,\s*\(long long\)0x([0-9A-Fa-f]{16})ULL\)', shani)
kpairs = [(hi,lo) for (hi,lo) in pairs if not hi.lower().startswith('0c0d')]
recon = []
for hi, lo in kpairs:
hi_u = int(hi,16); lo_u = int(lo,16)
recon += [lo_u & 0xFFFFFFFF, (lo_u>>32)&0xFFFFFFFF, hi_u & 0xFFFFFFFF, (hi_u>>32)&0xFFFFFFFF]
sys.exit(0 if (len(ks)==64 and recon==ks) else 1)
PYEOF
if [ $? -eq 0 ]; then
P "SHA-NI round constants bit-identical to scalar K[] (64/64)"
else
F "SHA-NI round constants DIFFER from scalar K[] table"
fi
# ── 2. has_shani wired into CPU detection ──
if grep -q 'has_shani' include/zupt_cpuid.h && grep -q 'has_shani' src/zupt_cpuid.c; then
P "has_shani present in CPU feature struct + detection"
else
F "has_shani not wired into zupt_cpuid"
fi
# ── 3. Dispatch wired in zupt_sha256.c ──
if grep -q 'zupt_sha256_transform_shani' src/zupt_sha256.c && grep -q 'zupt_cpu.has_shani' src/zupt_sha256.c; then
P "SHA-256 update() dispatches to SHA-NI when available"
else
F "SHA-256 dispatch to SHA-NI not wired"
fi
# ── 4. Compile + execute the C correctness test ──
ARCH=$(uname -m)
if [ "$ARCH" = "x86_64" ] || [ "$ARCH" = "i686" ]; then
SHANI_CFLAGS="-msha -mssse3 -msse4.1"
else
SHANI_CFLAGS=""
fi
TMP=$(mktemp -d)
if gcc -Iinclude -Isrc -Wall -Wextra -Werror $SHANI_CFLAGS -O2 -std=c11 \
tests/test_sha256_shani.c src/zupt_sha256.c src/zupt_sha256_shani.c src/zupt_cpuid.c \
-o "$TMP/t" 2>"$TMP/cc.log"; then
P "SHA-NI test compiles clean (-Werror $SHANI_CFLAGS)"
OUT=$("$TMP/t")
echo "$OUT" | sed 's/^/ /'
if echo "$OUT" | grep -q "failed (skipped"; then
echo " (host lacks SHA-NI — execution-level checks deferred to SHA-NI hardware)"
elif echo "$OUT" | grep -qE "SHA-NI: [0-9]+ passed, 0 failed$"; then
P "SHA-NI path executes correctly (NIST vectors + scalar agreement)"
else
F "SHA-NI C test reported failures"
fi
else
F "SHA-NI test failed to compile"
head -10 "$TMP/cc.log" | sed 's/^/ /'
fi
rm -rf "$TMP"
echo ""
echo " ───────────────────────────────────────"
echo " SHA-NI hardware path: $PASS passed, $FAIL failed"
echo " ───────────────────────────────────────"
[ "$FAIL" = 0 ] || exit 1

180
tests/test_static_analysis.sh Executable file
View file

@ -0,0 +1,180 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# Static-analysis regression for v3.0.3.
#
# Asserts that our (non-vendored) C source compiles cleanly under:
# - GCC strict warnings + -Werror
# - GCC -Wconversion + -Wsign-conversion (silenced/false-positive-prone
# warnings; we enable for OUR code only, not vendored vv_*.c)
# - cppcheck warning + performance level
#
# History:
# F-13 (v3.0.2): -Woverlength-strings on usage() literal
# (v3.0.3): Two -Wconversion warnings (ECHO bit-clear, varint return).
# Two `knownConditionTrueFalse` cppcheck findings in varint
# decoders (dead AND-branch after early return).
# This test guards against regressions of all four classes.
set -u
PASS=0; FAIL=0
P() { echo "$1"; PASS=$((PASS+1)); }
F() { echo "$1"; FAIL=$((FAIL+1)); }
# Our (non-vendored) C source files. Vendored: vv_*.c, fips202.c,
# zupt_mlkem.c — these have their own upstream style and we don't
# enforce our warning set on them.
OUR_FILES=(
src/zupt_main.c
src/zupt_format.c
src/zupt_dedup.c
src/zupt_disk.c
src/zupt_crypto.c
src/zupt_aes256.c
src/zupt_sha256.c
src/zupt_xxh.c
src/zupt_parallel.c
)
# zupt_sha256_shani.c needs -msha -mssse3 -msse4.1 to compile its
# intrinsics; checked separately below so the main loop stays flag-clean.
SHANI_FILE=src/zupt_sha256_shani.c
# Filter to files that actually exist (architecture-conditional ones)
EXIST=()
for f in "${OUR_FILES[@]}"; do
[ -f "$f" ] && EXIST+=("$f")
done
echo "Static analysis"
# ─── Strict GCC + -Werror ───
STRICT_CFLAGS="-Wall -Wextra -Wpedantic -Wshadow -Wcast-align -Wstrict-prototypes \
-Wmissing-prototypes -Wnull-dereference -Wformat=2 -Wlogical-op -Wjump-misses-init \
-Wdouble-promotion -Woverlength-strings -Werror -O2 -std=c11 -Iinclude -Isrc"
STRICT_FAILS=0
for f in "${EXIST[@]}"; do
if ! gcc $STRICT_CFLAGS -c "$f" -o /dev/null 2>/tmp/sa-strict.log; then
STRICT_FAILS=$((STRICT_FAILS+1))
F "strict GCC -Werror failed on $f"
head -3 /tmp/sa-strict.log | sed 's/^/ /'
fi
done
[ "$STRICT_FAILS" = 0 ] && P "strict GCC -Werror clean on ${#EXIST[@]} files"
# ─── -Wconversion + -Wsign-conversion ───
CONV_CFLAGS="-Wall -Wextra -Wconversion -Wsign-conversion -O2 -std=c11 -Iinclude -Isrc"
CONV_FAILS=0
for f in "${EXIST[@]}"; do
n=$(gcc $CONV_CFLAGS -c "$f" -o /dev/null 2>&1 | grep -c "warning:")
if [ "$n" -gt 0 ]; then
CONV_FAILS=$((CONV_FAILS+1))
F "$f: $n -Wconversion warnings"
gcc $CONV_CFLAGS -c "$f" -o /dev/null 2>&1 | grep "warning:" | head -3 | sed 's/^/ /'
fi
done
[ "$CONV_FAILS" = 0 ] && P "-Wconversion -Wsign-conversion clean on ${#EXIST[@]} files"
# ── SHA-NI file (needs -msha -mssse3 -msse4.1 on x86_64) ──
if [ -f "$SHANI_FILE" ]; then
ARCH_SA=$(uname -m)
if [ "$ARCH_SA" = "x86_64" ] || [ "$ARCH_SA" = "i686" ]; then
SA_SHANI="-msha -mssse3 -msse4.1"
else
SA_SHANI=""
fi
if gcc $STRICT_CFLAGS $SA_SHANI -c "$SHANI_FILE" -o /dev/null 2>/tmp/sa-shani.log; then
P "SHA-NI file strict GCC -Werror clean"
else
F "SHA-NI file fails strict -Werror"
head -5 /tmp/sa-shani.log | sed 's/^/ /'
fi
if [ "$(gcc $CONV_CFLAGS $SA_SHANI -c "$SHANI_FILE" -o /dev/null 2>&1 | grep -c 'warning:')" = 0 ]; then
P "SHA-NI file -Wconversion -Wsign-conversion clean"
else
F "SHA-NI file has -Wconversion warnings"
fi
fi
# ─── cppcheck warning + performance ───
if command -v cppcheck >/dev/null 2>&1; then
SUPP=/tmp/cppcheck-supp-sa.txt
cat > "$SUPP" <<EOF
*:src/vv_ans.c
*:src/vv_decoder.c
*:src/vv_encoder.c
*:src/vv_huffman.c
*:src/vv_simd.c
*:src/vv_xxh64.c
*:src/fips202.c
*:src/zupt_mlkem.c
missingIncludeSystem
EOF
n=$(timeout 60 cppcheck --quiet --enable=warning,performance \
--inline-suppr --error-exitcode=0 \
-Iinclude -Isrc --max-configs=2 \
--suppressions-list="$SUPP" \
"${EXIST[@]}" 2>&1 | grep -cE "warning:|error:|performance:")
if [ "$n" = 0 ]; then
P "cppcheck warning+performance: 0 findings"
else
F "cppcheck warning+performance: $n findings"
timeout 60 cppcheck --quiet --enable=warning,performance \
--inline-suppr -Iinclude -Isrc --max-configs=2 \
--suppressions-list="$SUPP" \
"${EXIST[@]}" 2>&1 | grep -E "warning:|error:|performance:" | head -5 | sed 's/^/ /'
fi
# Specifically: no `knownConditionTrueFalse` style findings on our code
n=$(timeout 60 cppcheck --quiet --enable=style \
--inline-suppr -Iinclude -Isrc --max-configs=2 \
--suppressions-list="$SUPP" \
"${EXIST[@]}" 2>&1 | grep -c "knownConditionTrueFalse")
if [ "$n" = 0 ]; then
P "cppcheck: no knownConditionTrueFalse findings (dead conditions)"
else
F "cppcheck: $n knownConditionTrueFalse findings"
timeout 60 cppcheck --quiet --enable=style \
--inline-suppr -Iinclude -Isrc --max-configs=2 \
--suppressions-list="$SUPP" \
"${EXIST[@]}" 2>&1 | grep "knownConditionTrueFalse" | head -3 | sed 's/^/ /'
fi
# Critical: no error-level findings
n=$(timeout 60 cppcheck --quiet \
--inline-suppr --error-exitcode=0 \
-Iinclude -Isrc --max-configs=2 \
--suppressions-list="$SUPP" \
"${EXIST[@]}" 2>&1 | grep -cE "error:")
if [ "$n" = 0 ]; then
P "cppcheck error level: 0 findings"
else
F "cppcheck error level: $n findings"
fi
else
echo " - skipped: cppcheck not installed"
fi
# ─── Specific dead-code regression checks ───
# The varint decoders used to have `if(s>=64 && (x&0x80))return -1;`
# where the AND was dead. Ensure that pattern doesn't come back.
if grep -nE "s>=64 *&& *\([cx]&0x80\)" src/zupt_format.c >/dev/null 2>&1; then
F "varint decoder has the dead 's>=64 && (x|c)&0x80' pattern back"
grep -nE "s>=64 *&& *\([cx]&0x80\)" src/zupt_format.c | sed 's/^/ /'
else
P "varint decoders don't have the v3.0.2 dead-AND pattern"
fi
# ECHO bit-clear: should have explicit (tcflag_t) cast
if grep -qE 'c_lflag &= \(tcflag_t\)~ECHO' src/zupt_main.c; then
P "ECHO bit-clear uses explicit (tcflag_t) cast"
else
F "ECHO bit-clear missing the explicit (tcflag_t) cast"
fi
echo ""
echo " ───────────────────────────────────────"
echo " Static analysis: $PASS passed, $FAIL failed"
echo " ───────────────────────────────────────"
[ "$FAIL" = 0 ] || exit 1

View file

@ -5,6 +5,7 @@
* Copyright (c) 2026 Cristian Cezar Moisés AGPL-3.0-or-later
*
* Tests: SHA-256 (FIPS 180-4), HMAC-SHA256 (RFC 4231),
* AES-256-CTR (NIST SP 800-38A F.5.5/F.5.6),
* X25519 (RFC 7748 §6.1), ML-KEM-768 roundtrip,
* SHA3-256 (FIPS 202), SHAKE-128 (FIPS 202).
*
@ -113,6 +114,43 @@ int main(void) {
check("SHAKE-128('', 16B)", out, exp, 16);
}
/* ═══ AES-256-CTR (NIST SP 800-38A §F.5.5/F.5.6) ═══
*
* The bulk cipher. Validates zupt_aes256_ctr against the standard on
* whichever path the build selects: the Jasmin AES-NI assembly
* (zupt_aes256_ctr4 + zupt_aes256_blk) on x86_64 with -DZUPT_USE_JASMIN,
* or the C T-table fallback otherwise. CTR is symmetric, so the same
* vector checks both encrypt and decrypt.
*
* Note on the counter: SP 800-38A increments the full 128-bit block,
* while zupt increments the low 64 bits (top 64 fixed). The two agree
* for the standard's 4-block example because the IV's low byte is 0xff
* and the carries stay within the low 8 bytes so this is an exact
* KAT, not an approximation. */
printf("\n-- AES-256-CTR (NIST SP 800-38A F.5.5) --\n");
{
uint8_t key[32], iv[16], pt[64], ct[64], out[64], back[64];
hex2bin("603deb1015ca71be2b73aef0857d7781"
"1f352c073b6108d72d9810a30914dff4", key, 32);
hex2bin("f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff", iv, 16);
hex2bin("6bc1bee22e409f96e93d7e117393172a", pt + 0, 16);
hex2bin("ae2d8a571e03ac9c9eb76fac45af8e51", pt + 16, 16);
hex2bin("30c81c46a35ce411e5fbc1191a0a52ef", pt + 32, 16);
hex2bin("f69f2445df4f9b17ad2b417be66c3710", pt + 48, 16);
hex2bin("601ec313775789a5b7a7f504bbf3d228", ct + 0, 16);
hex2bin("f443e3ca4d62b59aca84e990cacaf5c5", ct + 16, 16);
hex2bin("2b0930daa23de94ce87017ba2d84988d", ct + 32, 16);
hex2bin("dfc9c58db67aada613c2dd08457941a6", ct + 48, 16);
/* Encrypt: PT -> CT must match the published vector. */
zupt_aes256_ctr(key, iv, pt, out, 64);
check("AES-256-CTR encrypt (F.5.5, 4 blocks)", out, ct, 64);
/* Decrypt: CT -> PT (CTR is symmetric). */
zupt_aes256_ctr(key, iv, ct, back, 64);
check("AES-256-CTR decrypt (F.5.6, 4 blocks)", back, pt, 64);
}
/* ═══ X25519 (RFC 7748 §6.1) ═══ */
printf("\n-- X25519 (RFC 7748 §6.1) --\n");
{
@ -168,6 +206,15 @@ int main(void) {
else { printf(" FAIL: XXH64('') = %016llx\n", (unsigned long long)h); fail++; }
}
/* ═══ ML-KEM-768 internal self-test (F-04, Zupt 2.2.4) ═══ */
printf("\n-- ML-KEM-768 internal self-test --\n");
{
/* zupt_mlkem768_selftest() returns 0 on success, -1 on failure. */
int rc = zupt_mlkem768_selftest();
if (rc == 0) { printf(" OK: ML-KEM-768 NTT/CBD self-test\n"); pass++; }
else { printf(" FAIL: ML-KEM-768 NTT/CBD self-test\n"); fail++; }
}
printf("\n================================\n");
printf("Results: %d passed, %d failed\n", pass, fail);
return fail > 0 ? 1 : 0;

113
tests/test_vv_decode_slack.sh Executable file
View file

@ -0,0 +1,113 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# Regression test for the VaptVupt AVX2 decode over-copy guard.
#
# History (codec 2.48.5 -> 2.53.3 integration, sprint 3.1.0):
# The VaptVupt codec's AVX2 decode hot path (match_copy_32_hot ->
# _mm256_storeu_si256) over-writes up to 32 bytes past the logical
# output end. vaptvupt.h documents this: "may over-read/write by up
# to 32 bytes. Caller must ensure sufficient slack in destination."
# Our decode buffers were malloc(uncompressed_size) with NO slack.
# Codec 2.48.5 never reached it on real inputs; 2.53.3's wider AVX2
# hot path does (ASAN: heap-buffer-overflow WRITE of size 32, 0 bytes
# after a 128 KB block buffer, on degenerate all-repeats input at L1).
#
# Fix: over-allocate every decode buffer by ZUPT_VV_DECODE_SLACK (64 B)
# and pass the padded capacity to the codec. Both decode paths
# (zupt_format.c single-threaded, zupt_parallel.c multi-threaded).
#
# This test asserts the guard is present and that the exact ASAN-failing
# input round-trips clean.
set -u
PASS=0; FAIL=0
P() { echo "$1"; PASS=$((PASS+1)); }
F() { echo "$1"; FAIL=$((FAIL+1)); }
BIN=./vaptvupt
[ -x ./vaptvupt ] || BIN=./zupt
[ -x "$BIN" ] || { echo "ERROR: no built binary"; exit 2; }
echo "VaptVupt decode over-copy guard"
# ── Source-level guards ──
# The shared constant must exist in zupt.h.
if grep -qE '#define\s+ZUPT_VV_DECODE_SLACK\s+[0-9]+' include/zupt.h; then
SLACK=$(grep -E '#define\s+ZUPT_VV_DECODE_SLACK' include/zupt.h | grep -oE '[0-9]+')
if [ "$SLACK" -ge 32 ]; then
P "ZUPT_VV_DECODE_SLACK defined in zupt.h and >= 32 (is $SLACK)"
else
F "ZUPT_VV_DECODE_SLACK is $SLACK — must be >= 32 (AVX2 over-copy width)"
fi
else
F "ZUPT_VV_DECODE_SLACK missing from zupt.h"
fi
# Single-threaded decode path must allocate with the slack.
if grep -qE 'malloc\(\*olen \+ ZUPT_VV_DECODE_SLACK\)' src/zupt_format.c; then
P "zupt_format.c decode buffer is over-allocated by the slack"
else
F "zupt_format.c decode buffer NOT over-allocated (regression)"
fi
# ...and pass the padded capacity to the codec.
if grep -qE '\*olen \+ ZUPT_VV_DECODE_SLACK' src/zupt_format.c; then
P "zupt_format.c passes padded capacity to vvz_decompress"
else
F "zupt_format.c does not pass padded capacity"
fi
# Parallel decode path must do the same.
if grep -qE 'malloc\(olen \+ ZUPT_VV_DECODE_SLACK\)' src/zupt_parallel.c; then
P "zupt_parallel.c decode buffer is over-allocated by the slack"
else
F "zupt_parallel.c decode buffer NOT over-allocated (regression)"
fi
if grep -qE 'olen \+ ZUPT_VV_DECODE_SLACK' src/zupt_parallel.c; then
P "zupt_parallel.c passes padded capacity to vv_decompress"
else
F "zupt_parallel.c does not pass padded capacity"
fi
# ── Functional: the exact ASAN-failing input round-trips ──
# Degenerate all-repeats: one 4.5 KB pattern repeated to 10 MB, the
# input class that triggered the original over-write at L1.
WORK=$(mktemp -d)
python3 -c "
pat = (b'The quick brown fox jumps over the lazy dog. ' * 100)
data = (pat * (10*1024*1024 // len(pat) + 1))[:10*1024*1024]
open('$WORK/redundant.dat','wb').write(data)
"
SLACK_OK=1
for L in 1 5 9; do
"$BIN" c -l $L "$WORK/a.zupt" "$WORK/redundant.dat" >/dev/null 2>&1
rm -rf "$WORK/out"; mkdir -p "$WORK/out"
"$BIN" x -o "$WORK/out" "$WORK/a.zupt" >/dev/null 2>&1
ex=$(find "$WORK/out" -type f | head -1)
if [ -z "$ex" ] || ! diff -q "$ex" "$WORK/redundant.dat" >/dev/null 2>&1; then
SLACK_OK=0; F "degenerate-input L$L round-trip mismatch"
fi
done
[ "$SLACK_OK" = 1 ] && P "degenerate all-repeats round-trips byte-exact (L1/5/9)"
# Multi-threaded variant (exercises zupt_parallel.c decode).
MT_OK=1
for L in 1 9; do
"$BIN" c -l $L -t 4 "$WORK/mt.zupt" "$WORK/redundant.dat" >/dev/null 2>&1
rm -rf "$WORK/mtout"; mkdir -p "$WORK/mtout"
"$BIN" x -t 4 -o "$WORK/mtout" "$WORK/mt.zupt" >/dev/null 2>&1
ex=$(find "$WORK/mtout" -type f | head -1)
if [ -z "$ex" ] || ! diff -q "$ex" "$WORK/redundant.dat" >/dev/null 2>&1; then
MT_OK=0; F "degenerate-input L$L (MT) round-trip mismatch"
fi
done
[ "$MT_OK" = 1 ] && P "degenerate all-repeats round-trips byte-exact (MT, L1/9)"
rm -rf "$WORK"
echo ""
echo " ───────────────────────────────────────"
echo " Decode over-copy guard: $PASS passed, $FAIL failed"
echo " ───────────────────────────────────────"
[ "$FAIL" = 0 ] || exit 1

32
vendor/pqvaptvupt/LICENSE vendored Normal file
View file

@ -0,0 +1,32 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
libpqvaptvupt — post-quantum sealed-box encryption.
Copyright (C) 2026 Cristian Cezar Moisés. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Full text of AGPL-3.0-or-later available at:
https://www.gnu.org/licenses/agpl-3.0.html
COMMERCIAL LICENSE:
For use cases that conflict with the AGPL's network-use obligations
(e.g. proprietary SaaS deployment without source release), a
commercial license is available. Contact sac@securityops.co.
VENDORED SOURCES:
src/vendor/zupt_*.{c,h} are derived from Zupt v2.1.5
Copyright (C) 2026 Cristian Cezar Moisés. Originally MIT-licensed.
Relicensed under AGPL-3.0-or-later for inclusion in this library
by the same author.

178
vendor/pqvaptvupt/include/pqvaptvupt.h vendored Normal file
View file

@ -0,0 +1,178 @@
/*
* libpqvaptvupt post-quantum sealed-box encryption.
*
* A minimal, libsodium-style sealed-box API backed by a real hybrid
* post-quantum KEM (ML-KEM-768 + X25519) plus AES-256-CTR + HMAC-SHA256
* Encrypt-then-MAC. The construction matches Zupt v2.1.5+.
*
* Three functions. No state. No streams.
*
* pqvv_keygen(pk, sk) one-time
* pqvv_seal(pk, pt, pt_len, &ct, &ct_len) encrypt
* pqvv_open(sk, ct, ct_len, &pt, &pt_len) decrypt
*
* Identical ergonomic to libsodium's crypto_box_seal / crypto_box_seal_open,
* but with PQ KEM beneath. Migration from libsodium is a sed:
*
* crypto_box_keypair pqvv_keygen
* crypto_box_seal pqvv_seal
* crypto_box_seal_open pqvv_open
*
* Copyright (c) 2026 Cristian Cezar Moisés.
* SPDX-License-Identifier: AGPL-3.0-or-later
* Commercial license: sac@securityops.co
*
* Vendored cryptographic primitives:
* - ML-KEM-768 (FIPS 203) from Zupt v2.1.5 src/zupt_mlkem.c
* - X25519 (RFC 7748) from Zupt v2.1.5 src/zupt_x25519.c
* - SHA-3 / SHAKE-128 / SHAKE-256 from Zupt v2.1.5 src/zupt_keccak.c
* - SHA-256 (FIPS 180-4) from Zupt v2.1.5 src/zupt_sha256.c
* - AES-256-CTR (NIST SP 800-38A) from Zupt v2.1.5 src/zupt_aes256.c
* - HMAC-SHA256 (RFC 2104) from Zupt v2.1.5 src/zupt_crypto.c
* - OS CSPRNG from Zupt v2.1.5 src/zupt_crypto.c
*
* All primitives are verified against NIST/RFC test vectors in tests/.
*/
#ifndef LIBPQVAPTVUPT_H
#define LIBPQVAPTVUPT_H
#include <stdint.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* ─────────────────────────────────────────────────────────────────────
* Version
* */
#define PQVV_VERSION_MAJOR 0
#define PQVV_VERSION_MINOR 6
#define PQVV_VERSION_PATCH 0
#define PQVV_VERSION_STRING "0.6.0"
/* ─────────────────────────────────────────────────────────────────────
* Sizes (compile-time constants for callers that want to stack-allocate)
* */
#define PQVV_PUBLICKEYBYTES 1216 /* ML-KEM-768 pk (1184) + X25519 pk (32) */
#define PQVV_SECRETKEYBYTES 2432 /* ML-KEM-768 sk (2400) + X25519 sk (32) */
#define PQVV_OVERHEAD 1184 /* per-message overhead (KEM ct + ephemeral pk + nonce + MAC) */
/* ML-KEM-768 ct (1088) + ephem X25519 pk (32) + nonce (16) + MAC (32) + magic (16) */
/* Return codes. Zero on success; negative on error. */
typedef enum {
PQVV_OK = 0,
PQVV_ERR_NULL = -1, /* NULL pointer in arguments */
PQVV_ERR_RANGE = -2, /* size out of range */
PQVV_ERR_AUTH = -3, /* MAC verification failed (tampered / wrong key) */
PQVV_ERR_CORRUPT = -4, /* ciphertext malformed */
PQVV_ERR_NOMEM = -5, /* allocation failed */
PQVV_ERR_INTERNAL = -6, /* unexpected internal error */
} pqvv_error_t;
/* ─────────────────────────────────────────────────────────────────────
* API
* */
/**
* Library version string. Same as PQVV_VERSION_STRING.
*/
const char *pqvv_version(void);
/**
* Generate a fresh hybrid keypair. Public key is PQVV_PUBLICKEYBYTES bytes,
* secret key is PQVV_SECRETKEYBYTES bytes; both layouts are opaque.
*
* Uses the OS CSPRNG for all randomness. Aborts the process if no CSPRNG
* is available (no fallback predictable keys would destroy security).
*
* @return PQVV_OK on success, PQVV_ERR_NULL if either pointer is NULL.
*/
int pqvv_keygen(uint8_t pk[PQVV_PUBLICKEYBYTES], uint8_t sk[PQVV_SECRETKEYBYTES]);
/**
* Seal plaintext to a recipient public key. Output is freshly malloc'd
* and the caller owns it (free with free()).
*
* Construction (in order, all binary):
* magic 16 bytes "pqvaptvupt-v1\0\0\0"
* kem_ct 1088 bytes ML-KEM-768 ciphertext (encapsulation)
* ephem_pk 32 bytes ephemeral X25519 public key
* nonce 16 bytes random
* mac 32 bytes HMAC-SHA256 over (magic||kem_ct||ephem_pk||nonce||body)
* body AES-256-CTR(plaintext) with key derived as
* HKDF-SHA256-Extract(salt=nonce, IKM=ml_kem_ss || x25519_ss)
* HKDF-SHA256-Expand(info="pqvv-seal-v1", L=64) enc_key||mac_key
*
* The recipient's pk encapsulates both ML-KEM-768 ss and X25519 ss; the
* sender contributes its own X25519 ephemeral. Combined entropy goes
* through HKDF; if either KEM is broken later, the other still protects.
*
* @param pk recipient's public key
* @param pt plaintext bytes
* @param pt_len plaintext length
* @param out set to pointer to ciphertext buffer (caller frees)
* @param out_len set to ciphertext length
* @return PQVV_OK on success, negative on error.
*/
int pqvv_seal(const uint8_t pk[PQVV_PUBLICKEYBYTES],
const uint8_t *pt, size_t pt_len,
uint8_t **out, size_t *out_len);
/**
* Open a sealed message. Verifies the MAC before doing any decryption.
* Output is freshly malloc'd and the caller owns it (free with free()).
*
* @param sk recipient's secret key
* @param ct ciphertext bytes from pqvv_seal
* @param ct_len ciphertext length
* @param out set to pointer to plaintext buffer (caller frees)
* @param out_len set to plaintext length
* @return PQVV_OK on success, PQVV_ERR_AUTH if MAC fails, PQVV_ERR_CORRUPT
* if ciphertext shape is invalid, negative on other error.
*/
int pqvv_open(const uint8_t sk[PQVV_SECRETKEYBYTES],
const uint8_t *ct, size_t ct_len,
uint8_t **out, size_t *out_len);
/* ─────────────────────────────────────────────────────────────────────
* Primitives (exported for testing against NIST/RFC vectors)
*
* Not the recommended user-facing API use pqvv_seal / pqvv_open. These
* are exported so the test suite can verify each primitive in isolation
* against the official test vectors.
* */
/**
* SHA-256 (FIPS 180-4). Computes the 32-byte digest of `len` bytes.
*/
void pqvv_sha256(const uint8_t *data, size_t len, uint8_t out[32]);
/**
* HMAC-SHA-256 (RFC 2104).
*/
void pqvv_hmac_sha256(const uint8_t *key, size_t klen,
const uint8_t *msg, size_t mlen,
uint8_t out[32]);
/**
* Fill `buf` with `len` cryptographically-strong random bytes from the
* OS CSPRNG. Aborts the process on failure.
*/
void pqvv_random_bytes(uint8_t *buf, size_t len);
/**
* Constant-time memory equality. Returns 1 if equal, 0 otherwise.
* Use for comparing secrets, MACs, etc. Never use memcmp.
*/
int pqvv_ct_memeq(const void *a, const void *b, size_t n);
/**
* Zero a memory region in a way the compiler cannot optimize away.
*/
void pqvv_memzero(void *p, size_t n);
#ifdef __cplusplus
}
#endif
#endif /* LIBPQVAPTVUPT_H */

1
vendor/pqvaptvupt/libpqvaptvupt.so vendored Symbolic link
View file

@ -0,0 +1 @@
libpqvaptvupt.so.0.6.0

1
vendor/pqvaptvupt/libpqvaptvupt.so.0 vendored Symbolic link
View file

@ -0,0 +1 @@
libpqvaptvupt.so.0.6.0

BIN
vendor/pqvaptvupt/libpqvaptvupt.so.0.6.0 vendored Executable file

Binary file not shown.