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

318 lines
14 KiB
Markdown

# 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.
```bash
# 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)