v4.2.0: full (pure) post-quantum mode + critical dedup nonce fix

Add a native full post-quantum encryption mode and fix a critical
keystream-reuse bug in deduplicated encrypted archives.

Full post-quantum mode (--pq-only)
- New envelope type 0x06 (ZUPT_ENC_PQ_ONLY): ML-KEM-768 (FIPS 203) as
  the sole key-establishment mechanism, with no classical X25519
  component. Archive key = SHA3-512(ml_ss || ml_ct || "ZUPT-PQ-ONLY-v1").
- For compliance postures that require a single NIST-standardised PQ
  primitive with no classical KEM in the envelope (CNSA 2.0-style
  "PQ-only"). Hybrid --pq stays the recommended default; --pq-only has
  no classical fallback, so a break of ML-KEM-768 alone breaks it.
- keygen --pq-only / keygen --pub --pq-only (ZPQK magic, 1200B pub /
  3600B priv; not interchangeable with hybrid --pq keys). Wrong or
  tampered ciphertext is rejected via ML-KEM FO implicit rejection plus
  the HMAC-SHA256 Encrypt-then-MAC envelope. In-tree, default build.

Security (critical): AES-256-CTR keystream reuse under --dedup
- Dedup assigns block sequence 0 to every data block (the sentinel that
  keeps cross-file dedup references authenticating consistently). The
  per-block nonce was base_nonce XOR block_seq, so under --dedup every
  block collapsed to the same nonce, reusing the CTR keystream across
  distinct plaintexts (a many-time-pad). Each block now uses a fresh
  random 128-bit nonce stored in the block prefix and bound into the
  block MAC; block_seq is still bound as MAC AAD. Regression test:
  tests/test_dedup_nonce.sh. Re-encrypt any --dedup encrypted archives
  written by <= 4.1.0.

Other
- keygen --sdk / --box on a source-only build now fails with a clear
  message pointing to native --pq / --pq-only (or a WITH_SDK=1 build).
- Documentation: README, SECURITY, THREAT_MODEL, man page, CHANGELOG,
  and all packaging recipes updated for the new mode and the security
  fix; version bumped to 4.2.0. Wire format v1.6 unchanged (0x06 is
  additive).

Validation: make check 16/16, quick suite 11/11 (incl. PQ-only),
dedup-nonce regression (all block nonces distinct), cppcheck clean.
This commit is contained in:
Cristian Cezar Moisés 2026-07-09 21:15:14 -03:00
commit 124958aea9
24 changed files with 811 additions and 123 deletions

View file

@ -1,6 +1,52 @@
# VaptVupt Changelog
## [4.2.0] — 2026-07-09 — Full (pure) post-quantum mode; dedup keystream-reuse fix
### Added — full post-quantum encryption (`--pq-only`)
- New native **full post-quantum** mode: `--pq-only` uses **ML-KEM-768**
(FIPS 203) as the *sole* key-establishment mechanism, with no classical
X25519 component. It complements the existing hybrid `--pq` for compliance
postures that require a single NIST-standardised PQ primitive with no
classical KEM in the envelope (CNSA 2.0-style "PQ-only" requirements).
- Wire format: new envelope type `0x06` (`ZUPT_ENC_PQ_ONLY`). The archive key
is `SHA3-512(ml_ss || ml_ct || "ZUPT-PQ-ONLY-v1")`. Keypairs use the `ZPQK`
magic (1200-byte public, 3600-byte private) and are **not** interchangeable
with hybrid `--pq` keys.
- Keygen: `vaptvupt keygen --pq-only` and `keygen --pub --pq-only`. Encrypt
with `compress --pq-only pub.key`, restore with `extract --pq-only priv.key`.
Wrong/tampered ciphertext is rejected via ML-KEM Fujisaki-Okamoto implicit
rejection plus the HMAC-SHA256 Encrypt-then-MAC envelope.
- Built entirely from the in-tree crypto — no external library, always
available in the default source-only build. The security trade-off vs the
hybrid is documented explicitly: `--pq-only` has no classical safety net, so
a future break of ML-KEM-768 alone breaks the envelope. `--pq` (hybrid)
remains the default recommendation.
### Fixed
- **Deterministic keygen guidance for the SDK path.** `keygen --sdk` /
`--box` on a source-only build now fails with a clear message pointing to the
native `--pq` / `--pq-only` keygen (or a `WITH_SDK=1` build) instead of an
opaque error.
### Security
- **Critical — AES-256-CTR keystream reuse under deduplication.** In `--dedup`
mode every data block was assigned block sequence 0 (the sentinel that lets
cross-file dedup references authenticate consistently). The per-block AEAD
nonce was previously derived as `base_nonce XOR block_seq`, so under dedup
every block collapsed to the *same* nonce — reusing the CTR keystream across
distinct plaintexts (a many-time-pad, from which XOR of ciphertexts leaks
plaintext XOR). Each block now uses a **fresh random 128-bit nonce** stored in
the block prefix and bound into the block MAC; `block_seq` is still bound as
MAC AAD. Nonces are now distinct across all blocks in every mode (regression
test `tests/test_dedup_nonce.sh`). Found by an adversarial review of the
encryption path and confirmed empirically. Archives written by 4.1.0 and
earlier in `--dedup` + encryption mode should be re-encrypted with 4.2.0.
## [4.1.0] — 2026-07-07 — Source-only build; multithreaded-encryption fix; hardening
### Source-only build (no vendored binaries)

View file

@ -10,7 +10,7 @@ Every packaging recipe expects an upstream tarball `vaptvupt-VERSION.tar.gz` pro
```sh
make dist
# → /tmp/vaptvupt-4.1.0.tar.gz
# → /tmp/vaptvupt-4.2.0.tar.gz
```
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.
@ -56,13 +56,13 @@ Maintainer flow:
```sh
# 1. Produce the upstream tarball
make dist
# → /tmp/vaptvupt-4.1.0.tar.gz
# → /tmp/vaptvupt-4.2.0.tar.gz
# 2. Upload to a stable URL (e.g. git.securityops.co releases)
# 3. Update packaging/aur/PKGBUILD:
# - Set pkgver=4.1.0
# - Set sha256sums=("$(sha256sum /tmp/vaptvupt-4.1.0.tar.gz | awk '{print $1}')")
# - Set pkgver=4.2.0
# - Set sha256sums=("$(sha256sum /tmp/vaptvupt-4.2.0.tar.gz | awk '{print $1}')")
# 4. Generate .SRCINFO
cd packaging/aur && makepkg --printsrcinfo > .SRCINFO
@ -73,7 +73,7 @@ makepkg -s
# 6. Push to AUR
git clone ssh://aur@aur.archlinux.org/vaptvupt.git aur-vaptvupt
cp packaging/aur/PKGBUILD packaging/aur/.SRCINFO aur-vaptvupt/
cd aur-vaptvupt && git add -A && git commit -m "v4.1.0" && git push
cd aur-vaptvupt && git add -A && git commit -m "v4.2.0" && git push
```
User install:
@ -117,10 +117,10 @@ The `packaging/debian/` tree is a Debian source-package layout. Maintainer flow:
# 1. Produce the upstream tarball with the standard Debian
# orig.tar.gz naming convention:
make dist
cp /tmp/vaptvupt-4.1.0.tar.gz /tmp/vaptvupt_4.1.0.orig.tar.gz
cp /tmp/vaptvupt-4.2.0.tar.gz /tmp/vaptvupt_4.2.0.orig.tar.gz
# 2. Unpack and overlay the debian/ tree:
cd /tmp && tar xzf vaptvupt_4.1.0.orig.tar.gz && cd vaptvupt-4.1.0
cd /tmp && tar xzf vaptvupt_4.2.0.orig.tar.gz && cd vaptvupt-4.2.0
cp -a /path/to/vaptvupt/packaging/debian ./debian
# 3. Build the source package:
@ -128,7 +128,7 @@ dpkg-buildpackage -S -us -uc # source-only
dpkg-buildpackage -b -us -uc # binary
# 4. Lint:
lintian vaptvupt_4.1.0-1_*.deb
lintian vaptvupt_4.2.0-1_*.deb
# 5. Submit via the standard Debian mentors process:
# https://mentors.debian.net/intro-maintainers/
@ -145,7 +145,7 @@ sudo apt install vaptvupt
```sh
# 1. Produce the tarball
make dist
cp /tmp/vaptvupt-4.1.0.tar.gz ~/rpmbuild/SOURCES/
cp /tmp/vaptvupt-4.2.0.tar.gz ~/rpmbuild/SOURCES/
# 2. Drop the .spec into the SPECS directory:
cp packaging/rpm/vaptvupt.spec ~/rpmbuild/SPECS/
@ -154,7 +154,7 @@ cp packaging/rpm/vaptvupt.spec ~/rpmbuild/SPECS/
cd ~/rpmbuild && rpmbuild -ba SPECS/vaptvupt.spec
# 4. Lint:
rpmlint RPMS/x86_64/vaptvupt-4.1.0-1.fc*.rpm
rpmlint RPMS/x86_64/vaptvupt-4.2.0-1.fc*.rpm
# 5. Submit via the Fedora new-package review process:
# https://docs.fedoraproject.org/en-US/package-maintainers/Package_Review_Process/
@ -177,7 +177,7 @@ The `packaging/opensuse/` tree carries an RPM `.spec` suited to the Open Build S
make dist
# 2. In an OBS package checkout (osc), stage the sources and spec:
cp /tmp/vaptvupt-4.1.0.tar.gz .
cp /tmp/vaptvupt-4.2.0.tar.gz .
cp /path/to/vaptvupt/packaging/opensuse/vaptvupt.spec .
# 3. Build locally against a target repository:
@ -230,7 +230,7 @@ nix build github:cristiancmoises/vaptvupt#vaptvupt
nix run github:cristiancmoises/vaptvupt#vaptvupt -- version
# 2. To consume from another flake:
# inputs.vaptvupt.url = "github:cristiancmoises/vaptvupt?ref=v4.1.0";
# inputs.vaptvupt.url = "github:cristiancmoises/vaptvupt?ref=v4.2.0";
# packages.x86_64-linux.default = inputs.vaptvupt.packages.x86_64-linux.vaptvupt;
# 3. To submit to nixpkgs (https://github.com/NixOS/nixpkgs):

View file

@ -5,13 +5,13 @@ If you're seeing the error:
```
vaptvupt-gui depende de python3-pyqt6 | python3-pyside6; porém:
Pacote python3-pyqt6 não está instalado.
vaptvupt-gui depende de vaptvupt (>= 4.1.0); porém:
vaptvupt-gui depende de vaptvupt (>= 4.2.0); porém:
Versão de vaptvupt no sistema é 2.1.7-1.
```
This is correct behavior. The `vaptvupt-gui` deb requires:
- Python 3 with **PyQt6** or **PySide6** (the GUI toolkit)
- The **vaptvupt CLI 4.1.0** or newer
- The **vaptvupt CLI 4.2.0** or newer
## The fastest fix — one command (Linux Mint, Ubuntu, Debian)
@ -33,8 +33,8 @@ the right order.
sudo apt update
sudo apt install -y python3-pyqt6
# 2. Upgrade vaptvupt CLI to 4.1.0
sudo dpkg -i vaptvupt_4.1.0_amd64.deb
# 2. Upgrade vaptvupt CLI to 4.2.0
sudo dpkg -i vaptvupt_4.2.0_amd64.deb
# 3. Install the GUI
sudo dpkg -i vaptvupt-gui_1.3.0_all.deb
@ -50,7 +50,7 @@ sudo apt --fix-broken install
```bash
sudo dnf install -y python3-pyqt6
sudo dnf install -y vaptvupt-4.1.0-1.x86_64.rpm vaptvupt-gui-1.3.0-1.noarch.rpm
sudo dnf install -y vaptvupt-4.2.0-1.x86_64.rpm vaptvupt-gui-1.3.0-1.noarch.rpm
```
(Or build the RPM from the SRPM tarball with `rpmbuild -bb SPECS/vaptvupt.spec`)
@ -98,7 +98,7 @@ Qt6 inside the deb because:
- Bundling would make the deb 80 MB+ instead of 35 KB
- Distribution-managed Qt gets security updates automatically
## Why does the GUI need vaptvupt 4.1.0?
## Why does the GUI need vaptvupt 4.2.0?
The GUI calls `vaptvupt --pq` and `vaptvupt keygen` for native
post-quantum encryption (ML-KEM-768 + X25519, in-tree implementation).
@ -108,7 +108,7 @@ fail against them.
## After installing — verify
```bash
vaptvupt version # should show: 4.1.0
vaptvupt version # should show: 4.2.0
vaptvupt-gui # should launch the GUI window
```
@ -178,8 +178,8 @@ sudo zypper install gcc make # openSUSE
### Build VaptVupt itself
```bash
tar -xzf vaptvupt-4.1.0-source.tar.gz
cd vaptvupt-4.1.0
tar -xzf vaptvupt-4.2.0-source.tar.gz
cd vaptvupt-4.2.0
make # build the `./vaptvupt` binary
sudo make install # install to /usr/local/bin (override with PREFIX=/usr)

View file

@ -417,6 +417,7 @@ 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_dedup_nonce.sh
$(Q)bash tests/test_f08_topmac.sh
$(Q)bash tests/test_f09_preface.sh
$(Q)bash tests/test_f10_kdf_default.sh
@ -475,6 +476,7 @@ check: $(TARGET) test-vectors
$(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_dedup_nonce.sh
$(Q)bash tests/test_f08_topmac.sh
$(Q)bash tests/test_f10_kdf_default.sh
$(Q)bash tests/test_f11_authfail_message.sh

111
README.md
View file

@ -17,28 +17,37 @@ License: AGPL-3.0-or-later (dual-licensed AGPL + commercial).
> command is preserved as a symlink to `vaptvupt` for one major version
> cycle.
## What's new in 4.1.0
## What's new in 4.2.0
- **Source-only tree.** The prebuilt vendored libraries `libzuptsdk.so`
and `libpqvaptvupt.so` have been removed. The default `make` needs only
a C compiler and make (plus libm/pthread) — no external crypto library —
and installs no `.so`.
- **Native `--pq` is the default post-quantum mode.** ML-KEM-768 + X25519
hybrid KEM, in-tree C implementation, available in the default build.
- **SDK-backed modes are optional.** `--pq-sdk`, `--pq-box`, and the
Argon2id KDF are only available in an upstream `make WITH_SDK=1` build
linked against the separately distributed `libzuptsdk`/`libpqvaptvupt`.
- **Wire/on-disk format is v1.6, unchanged.** Archives created by 4.0.0
are read and written identically.
- **Full (pure) post-quantum mode — `--pq-only`.** ML-KEM-768 (FIPS 203) as
the *sole* key-establishment mechanism, with no classical X25519 component.
For compliance postures that mandate a single NIST-standardised PQ primitive
with no classical KEM in the envelope (CNSA 2.0-style "PQ-only"). Envelope
type `0x06`; archive key `SHA3-512(ml_ss ‖ ml_ct ‖ "ZUPT-PQ-ONLY-v1")`.
In-tree crypto, available in the default build.
- **Hybrid `--pq` stays the default recommendation.** `--pq-only` trades away
the hybrid safety net: a future break of ML-KEM-768 *alone* would break a
`--pq-only` archive, whereas `--pq` would still require breaking X25519 too.
Use `--pq-only` only when a policy forbids the classical component.
- **Security fix (critical): AES-CTR keystream reuse under `--dedup`.** Dedup
blocks all share sequence 0, and the old nonce (`base_nonce XOR seq`)
therefore collapsed to one value across blocks — reusing the CTR keystream
(a many-time-pad). Every block now uses a **fresh random 128-bit nonce**.
Re-encrypt any `--dedup` + encrypted archives written by ≤ 4.1.0. Regression
test: `tests/test_dedup_nonce.sh`.
- **Clearer SDK keygen guidance.** `keygen --sdk`/`--box` on the source-only
build now points you to native `--pq`/`--pq-only` (or a `WITH_SDK=1` build).
- **Wire/on-disk format is v1.6, unchanged.** All 4.x archives interoperate;
the new `0x06` envelope is additive.
> **F-16 (data loss):** archives created by **≤ 3.8.0** at `-l 8`/`-l 9`
> whose inputs included x86/ELF/PE executables may be **undecodable by any
> version** (write-time defect in the old in-tree BCJ encoder). Re-create
> such archives with 4.1.0 and verify extraction before deleting source
> such archives with 4.2.0 and verify extraction before deleting source
> data. Details in [CHANGELOG.md](CHANGELOG.md).
Binaries for the CLI (4.1.0) and GUI (1.3.0) are on the
[release page](https://git.securityops.co/cristiancmoises/vaptvupt/releases/tag/v4.1.0).
Binaries for the CLI (4.2.0) and GUI (1.3.0) are on the
[release page](https://git.securityops.co/cristiancmoises/vaptvupt/releases/tag/v4.2.0).
---
@ -50,8 +59,10 @@ Binaries for the CLI (4.1.0) and GUI (1.3.0) are on the
`--lzhp`.
- **Post-quantum encryption**`--pq` uses ML-KEM-768 + X25519 hybrid
KEM (the approach used by Signal and iMessage), protecting against
"harvest now, decrypt later" attacks. In-tree, available in the default
build.
"harvest now, decrypt later" attacks. `--pq-only` offers a full (pure)
ML-KEM-768 mode with no classical component for "PQ-only" compliance
postures. Both are in-tree and available in the default build; hybrid
`--pq` is the recommended default.
- **AES-NI acceleration** — AES-256-CTR via Jasmin-verified assembly with
a 4-block interleaved pipeline. AVX detection validates OSXSAVE/XCR0 (no
SIGILL). Falls back to C table-based AES on unsupported hardware.
@ -111,18 +122,18 @@ Argon2id KDF.
### Pre-built packages
Assets are published on the
[v4.1.0 release page](https://git.securityops.co/cristiancmoises/vaptvupt/releases/tag/v4.1.0)
[v4.2.0 release page](https://git.securityops.co/cristiancmoises/vaptvupt/releases/tag/v4.2.0)
and verifiable against the published `SHA256SUMS.txt`.
**Command-line tool (`vaptvupt` 4.1.0):**
**Command-line tool (`vaptvupt` 4.2.0):**
| Format | File | Distros |
|---|---|---|
| Debian/Ubuntu | `vaptvupt_4.1.0_amd64.deb` | Debian 11+, Ubuntu 22.04+, Mint 21+ |
| RPM | `vaptvupt-4.1.0-1.x86_64.rpm` | Fedora 38+, RHEL 9+, openSUSE, AlmaLinux, Rocky, other RPM-based distributions |
| AppDir tarball | `vaptvupt-4.1.0-x86_64.AppDir.tar.gz` | Any glibc 2.28+ (extract & run, no FUSE) |
| Source tarball | `vaptvupt-4.1.0.tar.gz` | Build from source on any platform |
| openSUSE OBS | `vaptvupt-4.1.0-opensuse-obs.tar.gz` | Open Build Service source bundle |
| Debian/Ubuntu | `vaptvupt_4.2.0_amd64.deb` | Debian 11+, Ubuntu 22.04+, Mint 21+ |
| RPM | `vaptvupt-4.2.0-1.x86_64.rpm` | Fedora 38+, RHEL 9+, openSUSE, AlmaLinux, Rocky, other RPM-based distributions |
| AppDir tarball | `vaptvupt-4.2.0-x86_64.AppDir.tar.gz` | Any glibc 2.28+ (extract & run, no FUSE) |
| Source tarball | `vaptvupt-4.2.0.tar.gz` | Build from source on any platform |
| openSUSE OBS | `vaptvupt-4.2.0-opensuse-obs.tar.gz` | Open Build Service source bundle |
**Graphical front-end (`vaptvupt-gui` 1.3.0):**
@ -138,17 +149,17 @@ and verifiable against the published `SHA256SUMS.txt`.
sha256sum -c SHA256SUMS.txt
# Debian / Ubuntu / Mint
sudo dpkg -i vaptvupt_4.1.0_amd64.deb
sudo dpkg -i vaptvupt_4.2.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 vaptvupt-4.1.0-1.x86_64.rpm
sudo rpm -i vaptvupt-4.2.0-1.x86_64.rpm
# or
sudo dnf install ./vaptvupt-4.1.0-1.x86_64.rpm
sudo dnf install ./vaptvupt-4.2.0-1.x86_64.rpm
# AppDir tarball (no install, no FUSE required)
tar xzf vaptvupt-4.1.0-x86_64.AppDir.tar.gz
./vaptvupt-4.1.0-x86_64.AppDir/AppRun --help
tar xzf vaptvupt-4.2.0-x86_64.AppDir.tar.gz
./vaptvupt-4.2.0-x86_64.AppDir/AppRun --help
# GUI AppImage (single executable)
chmod +x VaptVupt-GUI-1.3.0-x86_64.AppImage
@ -158,10 +169,10 @@ chmod +x VaptVupt-GUI-1.3.0-x86_64.AppImage
### Building from SRPM (Fedora / RHEL / RPM-based distributions)
```bash
tar xzf vaptvupt-4.1.0.srpm.tar.gz
tar xzf vaptvupt-4.2.0.srpm.tar.gz
cd ~/rpmbuild # or use rpmbuild --define "_topdir $(pwd)"
rpmbuild -bb SPECS/vaptvupt.spec
sudo rpm -i RPMS/x86_64/vaptvupt-4.1.0-1.*.rpm
sudo rpm -i RPMS/x86_64/vaptvupt-4.2.0-1.*.rpm
```
### Basic usage
@ -209,6 +220,13 @@ vaptvupt keygen -o mykey.key
vaptvupt keygen --pub -o pub.key -k mykey.key
vaptvupt compress --pq pub.key backup.zupt ~/Documents/
vaptvupt extract --pq mykey.key -o ~/restored/ backup.zupt
# Native --pq-only (full/pure ML-KEM-768, no classical component).
# Use only for "PQ-only" compliance postures; --pq (hybrid) is safer.
vaptvupt keygen --pq-only -o pqkey
vaptvupt keygen --pub --pq-only -o pqkey.pub -k pqkey
vaptvupt compress --pq-only pqkey.pub backup.zupt ~/Documents/
vaptvupt extract --pq-only pqkey -o ~/restored/ backup.zupt
```
The SDK-backed modes below require a `make WITH_SDK=1` build linked against
@ -397,8 +415,10 @@ packaging-syntax checks.
## Post-Quantum Encryption
`--pq` uses hybrid ML-KEM-768 + X25519 key encapsulation per NIST FIPS 203,
in-tree and available in the default build.
VaptVupt has two native PQ modes, both in-tree and available in the default
build.
**`--pq` — hybrid ML-KEM-768 + X25519 (envelope `0x02`, recommended):**
```
Public key → ML-KEM-768 Encaps + X25519 ECDH → hybrid shared secret
@ -406,11 +426,27 @@ Public key → ML-KEM-768 Encaps + X25519 ECDH → hybrid shared secret
→ AES-256-CTR + HMAC-SHA256 per block
```
Security model: secure if EITHER ML-KEM-768 (post-quantum) OR X25519
(classical) is secure.
Security model: secure if **EITHER** ML-KEM-768 (post-quantum) **OR** X25519
(classical) is secure. This is the recommended default — it stays safe even
if one primitive is later broken.
Password mode (`-p`) is not quantum-safe. Use `--pq` for long-term
protection.
**`--pq-only` — full/pure ML-KEM-768 (envelope `0x06`):**
```
Public key → ML-KEM-768 Encaps → shared secret ss, ciphertext ct
→ archive_key = SHA3-512(ss ‖ ct ‖ "ZUPT-PQ-ONLY-v1")
→ AES-256-CTR + HMAC-SHA256 per block
```
Security model: secure if ML-KEM-768 is secure — there is **no classical
fallback**. Choose this only when a policy mandates a single NIST-standardised
PQ primitive with no classical KEM in the envelope (CNSA 2.0-style "PQ-only").
The trade-off is explicit: a future break of ML-KEM-768 *alone* breaks the
archive, whereas under `--pq` the attacker must also break X25519. **When in
doubt, use `--pq`.**
Password mode (`-p`) is not quantum-safe. Use `--pq` (or `--pq-only`) for
long-term protection.
The SDK-backed `--pq-sdk` and `--pq-box` modes are optional and require a
`make WITH_SDK=1` build against `libzuptsdk`/`libpqvaptvupt`.
@ -635,6 +671,7 @@ VaptVupt archives require VaptVupt v2.0+.
| v3.4.0v3.8.0 | F-15 KDF parameter transparency, measured constant-time MAC comparison (dudect), NIST SP 800-38A AES-CTR vectors, ML-KEM decaps through the CT primitive, consolidated benchmarks |
| v4.0.0 | Codec 2.60.4 security release (OOB heap write fixed in AVX2 decode fast path), `--pq-box` sealed-box mode, F-16 data-loss disclosure + fix (old in-tree BCJ encoder), CBMC-verified BCJ filters with auto ELF/PE/Mach-O detection, SHA-NI acceleration. Wire format v1.6 |
| v4.1.0 | Source-only tree (prebuilt libzuptsdk/libpqvaptvupt removed); default build needs only a C compiler + make; native `--pq` is the default PQ mode; `--pq-sdk`/`--pq-box`/Argon2id gated behind `make WITH_SDK=1`. Wire format stays v1.6 |
| v4.2.0 | Full (pure) post-quantum mode `--pq-only` (ML-KEM-768 only, envelope 0x06); critical fix for AES-CTR keystream reuse under `--dedup` (fresh random per-block nonce); clearer SDK keygen guidance. Wire format stays v1.6 |
See [CHANGELOG.md](CHANGELOG.md) for detailed per-version changes.

View file

@ -1,4 +1,4 @@
# Security Policy — VaptVupt 4.1.0
# Security Policy — VaptVupt 4.2.0
## Reporting Vulnerabilities
@ -26,7 +26,8 @@ deployments, treat it as "reviewed but unaudited" and do your own review.
| Mode | CLI Flag | Algorithm | PQ-Safe? | Use Case |
|------|----------|-----------|----------|----------|
| Password | `-p` | PBKDF2-SHA256 → AES-256-CTR + HMAC-SHA256 | No | Short-term backups, personal use |
| PQ Hybrid | `--pq` | ML-KEM-768 + X25519 → AES-256-CTR + HMAC-SHA256 | Yes | Long-term archives, high-value data |
| PQ Hybrid | `--pq` | ML-KEM-768 + X25519 → AES-256-CTR + HMAC-SHA256 | Yes | Long-term archives, high-value data (**recommended**) |
| PQ Only | `--pq-only` | ML-KEM-768 only → AES-256-CTR + HMAC-SHA256 | Yes | "PQ-only" compliance postures (no classical KEM) |
| None | (default) | No encryption (compression only) | N/A | Non-sensitive data |
Password mode (`-p`) is not quantum-safe. For protection against "harvest
@ -34,6 +35,17 @@ now, decrypt later" quantum attacks, use `--pq` — the recommended
post-quantum mode. `--pq` is native and in-tree; it needs no external
library.
`--pq-only` (envelope type `0x06`) uses ML-KEM-768 as the *sole* key
mechanism, with no classical X25519 component. It exists for compliance
postures that mandate a single NIST-standardised PQ primitive with no
classical KEM in the envelope (CNSA 2.0-style "PQ-only"). **This is a
deliberate reduction in defence-in-depth:** unlike `--pq`, there is no
classical fallback, so a future cryptanalytic break of ML-KEM-768 alone is
sufficient to break the archive. Under `--pq`, an attacker must break *both*
ML-KEM-768 and X25519. **Unless a policy forbids the classical component,
prefer `--pq`.** Both modes are native, in-tree, and need no external
library.
Optional SDK modes (`--pq-sdk`, `--pq-box`) are available only in an
upstream `make WITH_SDK=1` build linked against the separately distributed
libzuptsdk / libpqvaptvupt libraries. They are not part of the default
@ -51,7 +63,8 @@ build and are not defaults.
| Password KDF (WITH_SDK=1 option) | Argon2id | RFC 9106 | OWASP minimums | Password-dependent, memory-hard |
| Post-quantum KEM | ML-KEM-768 | FIPS 203 | 1184B pk / 2400B sk | NIST Level 3 |
| Classical KEM | X25519 | RFC 7748 | 32B scalar | ~128-bit classical |
| Hybrid KDF | SHA3-512 | FIPS 202 | 512-bit output | Secure if either KEM holds |
| Hybrid KDF (`--pq`) | SHA3-512 | FIPS 202 | 512-bit output | Secure if either KEM holds |
| PQ-only KDF (`--pq-only`) | SHA3-512 | FIPS 202 | 512-bit output | Secure if ML-KEM-768 holds (no classical fallback) |
| Integrity | XXH64 | xxHash spec | 64-bit checksum | Non-cryptographic |
| Hashing | SHA3-256, SHA3-512 | FIPS 202 | 256/512-bit | Standard |
| Random | OS CSPRNG | getrandom(2) / RtlGenRandom | N/A | Hard fail if unavailable |
@ -66,14 +79,28 @@ Argon2id is available only in a `make WITH_SDK=1` build.
### Per-Block Authenticated Encryption
```
For each data block (sequence 0, 1, 2, ...):
For each data block:
nonce = base_nonce XOR pad_le(block_seq, 8) [16 bytes]
nonce = CSPRNG(16) [16 bytes, fresh per block]
ciphertext = AES-256-CTR(enc_key, nonce, plaintext)
mac = HMAC-SHA256(mac_key, nonce ‖ ciphertext) [32 bytes]
mac = HMAC-SHA256(mac_key, aad ‖ nonce ‖ ciphertext) [32 bytes]
stored = nonce ‖ ciphertext ‖ mac
```
The nonce is a **fresh 128-bit random value per block**, stored in the block
prefix and bound into the block MAC. The block sequence number is bound into
the MAC AAD (not into the nonce), so reordering, splicing, or replaying blocks
is still detected.
> **History (fixed in 4.2.0):** earlier releases derived the nonce as
> `base_nonce XOR pad_le(block_seq, 8)`. In `--dedup` mode every data block is
> assigned sequence 0 (the sentinel that keeps cross-file dedup references
> authenticating consistently), so the nonce collapsed to a single value across
> all dedup blocks — reusing the AES-CTR keystream across distinct plaintexts
> (a many-time-pad). Switching to a fresh random per-block nonce closes this.
> Regression test: `tests/test_dedup_nonce.sh`. Re-encrypt any `--dedup` +
> encrypted archives written by ≤ 4.1.0.
### Encrypt-then-MAC
HMAC is computed over `nonce ‖ ciphertext` and verified **before** any
@ -106,6 +133,26 @@ The `--pq-sdk` mode (WITH_SDK=1 only) uses an HKDF-SHA3-256 combiner, a
32-byte key commitment tag, HPKE-style context binding (RFC 9180 §5),
anti-fault double decapsulation, and XChaCha20-Poly1305 AEAD.
### Full Post-Quantum KEM (`--pq-only`)
```
Encapsulation:
ML-KEM-768.Encaps(pk) → ml_ct[1088], ml_ss[32]
archive_key = SHA3-512(ml_ss ‖ ml_ct ‖ "ZUPT-PQ-ONLY-v1")
enc_key = archive_key[0:32]
mac_key = archive_key[32:64]
```
Security model: secure if ML-KEM-768 (post-quantum, NIST Level 3) remains
unbroken. **There is no classical component**, so — unlike `--pq` — a break of
ML-KEM-768 alone is sufficient to compromise the archive key. This mode exists
only for compliance postures that mandate a single NIST-standardised PQ
primitive with no classical KEM in the envelope (CNSA 2.0-style "PQ-only").
Decapsulation uses ML-KEM Fujisaki-Okamoto implicit rejection: a wrong or
tampered `ml_ct` yields a pseudorandom shared secret, so decryption fails
closed at the HMAC check rather than leaking a decapsulation-validity oracle.
**Unless a policy forbids the classical component, prefer `--pq`.**
---
## Constant-Time Guarantees

View file

@ -35,8 +35,15 @@ a substitute for full-disk encryption.
(600k iterations). Argon2id is available only in an upstream
`make WITH_SDK=1` build against the separately distributed
libraries.
- `--pq`: native post-quantum mode (ML-KEM-768 + X25519), the PQ mode
in the default build. The ML-KEM-768 implementation is in-tree.
- `--pq`: native post-quantum **hybrid** mode (ML-KEM-768 + X25519), the
recommended PQ mode in the default build. The ML-KEM-768 implementation
is in-tree.
- `--pq-only`: native **full/pure** post-quantum mode (ML-KEM-768 only, no
X25519), also in the default build. For compliance postures that mandate a
single NIST-standardised PQ primitive with no classical KEM in the envelope.
Its threat profile differs from `--pq` in exactly one axis: it has no
classical fallback, so a break of ML-KEM-768 alone breaks the archive
(see §5 and "Cryptographic assumptions").
- `--pq-sdk` / `--pq-box`: optional post-quantum modes backed by the
separately distributed `libzuptsdk` / `libpqvaptvupt` libraries.
Available only in a `make WITH_SDK=1` build. Key files for these
@ -99,7 +106,7 @@ 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 (`--pq` and optional `--pq-sdk`)
### 5. Post-quantum forward secrecy (`--pq`, `--pq-only`, and optional `--pq-sdk`)
The native `--pq` mode uses ML-KEM-768 (FIPS 203) hybridized with
X25519 via an HKDF combiner. Archives encrypted today cannot be
@ -111,8 +118,18 @@ assuming:
- X25519 hybridization protects against an unforeseen ML-KEM break
- The recipient's private key is not later compromised
The optional `--pq-sdk` mode provides the same hybrid guarantee via
the separately distributed SDK libraries.
The native `--pq-only` mode (envelope type `0x06`) provides the same
harvest-now-decrypt-later protection using ML-KEM-768 as the *sole* key
mechanism. It exists for compliance postures that mandate a single
NIST-standardised PQ primitive with no classical KEM in the envelope
(CNSA 2.0-style "PQ-only"). **The trade-off is a loss of the second
assumption above:** there is no X25519 hybridization, so an unforeseen
break of ML-KEM-768 alone is sufficient to recover the archive key. For
that reason `--pq` (hybrid) is the recommended default, and `--pq-only`
should be used only when a policy forbids the classical component.
The optional `--pq-sdk` mode provides the same hybrid guarantee as
`--pq` via the separately distributed SDK libraries.
### 6. Side-channel resistance for cryptographic primitives
@ -274,8 +291,8 @@ VaptVupt's security rests on the following standard assumptions:
| 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 |
| PBKDF2-SHA256 (or Argon2id, WITH_SDK) is a secure password KDF | Password-mode archives become brute-forceable faster |
| ML-KEM-768 retains NIST Category 3 security | `--pq` / `--pq-sdk` reduce to the X25519 layer |
| X25519 retains 128-bit security (no quantum) | PQ modes reduce to the ML-KEM layer; classical password mode unaffected |
| ML-KEM-768 retains NIST Category 3 security | `--pq` / `--pq-sdk` reduce to the X25519 layer; **`--pq-only` has no fallback and is broken** |
| X25519 retains 128-bit security (no quantum) | Hybrid PQ modes reduce to the ML-KEM layer; `--pq-only` and classical password mode unaffected |
| 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 |
@ -306,6 +323,6 @@ normally.
## Document version
This threat model covers archive format v1.6 as shipped in VaptVupt
4.1.0. It is part of the source tree (`THREAT_MODEL.md`) and
4.2.0. It is part of the source tree (`THREAT_MODEL.md`) and
versioned with the project; this section will be updated as the
format evolves.

View file

@ -1,7 +1,7 @@
.\" 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 "July 2026" "vaptvupt 4.1.0" "User Commands"
.TH VAPTVUPT 1 "July 2026" "vaptvupt 4.2.0" "User Commands"
.SH NAME
vaptvupt \- post-quantum backup compression utility (formerly zupt)
@ -125,7 +125,8 @@ archive without restoring it. Exit code is non-zero on any failure.
.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,
Argon2id / ML-KEM-768+X25519 hybrid / ML-KEM-768 pure-PQ), KDF
iteration count, file count,
creation timestamp, archive UUID, AIT presence. Safe to run on an
untrusted archive.
@ -157,18 +158,32 @@ after each block.
.TP
.B keygen
Generate a key file for keyfile-mode encryption. With
Generate a key file. With no PQ flag, writes a 32-byte raw key for
keyfile-mode encryption. With
.B --pq
generates a native ML-KEM-768 + X25519 \fBhybrid\fR keypair for
.B --pq
encryption (in-tree crypto; no external library). With
.B --pq-only
generates a native pure ML-KEM-768 keypair (magic
.BR ZPQK )
for
.B --pq-only
encryption. With
.B --sdk
generates an ML-KEM-768 + X25519 hybrid keypair suitable for
generates a keypair for the optional
.B --pq-sdk
mode. With
mode, and with
.B --box
generates a libpqvaptvupt sealed-box keypair (writes \fIfile\fR and
\fIfile\fR.pub) for
a libpqvaptvupt sealed-box keypair for
.B --pq-box
mode. With
(both need a
.B WITH_SDK=1
build). With
.B --pub
extracts the public key from an existing private key.
extracts the public key from an existing private key (combine with the
matching PQ flag, e.g.
.BR "keygen --pub --pq-only" ).
.SH GLOBAL OPTIONS
@ -233,10 +248,55 @@ Choose key-derivation function for password mode:
Use a 32-byte raw key file (generated with
.BR "vaptvupt keygen" ).
.TP
.B --pq \fIpub\fR
Enable native post-quantum \fBhybrid\fR encryption (envelope type 0x02,
recommended). Combines ML-KEM-768 (FIPS 203) with X25519 (RFC 7748) so
the archive key is secure unless \fBboth\fR the lattice KEM and the
elliptic-curve exchange are broken. Uses the in-tree crypto only — no
external library, always available. The
.I pub
argument is the recipient's public-key file from
.BR "vaptvupt keygen" .
On extraction, pass the secret key:
.B --pq
\fIpriv\fR.
.TP
.B --pq-only \fIpub\fR
Enable native \fBfull\fR (pure) post-quantum encryption (envelope type
0x06). ML-KEM-768 is the \fIsole\fR key-establishment mechanism — no
X25519 component. Choose this only when a policy mandates a single
NIST-standardised PQ primitive with no classical KEM in the envelope
(e.g. CNSA 2.0-style "PQ-only" postures). The archive key is
SHA3-512(ml_ss || ml_ct || "ZUPT-PQ-ONLY-v1"). Note the deliberate
trade-off: unlike
.BR --pq ,
a future weakness in ML-KEM-768 alone is sufficient to break the
envelope, because there is no classical KEM to fall back on. When in
doubt use
.B --pq
(hybrid). Keys are generated with
.BR "vaptvupt keygen --pq-only" ;
the private and public key files (magic
.BR ZPQK )
are not interchangeable with hybrid
.B --pq
keys. On extraction, pass the secret key:
.B --pq-only
\fIpriv\fR.
.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
Enable post-quantum sealed-box encryption via libpqvaptvupt (envelope
type 0x05). \fBRequires an optional\fR \fBWITH_SDK=1\fR \fBbuild\fR: the
default source-only tree ships no vendored library, so this mode is
absent unless you build against libpqvaptvupt yourself. Prefer the
native
.B --pq
or
.B --pq-only
modes, which need no external library. 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
@ -249,8 +309,13 @@ On extraction, pass the secret key:
\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
Enable post-quantum hybrid encryption via libzuptsdk (envelope type
0x03). \fBRequires an optional\fR \fBWITH_SDK=1\fR \fBbuild\fR and is
absent from the default source-only tree; use the native
.B --pq
instead, which provides the same ML-KEM-768 + X25519 hybrid with no
external dependency. 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" .
@ -293,11 +358,16 @@ 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:
offers two native post-quantum modes, both built entirely from the
in-tree crypto (no external library):
.RS
.IP "\fB--pq\fR (hybrid, recommended)" 4
A hybrid KEM combining ML-KEM-768 (FIPS 203) with X25519 (RFC 7748).
The archive 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,
@ -305,10 +375,38 @@ session = HKDF-SHA256(ss_pq || ss_ec,
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 \fBboth\fR to
recover the key. This is the default recommendation and the right choice
for almost every user, because it stays secure even if one primitive is
later found weak.
.IP "\fB--pq-only\fR (full / pure PQ)" 4
ML-KEM-768 as the \fIsole\fR key-establishment mechanism, with no
classical component. The archive key is derived as:
.RS
.nf
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.
(ss_pq, ct_pq) = ML-KEM-768.encaps(pk_pq)
archive_key = SHA3-512(ss_pq || ct_pq || "ZUPT-PQ-ONLY-v1")
.fi
.RE
Use this only when a compliance posture requires a single
NIST-standardised PQ primitive with no classical KEM in the envelope
(for example CNSA 2.0-style "PQ-only" requirements). The deliberate
trade-off is that the envelope has \fBno hybrid safety net\fR: a future
cryptanalytic break of ML-KEM-768 alone breaks the archive, whereas
under
.B --pq
the attacker would still have to break X25519 as well. Unless a policy
forbids the classical component, prefer
.BR --pq .
.RE
.PP
Both modes carry the same authenticated envelope as password mode:
per-block AES-256-CTR with a fresh random 128-bit nonce, HMAC-SHA256
Encrypt-then-MAC, and ML-KEM Fujisaki-Okamoto implicit rejection, so a
wrong or tampered ciphertext is rejected rather than yielding garbage.
.PP
.B Key commitment:
@ -537,6 +635,19 @@ $ vaptvupt compress --pq mykey.pub backup.zupt ~/Documents
.fi
.RE
Compress with full (pure) post-quantum encryption — ML-KEM-768 only,
no classical component (compliance postures that mandate a single PQ
primitive):
.RS
.nf
$ vaptvupt keygen --pq-only -o pqkey
$ vaptvupt keygen --pub --pq-only -o pqkey.pub -k pqkey
$ vaptvupt compress --pq-only pqkey.pub backup.zupt ~/Documents
$ vaptvupt extract --pq-only pqkey -o restored backup.zupt
.fi
.RE
Backup a block device, sparse-aware:
.RS

View file

@ -116,7 +116,7 @@ The GUI calls the vaptvupt CLI binary — all cryptography runs in native C, not
## Credits
- **vaptvupt** v4.1.0 — Cristian Cezar Moisés ([github](https://git.securityops.co/cristiancmoises/vaptvupt))
- **vaptvupt** v4.2.0 — Cristian Cezar Moisés ([github](https://git.securityops.co/cristiancmoises/vaptvupt))
## License

View file

@ -50,7 +50,7 @@
#define ZUPT_PRODUCT_EXTENSION ".zupt" /* on-disk archive extension (kept stable) */
#define ZUPT_PRODUCT_TAGLINE "Post-quantum backup compression"
#define ZUPT_VERSION_STRING "4.1.0"
#define ZUPT_VERSION_STRING "4.2.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"
@ -104,6 +104,7 @@
#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 */
#define ZUPT_ENC_PQ_ONLY 0x06 /* Full post-quantum: ML-KEM-768 only (no X25519), SHA3-512 KDF (v4.2.0) */
/* Argon2id KDF profile descriptor (v3.4.0).
*
@ -271,6 +272,7 @@ typedef struct {
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 pqonly_mode; /* 1 = full post-quantum mode: ML-KEM-768 only (ZUPT_ENC_PQ_ONLY) */
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];
@ -484,6 +486,14 @@ int zupt_hybrid_encrypt_init(zupt_keyring_t *kr, const char *pubkeyfile,
int zupt_hybrid_decrypt_init(zupt_keyring_t *kr, const char *privkeyfile,
const uint8_t *enc_hdr, size_t enc_hdr_len);
/* ─── Full post-quantum crypto: ML-KEM-768 only, no X25519 (v4.2.0) ─── */
int zupt_pq_keygen(const char *keyfile);
int zupt_pq_export_pubkey(const char *privfile, const char *pubfile);
int zupt_pq_encrypt_init(zupt_keyring_t *kr, const char *pubkeyfile,
uint8_t *enc_hdr, size_t *enc_hdr_len);
int zupt_pq_decrypt_init(zupt_keyring_t *kr, const char *privkeyfile,
const uint8_t *enc_hdr, size_t enc_hdr_len);
/* ─── SDK-backed crypto (zupt v2.2+, libzuptsdk under the hood) ─── */
int zupt_sdk_hybrid_keygen(const char *privkeyfile, const char *pubkeyfile);
int zupt_sdk_hybrid_encrypt_init(zupt_keyring_t *kr, const char *pubkeyfile,

View file

@ -11,7 +11,7 @@
# zupt-VERSION.tar.gz alongside the PKGBUILD.
pkgname=vaptvupt
pkgver=4.1.0
pkgver=4.2.0
pkgrel=1
provides=('zupt')
replaces=('zupt')

View file

@ -1,3 +1,27 @@
vaptvupt (4.2.0-1) UNRELEASED; urgency=high
* New native full (pure) post-quantum mode --pq-only: ML-KEM-768 (FIPS
203) as the sole key-establishment mechanism, no classical X25519
component (envelope type 0x06; archive key SHA3-512(ml_ss || ml_ct ||
"ZUPT-PQ-ONLY-v1")). For compliance postures that require a single
NIST-standardised PQ primitive with no classical KEM in the envelope
(CNSA 2.0-style "PQ-only"). Keys via keygen --pq-only (ZPQK magic;
not interchangeable with hybrid --pq keys). Hybrid --pq remains the
recommended default; --pq-only has no classical fallback, so a break
of ML-KEM-768 alone breaks the archive. In-tree, default build.
* Security (critical): AES-256-CTR keystream reuse under --dedup. Dedup
blocks all use sequence 0, so the previous nonce (base_nonce XOR seq)
collapsed to a single value across blocks, reusing the CTR keystream
(a many-time-pad). Each block now uses a fresh random 128-bit nonce
stored in the block prefix and bound into the block MAC; block_seq is
still bound as MAC AAD. Regression test tests/test_dedup_nonce.sh.
Re-encrypt any --dedup encrypted archives written by <= 4.1.0.
* keygen --sdk / --box on a source-only build now fails with a clear
message pointing to native --pq / --pq-only (or a WITH_SDK=1 build).
* Wire format v1.6 unchanged; the 0x06 envelope is additive.
-- Cristian Cezar Moisés <sac@securityops.co> Wed, 09 Jul 2026 12:00:00 +0000
vaptvupt (4.1.0-1) UNRELEASED; urgency=high
* Source-only build: the prebuilt vendored libraries libzuptsdk.so and

View file

@ -23,8 +23,8 @@
class Vaptvupt < Formula
desc "Post-quantum backup compression utility (ML-KEM-768 + AES-256-CTR + HMAC-SHA256)"
homepage "https://git.securityops.co/cristiancmoises/zupt"
url "https://git.securityops.co/cristiancmoises/zupt/releases/download/v4.1.0/vaptvupt-4.1.0.tar.gz"
version "4.1.0"
url "https://git.securityops.co/cristiancmoises/zupt/releases/download/v4.2.0/vaptvupt-4.2.0.tar.gz"
version "4.2.0"
sha256 "REPLACE_WITH_SHA256_OF_RELEASE_TARBALL"
license "AGPL-3.0-or-later"

View file

@ -33,7 +33,7 @@
zupt = pkgs.stdenv.mkDerivation {
pname = "vaptvupt";
version = "4.1.0";
version = "4.2.0";
# When publishing, replace this with `fetchurl` against the
# release tarball. For local development the flake assumes it

View file

@ -1,12 +1,12 @@
# openSUSE Build Service update for `home:cabelo:innovators/vaptvupt`
This directory contains the three files needed to build vaptvupt `4.1.0`
This directory contains the three files needed to build vaptvupt `4.2.0`
in OBS:
| File | Purpose |
|---------------|-------------------------------------------------------------------------|
| `_service` | `revision` pinned to `v4.1.0`. Format unchanged (still `tar_scm`). |
| `vaptvupt.spec` | `Version: 4.1.0`. `License: AGPL-3.0-or-later`. `%check` calls `make check`. |
| `_service` | `revision` pinned to `v4.2.0`. Format unchanged (still `tar_scm`). |
| `vaptvupt.spec` | `Version: 4.2.0`. `License: AGPL-3.0-or-later`. `%check` calls `make check`. |
| `vaptvupt.changes`| Changelog for the 4.x series. Older history preserved verbatim. |
## Spec notes
@ -58,22 +58,22 @@ cp /path/to/vaptvupt-source/packaging/opensuse/_service .
cp /path/to/vaptvupt-source/packaging/opensuse/vaptvupt.spec .
cp /path/to/vaptvupt-source/packaging/opensuse/vaptvupt.changes .
# 3. Trigger the service locally to fetch v4.1.0 from GitHub
# 3. Trigger the service locally to fetch v4.2.0 from GitHub
osc service runall
# Produces vaptvupt-4.1.0.tar.gz in the current directory.
# Produces vaptvupt-4.2.0.tar.gz in the current directory.
# 4. (Optional) Local build to verify before committing
osc build openSUSE_Tumbleweed x86_64
# 5. Commit upstream
osc status # confirm vaptvupt-4.1.0.tar.gz is staged alongside the
osc status # confirm vaptvupt-4.2.0.tar.gz is staged alongside the
# three text files
osc commit -m "Update to 4.1.0"
osc commit -m "Update to 4.2.0"
```
## Notes for future updates
* The `_service` `revision` is pinned to `v4.1.0`. To track a new
* The `_service` `revision` is pinned to `v4.2.0`. To track a new
release, 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.

View file

@ -2,7 +2,7 @@
<service name="tar_scm" mode="manual">
<param name="url">https://github.com/cristiancmoises/zupt</param>
<param name="scm">git</param>
<param name="revision">v4.1.0</param>
<param name="revision">v4.2.0</param>
<param name="versionformat">@PARENT_TAG@</param>
<param name="versionrewrite-pattern">v(.*)</param>
<param name="submodules">enable</param>

View file

@ -1,3 +1,26 @@
-------------------------------------------------------------------
Wed Jul 9 12:00:00 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>
- Update to 4.2.0:
* New native full (pure) post-quantum mode --pq-only: ML-KEM-768
(FIPS 203) as the sole key-establishment mechanism, with no
classical X25519 component (envelope type 0x06; archive key
SHA3-512(ml_ss || ml_ct || "ZUPT-PQ-ONLY-v1")). For compliance
postures that mandate a single NIST-standardised PQ primitive with
no classical KEM in the envelope (CNSA 2.0-style "PQ-only"). Keys
via keygen --pq-only. In-tree crypto, built in the default
source-only package. Hybrid --pq remains the recommended default;
--pq-only has no classical fallback.
* Security (critical): fixed AES-256-CTR keystream reuse under
--dedup. Dedup blocks all use sequence 0, so the previous nonce
(base_nonce XOR seq) collapsed to a single value across blocks,
reusing the CTR keystream. Each block now uses a fresh random
128-bit nonce. Re-encrypt any --dedup encrypted archives written
by <= 4.1.0.
* keygen --sdk / --box now gives clear guidance toward native --pq /
--pq-only on a source-only build.
* Wire format v1.6 unchanged; the 0x06 envelope is additive.
-------------------------------------------------------------------
Tue Jul 7 12:00:00 UTC 2026 - Alessandro de Oliveira Faria <cabelo@opensuse.org>

View file

@ -19,7 +19,7 @@
Name: vaptvupt
Version: 4.1.0
Version: 4.2.0
Release: 0
Summary: Post-quantum backup compression with AES-256 + ML-KEM-768 hybrid encryption
License: AGPL-3.0-or-later

View file

@ -20,7 +20,7 @@
# in the base.
Name: vaptvupt
Version: 4.1.0
Version: 4.2.0
Release: 1%{?dist}
Summary: Post-quantum backup compression utility (AES-256 + ML-KEM-768 + Argon2id, formerly Zupt)
@ -112,6 +112,16 @@ ln -sf libpqvaptvupt.so.0.6.0 %{buildroot}%{_libdir}/%{name}/libpqvaptvupt.so
%endif
%changelog
* Wed Jul 09 2026 Cristian Cezar Moisés <sac@securityops.co> - 4.2.0-1
- New native full (pure) post-quantum mode --pq-only: ML-KEM-768 as the
sole KEM, no classical X25519 (envelope 0x06). For "PQ-only" compliance
postures; hybrid --pq remains the recommended default. In-tree crypto.
- Security (critical): fixed AES-256-CTR keystream reuse under --dedup
(every block now uses a fresh random 128-bit nonce). Re-encrypt any
--dedup encrypted archives written by <= 4.1.0.
- Clearer keygen --sdk/--box guidance on source-only builds.
- Wire format v1.6 unchanged.
* 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

View file

@ -391,11 +391,23 @@ uint8_t *zupt_encrypt_buffer_aad(const zupt_keyring_t *kr,
uint8_t *pkg = (uint8_t *)malloc(*olen);
if (!pkg) return NULL;
/* Derive per-block nonce */
/* Per-block nonce: a fresh random 128-bit value for every block.
*
* SECURITY FIX (v4.2.0): the previous scheme derived the nonce as
* base_nonce XOR block_seq, but dedup mode hard-codes block_seq == 0 for
* every data block (the sentinel needed so cross-file dedup references MAC
* the same way). That collapsed every dedup block's nonce to the single
* per-archive base_nonce, reusing the AES-256-CTR keystream across distinct
* plaintext blocks a many-time-pad that leaks plaintext to a
* ciphertext-only attacker, in every encryption mode (password, hybrid PQ,
* full PQ). A random 128-bit nonce is unique with overwhelming probability
* regardless of dedup or thread scheduling. The nonce is stored in the
* package prefix and bound by the HMAC, and decrypt reads it back directly,
* so this is an encrypt-side change only the on-disk format, the MAC
* transcript (which still uses block_seq as aad_seq), and the decrypt path
* are all unchanged, and pre-4.2 archives still extract byte-exact. */
uint8_t nonce[16];
memcpy(nonce, kr->base_nonce, 16);
for (int i = 0; i < 8; i++)
nonce[i] ^= (uint8_t)(block_seq >> (i * 8));
zupt_random_bytes(nonce, 16);
/* Store nonce */
memcpy(pkg, nonce, 16);
@ -850,3 +862,207 @@ int zupt_hybrid_decrypt_init(zupt_keyring_t *kr, const char *privkeyfile,
return 0;
}
/* ═══════════════════════════════════════════════════════════════════
* FULL POST-QUANTUM KEM: ML-KEM-768 only (v4.2.0)
*
* Unlike the hybrid --pq mode (ML-KEM-768 + X25519), this mode uses
* ML-KEM-768 ALONE no classical X25519 component. It is "fully
* post-quantum": confidentiality of the archive key rests solely on
* ML-KEM (FIPS 203, IND-CCA2 with the Fujisaki-Okamoto transform /
* implicit rejection that zupt_mlkem768_decaps implements).
*
* SECURITY NOTE: the hybrid --pq mode remains the recommended default.
* A pure-PQ scheme has NO classical fallback, so a future break of
* ML-KEM-768 leaves no second layer. Use --pq-only only when a strictly
* post-quantum construction is a hard requirement (e.g. a policy that
* forbids classical primitives entirely).
*
* Key file (ZPQK):
* [4B] "ZPQK"
* [1B] version 0x01
* [1B] flags: bit0 = has_private
* [2B] reserved
* [1184B] ml_kem_pk
* [2400B] ml_kem_sk (only if has_private)
* [8B] xxh64 of everything above
*
* enc_hdr (ZUPT_ENC_PQ_ONLY = 0x06), 1105 bytes:
* [1B] 0x06
* [1088B] ml_kem_ciphertext
* [16B] base_nonce
*
* archive_key[64] = SHA3-512(ml_ss ml_ct "ZUPT-PQ-ONLY-v1")
* enc_key = archive_key[0:32], mac_key = archive_key[32:64]
* The ML-KEM ciphertext is bound into the KDF transcript (defense in
* depth) alongside the domain separator, which also prevents cross-mode
* key reuse with the hybrid path (different label).
* */
#define ZPQK_MAGIC "ZPQK"
#define ZPQK_VERSION 0x01
#define ZPQK_FLAG_PRIVATE 0x01
#define ZPQK_HDR 8
#define ZPQK_PUB_SIZE (ZPQK_HDR + 1184)
#define ZPQK_PRIV_SIZE (ZPQK_HDR + 1184 + 2400)
#define ZUPT_PQ_ONLY_LABEL "ZUPT-PQ-ONLY-v1" /* 15 bytes */
int zupt_pq_keygen(const char *keyfile) {
uint8_t ml_pk[MLKEM_PUBLICKEYBYTES], ml_sk[MLKEM_SECRETKEYBYTES];
if (zupt_mlkem768_keygen(ml_pk, ml_sk) != 0) return -1;
FILE *f = fopen(keyfile, "wb");
if (!f) { zupt_secure_wipe(ml_sk, sizeof(ml_sk)); return -1; }
size_t total = ZPQK_PRIV_SIZE;
uint8_t *buf = (uint8_t *)calloc(total + 8, 1);
if (!buf) { fclose(f); zupt_secure_wipe(ml_sk, sizeof(ml_sk)); return -1; }
memcpy(buf, ZPQK_MAGIC, 4);
buf[4] = ZPQK_VERSION;
buf[5] = ZPQK_FLAG_PRIVATE;
buf[6] = buf[7] = 0;
memcpy(buf + ZPQK_HDR, ml_pk, 1184);
memcpy(buf + ZPQK_HDR + 1184, ml_sk, 2400);
uint64_t ck = zupt_xxh64(buf, total, 0);
zupt_le64_put(buf + total, ck);
size_t written = fwrite(buf, 1, total + 8, f);
if (fclose(f) != 0) written = 0;
zupt_secure_wipe(ml_sk, sizeof(ml_sk));
zupt_secure_wipe(buf, total + 8);
free(buf);
return (written == total + 8) ? 0 : -1;
}
int zupt_pq_export_pubkey(const char *privfile, const char *pubfile) {
FILE *f = fopen(privfile, "rb");
if (!f) return -1;
uint8_t hdr[ZPQK_HDR];
if (fread(hdr, 1, ZPQK_HDR, f) != ZPQK_HDR || memcmp(hdr, ZPQK_MAGIC, 4) != 0 ||
!(hdr[5] & ZPQK_FLAG_PRIVATE)) { fclose(f); return -1; }
uint8_t ml_pk[1184];
if (fread(ml_pk, 1, 1184, f) != 1184) { fclose(f); return -1; }
fclose(f);
FILE *out = fopen(pubfile, "wb");
if (!out) return -1;
size_t total = ZPQK_PUB_SIZE;
uint8_t buf[ZPQK_PUB_SIZE + 8];
memcpy(buf, ZPQK_MAGIC, 4);
buf[4] = ZPQK_VERSION;
buf[5] = 0;
buf[6] = buf[7] = 0;
memcpy(buf + ZPQK_HDR, ml_pk, 1184);
uint64_t ck = zupt_xxh64(buf, total, 0);
zupt_le64_put(buf + total, ck);
size_t written = fwrite(buf, 1, total + 8, out);
if (fclose(out) != 0) written = 0;
return (written == total + 8) ? 0 : -1;
}
static int read_pq_pubkey(const char *path, uint8_t ml_pk[1184]) {
FILE *f = fopen(path, "rb");
if (!f) return -1;
uint8_t hdr[ZPQK_HDR];
if (fread(hdr, 1, ZPQK_HDR, f) != ZPQK_HDR || memcmp(hdr, ZPQK_MAGIC, 4) != 0) {
fclose(f); return -1;
}
if (fread(ml_pk, 1, 1184, f) != 1184) { fclose(f); return -1; }
fclose(f);
return 0;
}
static int read_pq_privkey(const char *path, uint8_t ml_pk[1184], uint8_t ml_sk[2400]) {
FILE *f = fopen(path, "rb");
if (!f) return -1;
uint8_t hdr[ZPQK_HDR];
if (fread(hdr, 1, ZPQK_HDR, f) != ZPQK_HDR || memcmp(hdr, ZPQK_MAGIC, 4) != 0 ||
!(hdr[5] & ZPQK_FLAG_PRIVATE)) { fclose(f); return -1; }
if (fread(ml_pk, 1, 1184, f) != 1184) { fclose(f); return -1; }
if (fread(ml_sk, 1, 2400, f) != 2400) { fclose(f); return -1; }
fclose(f);
return 0;
}
/* archive_key = SHA3-512(ml_ss ‖ ml_ct ‖ label). Shared by encrypt/decrypt. */
static void pq_only_derive(const uint8_t ml_ss[32], const uint8_t ml_ct[1088],
uint8_t archive_key[64]) {
uint8_t kdf_input[32 + 1088 + 15];
memcpy(kdf_input, ml_ss, 32);
memcpy(kdf_input + 32, ml_ct, 1088);
memcpy(kdf_input + 32 + 1088, ZUPT_PQ_ONLY_LABEL, 15);
zupt_sha3_512(kdf_input, sizeof(kdf_input), archive_key);
zupt_secure_wipe(kdf_input, sizeof(kdf_input));
}
int zupt_pq_encrypt_init(zupt_keyring_t *kr, const char *pubkeyfile,
uint8_t *enc_hdr, size_t *enc_hdr_len) {
uint8_t ml_pk[1184];
if (read_pq_pubkey(pubkeyfile, ml_pk) != 0) return -1;
uint8_t ml_ct[1088], ml_ss[32];
if (zupt_mlkem768_encaps(ml_ct, ml_ss, ml_pk) != 0) return -1;
uint8_t archive_key[64];
pq_only_derive(ml_ss, ml_ct, archive_key);
kr->canary_head = ZUPT_CANARY;
memcpy(kr->enc_key, archive_key, 32);
memcpy(kr->mac_key, archive_key + 32, 32);
zupt_random_bytes(kr->base_nonce, ZUPT_NONCE_SIZE);
kr->iterations = 0;
kr->active = 1;
kr->canary_tail = ZUPT_CANARY;
zupt_mlock_keys(kr->enc_key, ZUPT_AES_KEY_SIZE);
zupt_mlock_keys(kr->mac_key, ZUPT_HMAC_SIZE);
enc_hdr[0] = ZUPT_ENC_PQ_ONLY;
memcpy(enc_hdr + 1, ml_ct, 1088);
memcpy(enc_hdr + 1 + 1088, kr->base_nonce, 16);
*enc_hdr_len = 1 + 1088 + 16; /* 1105 bytes */
zupt_secure_wipe(ml_ss, 32);
zupt_secure_wipe(archive_key, 64);
return 0;
}
int zupt_pq_decrypt_init(zupt_keyring_t *kr, const char *privkeyfile,
const uint8_t *enc_hdr, size_t enc_hdr_len) {
if (enc_hdr_len < 1 + 1088 + 16) return -1;
if (enc_hdr[0] != ZUPT_ENC_PQ_ONLY) return -1;
const uint8_t *ml_ct = enc_hdr + 1;
const uint8_t *nonce = enc_hdr + 1 + 1088;
uint8_t ml_pk[1184], ml_sk[2400];
if (read_pq_privkey(privkeyfile, ml_pk, ml_sk) != 0) {
zupt_secure_wipe(ml_sk, sizeof(ml_sk)); /* wipe any partial secret from a truncated key file */
return -1;
}
/* ML-KEM-768 decapsulation (FO implicit rejection: an invalid ciphertext
* yields a pseudorandom shared secret, so a wrong/tampered ct produces a
* wrong archive key and the per-block HMAC fails-closed at extract time). */
uint8_t ml_ss[32];
zupt_mlkem768_decaps(ml_ss, ml_ct, ml_sk);
uint8_t archive_key[64];
pq_only_derive(ml_ss, ml_ct, archive_key);
kr->canary_head = ZUPT_CANARY;
memcpy(kr->enc_key, archive_key, 32);
memcpy(kr->mac_key, archive_key + 32, 32);
memcpy(kr->base_nonce, nonce, ZUPT_NONCE_SIZE);
kr->iterations = 0;
kr->active = 1;
kr->canary_tail = ZUPT_CANARY;
zupt_mlock_keys(kr->enc_key, ZUPT_AES_KEY_SIZE);
zupt_mlock_keys(kr->mac_key, ZUPT_HMAC_SIZE);
zupt_secure_wipe(ml_sk, sizeof(ml_sk));
zupt_secure_wipe(ml_ss, 32);
zupt_secure_wipe(archive_key, 64);
return 0;
}

View file

@ -377,6 +377,35 @@ zupt_error_t write_enc_header(FILE *out, zupt_archive_header_t *hdr,
if (!opts->quiet)
fprintf(stderr, " Encryption: SDK-v2 PQ Hybrid + XChaCha20-Poly1305 (commitment + HPKE)\n\n");
} else if (opts->pqonly_mode) {
/* ─── FULL POST-QUANTUM MODE (ML-KEM-768 only, no X25519) ─── */
hdr->global_flags |= ZUPT_FLAG_PQ_HYBRID; /* generic PQ indicator; enc_type distinguishes */
uint8_t enc_hdr_buf[1200];
size_t enc_hdr_len = 0;
if (!opts->quiet)
fprintf(stderr, " Full post-quantum key encapsulation (ML-KEM-768, no classical layer)...\n");
if (zupt_pq_encrypt_init(&opts->keyring, opts->keyfile,
enc_hdr_buf, &enc_hdr_len) != 0) {
fprintf(stderr, "Error: full-PQ key encapsulation failed (wrong key file?).\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: Full PQ (ML-KEM-768) + AES-256-CTR + HMAC-SHA256\n\n");
} else if (opts->pq_mode) {
/* ─── PQ HYBRID MODE ─── */
hdr->global_flags |= ZUPT_FLAG_PQ_HYBRID;
@ -2022,6 +2051,21 @@ zupt_error_t read_enc_header(FILE *f, zupt_archive_header_t *hdr, zupt_options_t
}
free(eb.payload);
return ZUPT_OK;
} else if (enc_type == ZUPT_ENC_PQ_ONLY) {
/* ─── FULL POST-QUANTUM MODE (ML-KEM-768 only) ─── */
if (opts->keyfile[0] == '\0') {
fprintf(stderr, "Error: Archive uses full post-quantum encryption. Use --pq-only <keyfile>.\n");
free(eb.payload);
return ZUPT_ERR_AUTH_FAIL;
}
if (zupt_pq_decrypt_init(&opts->keyring, opts->keyfile,
eb.payload, (size_t)eb.compressed_size) != 0) {
fprintf(stderr, "Error: full-PQ decryption key derivation failed (wrong key?).\n");
free(eb.payload);
return ZUPT_ERR_AUTH_FAIL;
}
free(eb.payload);
return ZUPT_OK;
} else if (enc_type == ZUPT_ENC_PBKDF2) {
/* ─── PASSWORD MODE (v0.7+ format with enc_type prefix) ─── */
if (opts->password[0] == '\0') {

View file

@ -72,8 +72,9 @@ static void usage(void) {
" 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 <pubkey> Post-quantum HYBRID encryption (ML-KEM-768 + X25519) [recommended]\n"
" --pq-only <pubkey> FULL post-quantum encryption (ML-KEM-768 only, no classical layer)\n"
" --pq-sdk <pubkey> Post-quantum encryption via libzuptsdk (WITH_SDK=1 builds only)\n"
" --pq-box <pubkey> Post-quantum sealed box via libpqvaptvupt (HKDF combiner)\n"
" (HKDF combiner + key commitment + HPKE binding\n"
" + Argon2id; recommended for new archives)\n"
@ -88,8 +89,9 @@ static void usage(void) {
"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 <privkey> Post-quantum HYBRID decryption (ML-KEM-768 + X25519)\n"
" --pq-only <privkey> FULL post-quantum decryption (ML-KEM-768 only)\n"
" --pq-sdk <privkey> Post-quantum decryption via libzuptsdk (WITH_SDK=1 builds only)\n"
" --pq-box <privkey> Post-quantum sealed-box decryption (libpqvaptvupt)\n"
" -v, --verbose Verbose output\n"
" -t, --threads <N> Thread count for decompression\n"
@ -98,9 +100,11 @@ static void usage(void) {
" -o <file> Output keyfile path (required)\n"
" --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"
" --box, --pq-box Generate pq-box keypair (libpqvaptvupt; writes <file> and <file>.pub)\n"
" Use these keys with --pq-sdk / --pq-box respectively.\n"
" (default) Generate HYBRID keypair (ML-KEM-768 + X25519) for --pq\n"
" --pq-only Generate FULL post-quantum keypair (ML-KEM-768 only) for --pq-only\n"
" --sdk, --pq-sdk Generate SDK v2 keypair (libzuptsdk; WITH_SDK=1 builds only)\n"
" --box, --pq-box Generate pq-box keypair (libpqvaptvupt; WITH_SDK=1 builds only)\n"
" Use each key with its matching mode.\n"
"\n"
"Directories are traversed recursively.\n"
"\n");
@ -268,6 +272,9 @@ int main(int argc, char **argv) {
} else if (streq(argv[ai],"--pq-sdk")&&ai+1<argc) {
opts.pq_mode=1; opts.sdk_mode=1; opts.encrypt=1;
strncpy(opts.keyfile, argv[++ai], sizeof(opts.keyfile)-1);
} else if (streq(argv[ai],"--pq-only")&&ai+1<argc) {
opts.pqonly_mode=1; opts.encrypt=1;
strncpy(opts.keyfile, argv[++ai], sizeof(opts.keyfile)-1);
} else if (streq(argv[ai],"--pq")&&ai+1<argc) {
opts.pq_mode=1; opts.encrypt=1;
strncpy(opts.keyfile, argv[++ai], sizeof(opts.keyfile)-1);
@ -410,6 +417,9 @@ int main(int argc, char **argv) {
} else if (streq(argv[ai],"--pq-sdk")&&ai+1<argc) {
opts.pq_mode=1; opts.sdk_mode=1; opts.encrypt=1;
strncpy(opts.keyfile, argv[++ai], sizeof(opts.keyfile)-1);
} else if (streq(argv[ai],"--pq-only")&&ai+1<argc) {
opts.pqonly_mode=1; opts.encrypt=1;
strncpy(opts.keyfile, argv[++ai], sizeof(opts.keyfile)-1);
} else if (streq(argv[ai],"--pq")&&ai+1<argc) {
opts.pq_mode=1; opts.encrypt=1;
strncpy(opts.keyfile, argv[++ai], sizeof(opts.keyfile)-1);
@ -446,6 +456,9 @@ int main(int argc, char **argv) {
} else if (streq(argv[ai],"--pq-sdk")&&ai+1<argc) {
opts.pq_mode=1; opts.sdk_mode=1; opts.encrypt=1;
strncpy(opts.keyfile, argv[++ai], sizeof(opts.keyfile)-1);
} else if (streq(argv[ai],"--pq-only")&&ai+1<argc) {
opts.pqonly_mode=1; opts.encrypt=1;
strncpy(opts.keyfile, argv[++ai], sizeof(opts.keyfile)-1);
} else if (streq(argv[ai],"--pq")&&ai+1<argc) {
opts.pq_mode=1; opts.encrypt=1;
strncpy(opts.keyfile, argv[++ai], sizeof(opts.keyfile)-1);
@ -481,6 +494,9 @@ int main(int argc, char **argv) {
} else if (streq(argv[ai],"--pq-sdk")&&ai+1<argc) {
opts.pq_mode=1; opts.sdk_mode=1; opts.encrypt=1;
strncpy(opts.keyfile, argv[++ai], sizeof(opts.keyfile)-1);
} else if (streq(argv[ai],"--pq-only")&&ai+1<argc) {
opts.pqonly_mode=1; opts.encrypt=1;
strncpy(opts.keyfile, argv[++ai], sizeof(opts.keyfile)-1);
} else if (streq(argv[ai],"--pq")&&ai+1<argc) {
opts.pq_mode=1; opts.encrypt=1;
strncpy(opts.keyfile, argv[++ai], sizeof(opts.keyfile)-1);
@ -743,6 +759,9 @@ int main(int argc, char **argv) {
opts.verbose=1;
} else if ((streq(argv[ai],"-t")||streq(argv[ai],"--threads"))&&ai+1<argc) {
opts.threads=atoi(argv[++ai]);
} else if (streq(argv[ai],"--pq-only")&&ai+1<argc) {
opts.pqonly_mode=1; opts.encrypt=1;
strncpy(opts.keyfile, argv[++ai], sizeof(opts.keyfile)-1);
} else if (streq(argv[ai],"--pq")&&ai+1<argc) {
opts.pq_mode=1; opts.encrypt=1;
strncpy(opts.keyfile, argv[++ai], sizeof(opts.keyfile)-1);
@ -821,6 +840,7 @@ int main(int argc, char **argv) {
int export_pub = 0;
int sdk_mode = 0;
int box_mode = 0;
int pqonly_mode = 0;
int ai = 2;
while (ai < argc && isopt(argv[ai])) {
if ((streq(argv[ai],"-o")||streq(argv[ai],"--output")) && ai+1 < argc)
@ -833,6 +853,8 @@ int main(int argc, char **argv) {
sdk_mode = 1;
else if (streq(argv[ai],"--box")||streq(argv[ai],"--pq-box"))
box_mode = 1;
else if (streq(argv[ai],"--pq-only")||streq(argv[ai],"--pqonly"))
pqonly_mode = 1;
else { fprintf(stderr, "Unknown option '%s'\n", argv[ai]); return 1; }
ai++;
}
@ -848,10 +870,22 @@ int main(int argc, char **argv) {
if (export_pub) {
if (!privfile) { fprintf(stderr, "Error: --pub requires -k <private_keyfile>\n"); return 1; }
fprintf(stderr, " Exporting public key from: %s\n", privfile);
if (zupt_hybrid_export_pubkey(privfile, outfile) != 0) {
fprintf(stderr, "Error: Failed to export public key.\n"); return 1;
int erc = pqonly_mode ? zupt_pq_export_pubkey(privfile, outfile)
: zupt_hybrid_export_pubkey(privfile, outfile);
if (erc != 0) {
fprintf(stderr, "Error: Failed to export public key%s.\n",
pqonly_mode ? "" : " (for full-PQ keys use: keygen --pub --pq-only)");
return 1;
}
fprintf(stderr, " Public key written to: %s\n", outfile);
} else if (pqonly_mode) {
fprintf(stderr, " Generating ML-KEM-768 keypair (full post-quantum, no X25519)...\n");
if (zupt_pq_keygen(outfile) != 0) {
fprintf(stderr, "Error: full-PQ key generation failed.\n"); return 1;
}
fprintf(stderr, " Private key written to: %s\n", outfile);
fprintf(stderr, " SECURITY: Keep this file secret. Back it up securely.\n");
fprintf(stderr, " To export public key: vaptvupt keygen --pub --pq-only -o pub.key -k %s\n", outfile);
} else if (box_mode) {
fprintf(stderr, " Generating ML-KEM-768 + X25519 keypair (pq-box format)...\n");
char pubfile[512];
@ -867,7 +901,14 @@ int main(int argc, char **argv) {
char pubfile[512];
snprintf(pubfile, sizeof(pubfile), "%s.pub", outfile);
if (zupt_sdk_hybrid_keygen(outfile, pubfile) != 0) {
fprintf(stderr, "Error: SDK key generation failed.\n"); return 1;
fprintf(stderr,
"Error: SDK-v2 key generation is unavailable in this build.\n"
" --pq-sdk needs libzuptsdk, which is not part of the source-only\n"
" build. For post-quantum keys use one of the native modes:\n"
" vaptvupt keygen -o key # hybrid ML-KEM-768 + X25519 (--pq)\n"
" vaptvupt keygen --pq-only -o key # full PQ, ML-KEM-768 only (--pq-only)\n"
" (Rebuild upstream with 'make WITH_SDK=1' to enable --pq-sdk.)\n");
return 1;
}
fprintf(stderr, " Private key: %s\n", outfile);
fprintf(stderr, " Public key: %s\n", pubfile);

View file

@ -23,10 +23,14 @@ E=$(find "$T/o7" -name a.txt -type f 2>/dev/null|head -1); [ -n "$E" ] && diff -
$Z keygen -o "$T/k.key" 2>/dev/null && $Z keygen --pub -o "$T/p.key" -k "$T/k.key" 2>/dev/null
$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"
# Full post-quantum (ML-KEM-768 only, no X25519) round-trip.
$Z keygen --pq-only -o "$T/kq.key" 2>/dev/null && $Z keygen --pub --pq-only -o "$T/pq.key" -k "$T/kq.key" 2>/dev/null
$Z compress --pq-only "$T/pq.key" "$T/9.zupt" "$T/d/" 2>/dev/null && $Z extract --pq-only "$T/kq.key" -o "$T/o9" "$T/9.zupt" 2>/dev/null
E=$(find "$T/o9" -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-only" || fl "PQ-only"
R=$($Z test "$T/1.zupt" 2>&1); echo "$R"|grep -q "0 failed" && ok "Integrity" || fl "Integrity"
# 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
echo ""; echo " Results: $P passed, $F failed (11 tests)"; [ "$F" -eq 0 ] && exit 0 || exit 1

56
tests/test_dedup_nonce.sh Normal file
View file

@ -0,0 +1,56 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Regression: dedup-encrypted archives must NOT reuse the AES-256-CTR nonce
# across blocks.
#
# v4.2.0 fix: the old per-block nonce was base_nonce XOR block_seq, but dedup
# mode hard-codes block_seq==0 for every data block (the sentinel needed so
# cross-file dedup references authenticate consistently). That collapsed every
# dedup block's nonce to a single value, reusing the CTR keystream across
# distinct plaintexts — a many-time-pad. The nonce is now a fresh random 128-bit
# value per block. This test asserts every encrypted DATA block in a
# dedup-encrypted archive carries a distinct stored nonce.
set -u
ZUPT="${ZUPT_BIN:-./zupt}"
echo "Dedup nonce uniqueness (keystream-reuse regression)"
if ! command -v python3 >/dev/null 2>&1; then
echo " - skipped: python3 not available"; exit 0
fi
T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
# 1 MiB of random data => many distinct 128 KiB blocks (random never dedups).
head -c 1048576 /dev/urandom > "$T/f.bin"
"$ZUPT" compress --dedup -p testpw "$T/a.zupt" "$T/f.bin" >/dev/null 2>&1
python3 - "$T/a.zupt" <<'PY'
import sys
d = open(sys.argv[1], 'rb').read()
nonces = []; i = 0
def rv(p):
v = s = 0
while True:
b = d[p]; p += 1; v |= (b & 127) << s
if not (b & 128): break
s += 7
return v, p
while True:
j = d.find(b'\xbb\x01', i)
if j < 0 or j + 7 > len(d): break
bt = d[j+2]; flags = d[j+5] | (d[j+6] << 8)
if bt == 0 and (flags & 1): # DATA + ENCRYPTED
p = j + 7
_, p = rv(p); _, p = rv(p); p += 8 # skip usz, csz, xxh64
nonces.append(bytes(d[p:p+16])) # 16-byte nonce prefix
i = j + 2
if len(nonces) < 2:
print(" - inconclusive: only %d encrypted block(s) parsed" % len(nonces)); sys.exit(0)
if len(set(nonces)) == len(nonces):
print(" ✓ %d encrypted dedup blocks, all %d nonces distinct" % (len(nonces), len(set(nonces))))
sys.exit(0)
print(" ✗ %d blocks but only %d distinct nonces — CTR KEYSTREAM REUSE" % (len(nonces), len(set(nonces))))
sys.exit(1)
PY
rc=$?
[ $rc -eq 0 ] && echo " Dedup nonce: 1 passed, 0 failed" || echo " Dedup nonce: 0 passed, 1 failed"
exit $rc