zupt/SECURITY.md
Cristian Cezar Moisés 124958aea9 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.
2026-07-09 21:15:14 -03:00

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 --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 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:

  1. Extract into a dedicated empty directory (not ~/Downloads or /tmp).
  2. Audit symlinks in the target directory before extraction.
  3. Run extraction as a low-privilege user, never root.
  4. On Windows, pre-create the target directory with restrictive ACLs (the O_NOFOLLOW defense 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)