diff --git a/CHANGELOG.md b/CHANGELOG.md index aa3a317..bc2f7f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/DISTRIBUTION.md b/DISTRIBUTION.md index 0dcc11f..6f92b6b 100644 --- a/DISTRIBUTION.md +++ b/DISTRIBUTION.md @@ -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): diff --git a/INSTALL.md b/INSTALL.md index c817a66..3ff4778 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -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) diff --git a/Makefile b/Makefile index 7395e4e..ec10014 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 865df6e..9e567bb 100644 --- a/README.md +++ b/README.md @@ -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.0–v3.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. diff --git a/SECURITY.md b/SECURITY.md index 3f96b39..8383305 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 8147b47..a497b1a 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -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. diff --git a/doc/vaptvupt.1 b/doc/vaptvupt.1 index 2b54d3c..3578b64 100644 --- a/doc/vaptvupt.1 +++ b/doc/vaptvupt.1 @@ -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 diff --git a/gui/README.md b/gui/README.md index 624bf7a..ae32def 100644 --- a/gui/README.md +++ b/gui/README.md @@ -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 diff --git a/include/zupt.h b/include/zupt.h index c295c6c..4ed6e21 100644 --- a/include/zupt.h +++ b/include/zupt.h @@ -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, diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD index f893a82..bb66ca0 100644 --- a/packaging/aur/PKGBUILD +++ b/packaging/aur/PKGBUILD @@ -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') diff --git a/packaging/debian/changelog b/packaging/debian/changelog index 45a1118..850c492 100644 --- a/packaging/debian/changelog +++ b/packaging/debian/changelog @@ -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 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 diff --git a/packaging/homebrew/vaptvupt.rb b/packaging/homebrew/vaptvupt.rb index 86af2d4..88bb44d 100644 --- a/packaging/homebrew/vaptvupt.rb +++ b/packaging/homebrew/vaptvupt.rb @@ -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" diff --git a/packaging/nix/flake.nix b/packaging/nix/flake.nix index 2755f17..ab46dcb 100644 --- a/packaging/nix/flake.nix +++ b/packaging/nix/flake.nix @@ -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 diff --git a/packaging/opensuse/README.md b/packaging/opensuse/README.md index 5bb2e09..34aa636 100644 --- a/packaging/opensuse/README.md +++ b/packaging/opensuse/README.md @@ -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. diff --git a/packaging/opensuse/_service b/packaging/opensuse/_service index 44dc47d..18125a2 100644 --- a/packaging/opensuse/_service +++ b/packaging/opensuse/_service @@ -2,7 +2,7 @@ https://github.com/cristiancmoises/zupt git - v4.1.0 + v4.2.0 @PARENT_TAG@ v(.*) enable diff --git a/packaging/opensuse/vaptvupt.changes b/packaging/opensuse/vaptvupt.changes index ea2896c..8a72875 100644 --- a/packaging/opensuse/vaptvupt.changes +++ b/packaging/opensuse/vaptvupt.changes @@ -1,3 +1,26 @@ +------------------------------------------------------------------- +Wed Jul 9 12:00:00 UTC 2026 - Alessandro de Oliveira Faria + +- 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 diff --git a/packaging/opensuse/vaptvupt.spec b/packaging/opensuse/vaptvupt.spec index 6a55236..b55b2b8 100644 --- a/packaging/opensuse/vaptvupt.spec +++ b/packaging/opensuse/vaptvupt.spec @@ -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 diff --git a/packaging/rpm/vaptvupt.spec b/packaging/rpm/vaptvupt.spec index c4550b7..4684c5b 100644 --- a/packaging/rpm/vaptvupt.spec +++ b/packaging/rpm/vaptvupt.spec @@ -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 - 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 - 2.4.4-1 - Initial Fedora/EPEL RPM package. - Tracks upstream v2.4.4: distribution packaging release; archive diff --git a/src/zupt_crypto.c b/src/zupt_crypto.c index 9d2a7e4..44a1c3e 100644 --- a/src/zupt_crypto.c +++ b/src/zupt_crypto.c @@ -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; +} diff --git a/src/zupt_format.c b/src/zupt_format.c index 8017830..7828bbc 100644 --- a/src/zupt_format.c +++ b/src/zupt_format.c @@ -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 .\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') { diff --git a/src/zupt_main.c b/src/zupt_main.c index fcb0cff..a106865 100644 --- a/src/zupt_main.c +++ b/src/zupt_main.c @@ -72,8 +72,9 @@ static void usage(void) { " Use 'pbkdf2' for v2.4.0-and-older reader compatibility.\n" " -c, --comment Embed a free-form archive comment (v2.4.3+).\n" " --comment-file Read comment from file (max 4096 bytes).\n" - " --pq Post-quantum encryption (legacy XOR+SHA3 combiner)\n" - " --pq-sdk Post-quantum encryption via libzuptsdk\n" + " --pq Post-quantum HYBRID encryption (ML-KEM-768 + X25519) [recommended]\n" + " --pq-only FULL post-quantum encryption (ML-KEM-768 only, no classical layer)\n" + " --pq-sdk Post-quantum encryption via libzuptsdk (WITH_SDK=1 builds only)\n" " --pq-box 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 Output directory (extract only)\n" " -p, --password Decryption password\n" - " --pq Post-quantum decryption (legacy combiner)\n" - " --pq-sdk Post-quantum decryption via libzuptsdk\n" + " --pq Post-quantum HYBRID decryption (ML-KEM-768 + X25519)\n" + " --pq-only FULL post-quantum decryption (ML-KEM-768 only)\n" + " --pq-sdk Post-quantum decryption via libzuptsdk (WITH_SDK=1 builds only)\n" " --pq-box Post-quantum sealed-box decryption (libpqvaptvupt)\n" " -v, --verbose Verbose output\n" " -t, --threads Thread count for decompression\n" @@ -98,9 +100,11 @@ static void usage(void) { " -o Output keyfile path (required)\n" " --pub Export public key from existing private key (-k)\n" " -k Source private keyfile (with --pub)\n" - " --sdk, --pq-sdk Generate SDK v2 keypair (writes and .pub)\n" - " --box, --pq-box Generate pq-box keypair (libpqvaptvupt; writes and .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\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); diff --git a/tests/run_quick.sh b/tests/run_quick.sh index 24ad797..75c395c 100644 --- a/tests/run_quick.sh +++ b/tests/run_quick.sh @@ -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 diff --git a/tests/test_dedup_nonce.sh b/tests/test_dedup_nonce.sh new file mode 100644 index 0000000..214d000 --- /dev/null +++ b/tests/test_dedup_nonce.sh @@ -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