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.
14 KiB
Security Policy — VaptVupt 4.2.0
Reporting Vulnerabilities
Report privately by email to zupt@riseup.net with [security] in the
subject. Do not open a public issue on the project's git server.
Include:
- Version (
vaptvupt --version) and platform. - Description, impact assessment, and a reproduction (a minimal archive or a code snippet).
Disclosure SLA: acknowledgement within 5 business days; target fix within 30 days for high-severity issues. Coordinated disclosure preferred; the timeline is discussed case by case. A PGP key is on the project's keyserver entry.
The project has not had an external independent audit. For high-stakes deployments, treat it as "reviewed but unaudited" and do your own review.
Encryption Modes
| 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 (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
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
build and are not defaults.
Cryptographic Algorithms
| Component | Algorithm | Standard | Key Size | Security Level |
|---|---|---|---|---|
| Symmetric encryption | AES-256-CTR | FIPS 197 | 256-bit | 128-bit post-quantum (Grover) |
| Authentication | HMAC-SHA256 | RFC 2104 | 256-bit | 128-bit post-quantum (Grover) |
| Password KDF (default) | PBKDF2-SHA256 | RFC 8018 | 600K iterations | Password-dependent |
| 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 (--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 |
The default build uses PBKDF2-SHA256 (600k iterations) for password mode.
Argon2id is available only in a make WITH_SDK=1 build.
Security Architecture
Per-Block Authenticated Encryption
For each data block:
nonce = CSPRNG(16) [16 bytes, fresh per block]
ciphertext = AES-256-CTR(enc_key, nonce, plaintext)
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--dedupmode 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
decryption. This prevents:
- Chosen-ciphertext attacks
- Padding oracle attacks
- Processing of tampered data
Hybrid Post-Quantum KEM (--pq)
Encapsulation:
ML-KEM-768.Encaps(pk) → ml_ct[1088], ml_ss[32]
eph_sk ← CSPRNG(32)
eph_pk = X25519(eph_sk, basepoint)
x25519_ss = X25519(eph_sk, recipient_pk)
hybrid_ikm = ml_ss XOR x25519_ss
archive_key = SHA3-512(hybrid_ikm ‖ ml_ct ‖ eph_pk ‖ "ZUPT-HYBRID-v1")
enc_key = archive_key[0:32]
mac_key = archive_key[32:64]
Security model: secure if EITHER ML-KEM-768 (post-quantum, NIST Level 3) OR X25519 (classical, ~128-bit) remains unbroken. Both must be compromised simultaneously to recover the archive key. Same approach as Signal (PQXDH), Apple iMessage (PQ3), and OpenSSH 9.0+.
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
Jasmin-Verified (assembly linked into binary)
| Function | Purpose | Proof |
|---|---|---|
zupt_mac_verify_ct |
HMAC comparison (32 bytes) | Jasmin type system: no branch on diff value |
zupt_ct_select_32 |
ML-KEM FO implicit rejection | Jasmin type system: no branch on cond value |
These functions are compiled from Jasmin source to x86-64 assembly. The Jasmin compiler enforces that no secret-typed variable flows into branch conditions or memory addresses. This guarantee holds at the machine code level — no C compiler optimization can introduce timing leaks.
C Constant-Time (branchless, compiler-dependent)
| Function | Method | Risk |
|---|---|---|
X25519 fe_cswap |
Masked XOR (mask & (a ^ b)) |
Low — branchless but compiler may optimize |
| ML-KEM NTT/basemul | Montgomery reduction (no branches) | Low |
| ML-KEM CBD sampling | Bitwise operations only | Low |
Key wipe (zupt_secure_wipe) |
explicit_bzero / volatile |
Low |
NOT Constant-Time (documented risks)
| Function | Risk | Mitigation |
|---|---|---|
| AES-256 block encrypt | HIGH on shared hardware — S-box table lookups leak via cache timing | Jasmin AES-NI path planned; do not use on multi-tenant VMs |
| SHA-256 | Low — table constants are public, not indexed by secret data | Accepted |
Threat Model
What VaptVupt Protects
| Asset | Protection |
|---|---|
| File contents | AES-256-CTR encryption |
| File names, sizes, structure | Encrypted in central index block, HMAC-protected |
| Archive integrity (payloads + index) | Per-block HMAC-SHA256 |
| Archive integrity (header + footer metadata) | v1.5+ archives: 32-byte archive-integrity-trailer HMAC-SHA256 over hdr ‖ ft[0..23]. v1.4 archives: not covered, downgrade warning on extract. |
| Against stolen backups | AES-256 requires key/password to read |
| Against tampering of file contents, names, sizes, offsets | HMAC detects any modification |
| Against tampering of per-block frame preface bytes (codec_id, block_flags, varints, plaintext-XXH64) | v1.6: per-block MAC binds the canonical preface AAD; encryption-header block validated structurally |
| Against tampering of archive comment (when present) | Comment block goes through the same per-block AEAD pipeline as data (AES-256-CTR + HMAC-SHA256 + preface AAD); hdr.comment_offset pointer is in the AIT-signed region |
| Against block-swap (reorder) attacks | MAC binds an 8-byte position AAD; a block moved to another position fails verification and its partial output is unlinked. Dedup refs use sentinel seq=0 and rely on plaintext XXH64 for per-block integrity. |
| Against malicious archive entries (Zip Slip / path traversal) | zupt_path_is_safe() rejects .., absolute paths, Windows drive/UNC paths, embedded NULs |
| Against symlink at extract target (TOCTOU) | zupt_safe_fopen_output() uses O_NOFOLLOW on POSIX. Windows relies on directory ACLs (documented limitation). |
| Against quantum adversary | --pq mode: ML-KEM-768 (NIST Level 3) hybridized with X25519 |
The wire/on-disk format is v1.6. See CHANGELOG.md for the per-release finding history behind these protections.
What VaptVupt Does NOT Protect Against
| Threat | Reason | Mitigation Path |
|---|---|---|
| Attacker who knows the password or has the private key | Fundamental to encryption | Use strong passwords (12+ chars); protect key files |
| Endpoint compromise (keylogger, malware on the host) | Outside the archive's trust boundary | Secure the machine where you type the password or hold the key |
| Cache-timing side channels (C AES) | Table-based S-box lookups | Build with Jasmin AES-NI when available; avoid multi-tenant VMs |
| Memory forensics during operation | Keys on stack during compress/extract | zupt_secure_wipe() on completion; mlock() planned |
| Deniability | Archive header identifies format | .zupt magic bytes visible; ENCRYPTED flag in header |
| Weak passwords | PBKDF2-SHA256 (600k) is the default KDF; Argon2id (memory-hard) is available in a WITH_SDK=1 build | Use --pq mode for critical data — keys are random, not derived from a password |
| Traffic analysis / metadata | Archive size reveals data volume; file list, sizes, mtimes not padded | Outside VaptVupt's scope |
| File permission/ownership | Not stored in archive | Documented in README.md |
| Spectre-class side channels in callers | Below the constant-time primitive layer | Host OS / compiler mitigations |
Quantum Threat Analysis
Scenario: adversary captures an encrypted archive today, stores it, and attempts decryption when a cryptographically-relevant quantum computer is available.
| Mode | Classical Security | Quantum Security | Verdict |
|---|---|---|---|
Password (-p) |
Password-dependent + 256-bit AES | ~128-bit (Grover on AES), PBKDF2 accelerated | Vulnerable — use --pq |
PQ Hybrid (--pq) |
~128-bit (X25519) | NIST Level 3 (ML-KEM-768) | Protected |
In --pq mode: even if Shor's algorithm breaks X25519, ML-KEM-768
protects the archive; even if a novel classical attack breaks ML-KEM,
X25519 still provides ~128-bit security. The hybrid design is secure if
either component holds.
Extracting untrusted archives — operational guidance
The in-binary defenses are the primary control; the following are defense in depth:
- Extract into a dedicated empty directory (not
~/Downloadsor/tmp). - Audit symlinks in the target directory before extraction.
- Run extraction as a low-privilege user, never root.
- On Windows, pre-create the target directory with restrictive ACLs
(the
O_NOFOLLOWdefense is POSIX-only).
Out of scope
- External independent audit.
- Side-channel testing on production hardware (timing leaks).
- Formal verification beyond the Jasmin constant-time primitives.
CSPRNG Policy
| Platform | Primary Source | Fallback | Failure Mode |
|---|---|---|---|
| Linux | getrandom(2) |
/dev/urandom |
Hard exit — no encryption without CSPRNG |
| macOS | /dev/urandom |
None | Hard exit |
| Windows | RtlGenRandom |
None | Hard exit |
There is no rand(), srand(), or any weak PRNG fallback anywhere in the
codebase. If the OS CSPRNG is unavailable, VaptVupt exits with an error.
This is a deliberate design choice — weak random keys are worse than no
encryption.
Supported Platforms
| Platform | Compiler | Threading | CSPRNG | Status |
|---|---|---|---|---|
| Linux x86-64 | GCC 5+ / Clang 3.5+ | pthreads | getrandom(2) |
Primary |
| Linux ARM64 | GCC 5+ | pthreads | getrandom(2) |
Tested |
| macOS x86-64/ARM64 | Apple Clang | pthreads | /dev/urandom |
Tested |
| Windows x86-64 | MinGW / MSVC 2015+ | Win32 threads | RtlGenRandom |
Tested |
| FreeBSD | GCC / Clang | pthreads | /dev/urandom |
Untested (expected to work) |
Verification Commands
Anyone can verify the security claims. The default build needs only a C compiler + make (plus libm/pthread); no external crypto library.
# Build
make
# Functional tests
make test-all
# Memory safety
make test-asan
# NIST/RFC test vectors
make test-vectors && ./test_vectors
# Verify Jasmin symbols are active
nm vaptvupt | grep "zupt_mac_verify_ct\|zupt_ct_select_32"
# Expected: T zupt_mac_verify_ct
# T zupt_ct_select_32
# Verify Jasmin compilation (requires jasminc)
jasminc -arch x86-64 -o /dev/null jasmin/zupt_mac_verify.jazz
jasminc -arch x86-64 -o /dev/null jasmin/zupt_mlkem_select.jazz
© 2026 Cristian Cezar Moisés — AGPL-3.0-or-later (dual-licensed AGPL + commercial)